修复:完善业务页面异步与扫码状态

This commit is contained in:
焦龙言
2026-07-27 09:39:53 +08:00
parent e4477f321c
commit a223971a3c
4 changed files with 182 additions and 19 deletions
@@ -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(/<el-button\b[\s\S]*?<\/el-button>/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\(\)/)
@@ -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
}
@@ -33,7 +33,7 @@
type="primary"
plain
icon="Plus"
:disabled="operationPending || detailLoading"
:disabled="operationPending || optionLoading || detailLoading || open"
@click="handleAdd"
v-hasPermi="['business:inbound:add']"
>新增入库草稿</el-button>
@@ -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
@@ -30,7 +30,7 @@
type="primary"
plain
icon="Plus"
:disabled="operationPending || detailLoading"
:disabled="operationPending || optionLoading || detailLoading || open"
@click="handleAdd"
v-hasPermi="['business:shipment:add']"
>新增发货草稿</el-button>
@@ -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