7 Commits

Author SHA1 Message Date
aa3a470d67 deploy: MOM prod 补充 Track 联动环境变量与网络互通
- 补 TRACK_WEBHOOK_URL/KEY、TRACK_API_URL、TRACK_OUTBOUND_WEBHOOK_URL(容器名 track_backend_prod)
- backend 加入 mom_net 外部网络(inventory-app_default)与 Track prod 互通
2026-09-01 18:02:38 +08:00
ee22927660 fix: 解耦 Webhook 身份证与库存序列号,恢复批号入库纯净
- 前端:新增临时字段 form.track_id 存 16位身份证,Batch 时 form.serial_number 清空
- 前端:submitForm 不再把身份证补进 payload.serial_number,改为透传 payload.track_id
- 后端:StockSemi 仅存 serial_number(Batch 为空),notify_track 优先用 track_id
2026-09-01 16:44:48 +08:00
c3637e0c25 fix: 出库备注欠料改用实时库存计算,不再自动填历史欠料
- buildRemarkWithShortage 改用 bomDetailList 实时 shortage(需量-当前库存)
- 库存补足后,欠料不再自动拼进出库备注(原 localStorage 历史记录一直带出)
2026-09-01 16:35:32 +08:00
8fd4a2ae53 style: 采购申请'完结本批'按钮样式统一为出库审批的 plain 实心样式
- 去掉 link 文字样式,改用 type=danger plain size=small,与出库审批完结按钮一致
2026-09-01 16:23:44 +08:00
4e09860b36 feat: 采购申请完结功能移到批次主行,与出库审批一致
- 移除展开行内的单条完结按钮(需点开),改为主行'完结本批'直接可见
- 后端 batch-approve 支持 action=close(批次内已通过→已完结)
- 超管/库管在主行操作列即可看到并完结
2026-09-01 16:21:48 +08:00
8c554fdd3b fix: 采购申请完结按钮不显示——v-else-if 被注释打断关联
- v-if 与 v-else-if 之间的 HTML 注释破坏 Vue 关联,导致完结按钮不渲染
- 移除注释,超管/库管在'已通过'单均显示完结按钮
2026-09-01 16:18:07 +08:00
fbb4160787 feat: 采购申请新增库管'完结'功能(仿出库审批)
- 后端:approve 接口支持 action=close(已通过→已完结 status 1→4),模型 status_text 支持已完结
- 前端:采购申请状态tab加'已完结',已通过单显示'完结'按钮(库管)
2026-09-01 16:15:48 +08:00
8 changed files with 115 additions and 35 deletions

View File

@ -37,8 +37,18 @@ services:
MAIL_DEFAULT_SENDER: wms@iris-rs.cn
MAIL_USE_SSL: "true"
MAIL_USE_TLS: "false"
# Track 系统联动(扫码入库/出库/Webhook 通知)
# ★ 生产用容器名 track_backend_prod 访问(MOM 与 Track 同处 mom_net 外部网络)
TRACK_WEBHOOK_URL: ${TRACK_WEBHOOK_URL:-http://track_backend_prod:8000/api/v1/external/webhooks/mom-inbound}
TRACK_WEBHOOK_KEY: ${TRACK_WEBHOOK_KEY:-2ce5fedb48fde3fd7e0abf67472a5027b03e9ae6f19cf768}
TRACK_API_URL: ${TRACK_API_URL:-http://track_backend_prod:8000}
TRACK_OUTBOUND_WEBHOOK_URL: ${TRACK_OUTBOUND_WEBHOOK_URL:-http://track_backend_prod:8000/api/v1/external/webhooks/mom-outbound}
depends_on:
- db
# 加入 mom_net 外部网络,与 Track 生产容器互通
networks:
- default
- mom_net
# --- 前端 (Nginx + Vue) (包含 HTTPS 配置) ---
frontend:
@ -57,4 +67,10 @@ services:
- ./ssl/nginx.crt:/etc/nginx/ssl/nginx.crt
- ./ssl/nginx.key:/etc/nginx/ssl/nginx.key
depends_on:
- backend
- backend
networks:
# 与 Track 生产容器共享的外部网络(Track prod 的 mom_net 也叫这个名字)
mom_net:
external: true
name: inventory-app_default

View File

@ -206,7 +206,7 @@ def approve_purchase_request(purchase_id):
action = data.get('action', 'approve')
reject_reason = data.get('reject_reason')
if action not in ('approve', 'reject'):
if action not in ('approve', 'reject', 'close'):
return jsonify({'code': 400, 'msg': '无效的审批操作'}), 400
if action == 'reject' and not reject_reason:
@ -219,7 +219,7 @@ def approve_purchase_request(purchase_id):
reject_reason=reject_reason
)
msg = '审批通过' if action == 'approve' else '已驳回'
msg = '审批通过' if action == 'approve' else ('已驳回' if action == 'reject' else '已完结')
# ★ Fail-Closed: 审批响应剥离价格字段
resp = purchase.to_dict()
for k in ('unit_price', 'total_price', 'tax_rate'):
@ -273,20 +273,23 @@ def batch_approve_purchase():
action = data.get('action', 'approve')
reject_reason = data.get('reject_reason')
# 若传 batch_key(request_no 去掉末尾流水号的前缀),查出该批次所有待审批记录
# 若传 batch_key(request_no 去掉末尾流水号的前缀),查出该批次对应状态的记录
if batch_key:
from app.models.purchase import PurchaseRequest
# 完结本批 → 找「已通过(1)」的单;审批/驳回 → 找「待审批(0)」的单
target_status = 1 if action == 'close' else 0
records = PurchaseRequest.query.filter(
PurchaseRequest.request_no.like(f"{batch_key}-%"),
PurchaseRequest.status == 0
PurchaseRequest.status == target_status
).all()
ids = [r.id for r in records]
if not ids:
return jsonify({'code': 400, 'msg': '该批次没有待审批的记录'}), 400
status_text = '已通过' if action == 'close' else '待审批'
return jsonify({'code': 400, 'msg': f'该批次没有{status_text}的记录'}), 400
if not ids or not isinstance(ids, list):
return jsonify({'code': 400, 'msg': 'ids 或 batch_key 不能为空'}), 400
if action not in ('approve', 'reject'):
if action not in ('approve', 'reject', 'close'):
return jsonify({'code': 400, 'msg': '无效的审批操作'}), 400
if action == 'reject' and not reject_reason:
return jsonify({'code': 400, 'msg': '驳回时必须提供原因'}), 400

View File

@ -91,7 +91,7 @@ class PurchaseRequest(db.Model):
'total_price': float(self.total_price) if self.total_price else 0,
'tax_rate': float(self.tax_rate) if self.tax_rate else 0,
'status': self.status,
'status_text': ['待审批', '已通过', '已驳回', '已完成'][self.status] if self.status in [0, 1, 2, 3] else '未知',
'status_text': ['待审批', '已通过', '已驳回', '已完成', '已完结'][self.status] if self.status in [0, 1, 2, 3, 4] else '未知',
'requester_id': self.requester_id,
'requester_name': self._get_user_name(self.requester_id),
'requester_email': self._get_user_email(self.requester_id),

View File

@ -252,11 +252,13 @@ class SemiInboundService:
)
# 真实扫码入库成功,异步通知 Track 系统(Webhook)
# ★ 优先用前端透传的 track_id(16位身份证)通知 Track,绝不写入库存序列号(保证批号入库纯净)
webhook_sn = (data.get('track_id') or '').strip() or (new_stock.serial_number or '')
notify_track({
'event': 'inbound.created',
'source_table': 'stock_semi',
'sku': material.spec_model or material.name,
'serial_number': new_stock.serial_number,
'serial_number': webhook_sn,
'quantity': float(new_stock.in_quantity or 0),
'operator': get_current_operator(),
})

View File

@ -145,12 +145,22 @@ class PurchaseService:
if not purchase:
raise ValueError("采购申请不存在")
if purchase.status != 0:
raise ValueError("当前状态不允许审批")
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz)
# ★ 完结:库管将「已通过(1)」的申请单置为「已完结(4)」(仿出库审批)
if action == 'close':
if purchase.status != 1:
raise ValueError("仅「已通过」的采购申请可完结")
purchase.status = 4
purchase.approver_id = user_id
purchase.approved_at = now
db.session.commit()
return purchase
if purchase.status != 0:
raise ValueError("当前状态不允许审批")
if action == 'approve':
purchase.status = 1
purchase.approver_id = user_id

View File

@ -937,23 +937,16 @@ const confirmBomAdd = async () => {
const BOM_SHORTAGE_KEY = 'MOM_BOM_SHORTAGE_RECORDS'
const bomShortageRecords = ref<Record<string, any>>(JSON.parse(localStorage.getItem(BOM_SHORTAGE_KEY) || '{}'))
// ★ 将缺货清单拼接进备注(借鸡生蛋:历史缺货记录永久留存在出库申请单备注)
// ★ 将缺货清单拼接进备注(仅拼当前实际缺货的物料,库存补足后不再出现)
const buildRemarkWithShortage = (baseRemark: string): string => {
const parts: string[] = []
if (baseRemark) parts.push(baseRemark)
const shortageBoms = Object.keys(bomShortageRecords.value)
if (shortageBoms.length > 0) {
const detail: string[] = []
for (const no of shortageBoms) {
const rec = bomShortageRecords.value[no]
if (!rec?.items?.length) continue
const items = rec.items.map((i: any) => `${i.name}(${i.shortage}个)`).join('、')
detail.push(`${no}: ${items}`)
}
if (detail.length > 0) {
parts.push(`[BOM欠料待补] ${detail.join(';')}`)
}
// ★ 用当前实时计算的缺货清单(需量 - 当前库存),而非 localStorage 历史记录
const shortageItems = bomDetailList.value.filter((i: any) => i.shortage > 0)
if (shortageItems.length > 0) {
const items = shortageItems.map((i: any) => `${i.name}(${i.shortage}个)`).join('、')
parts.push(`[BOM欠料待补] ${items}`)
}
return parts.join(' | ')

View File

@ -10,6 +10,7 @@
<el-radio-button :label="1">已通过</el-radio-button>
<el-radio-button :label="2">已驳回</el-radio-button>
<el-radio-button :label="3">已完成</el-radio-button>
<el-radio-button :label="4">已完结</el-radio-button>
</el-radio-group>
<el-button type="primary" :icon="Refresh" @click="fetchData">刷新</el-button>
<el-button type="success" :icon="Plus" @click="openCreateDialog">
@ -115,6 +116,10 @@
<el-button type="success" link size="small" :loading="batchLoading" @click="handleApproveBatch(row.batchKey)">审批本批</el-button>
<el-button type="danger" link size="small" :loading="batchLoading" @click="openRejectBatchDialog(row.batchKey)">驳回本批</el-button>
</template>
<!-- ★ 完结本批:批次内已通过的单,库管可直接完结(仿出库审批,主行可见,样式一致) -->
<template v-if="batchHasClosable(row) && canClose">
<el-button type="danger" plain size="small" :loading="batchLoading" @click="handleCloseBatch(row.batchKey)">完结本批</el-button>
</template>
</template>
</el-table-column>
</el-table>
@ -481,6 +486,34 @@ const handleApproveBatch = async (batchKey: string) => {
}
}
// ★ 批次内是否存在「已通过」的单(可完结)
const batchHasClosable = (row: any) => (row.items || []).some((it: any) => it.status === 1)
// ★ 完结本批:一键完结批次内所有「已通过」记录(仿出库审批)
const handleCloseBatch = async (batchKey: string) => {
const batch = list.value.find(b => b.batchKey === batchKey)
const closable = batch ? (batch.items || []).filter((it: any) => it.status === 1) : []
if (!closable.length) return ElMessage.warning('本批没有已通过记录可完结')
try {
await ElMessageBox.confirm(
`确定强制完结批次【${batchKey}】中 ${closable.length} 条已通过的记录吗?\n\n此操作不可恢复。`,
'⚠️ 完结本批确认',
{ confirmButtonText: '确认完结', cancelButtonText: '取消', type: 'warning' }
)
} catch { return }
batchLoading.value = true
try {
const res: any = await batchApprovePurchase({ batch_key: batchKey, action: 'close' })
ElMessage.success(res?.msg || '本批已完结')
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || err?.message || '完结失败')
} finally {
batchLoading.value = false
}
}
// ★ 驳回本批:批次内所有待审批记录统一驳回
const rejectBatchKey = ref('')
const rejectBatchDialogVisible = ref(false)
@ -703,10 +736,16 @@ const canApprove = computed(() => {
|| userStore.hasPermission('inbound_purchase:operation')
})
// ★ 完结权限:超级管理员 / 库管(采购操作权限)
const canClose = computed(() => {
return userStore.role === 'SUPER_ADMIN'
|| userStore.hasPermission('inbound_purchase:operation')
})
// --- 工具函数 ---
const statusTagType = (status: number) => {
const map: Record<number, string> = {
0: 'warning', 1: 'success', 2: 'danger', 3: 'info'
0: 'warning', 1: 'success', 2: 'danger', 3: 'info', 4: 'info'
}
return map[status] ?? 'info'
}
@ -1170,6 +1209,26 @@ const handleApprove = async (row: any) => {
}
}
// ★ 完结已通过的采购申请(库管)
const handleCloseRequest = async (row: any) => {
try {
await ElMessageBox.confirm(
`确定强制完结采购申请【${row.request_no}】吗?\n\n` +
`此操作将使该单从「已通过」列表中移除,且不可恢复。`,
'⚠️ 强制完结确认',
{ confirmButtonText: '确认完结', cancelButtonText: '取消', type: 'warning' }
)
} catch { return }
try {
await approvePurchase(row.id, { action: 'close' })
ElMessage.success(`采购申请 ${row.request_no} 已完结`)
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || err?.message || '完结失败')
}
}
const openRejectDialog = (row: any) => {
currentRejectRow.value = row
rejectReason.value = ''

View File

@ -983,7 +983,7 @@ const defaultColumns = ['company_name', 'material_name', 'spec_model', 'unit', '
const form = reactive({
id: undefined, base_id: undefined as number | undefined,
company_name: '',
material_name: '', spec_model: '', category: '', unit: '', material_type: '', sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', quality_status: '合格', in_quantity: 1, stock_quantity: 1, available_quantity: 1, print_copies: 1, warehouse_location: '', bom_code: '', bom_version: '', work_order_code: '',
material_name: '', spec_model: '', category: '', unit: '', material_type: '', sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', quality_status: '合格', in_quantity: 1, stock_quantity: 1, available_quantity: 1, print_copies: 1, warehouse_location: '', bom_code: '', bom_version: '', work_order_code: '', track_id: '',
raw_material_cost: undefined as number | undefined,
unit_total_cost: undefined as number | undefined,
total_price: undefined as number | undefined,
@ -1499,15 +1499,14 @@ const handleScannerConfirm = (result: string) => {
}
// 扫码获取的 Track 16 位身份证:SN 模式展示、Batch 模式随提交载荷供 Webhook 通知
let scannedTrackSerial = ''
// Track 扫码入库:扫码面板扫到身份证后,自动带出物料基础信息 + 序列号
const handleTrackScanFill = async (data: { track: any; material: any }) => {
const { track, material } = data
// 选择物料:触发原生联动(基础信息/历史库位/历史模式与批号自增)
await onMaterialSelected(material)
scannedTrackSerial = track.serial_number || ''
// ★ 16 位身份证存到临时字段 track_id(不进数据库,仅供 Webhook 通知 Track)
form.track_id = track.serial_number || ''
if (track.external_serial) {
// 序列号管理产品:切 SN,填真实外部业务序列号
entryMode.value = 'serial'
@ -1520,7 +1519,7 @@ const handleTrackScanFill = async (data: { track: any; material: any }) => {
modeLocked.value = false
// 批号兜底:无历史/历史为 SN 时确保有默认批号,避免阻断校验
if (!form.batch_number) form.batch_number = '000001'
// Batch 界面序列号留空(不显示身份证),身份证随提交载荷供 Webhook 精准通知 Track
// ★ 批号入库:序列号必须清空,绝不写入身份证,确保批号自增逻辑不受干扰
form.serial_number = ''
}
ElMessage.success('扫码入库信息已自动填充,请核对后补充库位/数量等')
@ -1560,10 +1559,8 @@ const submitForm = async () => {
production_end_time: form.production_time_range?.[1] || null
}
delete payload.production_time_range
// Batch 模式下序列号界面为空,补回 Track 身份证供 Webhook 精准通知 Track
if (entryMode.value === 'batch' && scannedTrackSerial && !payload.serial_number) {
payload.serial_number = scannedTrackSerial
}
// ★ 透传 Track 身份证到后端(仅 Webhook 通知 Track 用,绝不写入库存序列号,保证批号入库纯净)
if (form.track_id) payload.track_id = form.track_id
// Update 模式:无成本权限则剥离成本字段,防止库管修改已入库的成本数据
// Create 模式:保留成本字段,确保 BOM 自动计算的值能正确提交
if (dialogStatus.value === 'update') {