Compare commits
7 Commits
a2d3a2c43a
...
aa3a470d67
| Author | SHA1 | Date | |
|---|---|---|---|
| aa3a470d67 | |||
| ee22927660 | |||
| c3637e0c25 | |||
| 8fd4a2ae53 | |||
| 4e09860b36 | |||
| 8c554fdd3b | |||
| fbb4160787 |
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(' | ')
|
||||
|
||||
@ -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 = ''
|
||||
|
||||
@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user