diff --git a/inventory-web/src/api/inbound/return.ts b/inventory-web/src/api/inbound/return.ts index 0d13310..fb8e46e 100644 --- a/inventory-web/src/api/inbound/return.ts +++ b/inventory-web/src/api/inbound/return.ts @@ -71,15 +71,26 @@ export function restockDefective(id: number, data: { restock_qty?: number; remar } /** - * 报废销毁:在管坏件确认无法维修时销毁。 + * 提交在管坏件的**报废申请**(需审批人审批,通过后由库管执行报废)。 + * + * ★ 已由「直接报废」改为「申请」: + * 报废一律需审批(后端 SCRAP_ALWAYS_REQUIRES_APPROVAL)。原 + * POST /defective//scrap 直接写 trans_scrap 并扣在管量,绕过审批, + * 构成职责分离漏洞(同一库管可自行宣告实物销毁而无人复核),已删除。 + * + * ★ 副作用提醒:申请**不预占**在管量,`remaining_qty` 提交后不变。 + * 前端必须提示用户「待审批并执行后才会扣减」,否则会诱发重复提交。 * * @param id 在管台账 id - * @param data { scrap_qty?: number, reason: string } - * reason 必填;scrap_qty 缺省 = 全部剩余在管量 + * @param data { scrap_qty?: number, reason?: string, approver_id: number } + * scrap_qty 缺省 = 全部剩余在管量;approver_id 必填 */ -export function scrapDefective(id: number, data: { scrap_qty?: number; reason: string }) { +export function submitDefectiveScrapRequest( + id: number, + data: { scrap_qty?: number; reason?: string; approver_id: number }, +) { return request({ - url: `/inbound/stock/defective/${id}/scrap`, + url: `/inbound/stock/defective/${id}/scrap-request`, method: 'post', data }) diff --git a/inventory-web/src/api/scrap.ts b/inventory-web/src/api/scrap.ts index 8e1b1fe..468f1a1 100644 --- a/inventory-web/src/api/scrap.ts +++ b/inventory-web/src/api/scrap.ts @@ -9,14 +9,11 @@ export function scanBarcode(barcode: string) { }) } -// 2. 提交报废单 -export function createScrap(data: any) { - return request({ - url: '/v1/scrap', - method: 'post', - data - }) -} +// [已移除] 直接报废 createScrap —— 对应后端 POST /api/v1/scrap,该接口绕过 +// 审批流直接写 trans_scrap 并扣库存,与系统自陈的「报废一律需审批」规则冲突, +// 构成职责分离漏洞(同一库管可自行宣告实物销毁而无人复核),已整体删除。 +// 所有报废改走:submitScrapRequest → approveScrapRequest → executeScrapByRequest。 +// 该函数此前已是死代码(全仓无调用方),删除无行为影响。 // 3. 报废记录列表查询 export function getScrapRecords(params: any) { diff --git a/inventory-web/src/api/transaction.ts b/inventory-web/src/api/transaction.ts index 65411c6..1d4d277 100644 --- a/inventory-web/src/api/transaction.ts +++ b/inventory-web/src/api/transaction.ts @@ -230,4 +230,30 @@ export function dispatchBorrow(data: { method: 'post', data }) +} + +/** + * 提交「借出未归还」的**报废申请**(需审批人审批,通过后由库管执行报废)。 + * + * ★ 已由「直接报废」改为「申请」: + * 原 POST /v1/transactions/borrow/scrap 直接写 trans_scrap 并扣总库存, + * 绕过审批、构成职责分离漏洞,已删除。现统一走 申请 → 审批 → 执行。 + * + * ★ 副作用提醒:申请**不立即改变**借用记录状态,提交后该笔仍显示为未归还。 + * 前端必须提示用户「待审批并执行后才会标记为已报废」。 + * + * @param data record_ids: trans_borrow.id 列表(按整条待还量报废) + * reason?: 写入申请单备注 + * approver_id: 必填,指定审批人 + */ +export function submitBorrowScrapRequest(data: { + record_ids: number[] + reason?: string + approver_id: number +}) { + return request({ + url: '/v1/transactions/borrow/scrap-request', + method: 'post', + data + }) } \ No newline at end of file diff --git a/inventory-web/src/views/operation/scrap/create.vue b/inventory-web/src/views/operation/scrap/create.vue index de44813..a2918db 100644 --- a/inventory-web/src/views/operation/scrap/create.vue +++ b/inventory-web/src/views/operation/scrap/create.vue @@ -76,7 +76,43 @@ -
+ +
+ + 以下明细的实物不在仓库内(借出未还),无需扫码, + 执行时将按批准数量直接核销。 + + + + + + + + + + + + +
+ + +
+ +
+ +
@@ -295,28 +331,49 @@ const selectedRequestId = computed({ const plannedItems = computed(() => selectedRequest.value?.items ?? []) -// 扫码进度 +// ============================================================ +// ★ 按执行模式分流(与后端 scrap_mode 对齐) +// +// scan —— 需扫码:库存行、在管不良品(实物在库、有可扫标识) +// auto —— 免扫码:借出未还(东西在借用人手上,物理上不可能扫), +// 执行时按批准数量直接核销 +// +// 存量单据没有 scrap_mode 字段,回落为 scan —— 行为与改造前完全一致。 +// ============================================================ +const modeOf = (it: any) => (it?.scrap_mode === 'auto' ? 'auto' : 'scan') +const scanItems = computed(() => plannedItems.value.filter((it: any) => modeOf(it) === 'scan')) +const autoItems = computed(() => plannedItems.value.filter((it: any) => modeOf(it) === 'auto')) + +// 扫码进度 —— ★ 只统计需扫码的项。 +// 若把 auto 项也算进来,进度条永远达不到 100%,会让操作员误以为「没扫完」。 const scanTotalQty = computed(() => - plannedItems.value.reduce((sum: number, it: any) => sum + (Number(it.scrap_qty) || 0), 0) + scanItems.value.reduce((sum: number, it: any) => sum + (Number(it.scrap_qty) || 0), 0) ) const scanScannedQty = computed(() => cartItems.value.reduce((sum: number, it: any) => sum + (Number(it.scrap_quantity) || 0), 0) ) const scanScannedTypes = computed(() => cartItems.value.length) -const scanTotalTypes = computed(() => plannedItems.value.length) +const scanTotalTypes = computed(() => scanItems.value.length) // ============================================================ // ★ SKU 主键匹配(与后端 _match_key 完全同口径) // -// SKU 在同一库存表内唯一、且不存在跨表重名,故以 SKU 作为扫码匹配的唯一标识。 +// ★ 匹配键必须带来源表。原实现只用 SKU,其前提「SKU 跨表无重名」在引入 +// 逆向物流后不再成立:在管不良品台账的 SKU 是从原库存行**复制**的, +// 故 trans_defective_goods#N 与其源 stock_buy#M 的 SKU 必然相同。 +// 若同一张单同时含两者,不带来源就会串键 —— 扫码量会算到错误对象上。 +// 纯库存单两侧同源、键仍匹配,对既有流程零行为变更。 +// // 个别历史库存行 SKU 为空,这类行回退为 source_table + stock_id 复合键; -// 前缀区分(sku: / row:)确保空 SKU 行绝不会与正常 SKU 串键。 +// 前缀区分(sku: / row:)确保两种键绝不会互相串。 // ============================================================ const normSku = (sku: any) => String(sku ?? '').trim() const matchKey = (sourceTable: any, stockId: any, sku: any) => { const s = normSku(sku) - return s ? `sku:${s}` : `row:${String(sourceTable)}#${String(stockId)}` + return s + ? `sku:${String(sourceTable)}:${s}` + : `row:${String(sourceTable)}#${String(stockId)}` } // 按匹配键找批准明细行 @@ -396,7 +453,14 @@ const unscannedList = computed(() => const unscannedCount = computed(() => unscannedList.value.length) const sourceLabel = (st: string) => - ({ stock_buy: '采购件', stock_semi: '半成品', stock_product: '成品' } as any)[st] || st || '-' + ({ + stock_buy: '采购件', + stock_semi: '半成品', + stock_product: '成品', + // 逆向物流两类来源(与后端 scrap_sources 的 label 对齐) + trans_defective_goods: '在管不良品', + trans_borrow: '借出未还', + } as any)[st] || st || '-' // --- 加载已批准的报废申请单 --- const loadApprovalRequests = async () => { @@ -588,7 +652,11 @@ const submitForm = async () => { return } if (!selectedRequest.value) return ElMessage.warning('请先选择要执行的报废申请单') - if (cartItems.value.length === 0) return ElMessage.warning('请先扫码添加报废物料') + // ★ 守卫放宽为「购物车与免扫码明细都为空才拦」—— + // 纯免扫码单(借出未还)不存在可扫的实物,购物车恒为空。 + if (cartItems.value.length === 0 && autoItems.value.length === 0) { + return ElMessage.warning('请先扫码添加报废物料') + } // 逐项复核批准量 for (const item of cartItems.value) { @@ -603,8 +671,14 @@ const submitForm = async () => { } try { + // ★ 确认框必须点明「另有 N 项免扫码项将一并执行」—— + // 否则操作员会以为只报废了购物车里扫到的那些,导致误判。 + const autoNote = autoItems.value.length > 0 + ? `\n另有 ${autoItems.value.length} 项(借出未还)将按批准数量自动执行,无需扫码。` + : '' await ElMessageBox.confirm( - `确认按实扫数量执行报废吗?\n申请单:${selectedRequest.value.request_no}\n共 ${cartItems.value.length} 项 / ${scanScannedQty.value} 件\n\n执行后不可撤销。`, + `确认按实扫数量执行报废吗?\n申请单:${selectedRequest.value.request_no}\n` + + `共 ${cartItems.value.length} 项 / ${scanScannedQty.value} 件${autoNote}\n\n执行后不可撤销。`, '执行确认', { confirmButtonText: '确认执行', cancelButtonText: '取消', type: 'warning' } ) diff --git a/inventory-web/src/views/stock/defective/index.vue b/inventory-web/src/views/stock/defective/index.vue index 2818ed2..09a8ddc 100644 --- a/inventory-web/src/views/stock/defective/index.vue +++ b/inventory-web/src/views/stock/defective/index.vue @@ -132,7 +132,7 @@ type="danger" link size="small" @click="openScrapDialog(row)" > - 报废销毁 + 申请报废 无处置权限 @@ -198,11 +198,11 @@ - + @@ -230,17 +230,29 @@ :rows="3" maxlength="200" show-word-limit - placeholder="请填写报废原因(必填)" + placeholder="请填写报废原因(必填,审批人据此判断)" /> + + + + + -
- 报废后实物视为已销毁,不可撤销;同时会写入报废台账。 + +
+ 提交后进入审批流程,在管数量不会立即扣减; + 待审批人通过、库管执行报废后才会扣减并写入报废台账。
@@ -250,7 +262,8 @@