From a223971a3c094e28352edbab7cf1947409e34cef 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 09:39:53 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E9=A1=B5=E9=9D=A2=E5=BC=82=E6=AD=A5=E4=B8=8E?= =?UTF-8?q?=E6=89=AB=E7=A0=81=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/verify-business-contract.mjs | 187 ++++++++++++++++-- .../src/views/business/filling/index.vue | 6 +- .../src/views/business/inbound/index.vue | 4 +- .../src/views/business/shipment/index.vue | 4 +- 4 files changed, 182 insertions(+), 19 deletions(-) diff --git a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs index c29ee58..5e8e9b7 100644 --- a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs +++ b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs @@ -504,6 +504,25 @@ function extractPromiseCallback(functionSource, methodName, occurrence = 0) { return functionSource.slice(bodyStart + 1, bodyEnd) } +function assertPromiseCallbackStartsWithGuard(functionSource, methodName, guardName, occurrence = 0) { + const callback = extractPromiseCallback(functionSource, methodName, occurrence) + const escapedGuardName = guardName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const guardPattern = new RegExp( + `^\\s*if\\s*\\(\\s*!${escapedGuardName}\\([^)]*\\)\\s*\\)\\s*(?:\\{\\s*return;?\\s*\\}|return;?)` + ) + assert.match( + callback, + guardPattern, + `${guardName} 必须是第 ${occurrence + 1} 个 .${methodName} 回调写 UI 前的首个门禁` + ) +} + +function assertPromiseCallbacksStartWithGuard(functionSource, guardName) { + for (const methodName of ['then', 'catch', 'finally']) { + assertPromiseCallbackStartsWithGuard(functionSource, methodName, guardName) + } +} + function extractButtonBlocks(source, clickHandler) { return [...source.matchAll(//g)] .map((match) => match[0]) @@ -898,6 +917,7 @@ test('五个业务页面逐页满足 API、精确权限、列表和交互基础 assert.match(source, /let listGeneration = 0;?/) const listSource = extractNamedFunction(source, 'getList') assert.match(listSource, /const listContext = \{\s*generation: \+\+listGeneration,\s*query: \{ \.\.\.queryParams\.value \}\s*};/) + assertPromiseCallbacksStartWithGuard(listSource, 'isCurrentListRequest') assert.match(listSource, /if \(!isCurrentListRequest\(listContext\)\) \{\s*return;\s*\}/) assert.match(listSource, /\.catch\(\(\) => \{[\s\S]*?loadError\.value\s*=/) assert.match(listSource, /\.finally\(\(\) => \{[\s\S]*?loading\.value = false/) @@ -956,6 +976,95 @@ test('五个业务页面逐页满足 API、精确权限、列表和交互基础 } }) +test('五个业务页面的读取请求逐分支执行 stale guard,并在上下文切换时失效旧请求', async (t) => { + const contracts = { + inbound: { + flows: [ + ['loadWarehouseOptions', ['getInboundWarehouseOptions'], 'isCurrentOptionRequest'], + ['handleInboundScan', ['scanInboundPallet'], 'isCurrentScanRequest'], + ['handleUpdate', ['getInbound'], 'isCurrentDetailRequest'] + ], + resets: [['resetInboundForm', ['invalidateOptionRequest()', 'invalidateDetailRequest()', 'invalidateScanRequest()']]], + closes: [['cancelInbound', 'resetInboundForm()'], ['handleDialogBeforeClose', 'resetInboundForm()']], + watchInvalidators: ['invalidateScanRequest()'] + }, + filling: { + flows: [ + ['loadStrainOptions', ['getFillingStrainOptions'], 'isCurrentOptionRequest'], + ['handleFillingScan', ['scanFillingPallet'], 'isCurrentScanRequest'] + ], + resets: [['resetFillingOperation', ['invalidateOptionRequest()', 'invalidateScanRequest()']]], + closes: [['cancelFilling', 'resetFillingOperation()'], ['handleDialogBeforeClose', 'resetFillingOperation()']] + }, + shipment: { + flows: [ + ['loadShipmentOptions', ['getShipmentWarehouseOptions', 'getShipmentSiteOptions'], 'isCurrentOptionRequest'], + ['handleShipmentScan', ['scanShipmentPallet'], 'isCurrentScanRequest'], + ['handleUpdate', ['getShipment'], 'isCurrentDetailRequest'] + ], + resets: [['resetShipmentForm', ['invalidateOptionRequest()', 'invalidateDetailRequest()', 'invalidateScanRequest()']]], + closes: [['cancelShipment', 'resetShipmentForm()'], ['handleDialogBeforeClose', 'resetShipmentForm()']], + watchInvalidators: ['invalidateScanRequest()'] + }, + usage: { + flows: [ + ['loadLineOptions', ['getUsageLineOptions'], 'isCurrentOptionRequest'], + ['handleUsageScan', ['scanUsagePallet'], 'isCurrentScanRequest'] + ], + resets: [['resetUsageOperation', ['invalidateOptionRequest()', 'invalidateScanRequest()']]], + transitions: [['handleUsageScan', ['invalidateOptionRequest()']]] + }, + return: { + flows: [ + ['loadReturnWarehouseOptions', ['getReturnWarehouseOptions'], 'isCurrentOptionRequest'], + ['handleRequestScan', ['scanReturnPallet'], 'isCurrentRequestScan'], + ['handleConfirmReturnSelection', ['getReturn'], 'isCurrentDetailRequest'], + ['handleConfirmScan', ['scanReturnPallet'], 'isCurrentConfirmScan'] + ], + resets: [ + ['resetReturnRequest', ['invalidateOptionRequest()', 'invalidateRequestScan()']], + ['resetReturnConfirm', ['invalidateDetailRequest()', 'invalidateConfirmScan()']] + ], + transitions: [['handleConfirmReturnSelection', ['invalidateDetailRequest()', 'invalidateConfirmScan()']]], + watchInvalidators: ['invalidateRequestScan()'] + } + } + + for (const [resource, contract] of Object.entries(contracts)) { + await t.test(businessPageContracts[resource].relativePath, async () => { + const source = await readFile(path.join(frontendRoot, businessPageContracts[resource].relativePath), 'utf8') + for (const [functionName, requestCalls, guardName] of contract.flows) { + const functionSource = extractNamedFunction(source, functionName) + for (const requestCall of requestCalls) { + assert.ok(functionSource.includes(`${requestCall}(`), `${functionName} 缺少 ${requestCall} 实际请求`) + } + assertPromiseCallbacksStartWithGuard(functionSource, guardName) + } + for (const [functionName, invalidators] of contract.resets) { + const functionSource = extractNamedFunction(source, functionName) + for (const invalidator of invalidators) { + assert.ok(functionSource.includes(invalidator), `${functionName} 必须调用 ${invalidator}`) + } + } + for (const [functionName, resetCall] of contract.closes || []) { + assert.ok(extractNamedFunction(source, functionName).includes(resetCall), `${functionName} 必须调用 ${resetCall}`) + } + for (const [functionName, invalidators] of contract.transitions || []) { + const functionSource = extractNamedFunction(source, functionName) + for (const invalidator of invalidators) { + assert.ok(functionSource.includes(invalidator), `${functionName} 切换上下文时必须调用 ${invalidator}`) + } + } + if (contract.watchInvalidators) { + const watchSource = source.slice(source.lastIndexOf('watch('), source.lastIndexOf('getList();')) + for (const invalidator of contract.watchInvalidators) { + assert.ok(watchSource.includes(invalidator), `${resource} 输入切换 watch 必须调用 ${invalidator}`) + } + } + }) + } +}) + test('入库页面只允许 DRAFT 变更并以 authoritative 详情和独立确认幂等键工作', async () => { const source = await readFile(path.join(frontendRoot, businessPageContracts.inbound.relativePath), 'utf8') @@ -966,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 addButton = extractButtonBlocks(source, 'handleAdd')[0] + assert.match(addButton, /:disabled="[^"]*operationPending[^"]*optionLoading[^"]*detailLoading[^"]*open[^"]*"/) + const addSource = extractNamedFunction(source, 'handleAdd') + assert.match( + addSource, + /^function handleAdd\(\)\s*\{\s*if \(!canAddInbound\.value \|\| operationPending\.value \|\| optionLoading\.value \|\| detailLoading\.value \|\| open\.value\) return/ + ) assert.match(source, /scope\.row\.status === ['"]DRAFT['"][\s\S]*?handleUpdate\(scope\.row\)/) assert.match(source, /scope\.row\.status === ['"]DRAFT['"][\s\S]*?handleDelete\(scope\.row\)/) assert.match(source, /scope\.row\.status === ['"]DRAFT['"][\s\S]*?handleConfirm\(scope\.row\)/) @@ -1053,20 +1169,41 @@ test('装填页面锁定扫码上下文、校验正整数并在失败重试时 const scanSource = extractNamedFunction(source, 'handleFillingScan') const scanCallIndex = scanSource.indexOf('scanFillingPallet(') assert.ok(scanCallIndex >= 0, '装填扫码必须调用 scanFillingPallet') - for (const [snippet, message] of [ - ['clearFillingIdempotencyKey()', '新装填扫码前必须清除旧幂等键'], - ['scannedPallet.value = null', '新装填扫码前必须清除旧托盘'], - ["form.value.qrToken = ''", '新装填扫码前必须清除旧二维码令牌'], - ['form.value.warehouseId = undefined', '新装填扫码前必须清除旧仓库'] + const scanPrefix = scanSource.slice(0, scanCallIndex) + const clearKeyIndex = scanPrefix.indexOf('clearFillingIdempotencyKey()') + const scanPendingIndex = scanPrefix.indexOf('scanLoading.value = true') + assert.ok( + clearKeyIndex >= 0 && scanPendingIndex >= 0 && clearKeyIndex < scanPendingIndex, + '新装填扫码必须在进入 pending 前明确清除旧幂等键' + ) + assert.match(scanPrefix, /scannedPallet\.value = null/, '新装填扫码前必须清除旧托盘') + for (const [field, emptyValue] of [ + ['qrToken', "''"], + ['warehouseId', 'undefined'], + ['strainId', 'undefined'], + ['batchNo', "''"], + ['packageSpec', "''"], + ['initialPackages', 'undefined'] ]) { - const index = scanSource.indexOf(snippet) - assert.ok(index >= 0 && index < scanCallIndex, message) + 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/) - assert.match(invalidSource, /form\.value\.qrToken = ''/) - assert.match(invalidSource, /form\.value\.warehouseId = undefined/) + 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 fillingSubmitButton = extractButtonBlocks(source, 'submitFilling')[0] + assert.match(fillingSubmitButton, /:disabled="[^"]*!scannedPallet[^"]*"/) const ensureSource = extractNamedFunction(source, 'ensureFillingIdempotencyKey') assert.match(ensureSource, /if \(!fillingIdempotencyKey\.value\)/) @@ -1077,6 +1214,7 @@ test('装填页面锁定扫码上下文、校验正整数并在失败重试时 '装填提交必须在异步校验前加锁' ) assert.match(submitSource, /if \(!valid\) \{\s*submitLoading\.value = false\s*return/) + assert.match(submitSource, /if \(!scannedPallet\.value \|\| !form\.value\.qrToken \|\| !form\.value\.warehouseId\)/) assert.match(submitSource, /\.finally\(\(\) => \{\s*submitLoading\.value = false/) assert.match(submitSource, /const idempotencyKey = ensureFillingIdempotencyKey\(\)/) assert.match(extractPromiseCallback(submitSource, 'then'), /clearFillingIdempotencyKey\(\)/) @@ -1084,7 +1222,7 @@ test('装填页面锁定扫码上下文、校验正整数并在失败重试时 assert.match(catchSource, /formError\.value\s*=/) assert.doesNotMatch(catchSource, /clearFillingIdempotencyKey/) assert.match(extractNamedFunction(source, 'cancelFilling'), /clearFillingIdempotencyKey\(\)/) - assert.match(source, /watch\([\s\S]*?clearFillingIdempotencyKey\(\)/) + assert.match(source, /watch\([\s\S]*?if \(!submitLoading\.value\) clearFillingIdempotencyKey\(\)/) }) test('发货页面只允许 DRAFT 变更、展示只读余量并复用确认幂等键', async () => { @@ -1097,6 +1235,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 addButton = extractButtonBlocks(source, 'handleAdd')[0] + assert.match(addButton, /:disabled="[^"]*operationPending[^"]*optionLoading[^"]*detailLoading[^"]*open[^"]*"/) + const addSource = extractNamedFunction(source, 'handleAdd') + assert.match( + addSource, + /^function handleAdd\(\)\s*\{\s*if \(!canAddShipment\.value \|\| operationPending\.value \|\| optionLoading\.value \|\| detailLoading\.value \|\| open\.value\) return/ + ) assert.match(source, /scope\.row\.status === ['"]DRAFT['"][\s\S]*?handleUpdate\(scope\.row\)/) assert.match(source, /scope\.row\.status === ['"]DRAFT['"][\s\S]*?handleDelete\(scope\.row\)/) assert.match(source, /scope\.row\.status === ['"]DRAFT['"][\s\S]*?handleConfirm\(scope\.row\)/) @@ -1153,6 +1298,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\(\)/) @@ -1298,8 +1450,21 @@ test('退回页面分离申请与确认幂等状态并在提交前严格复核 assert.match(confirmSubmit, /!sameIdSet\(latestPalletIds, initialExpectedIds\)\s*\|\|\s*!sameIdSet\(latestPalletIds, scannedPalletIds\)/) assert.match(confirmSubmit, /latestDetail\.status !== ['"]REQUESTED['"]/) assert.match(confirmSubmit, /const idempotencyKey = ensureReturnConfirmIdempotencyKey\(returnId\)/) + const detailThen = extractPromiseCallback(confirmSubmit, 'then') + assert.match(detailThen, /^\s*if \(!isCurrentConfirmSubmit\(submitContext\)\) throw/) + assertPromiseCallbackStartsWithGuard(confirmSubmit, 'then', 'isCurrentConfirmSubmit', 1) + const confirmSubmitCatch = extractPromiseCallback(confirmSubmit, 'catch') + const confirmSubmitCatchGuardIndex = confirmSubmitCatch.indexOf('if (!isCurrentConfirmSubmit(submitContext)) return') + const confirmSubmitCatchWriteIndex = confirmSubmitCatch.indexOf('confirmError.value =') + assert.ok( + confirmSubmitCatchGuardIndex >= 0 && + confirmSubmitCatchWriteIndex >= 0 && + confirmSubmitCatchGuardIndex < confirmSubmitCatchWriteIndex, + '退回确认 catch 写错误状态前必须执行提交上下文门禁' + ) + assertPromiseCallbackStartsWithGuard(confirmSubmit, 'finally', 'isCurrentConfirmSubmit') assert.match(extractPromiseCallback(confirmSubmit, 'then', 1), /clearReturnConfirmIdempotencyKey\(\)/) - assert.doesNotMatch(extractPromiseCallback(confirmSubmit, 'catch'), /clearReturnConfirmIdempotencyKey/) + assert.doesNotMatch(confirmSubmitCatch, /clearReturnConfirmIdempotencyKey/) assert.match(extractNamedFunction(source, 'resetReturnRequest'), /clearRequestIdempotencyKey\(\)/) assert.match(extractNamedFunction(source, 'resetReturnConfirm'), /clearReturnConfirmIdempotencyKey\(\)/) diff --git a/shierfeng-fastapi-frontend/src/views/business/filling/index.vue b/shierfeng-fastapi-frontend/src/views/business/filling/index.vue index 6b37d04..3e0e931 100644 --- a/shierfeng-fastapi-frontend/src/views/business/filling/index.vue +++ b/shierfeng-fastapi-frontend/src/views/business/filling/index.vue @@ -339,8 +339,7 @@ function handleFillingScan(qrToken) { if (operationPending.value || scanLoading.value) return clearFillingIdempotencyKey() scannedPallet.value = null - form.value.qrToken = '' - form.value.warehouseId = undefined + form.value = { qrToken: '', warehouseId: undefined, strainId: undefined, batchNo: '', packageSpec: '', initialPackages: undefined } const scanContext = { generation: ++scanGeneration, dialogSession, qrToken } scanLoading.value = true scanError.value = '' @@ -372,8 +371,7 @@ function handleFillingScan(qrToken) { function handleScanInvalid(message) { clearFillingIdempotencyKey() scannedPallet.value = null - form.value.qrToken = '' - form.value.warehouseId = undefined + form.value = { qrToken: '', warehouseId: undefined, strainId: undefined, batchNo: '', packageSpec: '', initialPackages: undefined } 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 c426700..074b050 100644 --- a/shierfeng-fastapi-frontend/src/views/business/inbound/index.vue +++ b/shierfeng-fastapi-frontend/src/views/business/inbound/index.vue @@ -33,7 +33,7 @@ type="primary" plain icon="Plus" - :disabled="operationPending || detailLoading" + :disabled="operationPending || optionLoading || detailLoading || open" @click="handleAdd" v-hasPermi="['business:inbound:add']" >新增入库草稿 @@ -371,7 +371,7 @@ function resetQuery() { } function handleAdd() { - if (!canAddInbound.value || operationPending.value) return + if (!canAddInbound.value || operationPending.value || optionLoading.value || detailLoading.value || open.value) return resetInboundForm() title.value = '新增首次入库草稿' open.value = true diff --git a/shierfeng-fastapi-frontend/src/views/business/shipment/index.vue b/shierfeng-fastapi-frontend/src/views/business/shipment/index.vue index 42405ec..fe0e0a4 100644 --- a/shierfeng-fastapi-frontend/src/views/business/shipment/index.vue +++ b/shierfeng-fastapi-frontend/src/views/business/shipment/index.vue @@ -30,7 +30,7 @@ type="primary" plain icon="Plus" - :disabled="operationPending || detailLoading" + :disabled="operationPending || optionLoading || detailLoading || open" @click="handleAdd" v-hasPermi="['business:shipment:add']" >新增发货草稿 @@ -388,7 +388,7 @@ function resetQuery() { } function handleAdd() { - if (!canAddShipment.value || operationPending.value) return + if (!canAddShipment.value || operationPending.value || optionLoading.value || detailLoading.value || open.value) return resetShipmentForm() title.value = '新增整托发货草稿' open.value = true