refactor(purchase): 批次概念改为基于 request_no 前缀推导,撤销数据库字段

按用户反馈:不需要新增数据库字段,现有 request_no (PUR-20260831-1021-0001)
本身就能推导批次——去掉末尾4位流水号即为批次前缀。

- 撤销 purchase_request.batch_no 字段(模型/数据库)
- 撤销 create 接口的 batch_no 参数与 generate_batch_no
- 批次标识 = request_no 去掉末尾 -XXXX 段
  getBatchKey('PUR-20260831-1021-0001') = 'PUR-20260831-1021'
- 按批次查询/批量审批接口改用 batch_key (request_no LIKE 前缀)
- 前端列表批次号列、整批弹窗、一键审批本批均基于前缀推导
- 零数据库迁移,历史数据立即可用
This commit is contained in:
yueli
2026-08-31 13:49:11 +08:00
parent 664127ee30
commit f499346f74
5 changed files with 40 additions and 63 deletions

View File

@ -158,18 +158,18 @@ def get_purchase_detail(purchase_id):
# -------------------------------------------------------- # --------------------------------------------------------
# 3.5 按批次查询采购申请(同一批提交的多条记录) # 3.5 按批次查询采购申请(同一批提交的多条记录,批次=request_no去掉末尾流水号)
# GET /api/v1/purchase/batch/<batch_no> # GET /api/v1/purchase/batch/<batch_key>
# -------------------------------------------------------- # --------------------------------------------------------
@purchase_bp.route('/batch/<path:batch_no>', methods=['GET']) @purchase_bp.route('/batch/<path:batch_key>', methods=['GET'])
@jwt_required() @jwt_required()
def get_purchase_by_batch(batch_no): def get_purchase_by_batch(batch_key):
"""按批次号查询同一批提交的所有采购申请记录""" """按批次前缀(request_no 去掉末尾4位流水号)查询同一批的所有记录"""
try: try:
from app.models.purchase import PurchaseRequest from app.models.purchase import PurchaseRequest
records = PurchaseRequest.query.filter( records = PurchaseRequest.query.filter(
PurchaseRequest.batch_no == batch_no PurchaseRequest.request_no.like(f"{batch_key}-%")
).order_by(PurchaseRequest.id.asc()).all() ).order_by(PurchaseRequest.request_no.asc()).all()
user_id = get_current_user_id() user_id = get_current_user_id()
has_perm = _user_has_purchase_perm() has_perm = _user_has_purchase_perm()
@ -183,7 +183,7 @@ def get_purchase_by_batch(batch_no):
d.pop(k, None) d.pop(k, None)
items.append(d) items.append(d)
return jsonify({'code': 200, 'msg': '获取成功', 'data': {'batch_no': batch_no, 'items': items}}), 200 return jsonify({'code': 200, 'msg': '获取成功', 'data': {'batch_key': batch_key, 'items': items}}), 200
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500 return jsonify({'code': 500, 'msg': str(e)}), 500
@ -248,7 +248,7 @@ def batch_approve_purchase():
{ {
"ids": [1, 2, 3], // 按 ID 列表审批 "ids": [1, 2, 3], // 按 ID 列表审批
或 或
"batch_no": "BATCH-xxx", // 按批次号审批整批(同一批提交的记录) "batch_key": "PUR-20260831-1021", // 按批次前缀审批整批(同批流水号连续)
"action": "approve" | "reject", "action": "approve" | "reject",
"reject_reason": "驳回原因(reject 时必填)" "reject_reason": "驳回原因(reject 时必填)"
} }
@ -257,15 +257,15 @@ def batch_approve_purchase():
user_id = get_current_user_id() user_id = get_current_user_id()
data = request.get_json() or {} data = request.get_json() or {}
ids = data.get('ids', []) ids = data.get('ids', [])
batch_no = data.get('batch_no') batch_key = data.get('batch_key')
action = data.get('action', 'approve') action = data.get('action', 'approve')
reject_reason = data.get('reject_reason') reject_reason = data.get('reject_reason')
# 若传 batch_no,则查出该批次的所有待审批记录 ID # 若传 batch_key(request_no 去掉末尾流水号的前缀),查出该批次所有待审批记录
if batch_no: if batch_key:
from app.models.purchase import PurchaseRequest from app.models.purchase import PurchaseRequest
records = PurchaseRequest.query.filter( records = PurchaseRequest.query.filter(
PurchaseRequest.batch_no == batch_no, PurchaseRequest.request_no.like(f"{batch_key}-%"),
PurchaseRequest.status == 0 PurchaseRequest.status == 0
).all() ).all()
ids = [r.id for r in records] ids = [r.id for r in records]
@ -273,7 +273,7 @@ def batch_approve_purchase():
return jsonify({'code': 400, 'msg': '该批次没有待审批的记录'}), 400 return jsonify({'code': 400, 'msg': '该批次没有待审批的记录'}), 400
if not ids or not isinstance(ids, list): if not ids or not isinstance(ids, list):
return jsonify({'code': 400, 'msg': 'ids 或 batch_no 不能为空'}), 400 return jsonify({'code': 400, 'msg': 'ids 或 batch_key 不能为空'}), 400
if action not in ('approve', 'reject'): if action not in ('approve', 'reject'):
return jsonify({'code': 400, 'msg': '无效的审批操作'}), 400 return jsonify({'code': 400, 'msg': '无效的审批操作'}), 400
if action == 'reject' and not reject_reason: if action == 'reject' and not reject_reason:

View File

@ -11,8 +11,6 @@ class PurchaseRequest(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
request_no = db.Column(db.String(100), unique=True, nullable=False, index=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') base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), index=True, comment='关联基础物料ID')
name = db.Column(db.String(255), nullable=False, comment='采购名称') name = db.Column(db.String(255), nullable=False, comment='采购名称')
@ -81,7 +79,6 @@ class PurchaseRequest(db.Model):
return { return {
'id': self.id, 'id': self.id,
'request_no': self.request_no, 'request_no': self.request_no,
'batch_no': self.batch_no or '',
'base_id': self.base_id, 'base_id': self.base_id,
'name': self.name, 'name': self.name,
'spec_model': self.spec_model or '', 'spec_model': self.spec_model or '',

View File

@ -20,18 +20,6 @@ class PurchaseService:
.filter(PurchaseRequest.request_no.like(f"{prefix}%")).scalar() .filter(PurchaseRequest.request_no.like(f"{prefix}%")).scalar()
return f"{prefix}{(existing_count + 1):04d}" 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 @staticmethod
def auto_fill_from_material(keyword: str): def auto_fill_from_material(keyword: str):
""" """
@ -79,12 +67,8 @@ class PurchaseService:
if material: if material:
base_id = material.id base_id = material.id
# [新增] 批次号:前端同一批循环提交时传入同一 batch_no,无则自动生成
batch_no = data.get('batch_no') or PurchaseService.generate_batch_no()
purchase = PurchaseRequest( purchase = PurchaseRequest(
request_no=request_no, request_no=request_no,
batch_no=batch_no, # [新增]
base_id=base_id, # [新增] base_id=base_id, # [新增]
name=data['name'], name=data['name'],
spec_model=data.get('spec_model', ''), spec_model=data.get('spec_model', ''),

View File

@ -90,7 +90,7 @@ export function approvePurchase(id: number, data: {
// 批量审批采购申请(循环提交产生的多条记录可一次审批) // 批量审批采购申请(循环提交产生的多条记录可一次审批)
export function batchApprovePurchase(data: { export function batchApprovePurchase(data: {
ids?: number[] ids?: number[]
batch_no?: string batch_key?: string
action: 'approve' | 'reject' action: 'approve' | 'reject'
reject_reason?: string reject_reason?: string
}) { }) {
@ -101,10 +101,10 @@ export function batchApprovePurchase(data: {
}) })
} }
// 按批次号查询同批次的采购申请记录 // 按批次前缀查询同批次的采购申请记录(批次=request_no去掉末尾流水号)
export function getPurchaseByBatch(batchNo: string) { export function getPurchaseByBatch(batchKey: string) {
return request({ return request({
url: `/purchase/batch/${encodeURIComponent(batchNo)}`, url: `/purchase/batch/${encodeURIComponent(batchKey)}`,
method: 'get' method: 'get'
}) })
} }

View File

@ -52,11 +52,11 @@
</el-table-column> </el-table-column>
<el-table-column prop="requester_name" label="申请人" width="100" /> <el-table-column prop="requester_name" label="申请人" width="100" />
<el-table-column prop="approver_name" label="审批人" width="100" /> <el-table-column prop="approver_name" label="审批人" width="100" />
<!-- ★ 批次号列:同一批提交的记录共享,可点击查看整批 --> <!-- ★ 批次号列:同一批提交的记录共享(request_no 去掉末尾流水号),可点击查看整批 -->
<el-table-column label="批次号" width="170"> <el-table-column label="批次号" width="170">
<template #default="{ row }"> <template #default="{ row }">
<el-link v-if="row.batch_no" type="primary" :underline="false" @click="openBatchDialog(row.batch_no)"> <el-link v-if="getBatchKey(row.request_no)" type="primary" :underline="false" @click="openBatchDialog(row.request_no)">
{{ row.batch_no }} {{ getBatchKey(row.request_no) }}
</el-link> </el-link>
<span v-else style="color:#ccc">-</span> <span v-else style="color:#ccc">-</span>
</template> </template>
@ -69,7 +69,7 @@
<el-table-column label="操作" width="240" fixed="right" align="center"> <el-table-column label="操作" width="240" fixed="right" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" link size="small" @click="openDetailDialog(row)">详情</el-button> <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> <el-button v-if="getBatchKey(row.request_no)" type="info" link size="small" @click="openBatchDialog(row.request_no)">整批</el-button>
<template v-if="row.status === 0 && canApprove"> <template v-if="row.status === 0 && canApprove">
<el-button type="success" link size="small" @click="handleApprove(row)">通过</el-button> <el-button type="success" link size="small" @click="handleApprove(row)">通过</el-button>
<el-button type="danger" link size="small" @click="openRejectDialog(row)">驳回</el-button> <el-button type="danger" link size="small" @click="openRejectDialog(row)">驳回</el-button>
@ -286,7 +286,7 @@
<el-dialog v-model="batchDialogVisible" title="采购批次详情" width="900px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false"> <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;"> <div style="margin-bottom: 12px;">
<span style="font-weight: 600;">批次号:</span> <span style="font-weight: 600;">批次号:</span>
<el-tag style="margin-left: 6px;">{{ currentBatchNo }}</el-tag> <el-tag style="margin-left: 6px;">{{ currentBatchKey }}</el-tag>
<span style="margin-left: 12px; color: #909399;">共 {{ batchItems.length }} 条采购记录</span> <span style="margin-left: 12px; color: #909399;">共 {{ batchItems.length }} 条采购记录</span>
<el-button <el-button
v-if="canApprove && batchItems.some(i => i.status === 0)" v-if="canApprove && batchItems.some(i => i.status === 0)"
@ -456,25 +456,26 @@ const detail = ref<any>({})
// ★ 整批查看 // ★ 整批查看
const batchDialogVisible = ref(false) const batchDialogVisible = ref(false)
const currentBatchNo = ref('') const currentBatchKey = ref('')
const batchItems = ref<any[]>([]) const batchItems = ref<any[]>([])
// 打开整批查看弹窗 // 打开整批查看弹窗(传入任一 request_no,推导批次前缀)
const openBatchDialog = async (batchNo: string) => { const openBatchDialog = async (requestNo: string) => {
currentBatchNo.value = batchNo const batchKey = getBatchKey(requestNo)
currentBatchKey.value = batchKey
batchItems.value = [] batchItems.value = []
batchDialogVisible.value = true batchDialogVisible.value = true
try { try {
const res: any = await getPurchaseByBatch(batchNo) const res: any = await getPurchaseByBatch(batchKey)
batchItems.value = res.data?.items || [] batchItems.value = res.data?.items || []
} catch (err: any) { } catch (err: any) {
ElMessage.error(err?.msg || '加载批次记录失败') ElMessage.error(err?.msg || '加载批次记录失败')
} }
} }
// 批量通过本批(按批次号) // 批量通过本批(按批次前缀)
const handleBatchApproveByNo = async () => { const handleBatchApproveByNo = async () => {
if (!currentBatchNo.value) return if (!currentBatchKey.value) return
const pendingCount = batchItems.value.filter(i => i.status === 0).length const pendingCount = batchItems.value.filter(i => i.status === 0).length
if (pendingCount === 0) return ElMessage.warning('本批没有待审批记录') if (pendingCount === 0) return ElMessage.warning('本批没有待审批记录')
try { try {
@ -487,9 +488,9 @@ const handleBatchApproveByNo = async () => {
batchLoading.value = true batchLoading.value = true
try { try {
const res: any = await batchApprovePurchase({ batch_no: currentBatchNo.value, action: 'approve' }) const res: any = await batchApprovePurchase({ batch_key: currentBatchKey.value, action: 'approve' })
ElMessage.success(res?.msg || '本批已全部通过') ElMessage.success(res?.msg || '本批已全部通过')
await openBatchDialog(currentBatchNo.value) // 刷新整批数据 await openBatchDialog(batchItems.value[0]?.request_no || currentBatchKey.value) // 刷新整批数据
await fetchData() await fetchData()
} catch (err: any) { } catch (err: any) {
ElMessage.error(err?.msg || '批量通过失败') ElMessage.error(err?.msg || '批量通过失败')
@ -894,14 +895,12 @@ const syncRemarkToAll = () => {
ElMessage.success('已将第一行备注同步到所有行') ElMessage.success('已将第一行备注同步到所有行')
} }
// ★ 生成采购批次号(与后端 generate_batch_no 格式一致) // ★ 推导批次前缀:request_no 去掉末尾"-流水号"(如 PUR-20260831-1021-0001 → PUR-20260831-1021)
const generateBatchNo = () => { const getBatchKey = (requestNo: string): string => {
const now = new Date() if (!requestNo) return ''
const pad = (n: number) => String(n).padStart(2, '0') // 去掉末尾的 -0001 段(最后5个字符:- + 4位流水)
const dateStr = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` const idx = requestNo.lastIndexOf('-')
const timeStr = `${pad(now.getHours())}${pad(now.getMinutes())}` return idx > 0 ? requestNo.slice(0, idx) : requestNo
const rand = Math.random().toString(16).slice(2, 6).toUpperCase()
return `BATCH-${dateStr}-${timeStr}-${rand}`
} }
// ★ 照片一键同步所有行:取第一行的照片深拷贝到所有行 // ★ 照片一键同步所有行:取第一行的照片深拷贝到所有行
@ -952,15 +951,12 @@ const submitForm = async () => {
} }
} }
// 3. 逐行提交(每行携带公共信息 + 同一批次号) // 3. 逐行提交(每行携带公共信息)
submitLoading.value = true submitLoading.value = true
let successCount = 0 let successCount = 0
// ★ 生成批次号:同一批提交的所有记录共享,便于审批端按批次查看/统计
const batchNo = generateBatchNo()
try { try {
for (const row of validItems) { for (const row of validItems) {
const payload: any = { const payload: any = {
batch_no: batchNo,
name: row.name, name: row.name,
spec_model: row.spec_model, spec_model: row.spec_model,
quantity: row.quantity, quantity: row.quantity,