diff --git a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs index b52b755..c29ee58 100644 --- a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs +++ b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs @@ -171,9 +171,91 @@ const masterPageContracts = { } } +const businessPageContracts = { + inbound: { + relativePath: 'src/views/business/inbound/index.vue', + apiNames: [ + 'addInbound', + 'confirmInbound', + 'delInbound', + 'getInbound', + 'getInboundWarehouseOptions', + 'listInbound', + 'scanInboundPallet', + 'updateInbound' + ], + permissions: ['add', 'confirm', 'edit', 'list', 'query', 'remove'], + payloads: { + addInbound: ['palletIds', 'warehouseId'], + updateInbound: ['inboundId', 'palletIds', 'warehouseId'], + confirmInbound: ['idempotencyKey'] + } + }, + filling: { + relativePath: 'src/views/business/filling/index.vue', + apiNames: ['addFilling', 'getFillingStrainOptions', 'listFilling', 'scanFillingPallet'], + permissions: ['add', 'list', 'query'], + payloads: { + addFilling: [ + 'batchNo', + 'idempotencyKey', + 'initialPackages', + 'packageSpec', + 'qrToken', + 'strainId', + 'warehouseId' + ] + } + }, + shipment: { + relativePath: 'src/views/business/shipment/index.vue', + apiNames: [ + 'addShipment', + 'confirmShipment', + 'delShipment', + 'getShipment', + 'getShipmentSiteOptions', + 'getShipmentWarehouseOptions', + 'listShipment', + 'scanShipmentPallet', + 'updateShipment' + ], + permissions: ['add', 'confirm', 'edit', 'list', 'query', 'remove'], + payloads: { + addShipment: ['palletIds', 'sourceWarehouseId', 'targetSiteId'], + updateShipment: ['palletIds', 'shipmentId', 'sourceWarehouseId', 'targetSiteId'], + confirmShipment: ['idempotencyKey'] + } + }, + usage: { + relativePath: 'src/views/business/usage/index.vue', + apiNames: ['addUsage', 'getUsageLineOptions', 'listUsage', 'scanUsagePallet'], + permissions: ['add', 'list', 'query'], + payloads: { + addUsage: ['idempotencyKey', 'lineId', 'qrToken', 'usedPackages'] + } + }, + return: { + relativePath: 'src/views/business/return/index.vue', + apiNames: [ + 'confirmReturn', + 'getReturn', + 'getReturnWarehouseOptions', + 'listReturn', + 'requestReturn', + 'scanReturnPallet' + ], + permissions: ['confirm', 'list', 'query', 'request'], + payloads: { + requestReturn: ['idempotencyKey', 'palletIds', 'sourceSiteId', 'targetWarehouseId'], + confirmReturn: ['idempotencyKey', 'scannedPalletIds'] + } + } +} + const delimiterPairs = { '(': ')', '[': ']', '{': '}' } -function sanitizeSource(source) { +function sanitizeSource(source, { allowTemplateInterpolation = false } = {}) { const code = source.split('') const structure = source.split('') @@ -184,7 +266,12 @@ function sanitizeSource(source) { while (literalEnd < source.length && source[literalEnd] !== quote) { if (source[literalEnd] === '\\') { literalEnd += 1 - } else if (quote === '`' && source[literalEnd] === '$' && source[literalEnd + 1] === '{') { + } else if ( + !allowTemplateInterpolation && + quote === '`' && + source[literalEnd] === '$' && + source[literalEnd + 1] === '{' + ) { throw new Error('契约解析器不支持包含插值的模板字符串') } literalEnd += 1 @@ -370,6 +457,53 @@ function extractCallObjectKeys(source, callName) { }).sort() } +function extractSecondCallObjectKeys(source, callName) { + const escapedCallName = callName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const callMatch = new RegExp( + `(? property.trim()).filter(Boolean) + return properties.map((property) => { + const keyMatch = /^([A-Za-z_$][\w$]*)\s*:/.exec(property) + assert.ok(keyMatch, `${callName} payload 禁止展开、简写或动态属性`) + return keyMatch[1] + }).sort() +} + +function extractNamedFunction(source, functionName) { + const sanitized = sanitizeSource(source, { allowTemplateInterpolation: true }) + const escapedFunctionName = functionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const match = new RegExp(`function\\s+${escapedFunctionName}\\s*\\(`).exec(sanitized.structure) + assert.ok(match, `缺少函数 ${functionName}`) + const parametersStart = sanitized.structure.indexOf('(', match.index) + const parametersEnd = findClosingDelimiter(sanitized.structure, parametersStart) + const bodyStart = sanitized.structure.indexOf('{', parametersEnd) + assert.ok(bodyStart >= 0, `${functionName} 缺少函数体`) + const bodyEnd = findClosingDelimiter(sanitized.structure, bodyStart) + return source.slice(match.index, bodyEnd + 1) +} + +function extractPromiseCallback(functionSource, methodName, occurrence = 0) { + const sanitized = sanitizeSource(functionSource, { allowTemplateInterpolation: true }) + const marker = `.${methodName}(` + let markerIndex = -1 + for (let index = 0; index <= occurrence; index += 1) { + markerIndex = sanitized.structure.indexOf(marker, markerIndex + 1) + assert.ok(markerIndex >= 0, `缺少第 ${occurrence + 1} 个 .${methodName}(...)`) + } + const callStart = markerIndex + marker.length - 1 + const callEnd = findClosingDelimiter(sanitized.structure, callStart) + const arrowIndex = sanitized.structure.indexOf('=>', callStart) + assert.ok(arrowIndex > callStart && arrowIndex < callEnd, `.${methodName} 必须传入箭头函数`) + const bodyStart = sanitized.structure.indexOf('{', arrowIndex) + assert.ok(bodyStart > arrowIndex && bodyStart < callEnd, `.${methodName} 回调必须使用块函数体`) + const bodyEnd = findClosingDelimiter(sanitized.structure, bodyStart) + assert.ok(bodyEnd < callEnd, `.${methodName} 回调块超出调用范围`) + return functionSource.slice(bodyStart + 1, bodyEnd) +} + function extractButtonBlocks(source, clickHandler) { return [...source.matchAll(//g)] .map((match) => match[0]) @@ -729,6 +863,448 @@ test('六个基础资料页面逐页满足 API、权限、分页和安全删除 } }) +test('五个业务页面逐页满足 API、精确权限、列表和交互基础契约', async (t) => { + for (const [resource, contract] of Object.entries(businessPageContracts)) { + await t.test(contract.relativePath, async () => { + const source = await readFile(path.join(frontendRoot, contract.relativePath), 'utf8') + const permissionPrefix = `business:${resource}` + const importedBusinessModules = [...source.matchAll(/from\s*['"]@\/api\/business\/([a-z]+)['"]/g)] + .map((match) => match[1]) + + assert.deepEqual(importedBusinessModules, [resource], `${resource} 页面只能导入本业务 API`) + assert.deepEqual(extractImportedNames(source, `@/api/business/${resource}`), contract.apiNames) + assert.doesNotMatch(source, /@\/api\/master\//, `${resource} 页面不得借用主数据 API`) + assert.doesNotMatch(source, />\s*(?:导入|导出)\s*<\/el-button>/) + assert.doesNotMatch(source, /\bhandle(?:Import|Export)\b|proxy\.download\(/) + assert.doesNotMatch(source, /BarcodeDetector|getUserMedia|camera/i) + assert.doesNotMatch(source, /\.catch\(\(\) => \{\}\)/, `${resource} 页面禁止空 catch`) + + const actualPermissions = [...new Set( + [...source.matchAll(new RegExp(`['"](${permissionPrefix}:[a-z]+)['"]`, 'g'))] + .map((match) => match[1].slice(permissionPrefix.length + 1)) + )].sort() + assert.deepEqual(actualPermissions, contract.permissions, `${resource} 页面权限集合不精确`) + assertButtonsUsePermission(source, 'handleQuery', `${permissionPrefix}:list`) + assertButtonsUsePermission(source, 'resetQuery', `${permissionPrefix}:list`) + + assert.match(source, /function handleQuery\(\)\s*{[\s\S]*?queryParams\.value\.pageNum\s*=\s*1[\s\S]*?getList\(\)/) + assert.match(source, /function resetQuery\(\)\s*{[\s\S]*?resetForm\(['"]queryRef['"]\)[\s\S]*?handleQuery\(\)/) + assert.match(source, /]*v-loading="loading"[^>]*empty-text="暂无数据"/) + assert.match(source, /]*v-if="loadError"/) + assert.match(source, /\.value\s*=\s*response\.rows/) + assert.match(source, /total\.value\s*=\s*response\.total/) + + assert.match(source, /let listGeneration = 0;?/) + const listSource = extractNamedFunction(source, 'getList') + assert.match(listSource, /const listContext = \{\s*generation: \+\+listGeneration,\s*query: \{ \.\.\.queryParams\.value \}\s*};/) + 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/) + + assert.match(source, /import PalletScanInput from ['"]@\/components\/PalletScanInput['"]/) + const scanTags = [...source.matchAll(/]*\/>/g)].map((match) => match[0]) + assert.ok(scanTags.length > 0, `${resource} 页面必须复用 PalletScanInput`) + for (const scanTag of scanTags) { + assert.match(scanTag, /:disabled="[^"]*operationPending[^"]*"/, `${resource} 提交期间必须禁用扫码`) + assert.match(scanTag, /:loading="[^"]*scanLoading[^"]*"/, `${resource} 扫码请求必须显示 loading`) + } + assert.match(source, /const operationPending = computed\(\(\) =>/) + assert.match(source, /let optionGeneration = 0;?/) + assert.match(source, /function invalidateOptionRequest\(\)/) + assert.match(source, /function isCurrentOptionRequest\(/) + if (resource === 'return') { + assert.match(source, /let requestScanGeneration = 0;?/) + assert.match(source, /let confirmScanGeneration = 0;?/) + assert.match(source, /function invalidateRequestScan\(\)/) + assert.match(source, /function invalidateConfirmScan\(\)/) + assert.match(source, /function isCurrentRequestScan\(/) + assert.match(source, /function isCurrentConfirmScan\(/) + } else { + assert.match(source, /let scanGeneration = 0;?/) + assert.match(source, /function invalidateScanRequest\(\)/) + assert.match(source, /function isCurrentScanRequest\(/) + } + + const businessControls = [...source.matchAll(/<(?:el-input|el-input-number|el-select)\b[^>]*class="business-control"[^>]*>/g)] + .map((match) => match[0]) + assert.ok(businessControls.length > 0, `${resource} 页面缺少业务输入控件`) + for (const control of businessControls) { + assert.match(control, /:disabled="[^"]*operationPending[^"]*"/, `${resource} 提交期间必须禁用业务输入`) + } + const businessActions = [...source.matchAll(/]*class="business-action"[^>]*>/g)] + .map((match) => match[0]) + assert.ok(businessActions.length > 0, `${resource} 页面缺少业务提交按钮`) + for (const action of businessActions) { + assert.match(action, /:disabled="[^"]*operationPending[^"]*"/, `${resource} 提交期间必须禁用业务按钮`) + } + + for (const [callName, payloadKeys] of Object.entries(contract.payloads)) { + const actualKeys = callName.startsWith('confirm') + ? extractSecondCallObjectKeys(source, callName) + : extractCallObjectKeys(source, callName) + assert.deepEqual(actualKeys, payloadKeys, `${callName} payload 必须最小且精确`) + } + + const scriptTail = source.slice(source.lastIndexOf('getList();')) + assert.doesNotMatch( + scriptTail, + /get(?:InboundWarehouseOptions|FillingStrainOptions|ShipmentWarehouseOptions|ShipmentSiteOptions|UsageLineOptions|ReturnWarehouseOptions)\(/, + `${resource} 页面不得在启动阶段无条件请求 query 权限接口` + ) + }) + } +}) + +test('入库页面只允许 DRAFT 变更并以 authoritative 详情和独立确认幂等键工作', async () => { + const source = await readFile(path.join(frontendRoot, businessPageContracts.inbound.relativePath), 'utf8') + + assertButtonsUsePermission(source, 'handleAdd', 'business:inbound:add') + assertButtonsUsePermission(source, 'submitInbound', 'business:inbound:add') + assertButtonsUsePermission(source, 'handleUpdate', 'business:inbound:edit') + assertButtonsUsePermission(source, 'handleDelete', 'business:inbound:remove') + 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'\]\)/) + 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\)/) + for (const functionName of ['handleUpdate', 'handleDelete', 'handleConfirm']) { + assert.match(extractNamedFunction(source, functionName), /status !== ['"]DRAFT['"]/) + } + + assert.match(source, /const editDetailReady = ref\(false\)/) + const updateSource = extractNamedFunction(source, 'handleUpdate') + const detailRequestIndex = updateSource.indexOf('getInbound(') + assert.ok(detailRequestIndex >= 0, '编辑入库草稿必须读取 authoritative 详情') + for (const [snippet, message] of [ + ['form.value.inboundId = row.inboundId', '读取详情前必须固定入库编辑身份'], + ['editDetailReady.value = false', '读取详情前必须关闭入库编辑就绪门禁'] + ]) { + const index = updateSource.indexOf(snippet) + assert.ok(index >= 0 && index < detailRequestIndex, message) + } + const detailSuccessSource = extractPromiseCallback(updateSource, 'then') + assert.ok( + detailSuccessSource.indexOf("detail.status !== 'DRAFT'") < detailSuccessSource.indexOf('editDetailReady.value = true'), + '仅 authoritative DRAFT 入库详情可开启编辑就绪门禁' + ) + const warehouseSelect = source.match(//)?.[0] + assert.ok(warehouseSelect, '缺少入库仓库选择器') + assert.match(warehouseSelect, /:disabled="[^"]*!editDetailReady[^"]*"/) + const inboundScanTag = [...source.matchAll(/]*\/>/g)] + .map((match) => match[0]) + .find((tag) => tag.includes('@scan="handleInboundScan"')) + assert.ok(inboundScanTag, '缺少入库扫码控件') + assert.match(inboundScanTag, /:disabled="[^"]*!editDetailReady[^"]*"/) + const inboundRemoveButton = extractButtonBlocks(source, 'removeScannedPallet')[0] + assert.match(inboundRemoveButton, /:disabled="[^"]*!editDetailReady[^"]*"/) + const inboundEditButton = extractButtonBlocks(source, 'saveInboundUpdate')[0] + assert.match(inboundEditButton, /:disabled="[^"]*!editDetailReady[^"]*"/) + const submitSource = extractNamedFunction(source, 'submitInboundForm') + assert.match(submitSource, /editing !== isEdit\.value\s*\|\|\s*\(editing && !editDetailReady\.value\)/) + assert.ok( + submitSource.indexOf('submitLoading.value = true') < submitSource.indexOf('.validate('), + '入库提交必须在异步校验前加锁' + ) + assert.match(submitSource, /if \(!valid\) \{\s*submitLoading\.value = false\s*return/) + assert.match(submitSource, /\.finally\(\(\) => \{\s*submitLoading\.value = false/) + const deleteSource = extractNamedFunction(source, 'handleDelete') + assert.ok( + deleteSource.indexOf('deleteLoading.value = true') < deleteSource.indexOf('proxy.$modal.confirm('), + '入库删除必须在打开二次确认框前加锁' + ) + + assert.deepEqual(extractSecondCallObjectKeys(source, 'scanInboundPallet'), ['warehouseId']) + const scanSource = extractNamedFunction(source, 'handleInboundScan') + assert.match(scanSource, /pallet\.flowState !== ['"]UNSTOCKED['"]/) + assert.match(scanSource, /scannedPallets\.value\.some\(\(item\) => item\.palletId === pallet\.palletId\)/) + 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\}`/) + + const ensureSource = extractNamedFunction(source, 'ensureConfirmIdempotencyKey') + assert.match(ensureSource, /createIdempotencyKey\(\)/) + assert.match(ensureSource, /confirmIdempotency\.key/) + const confirmSource = extractNamedFunction(source, 'handleConfirm') + assert.ok( + confirmSource.indexOf('confirmLoading.value = true') < confirmSource.indexOf('proxy.$modal.confirm('), + '入库确认必须在打开二次确认框前加锁' + ) + assert.ok(confirmSource.indexOf('proxy.$modal.confirm(') < confirmSource.indexOf('confirmInbound(')) + assert.match(confirmSource, /confirmInbound\([^,]+,\s*\{\s*idempotencyKey:/) + assert.match(extractPromiseCallback(confirmSource, 'then', 1), /clearConfirmIdempotency\(\)/) + const confirmCatch = extractPromiseCallback(confirmSource, 'catch') + assert.match(confirmCatch, /error === ['"]cancel['"] \|\| error === ['"]close['"]/) + assert.doesNotMatch(confirmCatch, /clearConfirmIdempotency\(\)[\s\S]*?(?:loadError|msgError)/) +}) + +test('装填页面锁定扫码上下文、校验正整数并在失败重试时保留幂等键', async () => { + const source = await readFile(path.join(frontendRoot, businessPageContracts.filling.relativePath), 'utf8') + + assertButtonsUsePermission(source, 'handleOpenFilling', 'business:filling:add') + assertButtonsUsePermission(source, 'submitFilling', 'business:filling:add') + assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:filling:query', 'business:filling:add'\]\)/) + assert.match(source, /validator:\s*validatePositiveInteger/) + assert.match(source, /pallet\.warehouseId/) + assert.match(source, /pallet\.palletCode \|\| `#\$\{pallet\.palletId\}`/) + assert.match(source, /getFillingStrainOptions\(\)/) + + 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 index = scanSource.indexOf(snippet) + assert.ok(index >= 0 && index < scanCallIndex, message) + } + 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/) + + const ensureSource = extractNamedFunction(source, 'ensureFillingIdempotencyKey') + assert.match(ensureSource, /if \(!fillingIdempotencyKey\.value\)/) + assert.match(ensureSource, /createIdempotencyKey\(\)/) + const submitSource = extractNamedFunction(source, 'submitFilling') + assert.ok( + submitSource.indexOf('submitLoading.value = true') < submitSource.indexOf('.validate('), + '装填提交必须在异步校验前加锁' + ) + assert.match(submitSource, /if \(!valid\) \{\s*submitLoading\.value = false\s*return/) + assert.match(submitSource, /\.finally\(\(\) => \{\s*submitLoading\.value = false/) + assert.match(submitSource, /const idempotencyKey = ensureFillingIdempotencyKey\(\)/) + assert.match(extractPromiseCallback(submitSource, 'then'), /clearFillingIdempotencyKey\(\)/) + const catchSource = extractPromiseCallback(submitSource, 'catch') + assert.match(catchSource, /formError\.value\s*=/) + assert.doesNotMatch(catchSource, /clearFillingIdempotencyKey/) + assert.match(extractNamedFunction(source, 'cancelFilling'), /clearFillingIdempotencyKey\(\)/) + assert.match(source, /watch\([\s\S]*?clearFillingIdempotencyKey\(\)/) +}) + +test('发货页面只允许 DRAFT 变更、展示只读余量并复用确认幂等键', async () => { + const source = await readFile(path.join(frontendRoot, businessPageContracts.shipment.relativePath), 'utf8') + + assertButtonsUsePermission(source, 'handleAdd', 'business:shipment:add') + assertButtonsUsePermission(source, 'submitShipment', 'business:shipment:add') + assertButtonsUsePermission(source, 'handleUpdate', 'business:shipment:edit') + assertButtonsUsePermission(source, 'handleDelete', 'business:shipment:remove') + 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'\]\)/) + 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\)/) + for (const functionName of ['handleUpdate', 'handleDelete', 'handleConfirm']) { + assert.match(extractNamedFunction(source, functionName), /status !== ['"]DRAFT['"]/) + } + + assert.match(source, /const editDetailReady = ref\(false\)/) + const updateSource = extractNamedFunction(source, 'handleUpdate') + const detailRequestIndex = updateSource.indexOf('getShipment(') + assert.ok(detailRequestIndex >= 0, '编辑发货草稿必须读取 authoritative 详情') + for (const [snippet, message] of [ + ['form.value.shipmentId = row.shipmentId', '读取详情前必须固定发货编辑身份'], + ['editDetailReady.value = false', '读取详情前必须关闭发货编辑就绪门禁'] + ]) { + const index = updateSource.indexOf(snippet) + assert.ok(index >= 0 && index < detailRequestIndex, message) + } + const detailSuccessSource = extractPromiseCallback(updateSource, 'then') + assert.ok( + detailSuccessSource.indexOf("detail.status !== 'DRAFT'") < detailSuccessSource.indexOf('editDetailReady.value = true'), + '仅 authoritative DRAFT 发货详情可开启编辑就绪门禁' + ) + for (const model of ['sourceWarehouseId', 'targetSiteId']) { + const select = source.match(new RegExp(``))?.[0] + assert.ok(select, `缺少发货 ${model} 选择器`) + assert.match(select, /:disabled="[^"]*!editDetailReady[^"]*"/) + } + const shipmentScanTag = [...source.matchAll(/]*\/>/g)] + .map((match) => match[0]) + .find((tag) => tag.includes('@scan="handleShipmentScan"')) + assert.ok(shipmentScanTag, '缺少发货扫码控件') + assert.match(shipmentScanTag, /:disabled="[^"]*!editDetailReady[^"]*"/) + const shipmentRemoveButton = extractButtonBlocks(source, 'removeScannedPallet')[0] + assert.match(shipmentRemoveButton, /:disabled="[^"]*!editDetailReady[^"]*"/) + const shipmentEditButton = extractButtonBlocks(source, 'saveShipmentUpdate')[0] + assert.match(shipmentEditButton, /:disabled="[^"]*!editDetailReady[^"]*"/) + const submitSource = extractNamedFunction(source, 'submitShipmentForm') + assert.match(submitSource, /editing !== isEdit\.value\s*\|\|\s*\(editing && !editDetailReady\.value\)/) + assert.ok( + submitSource.indexOf('submitLoading.value = true') < submitSource.indexOf('.validate('), + '发货提交必须在异步校验前加锁' + ) + assert.match(submitSource, /if \(!valid\) \{\s*submitLoading\.value = false\s*return/) + assert.match(submitSource, /\.finally\(\(\) => \{\s*submitLoading\.value = false/) + const deleteSource = extractNamedFunction(source, 'handleDelete') + assert.ok( + deleteSource.indexOf('deleteLoading.value = true') < deleteSource.indexOf('proxy.$modal.confirm('), + '发货删除必须在打开二次确认框前加锁' + ) + + assert.deepEqual(extractSecondCallObjectKeys(source, 'scanShipmentPallet'), ['sourceWarehouseId', 'targetSiteId']) + assert.match(source, /label="[^"]*只读[^"]*"[^>]*prop="remainingPackages"|prop="remainingPackages"[\s\S]*?不可修改/) + 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\}`/) + + const ensureSource = extractNamedFunction(source, 'ensureConfirmIdempotencyKey') + assert.match(ensureSource, /createIdempotencyKey\(\)/) + const confirmSource = extractNamedFunction(source, 'handleConfirm') + assert.ok( + confirmSource.indexOf('confirmLoading.value = true') < confirmSource.indexOf('proxy.$modal.confirm('), + '发货确认必须在打开二次确认框前加锁' + ) + assert.ok(confirmSource.indexOf('proxy.$modal.confirm(') < confirmSource.indexOf('confirmShipment(')) + assert.match(extractPromiseCallback(confirmSource, 'then', 1), /clearConfirmIdempotency\(\)/) + const confirmCatch = extractPromiseCallback(confirmSource, 'catch') + assert.match(confirmCatch, /error === ['"]cancel['"] \|\| error === ['"]close['"]/) + assert.doesNotMatch(confirmCatch, /clearConfirmIdempotency\(\)[\s\S]*?(?:loadError|msgError)/) +}) + +test('使用页面依扫码结果锁定产线并只采用后端返回的余量和状态', async () => { + const source = await readFile(path.join(frontendRoot, businessPageContracts.usage.relativePath), 'utf8') + + assertButtonsUsePermission(source, 'submitUsage', 'business:usage:add') + assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:usage:query', 'business:usage:add'\]\)/) + assert.match(source, /pallet\.currentLineId == null/) + assert.match(source, /getUsageLineOptions\(pallet\.siteId\)/) + assert.match(source, /:disabled="operationPending \|\| lineLocked/) + assert.match(source, /validator:\s*validatePositiveInteger/) + + const scanSource = extractNamedFunction(source, 'handleUsageScan') + const scanCallIndex = scanSource.indexOf('scanUsagePallet(') + assert.ok(scanCallIndex >= 0, '使用扫码必须调用 scanUsagePallet') + for (const [snippet, message] of [ + ['clearUsageIdempotencyKey()', '新使用扫码前必须清除旧幂等键'], + ['scannedPallet.value = null', '新使用扫码前必须清除旧托盘'], + ['usageResult.value = null', '新使用扫码前必须清除旧成功结果'], + ['lineOptions.value = []', '新使用扫码前必须清除旧产线选项'], + ['lineLocked.value = false', '新使用扫码前必须解除旧产线锁定'], + ["form.value = { qrToken: '', lineId: undefined, usedPackages: undefined }", '新使用扫码前必须清除旧提交 payload'] + ]) { + const index = scanSource.indexOf(snippet) + assert.ok(index >= 0 && index < scanCallIndex, message) + } + const invalidSource = extractNamedFunction(source, 'handleScanInvalid') + assert.match(invalidSource, /clearUsageIdempotencyKey\(\)/) + assert.match(invalidSource, /scannedPallet\.value = null/) + assert.match(invalidSource, /usageResult\.value = null/) + assert.match(invalidSource, /lineOptions\.value = \[\]/) + assert.match(invalidSource, /lineLocked\.value = false/) + assert.match(invalidSource, /form\.value = \{ qrToken: '', lineId: undefined, usedPackages: undefined \}/) + + const submitSource = extractNamedFunction(source, 'submitUsage') + assert.ok( + submitSource.indexOf('submitLoading.value = true') < submitSource.indexOf('.validate('), + '使用提交必须在异步校验前加锁' + ) + assert.match(submitSource, /if \(!valid\) \{\s*submitLoading\.value = false\s*return/) + assert.match(submitSource, /\.finally\(\(\) => \{\s*submitLoading\.value = false/) + assert.match(submitSource, /const idempotencyKey = ensureUsageIdempotencyKey\(\)/) + const successSource = extractPromiseCallback(submitSource, 'then') + assert.match(successSource, /const result = response\.data;/) + assert.match(successSource, /remainingPackages:\s*result\.remainingPackages/) + assert.match(successSource, /flowState:\s*result\.flowState/) + assert.doesNotMatch(source, /remainingPackages\s*[-+]=|remainingPackages\s*[-+]\s*form|Math\.(?:max|min)\([^)]*remainingPackages/) + assert.match(successSource, /clearUsageIdempotencyKey\(\)/) + const catchSource = extractPromiseCallback(submitSource, 'catch') + assert.doesNotMatch(catchSource, /clearUsageIdempotencyKey/) + assert.match(extractNamedFunction(source, 'resetUsageOperation'), /clearUsageIdempotencyKey\(\)/) + assert.match(source, /watch\([\s\S]*?clearUsageIdempotencyKey\(\)/) + assert.match(source, /@media \(max-width: 767px\)[\s\S]*?min-height:\s*(?:48|52|56)px/) +}) + +test('退回页面分离申请与确认幂等状态并在提交前严格复核三组托盘集合', async () => { + const source = await readFile(path.join(frontendRoot, businessPageContracts.return.relativePath), 'utf8') + + assertButtonsUsePermission(source, 'submitReturnRequest', 'business:return:request') + assertButtonsUsePermission(source, 'submitReturnConfirm', 'business:return:confirm') + assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:return:query', 'business:return:request'\]\)/) + assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:return:query', 'business:return:confirm'\]\)/) + assert.match(source, /status === ['"]REQUESTED['"]/) + assert.doesNotMatch(source, /\b(?:IN_TRANSIT|RECEIVED|REJECTED)\b/) + assert.match(source, /pallet\.siteId !== requestForm\.value\.sourceSiteId/) + assert.match(source, /mode:\s*['"]request['"]/) + assert.match(source, /mode:\s*['"]confirm['"][\s\S]*?returnId:/) + + const invalidateRequestScan = extractNamedFunction(source, 'invalidateRequestScan') + assert.match(invalidateRequestScan, /requestScanGeneration \+= 1/) + assert.match(invalidateRequestScan, /scanLoading\.request = false/) + assert.doesNotMatch(invalidateRequestScan, /confirmScanGeneration|scanLoading\.confirm/) + const invalidateConfirmScan = extractNamedFunction(source, 'invalidateConfirmScan') + assert.match(invalidateConfirmScan, /confirmScanGeneration \+= 1/) + assert.match(invalidateConfirmScan, /scanLoading\.confirm = false/) + assert.doesNotMatch(invalidateConfirmScan, /requestScanGeneration|scanLoading\.request/) + + const requestScan = extractNamedFunction(source, 'handleRequestScan') + assert.ok( + requestScan.indexOf('invalidateRequestScan()') < requestScan.indexOf('scanReturnPallet('), + '申请扫码必须先失效本区旧请求' + ) + assert.match(requestScan, /generation: \+\+requestScanGeneration/) + assert.match(requestScan, /isCurrentRequestScan\(scanContext\)/) + assert.match(extractPromiseCallback(requestScan, 'finally'), /scanLoading\.request = false/) + assert.doesNotMatch(requestScan, /invalidateConfirmScan|confirmScanGeneration|scanLoading\.confirm/) + + const confirmScan = extractNamedFunction(source, 'handleConfirmScan') + assert.ok( + confirmScan.indexOf('invalidateConfirmScan()') < confirmScan.indexOf('scanReturnPallet('), + '确认扫码必须先失效本区旧请求' + ) + assert.match(confirmScan, /generation: \+\+confirmScanGeneration/) + assert.match(confirmScan, /isCurrentConfirmScan\(scanContext\)/) + assert.match(extractPromiseCallback(confirmScan, 'finally'), /scanLoading\.confirm = false/) + assert.doesNotMatch(confirmScan, /invalidateRequestScan|requestScanGeneration|scanLoading\.request/) + + const resetRequest = extractNamedFunction(source, 'resetReturnRequest') + assert.match(resetRequest, /invalidateRequestScan\(\)/) + assert.doesNotMatch(resetRequest, /invalidateConfirmScan/) + const resetConfirm = extractNamedFunction(source, 'resetReturnConfirm') + assert.match(resetConfirm, /invalidateConfirmScan\(\)/) + assert.doesNotMatch(resetConfirm, /invalidateRequestScan/) + const confirmSelection = extractNamedFunction(source, 'handleConfirmReturnSelection') + assert.match(confirmSelection, /invalidateConfirmScan\(\)/) + assert.doesNotMatch(confirmSelection, /invalidateRequestScan/) + const warehouseWatch = source.slice(source.lastIndexOf('watch('), source.lastIndexOf('getList();')) + assert.match(warehouseWatch, /invalidateRequestScan\(\)/) + assert.doesNotMatch(warehouseWatch, /invalidateConfirmScan/) + + const requestEnsure = extractNamedFunction(source, 'ensureRequestIdempotencyKey') + const confirmEnsure = extractNamedFunction(source, 'ensureReturnConfirmIdempotencyKey') + assert.match(requestEnsure, /createIdempotencyKey\(\)/) + assert.match(confirmEnsure, /createIdempotencyKey\(\)/) + assert.doesNotMatch(requestEnsure, /confirmIdempotencyKey/) + assert.doesNotMatch(confirmEnsure, /requestIdempotencyKey/) + + const requestSubmit = extractNamedFunction(source, 'submitReturnRequest') + assert.match(requestSubmit, /const idempotencyKey = ensureRequestIdempotencyKey\(\)/) + assert.match(extractPromiseCallback(requestSubmit, 'then'), /clearRequestIdempotencyKey\(\)/) + assert.doesNotMatch(extractPromiseCallback(requestSubmit, 'catch'), /clearRequestIdempotencyKey/) + + const confirmSubmit = extractNamedFunction(source, 'submitReturnConfirm') + const detailIndex = confirmSubmit.indexOf('getReturn(') + const confirmIndex = confirmSubmit.indexOf('confirmReturn(') + assert.ok(detailIndex >= 0 && confirmIndex > detailIndex, '确认退回写请求前必须重新读取详情') + assert.match(confirmSubmit, /const initialExpectedIds = \[\.\.\.confirmExpectedPalletIds\.value\]/) + assert.match(confirmSubmit, /const scannedPalletIds = \[\.\.\.confirmScannedPallets\.value\.map\(\(item\) => item\.palletId\)\]/) + assert.match(confirmSubmit, /const latestPalletIds = latestDetail\.items\.map\(\(item\) => item\.palletId\)/) + assert.match(confirmSubmit, /!sameIdSet\(latestPalletIds, initialExpectedIds\)\s*\|\|\s*!sameIdSet\(latestPalletIds, scannedPalletIds\)/) + assert.match(confirmSubmit, /latestDetail\.status !== ['"]REQUESTED['"]/) + assert.match(confirmSubmit, /const idempotencyKey = ensureReturnConfirmIdempotencyKey\(returnId\)/) + assert.match(extractPromiseCallback(confirmSubmit, 'then', 1), /clearReturnConfirmIdempotencyKey\(\)/) + assert.doesNotMatch(extractPromiseCallback(confirmSubmit, 'catch'), /clearReturnConfirmIdempotencyKey/) + + assert.match(extractNamedFunction(source, 'resetReturnRequest'), /clearRequestIdempotencyKey\(\)/) + assert.match(extractNamedFunction(source, 'resetReturnConfirm'), /clearReturnConfirmIdempotencyKey\(\)/) +}) + test('业务用户分配组件使用固定映射并允许空数组完整替换', async () => { const relativePath = 'src/components/BusinessUserAssignDialog/index.vue' const source = await readFile(path.join(frontendRoot, relativePath), 'utf8') diff --git a/shierfeng-fastapi-frontend/src/views/business/filling/index.vue b/shierfeng-fastapi-frontend/src/views/business/filling/index.vue new file mode 100644 index 0000000..6b37d04 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/business/filling/index.vue @@ -0,0 +1,461 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/views/business/inbound/index.vue b/shierfeng-fastapi-frontend/src/views/business/inbound/index.vue new file mode 100644 index 0000000..c426700 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/business/inbound/index.vue @@ -0,0 +1,646 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/views/business/return/index.vue b/shierfeng-fastapi-frontend/src/views/business/return/index.vue new file mode 100644 index 0000000..207c64e --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/business/return/index.vue @@ -0,0 +1,735 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/views/business/shipment/index.vue b/shierfeng-fastapi-frontend/src/views/business/shipment/index.vue new file mode 100644 index 0000000..42405ec --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/business/shipment/index.vue @@ -0,0 +1,670 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/views/business/usage/index.vue b/shierfeng-fastapi-frontend/src/views/business/usage/index.vue new file mode 100644 index 0000000..34d7728 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/business/usage/index.vue @@ -0,0 +1,496 @@ + + + + +