From 4340d03a709b42e28e89f6ec2e444cd53ca54291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=84=A6=E9=BE=99=E8=A8=80?= Date: Mon, 27 Jul 2026 10:12:05 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E8=A3=85=E5=A1=AB=E7=8A=B6=E6=80=81=E4=B8=8E=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/verify-business-contract.mjs | 116 +++++++++++++----- .../src/views/business/filling/index.vue | 35 ++++-- .../src/views/business/inbound/index.vue | 3 +- .../src/views/business/shipment/index.vue | 8 +- 4 files changed, 111 insertions(+), 51 deletions(-) diff --git a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs index 5e8e9b7..cf4ef7c 100644 --- a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs +++ b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs @@ -1075,6 +1075,13 @@ test('入库页面只允许 DRAFT 变更并以 authoritative 详情和独立确 assertButtonsUsePermission(source, 'handleConfirm', 'business:inbound:confirm') assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:inbound:query', 'business:inbound:add'\]\)/) assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:inbound:query', 'business:inbound:edit'\]\)/) + const confirmButton = extractButtonBlocks(source, 'handleConfirm')[0] + assert.match(confirmButton, /v-if="scope\.row\.status === 'DRAFT'"/) + assert.doesNotMatch(source, /canConfirmInbound/) + assert.doesNotMatch( + source, + /proxy\.\$auth\.hasPermiAnd\(\['business:inbound:query', 'business:inbound:confirm'\]\)/ + ) const addButton = extractButtonBlocks(source, 'handleAdd')[0] assert.match(addButton, /:disabled="[^"]*operationPending[^"]*optionLoading[^"]*detailLoading[^"]*open[^"]*"/) const addSource = extractNamedFunction(source, 'handleAdd') @@ -1143,6 +1150,7 @@ test('入库页面只允许 DRAFT 变更并以 authoritative 详情和独立确 assert.match(ensureSource, /createIdempotencyKey\(\)/) assert.match(ensureSource, /confirmIdempotency\.key/) const confirmSource = extractNamedFunction(source, 'handleConfirm') + assert.doesNotMatch(confirmSource, /canConfirmInbound|business:inbound:query/) assert.ok( confirmSource.indexOf('confirmLoading.value = true') < confirmSource.indexOf('proxy.$modal.confirm('), '入库确认必须在打开二次确认框前加锁' @@ -1155,6 +1163,16 @@ test('入库页面只允许 DRAFT 变更并以 authoritative 详情和独立确 assert.doesNotMatch(confirmCatch, /clearConfirmIdempotency\(\)[\s\S]*?(?:loadError|msgError)/) }) +test('装填扫码 pending 期间禁用全部业务输入', async () => { + const source = await readFile(path.join(frontendRoot, businessPageContracts.filling.relativePath), 'utf8') + + for (const field of ['strainId', 'batchNo', 'packageSpec', 'initialPackages']) { + const businessControl = source.match(new RegExp(`<[^>]+v-model="form\\.${field}"[^>]*>`))?.[0] + assert.ok(businessControl, `缺少装填 ${field} 业务控件`) + assert.match(businessControl, /:disabled="[^"]*scanLoading[^"]*"/, `装填扫码 pending 时必须禁用 ${field}`) + } +}) + test('装填页面锁定扫码上下文、校验正整数并在失败重试时保留幂等键', async () => { const source = await readFile(path.join(frontendRoot, businessPageContracts.filling.relativePath), 'utf8') @@ -1166,42 +1184,59 @@ test('装填页面锁定扫码上下文、校验正整数并在失败重试时 assert.match(source, /pallet\.palletCode \|\| `#\$\{pallet\.palletId\}`/) assert.match(source, /getFillingStrainOptions\(\)/) + const clearPayloadSource = extractNamedFunction(source, 'clearFillingPayload') + assert.match(clearPayloadSource, /clearFillingIdempotencyKey\(\)/) + assert.match(clearPayloadSource, /scannedPallet\.value = null/) + for (const [field, emptyValue] of [ + ['qrToken', "''"], + ['warehouseId', 'undefined'], + ['strainId', 'undefined'], + ['batchNo', "''"], + ['packageSpec', "''"], + ['initialPackages', 'undefined'] + ]) { + const fieldPattern = new RegExp(`(?:form\\.value\\.${field}\\s*=\\s*${emptyValue}|${field}:\\s*${emptyValue})`) + assert.match(clearPayloadSource, fieldPattern, `装填载荷清理必须重置 ${field}`) + } + assert.match(extractNamedFunction(source, 'resetFillingOperation'), /clearFillingPayload\(\)/) + const scanSource = extractNamedFunction(source, 'handleFillingScan') const scanCallIndex = scanSource.indexOf('scanFillingPallet(') assert.ok(scanCallIndex >= 0, '装填扫码必须调用 scanFillingPallet') const scanPrefix = scanSource.slice(0, scanCallIndex) - const clearKeyIndex = scanPrefix.indexOf('clearFillingIdempotencyKey()') + const clearPayloadIndex = scanPrefix.indexOf('clearFillingPayload()') const scanPendingIndex = scanPrefix.indexOf('scanLoading.value = true') assert.ok( - clearKeyIndex >= 0 && scanPendingIndex >= 0 && clearKeyIndex < scanPendingIndex, - '新装填扫码必须在进入 pending 前明确清除旧幂等键' + clearPayloadIndex >= 0 && scanPendingIndex >= 0 && clearPayloadIndex < scanPendingIndex, + '新装填扫码必须在进入 pending 前清除旧业务载荷' + ) + const scanSuccessSource = extractPromiseCallback(scanSource, 'then') + const scanSuccessGuardIndex = scanSuccessSource.indexOf('if (!isCurrentScanRequest(scanContext)) return') + const invalidStateIndex = scanSuccessSource.indexOf("if (pallet.flowState !== 'EMPTY_IN_WAREHOUSE')") + const invalidStateClearIndex = scanSuccessSource.indexOf('clearFillingPayload()', invalidStateIndex) + const invalidStateErrorIndex = scanSuccessSource.indexOf("scanError.value = '该托盘当前不可装填'", invalidStateIndex) + assert.ok( + scanSuccessGuardIndex >= 0 && + invalidStateIndex > scanSuccessGuardIndex && + invalidStateClearIndex > invalidStateIndex && + invalidStateErrorIndex > invalidStateClearIndex, + '不可装填响应必须在当前请求门禁后、写入错误状态前清除业务载荷' + ) + const scanCatchSource = extractPromiseCallback(scanSource, 'catch') + const scanCatchGuardIndex = scanCatchSource.indexOf('if (!isCurrentScanRequest(scanContext)) return') + const scanCatchClearIndex = scanCatchSource.indexOf('clearFillingPayload()') + const scanCatchErrorIndex = scanCatchSource.indexOf("scanError.value = '托盘扫码校验失败,请检查后重试'") + assert.ok( + scanCatchGuardIndex >= 0 && scanCatchClearIndex > scanCatchGuardIndex && scanCatchErrorIndex > scanCatchClearIndex, + '装填扫码异常必须在当前请求门禁后、写入错误状态前清除业务载荷' ) - assert.match(scanPrefix, /scannedPallet\.value = null/, '新装填扫码前必须清除旧托盘') - for (const [field, emptyValue] of [ - ['qrToken', "''"], - ['warehouseId', 'undefined'], - ['strainId', 'undefined'], - ['batchNo', "''"], - ['packageSpec', "''"], - ['initialPackages', 'undefined'] - ]) { - const fieldPattern = new RegExp(`(?:form\\.value\\.${field}\\s*=\\s*${emptyValue}|${field}:\\s*${emptyValue})`) - assert.match(scanPrefix, fieldPattern, `新装填扫码前必须清除旧 ${field}`) - } const invalidSource = extractNamedFunction(source, 'handleScanInvalid') - assert.match(invalidSource, /clearFillingIdempotencyKey\(\)/) - assert.match(invalidSource, /scannedPallet\.value = null/) - for (const [field, emptyValue] of [ - ['qrToken', "''"], - ['warehouseId', 'undefined'], - ['strainId', 'undefined'], - ['batchNo', "''"], - ['packageSpec', "''"], - ['initialPackages', 'undefined'] - ]) { - const fieldPattern = new RegExp(`(?:form\\.value\\.${field}\\s*=\\s*${emptyValue}|${field}:\\s*${emptyValue})`) - assert.match(invalidSource, fieldPattern, `装填扫码输入失败后必须清除旧 ${field}`) - } + const invalidClearIndex = invalidSource.indexOf('clearFillingPayload()') + const invalidErrorIndex = invalidSource.indexOf('scanError.value = message') + assert.ok( + invalidClearIndex >= 0 && invalidErrorIndex > invalidClearIndex, + '装填扫码输入失败后必须先清除业务载荷再展示错误' + ) const fillingSubmitButton = extractButtonBlocks(source, 'submitFilling')[0] assert.match(fillingSubmitButton, /:disabled="[^"]*!scannedPallet[^"]*"/) @@ -1225,6 +1260,18 @@ test('装填页面锁定扫码上下文、校验正整数并在失败重试时 assert.match(source, /watch\([\s\S]*?if \(!submitLoading\.value\) clearFillingIdempotencyKey\(\)/) }) +test('发货详情仅在 remainingPackages 缺失时回退快照并保留 null', async () => { + const source = await readFile(path.join(frontendRoot, businessPageContracts.shipment.relativePath), 'utf8') + const detailSuccessSource = extractPromiseCallback(extractNamedFunction(source, 'handleUpdate'), 'then') + + assert.match( + detailSuccessSource, + /remainingPackages:\s*item\.remainingPackages\s*===\s*undefined\s*\?\s*\(?\s*item\.packageQty\s*\?\?\s*null\s*\)?\s*:\s*item\.remainingPackages/, + '发货详情仅在 remainingPackages 缺失时回退确认快照 packageQty,并保留 null' + ) + assert.doesNotMatch(detailSuccessSource, /item\.remainingPackages\s*\?\?\s*item\.packageQty/) +}) + test('发货页面只允许 DRAFT 变更、展示只读余量并复用确认幂等键', async () => { const source = await readFile(path.join(frontendRoot, businessPageContracts.shipment.relativePath), 'utf8') @@ -1235,6 +1282,13 @@ test('发货页面只允许 DRAFT 变更、展示只读余量并复用确认幂 assertButtonsUsePermission(source, 'handleConfirm', 'business:shipment:confirm') assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:shipment:query', 'business:shipment:add'\]\)/) assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:shipment:query', 'business:shipment:edit'\]\)/) + const confirmButton = extractButtonBlocks(source, 'handleConfirm')[0] + assert.match(confirmButton, /v-if="scope\.row\.status === 'DRAFT'"/) + assert.doesNotMatch(source, /canConfirmShipment/) + assert.doesNotMatch( + source, + /proxy\.\$auth\.hasPermiAnd\(\['business:shipment:query', 'business:shipment:confirm'\]\)/ + ) const addButton = extractButtonBlocks(source, 'handleAdd')[0] assert.match(addButton, /:disabled="[^"]*operationPending[^"]*optionLoading[^"]*detailLoading[^"]*open[^"]*"/) const addSource = extractNamedFunction(source, 'handleAdd') @@ -1298,17 +1352,13 @@ test('发货页面只允许 DRAFT 变更、展示只读余量并复用确认幂 assert.match(source, /const detail = response\.data;/) assert.match(source, /\(Array\.isArray\(detail\.items\) \? detail\.items : \[\]\)\.map\(\(item\) => \(\{[\s\S]*?palletId: item\.palletId/) assert.match(source, /item\.palletCode \|\| `#\$\{item\.palletId\}`/) - assert.match( - detailSuccessSource, - /remainingPackages:\s*item\.remainingPackages\s*\?\?\s*item\.packageQty\s*\?\?\s*null/, - '发货详情必须优先使用当前余量,仅以确认快照 packageQty 回退' - ) assert.doesNotMatch(source, /remainingPackages\s*[-+]=|Math\.(?:max|min)\([^)]*remainingPackages/) assert.match(extractNamedFunction(source, 'handleShipmentScan'), /remainingPackages:\s*pallet\.remainingPackages/) const ensureSource = extractNamedFunction(source, 'ensureConfirmIdempotencyKey') assert.match(ensureSource, /createIdempotencyKey\(\)/) const confirmSource = extractNamedFunction(source, 'handleConfirm') + assert.doesNotMatch(confirmSource, /canConfirmShipment|business:shipment:query/) assert.ok( confirmSource.indexOf('confirmLoading.value = true') < confirmSource.indexOf('proxy.$modal.confirm('), '发货确认必须在打开二次确认框前加锁' diff --git a/shierfeng-fastapi-frontend/src/views/business/filling/index.vue b/shierfeng-fastapi-frontend/src/views/business/filling/index.vue index 3e0e931..af50178 100644 --- a/shierfeng-fastapi-frontend/src/views/business/filling/index.vue +++ b/shierfeng-fastapi-frontend/src/views/business/filling/index.vue @@ -98,7 +98,7 @@ filterable style="width: 100%" :loading="optionLoading" - :disabled="operationPending || optionLoading" + :disabled="operationPending || scanLoading || optionLoading" > @@ -125,7 +125,7 @@ maxlength="64" show-word-limit placeholder="请输入包装规格" - :disabled="operationPending" + :disabled="operationPending || scanLoading" /> @@ -136,7 +136,7 @@ :precision="0" controls-position="right" style="width: 100%" - :disabled="operationPending" + :disabled="operationPending || scanLoading" /> @@ -285,6 +285,19 @@ function clearFillingIdempotencyKey() { fillingIdempotencyKey.value = '' } +function clearFillingPayload() { + clearFillingIdempotencyKey() + scannedPallet.value = null + form.value = { + qrToken: '', + warehouseId: undefined, + strainId: undefined, + batchNo: '', + packageSpec: '', + initialPackages: undefined + } +} + function ensureFillingIdempotencyKey() { if (!fillingIdempotencyKey.value) { fillingIdempotencyKey.value = createIdempotencyKey() @@ -296,9 +309,7 @@ function resetFillingOperation() { dialogSession += 1 invalidateOptionRequest() invalidateScanRequest() - clearFillingIdempotencyKey() - scannedPallet.value = null - form.value = { qrToken: '', warehouseId: undefined, strainId: undefined, batchNo: '', packageSpec: '', initialPackages: undefined } + clearFillingPayload() optionError.value = '' scanError.value = '' formError.value = '' @@ -337,9 +348,7 @@ function resetQuery() { function handleFillingScan(qrToken) { if (operationPending.value || scanLoading.value) return - clearFillingIdempotencyKey() - scannedPallet.value = null - form.value = { qrToken: '', warehouseId: undefined, strainId: undefined, batchNo: '', packageSpec: '', initialPackages: undefined } + clearFillingPayload() const scanContext = { generation: ++scanGeneration, dialogSession, qrToken } scanLoading.value = true scanError.value = '' @@ -347,6 +356,7 @@ function handleFillingScan(qrToken) { if (!isCurrentScanRequest(scanContext)) return const pallet = response.data if (pallet.flowState !== 'EMPTY_IN_WAREHOUSE') { + clearFillingPayload() scanError.value = '该托盘当前不可装填' return } @@ -361,6 +371,7 @@ function handleFillingScan(qrToken) { form.value.warehouseId = pallet.warehouseId }).catch(() => { if (!isCurrentScanRequest(scanContext)) return + clearFillingPayload() scanError.value = '托盘扫码校验失败,请检查后重试' }).finally(() => { if (!isCurrentScanRequest(scanContext)) return @@ -369,9 +380,7 @@ function handleFillingScan(qrToken) { } function handleScanInvalid(message) { - clearFillingIdempotencyKey() - scannedPallet.value = null - form.value = { qrToken: '', warehouseId: undefined, strainId: undefined, batchNo: '', packageSpec: '', initialPackages: undefined } + clearFillingPayload() scanError.value = message } diff --git a/shierfeng-fastapi-frontend/src/views/business/inbound/index.vue b/shierfeng-fastapi-frontend/src/views/business/inbound/index.vue index 074b050..aa4afeb 100644 --- a/shierfeng-fastapi-frontend/src/views/business/inbound/index.vue +++ b/shierfeng-fastapi-frontend/src/views/business/inbound/index.vue @@ -80,7 +80,7 @@ v-hasPermi="['business:inbound:remove']" >删除 Boolean(form.value.inboundId)) const canAddInbound = computed(() => proxy.$auth.hasPermiAnd(['business:inbound:query', 'business:inbound:add'])) const canEditInbound = computed(() => proxy.$auth.hasPermiAnd(['business:inbound:query', 'business:inbound:edit'])) -const canConfirmInbound = computed(() => proxy.$auth.hasPermiAnd(['business:inbound:query', 'business:inbound:confirm'])) const operationPending = computed(() => submitLoading.value || deleteLoading.value || confirmLoading.value) let listGeneration = 0 let optionGeneration = 0 diff --git a/shierfeng-fastapi-frontend/src/views/business/shipment/index.vue b/shierfeng-fastapi-frontend/src/views/business/shipment/index.vue index fe0e0a4..d86c96c 100644 --- a/shierfeng-fastapi-frontend/src/views/business/shipment/index.vue +++ b/shierfeng-fastapi-frontend/src/views/business/shipment/index.vue @@ -77,7 +77,7 @@ v-hasPermi="['business:shipment:remove']" >删除 Boolean(form.value.shipmentId)) const canAddShipment = computed(() => proxy.$auth.hasPermiAnd(['business:shipment:query', 'business:shipment:add'])) const canEditShipment = computed(() => proxy.$auth.hasPermiAnd(['business:shipment:query', 'business:shipment:edit'])) -const canConfirmShipment = computed(() => proxy.$auth.hasPermiAnd(['business:shipment:query', 'business:shipment:confirm'])) const operationPending = computed(() => submitLoading.value || deleteLoading.value || confirmLoading.value) let listGeneration = 0 let optionGeneration = 0 @@ -429,7 +428,10 @@ function handleUpdate(row) { strainCode: item.strainCode, strainName: item.strainName, batchNo: item.batchNo, - remainingPackages: item.remainingPackages ?? item.packageQty ?? null + remainingPackages: + item.remainingPackages === undefined + ? (item.packageQty ?? null) + : item.remainingPackages })) hydratingForm = false editDetailReady.value = true