feat(purchase): 采购批次号支持,同一批提交的记录可分组查看/统计
背景: 循环提交把一批物品生成多条独立申请,无法识别'哪些是同一批采购' 后端: - purchase_request 模型新增 batch_no 字段(同一批提交共享) - create 接口接收 batch_no,无则自动生成 BATCH-yyyyMMdd-HHmm-随机 - 新增 GET /purchase/batch/<batch_no> 按批次查询所有记录 - 批量审批接口支持按 batch_no 一键审批整批 前端: - 提交时生成 batch_no 共享给所有行 - 列表新增批次号列(可点击) - 新增'整批查看'弹窗:显示同批次所有记录 + 一键批量通过本批 - 操作列新增'整批'按钮 数据库需执行: ALTER TABLE purchase_request ADD COLUMN batch_no VARCHAR(100);
This commit is contained in:
@ -157,6 +157,38 @@ def get_purchase_detail(purchase_id):
|
||||
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 3.5 按批次查询采购申请(同一批提交的多条记录)
|
||||
# GET /api/v1/purchase/batch/<batch_no>
|
||||
# --------------------------------------------------------
|
||||
@purchase_bp.route('/batch/<path:batch_no>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_purchase_by_batch(batch_no):
|
||||
"""按批次号查询同一批提交的所有采购申请记录"""
|
||||
try:
|
||||
from app.models.purchase import PurchaseRequest
|
||||
records = PurchaseRequest.query.filter(
|
||||
PurchaseRequest.batch_no == batch_no
|
||||
).order_by(PurchaseRequest.id.asc()).all()
|
||||
|
||||
user_id = get_current_user_id()
|
||||
has_perm = _user_has_purchase_perm()
|
||||
|
||||
items = []
|
||||
for r in records:
|
||||
d = r.to_dict()
|
||||
# 非本人且无权限时,价格字段剥离
|
||||
if d['requester_id'] != user_id and not has_perm:
|
||||
for k in ('unit_price', 'total_price', 'tax_rate'):
|
||||
d.pop(k, None)
|
||||
items.append(d)
|
||||
|
||||
return jsonify({'code': 200, 'msg': '获取成功', 'data': {'batch_no': batch_no, 'items': items}}), 200
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 4. 审批采购申请
|
||||
# PATCH /api/v1/purchase/<id>/approve
|
||||
@ -214,7 +246,9 @@ def batch_approve_purchase():
|
||||
|
||||
请求体:
|
||||
{
|
||||
"ids": [1, 2, 3],
|
||||
"ids": [1, 2, 3], // 按 ID 列表审批
|
||||
或
|
||||
"batch_no": "BATCH-xxx", // 按批次号审批整批(同一批提交的记录)
|
||||
"action": "approve" | "reject",
|
||||
"reject_reason": "驳回原因(reject 时必填)"
|
||||
}
|
||||
@ -223,11 +257,23 @@ def batch_approve_purchase():
|
||||
user_id = get_current_user_id()
|
||||
data = request.get_json() or {}
|
||||
ids = data.get('ids', [])
|
||||
batch_no = data.get('batch_no')
|
||||
action = data.get('action', 'approve')
|
||||
reject_reason = data.get('reject_reason')
|
||||
|
||||
# 若传 batch_no,则查出该批次的所有待审批记录 ID
|
||||
if batch_no:
|
||||
from app.models.purchase import PurchaseRequest
|
||||
records = PurchaseRequest.query.filter(
|
||||
PurchaseRequest.batch_no == batch_no,
|
||||
PurchaseRequest.status == 0
|
||||
).all()
|
||||
ids = [r.id for r in records]
|
||||
if not ids:
|
||||
return jsonify({'code': 400, 'msg': '该批次没有待审批的记录'}), 400
|
||||
|
||||
if not ids or not isinstance(ids, list):
|
||||
return jsonify({'code': 400, 'msg': 'ids 不能为空'}), 400
|
||||
return jsonify({'code': 400, 'msg': 'ids 或 batch_no 不能为空'}), 400
|
||||
if action not in ('approve', 'reject'):
|
||||
return jsonify({'code': 400, 'msg': '无效的审批操作'}), 400
|
||||
if action == 'reject' and not reject_reason:
|
||||
|
||||
@ -11,6 +11,8 @@ class PurchaseRequest(db.Model):
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
request_no = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
||||
# [新增] 采购批次号:同一批循环提交的多条记录共享,用于分组查看/统计
|
||||
batch_no = db.Column(db.String(100), index=True, comment='采购批次号(同一批提交的多条记录共享)')
|
||||
# [新增] 硬关联基础物料表,打通"按单入库"链路
|
||||
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), index=True, comment='关联基础物料ID')
|
||||
name = db.Column(db.String(255), nullable=False, comment='采购名称')
|
||||
@ -79,6 +81,7 @@ class PurchaseRequest(db.Model):
|
||||
return {
|
||||
'id': self.id,
|
||||
'request_no': self.request_no,
|
||||
'batch_no': self.batch_no or '',
|
||||
'base_id': self.base_id,
|
||||
'name': self.name,
|
||||
'spec_model': self.spec_model or '',
|
||||
|
||||
@ -20,6 +20,18 @@ class PurchaseService:
|
||||
.filter(PurchaseRequest.request_no.like(f"{prefix}%")).scalar()
|
||||
return f"{prefix}{(existing_count + 1):04d}"
|
||||
|
||||
@staticmethod
|
||||
def generate_batch_no():
|
||||
"""生成采购批次号: BATCH-yyyyMMdd-HHmm-当日流水(4位)
|
||||
同一批循环提交的多条记录共享同一 batch_no"""
|
||||
import uuid
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
now = datetime.now(beijing_tz)
|
||||
date_str = now.strftime('%Y%m%d')
|
||||
time_str = now.strftime('%H%M')
|
||||
# 用时间戳 + 随机段确保唯一
|
||||
return f"BATCH-{date_str}-{time_str}-{uuid.uuid4().hex[:4].upper()}"
|
||||
|
||||
@staticmethod
|
||||
def auto_fill_from_material(keyword: str):
|
||||
"""
|
||||
@ -67,8 +79,12 @@ class PurchaseService:
|
||||
if material:
|
||||
base_id = material.id
|
||||
|
||||
# [新增] 批次号:前端同一批循环提交时传入同一 batch_no,无则自动生成
|
||||
batch_no = data.get('batch_no') or PurchaseService.generate_batch_no()
|
||||
|
||||
purchase = PurchaseRequest(
|
||||
request_no=request_no,
|
||||
batch_no=batch_no, # [新增]
|
||||
base_id=base_id, # [新增]
|
||||
name=data['name'],
|
||||
spec_model=data.get('spec_model', ''),
|
||||
|
||||
@ -89,7 +89,8 @@ export function approvePurchase(id: number, data: {
|
||||
|
||||
// 批量审批采购申请(循环提交产生的多条记录可一次审批)
|
||||
export function batchApprovePurchase(data: {
|
||||
ids: number[]
|
||||
ids?: number[]
|
||||
batch_no?: string
|
||||
action: 'approve' | 'reject'
|
||||
reject_reason?: string
|
||||
}) {
|
||||
@ -100,6 +101,14 @@ export function batchApprovePurchase(data: {
|
||||
})
|
||||
}
|
||||
|
||||
// 按批次号查询同批次的采购申请记录
|
||||
export function getPurchaseByBatch(batchNo: string) {
|
||||
return request({
|
||||
url: `/purchase/batch/${encodeURIComponent(batchNo)}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取可选审批人列表
|
||||
export function getPurchaseApprovers() {
|
||||
return request({
|
||||
|
||||
@ -52,14 +52,24 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="requester_name" label="申请人" width="100" />
|
||||
<el-table-column prop="approver_name" label="审批人" width="100" />
|
||||
<!-- ★ 批次号列:同一批提交的记录共享,可点击查看整批 -->
|
||||
<el-table-column label="批次号" width="170">
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.batch_no" type="primary" :underline="false" @click="openBatchDialog(row.batch_no)">
|
||||
{{ row.batch_no }}
|
||||
</el-link>
|
||||
<span v-else style="color:#ccc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">{{ row.status_text }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right" align="center">
|
||||
<el-table-column label="操作" width="240" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="openDetailDialog(row)">详情</el-button>
|
||||
<el-button v-if="row.batch_no" type="info" link size="small" @click="openBatchDialog(row.batch_no)">整批</el-button>
|
||||
<template v-if="row.status === 0 && canApprove">
|
||||
<el-button type="success" link size="small" @click="handleApprove(row)">通过</el-button>
|
||||
<el-button type="danger" link size="small" @click="openRejectDialog(row)">驳回</el-button>
|
||||
@ -272,6 +282,41 @@
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ========== 整批查看弹窗(同一批提交的所有记录) ========== -->
|
||||
<el-dialog v-model="batchDialogVisible" title="采购批次详情" width="900px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
|
||||
<div style="margin-bottom: 12px;">
|
||||
<span style="font-weight: 600;">批次号:</span>
|
||||
<el-tag style="margin-left: 6px;">{{ currentBatchNo }}</el-tag>
|
||||
<span style="margin-left: 12px; color: #909399;">共 {{ batchItems.length }} 条采购记录</span>
|
||||
<el-button
|
||||
v-if="canApprove && batchItems.some(i => i.status === 0)"
|
||||
type="success" size="small" style="float: right;"
|
||||
:loading="batchLoading" @click="handleBatchApproveByNo"
|
||||
>批量通过本批</el-button>
|
||||
</div>
|
||||
<el-table :data="batchItems" border size="small" max-height="450">
|
||||
<el-table-column type="index" label="#" width="40" align="center" />
|
||||
<el-table-column prop="request_no" label="单号" width="170" />
|
||||
<el-table-column prop="name" label="物品" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="spec_model" label="规格" min-width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="quantity" label="数量" width="60" align="center" />
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">{{ row.status_text }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="purchase_date" label="采购日期" width="110" />
|
||||
<el-table-column label="操作" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="openDetailDialog(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="batchDialogVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ========== 驳回原因弹窗 ========== -->
|
||||
<el-dialog v-model="rejectDialogVisible" title="驳回申请" width="480px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
|
||||
<el-form label-width="80px">
|
||||
@ -316,7 +361,8 @@ import { useUserStore } from '@/stores/user'
|
||||
import { searchMaterialPurchase } from '@/api/purchase'
|
||||
import {
|
||||
getPurchaseList, createPurchase, getPurchaseDetail,
|
||||
approvePurchase, batchApprovePurchase, getPurchaseApprovers, autoFillPurchase
|
||||
approvePurchase, batchApprovePurchase, getPurchaseApprovers, autoFillPurchase,
|
||||
getPurchaseByBatch
|
||||
} from '@/api/purchase'
|
||||
import { uploadFile, deleteFile } from '@/api/common/upload'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
@ -408,6 +454,50 @@ const confirmBatchReject = async () => {
|
||||
const detailDialogVisible = ref(false)
|
||||
const detail = ref<any>({})
|
||||
|
||||
// ★ 整批查看
|
||||
const batchDialogVisible = ref(false)
|
||||
const currentBatchNo = ref('')
|
||||
const batchItems = ref<any[]>([])
|
||||
|
||||
// 打开整批查看弹窗
|
||||
const openBatchDialog = async (batchNo: string) => {
|
||||
currentBatchNo.value = batchNo
|
||||
batchItems.value = []
|
||||
batchDialogVisible.value = true
|
||||
try {
|
||||
const res: any = await getPurchaseByBatch(batchNo)
|
||||
batchItems.value = res.data?.items || []
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err?.msg || '加载批次记录失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 批量通过本批(按批次号)
|
||||
const handleBatchApproveByNo = async () => {
|
||||
if (!currentBatchNo.value) return
|
||||
const pendingCount = batchItems.value.filter(i => i.status === 0).length
|
||||
if (pendingCount === 0) return ElMessage.warning('本批没有待审批记录')
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定批量通过本批 ${pendingCount} 条采购申请吗?`,
|
||||
'批量审批确认',
|
||||
{ confirmButtonText: '批量通过', cancelButtonText: '取消', type: 'info' }
|
||||
)
|
||||
} catch { return }
|
||||
|
||||
batchLoading.value = true
|
||||
try {
|
||||
const res: any = await batchApprovePurchase({ batch_no: currentBatchNo.value, action: 'approve' })
|
||||
ElMessage.success(res?.msg || '本批已全部通过')
|
||||
await openBatchDialog(currentBatchNo.value) // 刷新整批数据
|
||||
await fetchData()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err?.msg || '批量通过失败')
|
||||
} finally {
|
||||
batchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建弹窗
|
||||
const formDialogVisible = ref(false)
|
||||
const dialogTitle = ref('新建采购申请')
|
||||
@ -804,6 +894,16 @@ const syncRemarkToAll = () => {
|
||||
ElMessage.success('已将第一行备注同步到所有行')
|
||||
}
|
||||
|
||||
// ★ 生成采购批次号(与后端 generate_batch_no 格式一致)
|
||||
const generateBatchNo = () => {
|
||||
const now = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const dateStr = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
|
||||
const timeStr = `${pad(now.getHours())}${pad(now.getMinutes())}`
|
||||
const rand = Math.random().toString(16).slice(2, 6).toUpperCase()
|
||||
return `BATCH-${dateStr}-${timeStr}-${rand}`
|
||||
}
|
||||
|
||||
// ★ 照片一键同步所有行:取第一行的照片深拷贝到所有行
|
||||
const syncImagesToAll = () => {
|
||||
if (formItems.value.length <= 1) {
|
||||
@ -852,12 +952,15 @@ const submitForm = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 逐行提交(每行携带公共信息)
|
||||
// 3. 逐行提交(每行携带公共信息 + 同一批次号)
|
||||
submitLoading.value = true
|
||||
let successCount = 0
|
||||
// ★ 生成批次号:同一批提交的所有记录共享,便于审批端按批次查看/统计
|
||||
const batchNo = generateBatchNo()
|
||||
try {
|
||||
for (const row of validItems) {
|
||||
const payload: any = {
|
||||
batch_no: batchNo,
|
||||
name: row.name,
|
||||
spec_model: row.spec_model,
|
||||
quantity: row.quantity,
|
||||
|
||||
Reference in New Issue
Block a user