fix: complete October 22 workflow feedback and request failure recovery

This commit is contained in:
2026-10-22 23:06:00 +08:00
parent f701456de1
commit a16a85e14a
85 changed files with 1518 additions and 277 deletions
+4
View File
@@ -226,6 +226,10 @@ PDA 待复核通过后,`manual-loading-complete-fixture.mjs` 冻结无证据
`fee-items` 需要同一 marker 的客户订单和入库单。执行 `--modules fee-items` 时,入口自动扩展为 `merchant-qualification-fixture,orders,fee-items`,无需手工补齐前置模块。
固定客户模式下 `fee-items` 只查询现有费用目录、核对112条正式基线及客户权限,不新增、编辑或删除费用主档,增删改生命周期单独记录为未覆盖。所有模式均不自动删除历史 `ACPT_` 项目;非固定模式的隔离写入仅清理本轮返回的项目和明细 ID。
`finance-full-fixtures` 在固定客户模式下只复用已有不包税计费规则:唯一规则或唯一默认规则必须属于当前客户并匹配所需计费方式;缺失、不匹配或默认规则有歧义即停止,不自动创建规则版本或切换默认规则。供应商同样复用既有启用记录;下述创建隔离供应商的说明仅适用于非固定客户模式。
模块只需导出:
```js
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
import test from 'node:test'
import { normalizeModuleOrder, parseArguments } from '../admin-test-env-acceptance.mjs'
import { cleanupSucceeded, isAcceptanceFeeItem, formalFeeItems } from './fee-items.mjs'
import { cleanupSucceeded, formalFeeItems, run } from './fee-items.mjs'
const source = readFileSync(new URL('./fee-items.mjs', import.meta.url), 'utf8')
@@ -23,15 +23,44 @@ test('cleanup treats successful deletion and an already absent resource as clean
assert.equal(cleanupSucceeded({ http: 200, body: { code: 'A0422' } }), false)
})
test('stale cleanup only recognizes the acceptance-owned code and name pair', () => {
assert.equal(isAcceptanceFeeItem({ feeCode: 'ACPT_813083345572', feeTypeName: '全量验收费用-813083345572-已编辑' }), true)
assert.equal(isAcceptanceFeeItem({ feeCode: 'FEE_TYPE_001', feeTypeName: '全量验收费用-001' }), false)
assert.equal(isAcceptanceFeeItem({ feeCode: 'ACPT_813083345572', feeTypeName: '正式费用项目' }), false)
test('no automatic historical fee-item cleanup remains', () => {
assert.doesNotMatch(source, /cleanupStaleAcceptanceItems|isAcceptanceFeeItem/)
})
test('baseline checks run only after stale acceptance cleanup', () => {
assert.ok(source.indexOf('cleanupStaleAcceptanceItems(context)')
< source.indexOf("'全部目录默认20条分页与完整列表一致'"))
for (const missingBaseline of [false, true]) test(`fixed fee catalogue preserves old test items and performs no master writes; missing=${missingBaseline}`, async () => {
const codes = ['FBA_HEAD_FREIGHT', 'PRIVATE_LAST_MILE_DELIVERY', ...Array.from({ length: 110 }, (_, i) => `FEE_TYPE_${String(i + 1).padStart(3, '0')}`)]
const catalogue = codes.map((feeCode, i) => ({ id: String(i + 1), feeCode, tenantId: '0', status: 'ACTIVE',
tradeScope: i < 84 ? 'DOMESTIC' : 'INTERNATIONAL', category: '标准', feeTypeName: `费用${i}` }))
catalogue.push({ id: '999', feeCode: 'ACPT_123456', feeTypeName: '全量验收费用-123456', tenantId: '0', status: 'DISABLED', category: '临时', tradeScope: 'DOMESTIC' })
if (missingBaseline) catalogue.splice(3, 1)
const calls = [], gaps = []
const context = { options: { fixedCustomerId: '101' }, marker: 'TEST', createdResources: {},
async request(method, path, body, token) {
calls.push({ method, path })
const response = data => ({ http: 200, path, body: { code: '200', data } })
if (path === '/api/auth/login') return response({ accessToken: 'client', refreshToken: 'refresh' })
if (path === '/api/auth/logout') return response({})
assert.equal(method, 'GET')
if (token === 'client') return { http: 403, body: { code: 'A0403' } }
const params = new URL(path, 'https://test.invalid').searchParams
const selected = catalogue.filter(row => ['category', 'tradeScope', 'status'].every(key => !params.has(key) || row[key] === params.get(key))
&& (!params.has('keyword') || row.feeTypeName.includes(params.get('keyword'))))
const size = Number(params.get('size')), page = Number(params.get('page'))
return response({ records: selected.slice((page - 1) * size, page * size), total: selected.length, page, size, pages: Math.ceil(selected.length / size) })
}, apiSuccess(_m, _n, response) { return response.body.data },
assert(_m, name, _r, passed) { assert.ok(passed, name) },
addCoverageGap(...args) { gaps.push(args) },
}
const before = structuredClone(catalogue)
if (missingBaseline) await assert.rejects(run(context), /112条/)
else {
await run(context)
assert.equal(context.createdResources.feeItems.mode, 'READ_ONLY')
assert.equal(gaps.length, 1)
assert.equal(calls.filter(row => row.path === '/api/auth/logout').length, 1)
}
assert.deepEqual(catalogue, before)
assert.ok(calls.every(row => row.method === 'GET' || row.path.startsWith('/api/auth/')))
})
test('正式费用按编码识别,扩展停用项目保留且不掩盖缺失正式编码', () => {
+14 -23
View File
@@ -56,10 +56,6 @@ export function formalFeeItems(rows) {
const codes = new Set(expectedCodes())
return rows.filter(row => codes.has(row.feeCode))
}
export function isAcceptanceFeeItem(row) {
return /^ACPT_\d+$/.test(String(row?.feeCode || ''))
&& String(row?.feeTypeName || '').startsWith('全量验收费用-')
}
function fixture(context) {
const candidates = [context.createdResources?.orders, context.createdResources?.testInbound,
context.createdResources?.inboundMeasurement, context.context?.testInbound]
@@ -73,26 +69,7 @@ async function listAll(context) {
return { response, page, rows: records(page) }
}
async function cleanupStaleAcceptanceItems(context) {
const listed = await listAll(context)
const stale = listed.rows.filter(isAcceptanceFeeItem)
const failed = []
for (const row of stale) {
const response = await context.request('DELETE', `${BASE}/${encodeURIComponent(id(row.id))}`)
if (!cleanupSucceeded(response)) failed.push({ row, response })
}
const response = failed[0]?.response || listed.response
context.assert(MODULE, '前置清理历史验收临时费用项目', response, failed.length === 0,
failed.length === 0
? `stale=${stale.length}, cleaned=${stale.length}`
: `failed=${failed.map(({ row, response: failure }) => `${row.feeCode}:${failure.body?.code || failure.http}`).join(',')}`,
OPTIONS)
return failed.length === 0
}
export async function run(context) {
if (!await cleanupStaleAcceptanceItems(context)) return
const initialPageResponse = await context.request('GET', query({ page: 1, size: 20 }))
const initialPage = context.apiSuccess(MODULE, '费用项目查看权限与分页查询', initialPageResponse,
'page=1,size=20', OPTIONS)
@@ -158,12 +135,26 @@ export async function run(context) {
}, '')
const merchantLogin = success(merchantLoginResponse) ? merchantLoginResponse.body?.data : null
const merchantToken = merchantLogin?.accessToken
try {
const forbiddenResponse = merchantToken
? await context.request('GET', query({ page: 1, size: 20 }), undefined, merchantToken)
: merchantLoginResponse
context.assert(MODULE, '无费用项目平台权限的客户账号被拒绝', forbiddenResponse,
Boolean(merchantToken) && forbidden(forbiddenResponse),
`HTTP=${forbiddenResponse.http}, code=${forbiddenResponse.body?.code ?? 'missing'}`, OPTIONS)
} finally {
if (merchantToken && merchantLogin.refreshToken) {
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: merchantLogin.refreshToken }, merchantToken)
context.assert(MODULE, '退出费用目录权限核验会话', logout, success(logout), '', OPTIONS)
}
}
if (context.options?.fixedCustomerId) {
context.createdResources.feeItems = { marker: context.marker, mode: 'READ_ONLY', baselineCount: initialRows.length }
context.addCoverageGap(MODULE, 'fixed-fee-item-lifecycle', '费用项目增删改生命周期',
'固定客户模式只读核对现有费用目录,不创建、修改或删除公共主档及历史测试项目', 'not-implemented', { blocking: false })
return
}
const suffix = context.marker.replace(/\D/g, '').slice(-12)
const createPayload = payload(suffix)
@@ -4,7 +4,33 @@ import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { buildContainerCostBody, buildSupplierBody, normalizeFinanceFixtureSources, run,
supplierCategoryForFeeItem, loadSupplier } from './finance-full-fixtures.mjs'
supplierCategoryForFeeItem, loadSupplier, ensureCustomerChargeRule } from './finance-full-fixtures.mjs'
for (const mode of ['single', 'default', 'missing', 'wrong mode', 'foreign customer', 'ambiguous', 'two defaults']) {
test(`fixed customer charge rules are read-only and fail closed: ${mode}`, async () => {
const good = { id: '701', customerId: '456', status: 'ACTIVE', businessType: 'FBA', taxMode: 'TAX_EXCLUDED', chargeMode: 'CBM', defaultRule: false }
let rules = [good]
if (mode === 'default') rules = [{ ...good, defaultRule: true }, { ...good, id: '702', chargeMode: 'KG' }]
if (mode === 'missing') rules = []
if (mode === 'wrong mode') rules = [{ ...good, chargeMode: 'KG' }]
if (mode === 'foreign customer') rules = [{ ...good, customerId: '999' }]
if (mode === 'ambiguous') rules = [good, { ...good, id: '702', chargeMode: 'KG' }]
if (mode === 'two defaults') rules = [{ ...good, defaultRule: true }, { ...good, id: '702', defaultRule: true }]
const calls = [], checks = [], before = structuredClone(rules)
const context = { options: { fixedCustomerId: '456' },
async request(method, path) { calls.push({ method, path }); assert.equal(method, 'GET');
return { body: { data: path.includes('/inbounds/') ? { summary: { businessType: 'FBA' } } : rules } } },
apiSuccess(_m, _n, response) { return response.body.data },
assert(_m, _n, _r, passed) { checks.push(passed) },
}
const invoke = () => ensureCustomerChargeRule(context, { tenantId: '456', inboundId: '100' }, { pricingTaxMode: 'TAX_EXCLUDED', chargeUnit: 'CBM' })
if (['single', 'default'].includes(mode)) assert.equal((await invoke()).id, '701')
else await assert.rejects(invoke(), /禁止自动创建或切换/)
assert.deepEqual(rules, before)
assert.equal(calls.length, 2)
assert.equal(checks.at(-1), ['single', 'default'].includes(mode))
})
}
test('fixed customer supplier selection paginates and never creates a fallback', async () => {
const calls = [], failures = []
@@ -366,7 +366,7 @@ async function loadPricingEvidence(context) {
return { activeVersionIds: active.map((item) => id(item.id)).filter(Boolean), profiles }
}
async function ensureCustomerChargeRule(context, resource, profile) {
export async function ensureCustomerChargeRule(context, resource, profile) {
if (context.financeFixtureHooks?.ensureCustomerChargeRule) {
return context.financeFixtureHooks.ensureCustomerChargeRule(context, resource, profile)
}
@@ -389,6 +389,15 @@ async function ensureCustomerChargeRule(context, resource, profile) {
const applicable = rows(listed).filter((rule) => rule?.status === 'ACTIVE'
&& rule?.businessType === businessType && rule?.taxMode === 'TAX_EXCLUDED')
let desired = applicable.find((rule) => rule?.chargeMode === profile.chargeUnit)
if (context.options?.fixedCustomerId) {
const defaults = applicable.filter(rule => rule.defaultRule === true)
const selected = applicable.length === 1 ? applicable[0] : defaults.length === 1 ? defaults[0] : null
const valid = selected?.id && id(selected.customerId) === tenantId && selected.chargeMode === profile.chargeUnit
context.assert(MODULE, '固定客户复用现有有效默认计费方式', listResponse, Boolean(valid),
`customerId=${tenantId}, chargeMode=${profile.chargeUnit}`, OPTIONS)
if (!valid) throw new Error('固定客户缺少匹配且无歧义的计费规则,禁止自动创建或切换默认规则')
return selected
}
if (!desired) {
const effectiveFrom = new Date(Date.now() - 86_400_000).toISOString().slice(0, 19)
const body = {
@@ -11,11 +11,12 @@ const acceptanceUrl = new URL('../admin-test-env-acceptance.mjs', import.meta.ur
test('both pallet receipt batches supply SKU box counts and measured pallet fields', () => {
for (const [sku, quantity, startedBy] of [['A', 3, 'RETURN_NO'], ['B', 2, 'SKU']]) {
const body = palletReceiptBody('RT-1', `P-${sku}`, sku, quantity, `KEY-${sku}`, startedBy)
const body = palletReceiptBody('RT-1', `P-${sku}`, sku, quantity, `KEY-${sku}`, startedBy, sku === 'A' ? 0 : 3)
assert.equal(body.unitType, 'PALLET')
assert.equal(body.unitCode, `P-${sku}`)
assert.equal(body.palletCode, body.unitCode)
assert.equal(body.startedBy, startedBy)
assert.equal(body.expectedReceivedQuantity, sku === 'A' ? 0 : 3)
assert.deepEqual(body.items, [{ skuCode: sku, receivedQuantity: quantity, boxCount: 1 }])
for (const field of ['length', 'width', 'height', 'weight']) assert.ok(body[field] > 0)
assert.equal(body.totalBoxCount, undefined, 'server derives the total from SKU box counts')
@@ -263,7 +263,7 @@ async function runWithSession(context, merchantToken) {
|| !success(palletB.response) || !nonBlank(palletB.data?.palletCode)) throw new Error('签收托盘准备失败')
const palletCodeA = palletA.data.palletCode
const firstBody = {
...palletReceiptBody(returnNo, palletCodeA, skuA, 3, key(context, 'RECEIPT-1'), 'RETURN_NO'),
...palletReceiptBody(returnNo, palletCodeA, skuA, 3, key(context, 'RECEIPT-1'), 'RETURN_NO', 0),
trackingNo: `TRACK-${suffix}`,
}
const first = await overseas(context, picker.token, 'POST', 'PDA首次单SKU签收甲3件', `${PDA_RECEIPTS}/confirm`, firstBody)
@@ -291,7 +291,7 @@ async function runWithSession(context, merchantToken) {
await reject('超收被拒绝且不得生成费用批次', conflict, async () => {
const body = {
...palletReceiptBody(returnNo, palletCodeA, skuA, 2, key(context, 'OVER-RECEIPT'), 'SKU'),
...palletReceiptBody(returnNo, palletCodeA, skuA, 2, key(context, 'OVER-RECEIPT'), 'SKU', 3),
}
let response = null
for (let attempt = 1; attempt <= 3; attempt += 1) {
@@ -310,7 +310,7 @@ async function runWithSession(context, merchantToken) {
['非法签收方式', { startedBy: 'INVALID' }, validationFailure],
['未预报SKU', { items: [{ skuCode: `UNKNOWN-${suffix}`, receivedQuantity: 1, boxCount: 1 }] }, conflict],
]) {
await reject(`拒绝${name}`, expected, () => context.request('POST', `${PDA_RECEIPTS}/confirm`, { ...firstBody, ...changes, idempotencyKey: key(context, `INVALID-RECEIPT-${encodeURIComponent(name)}`) }, picker.token, {}))
await reject(`拒绝${name}`, expected, () => context.request('POST', `${PDA_RECEIPTS}/confirm`, { ...firstBody, expectedReceivedQuantity: 3, ...changes, idempotencyKey: key(context, `INVALID-RECEIPT-${encodeURIComponent(name)}`) }, picker.token, {}))
}
await reject('部分签收退货单禁止合箱', conflict, () => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', {
returnNos: [returnNo], items: [{ skuCode: skuA, quantity: 1 }], length: 30, width: 20, height: 10,
@@ -327,7 +327,7 @@ async function runWithSession(context, merchantToken) {
`receipts=${afterPartial.data?.receipts?.length ?? 'missing'}`, OPTIONS)
const secondBody = {
...palletReceiptBody(returnNo, palletB.data.palletCode, skuB, 2, key(context, 'RECEIPT-2'), 'SKU'),
...palletReceiptBody(returnNo, palletB.data.palletCode, skuB, 2, key(context, 'RECEIPT-2'), 'SKU', 3),
}
const second = await overseas(context, picker.token, 'POST', 'PDA无照片按单SKU完成乙2件签收', `${PDA_RECEIPTS}/confirm`, secondBody)
if (!success(second.response)) throw new Error('最终签收失败,停止合箱和换标后续')
@@ -419,6 +419,7 @@ async function runWithSession(context, merchantToken) {
const mergeBody = {
sourceCartonMarks: [cartonA, cartonB],
expectedSourceQuantities: [{ cartonMark: cartonA, skuCode: skuA, quantity: 3 }, { cartonMark: cartonB, skuCode: skuB, quantity: 2 }],
sourceDeductions: [{ cartonMark: cartonA, skuCode: skuA, quantity: 1 }, { cartonMark: cartonB, skuCode: skuB, quantity: 1 }],
length: 30, width: 20, height: 10, weight: 2, idempotencyKey: key(context, 'MERGE'),
}
@@ -469,7 +470,7 @@ async function runWithSession(context, merchantToken) {
['空来源箱', { sourceCartonMarks: [] }, validationFailure],
['零合箱数量', { sourceDeductions: [{ cartonMark: cartonA, skuCode: skuA, quantity: 0 }] }, validationFailure],
['超可用库存', { sourceDeductions: [{ cartonMark: cartonA, skuCode: skuA, quantity: 999 }] }, conflict],
['不存在来源箱', { sourceCartonMarks: [`MISSING-${suffix}`] }, conflict],
['不存在来源箱', { sourceCartonMarks: [`MISSING-${suffix}`], expectedSourceQuantities: [{ cartonMark: `MISSING-${suffix}`, skuCode: skuA, quantity: 1 }] }, conflict],
]) {
await reject(`合箱拒绝${name}`, expected, () => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge-boxes', { ...mergeBody, ...changes, idempotencyKey: key(context, `INVALID-MERGE-${encodeURIComponent(name)}`) }, picker.token, {}))
}
@@ -47,9 +47,9 @@ export function exactReceiptLedger(data, expected) {
&& JSON.stringify(stableState(rows(data).map(project))) === JSON.stringify(stableState(expected.map(project)))
}
export function palletReceiptBody(returnNo, palletCode, skuCode, receivedQuantity, idempotencyKey, startedBy) {
export function palletReceiptBody(returnNo, palletCode, skuCode, receivedQuantity, idempotencyKey, startedBy, expectedReceivedQuantity) {
return {
returnNo, palletCode, unitType: 'PALLET', unitCode: palletCode, startedBy,
returnNo, palletCode, unitType: 'PALLET', unitCode: palletCode, startedBy, expectedReceivedQuantity,
length: 120, width: 100, height: 80, weight: 10,
items: [{ skuCode, receivedQuantity, boxCount: 1 }], idempotencyKey,
}
@@ -109,7 +109,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
}
function receiptBody(order, quantity = order.quantity, idempotencyKey = key('RECEIPT')) {
// 合成包裹明确原包装选择与实测输入,使边界场景进入待验证的库存/并发规则。
return { returnNo: order.returnNo, startedBy: 'RETURN_NO', unitType: 'PARCEL', unitCode: order.unitCode,
return { returnNo: order.returnNo, expectedReceivedQuantity: 0, startedBy: 'RETURN_NO', unitType: 'PARCEL', unitCode: order.unitCode,
originalPackaging: true, length: 30, width: 20, height: 10, weight: 2,
items: [{ skuCode: order.sku, receivedQuantity: quantity }], idempotencyKey }
}
@@ -399,9 +399,9 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
const withLedger = async () => ({ inventory: await snapshot([order]), ledger: await ledger() })
await verifyNoMutation({ snapshot: withLedger, action: () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, body),
check, name: '签收重放不重复产生流水', expected: result => ok(result) && result.body.data?.receiptNo === signed.receiptNo })
await verifyNoMutation({ snapshot: withLedger, action: () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, receiptBody(order, 2)),
await verifyNoMutation({ snapshot: withLedger, action: () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, { ...receiptBody(order, 2), expectedReceivedQuantity: 2 }),
check, name: '超收失败不留下流水', expected: result => rejected(result, [409]) })
const final = await good(picker.token, 'POST', '流水场景第二批实收一件', `${RECEIPTS}/confirm`, { ...receiptBody(order, 1), unitCode: `${order.unitCode}-SECOND` })
const final = await good(picker.token, 'POST', '流水场景第二批实收一件', `${RECEIPTS}/confirm`, { ...receiptBody(order, 1), expectedReceivedQuantity: 2, unitCode: `${order.unitCode}-SECOND` })
const data = await ledger()
check('两批签收流水业务引用唯一且分别两件一件', null, exactReceiptLedger(data,
[entry, { ...entry, referenceNo: final.receiptNo, totalDelta: 1, availableDelta: 1 }]) && signed.receiptNo !== final.receiptNo)
+1 -1
View File
@@ -1296,7 +1296,7 @@ $criticalPaths = @(
'apps/pda-uniapp/tests/app-update.test.mjs',
'apps/pda-uniapp/tests/page-contract.test.mjs',
'apps/pda-uniapp/tests/pda-loading-api.test.mjs',
'apps/pda-uniapp/utils/evidence-upload-queue.js',
'apps/pda-uniapp/tests/picking-failure-recovery.test.mjs',
'apps/pda-overseas-uniapp/package.json',
'apps/pda-overseas-uniapp/src/pages.json',
'apps/pda-overseas-uniapp/src/data/overseas-workbench.js',
+1 -1
View File
@@ -86,7 +86,7 @@ try {
const pallet = await command('os', picker.token, 'POST', `${receipts}/pallets`, { returnNo, skuCode })
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')
await upload(picker.token, `${receipts}/${returnNo}/photos/files`, png, 'synthetic-receipt.png', 'image/png')
const receiptBody = { returnNo, startedBy: 'RETURN_NO', palletCode: pallet.palletCode, items: [{ skuCode, receivedQuantity: receiptQuantity }], idempotencyKey: `${context.marker}-RECEIPT` }
const receiptBody = { returnNo, expectedReceivedQuantity: 0, startedBy: 'RETURN_NO', palletCode: pallet.palletCode, items: [{ skuCode, receivedQuantity: receiptQuantity }], idempotencyKey: `${context.marker}-RECEIPT` }
const received = await command('os', picker.token, 'POST', `${receipts}/confirm`, receiptBody)
const replay = await command('os', picker.token, 'POST', `${receipts}/confirm`, receiptBody)
check('Receipt completed once with one stock and fee batch', received.orderStatus === 'COMPLETED' && Number(received.receivedQuantity) === receiptQuantity && received.receiptNo === replay.receiptNo && received.receiptBatchNo === replay.receiptBatchNo)