diff --git a/scripts/admin-test-env-modules/inbound-hold-contract.test.mjs b/scripts/admin-test-env-modules/inbound-hold-contract.test.mjs index cbdfff879..308fdb787 100644 --- a/scripts/admin-test-env-modules/inbound-hold-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-hold-contract.test.mjs @@ -11,18 +11,39 @@ test('入库扣货真实 HTTP 模块进入默认全量并依赖订单造数', () }) test('入库扣货模块覆盖隔离、幂等、冲突、候选门禁和统一订单投影', () => { + assert.match(source, /context\.createdResources\.orders/) + assert.match(source, /fixture\.orderNo/) + assert.match(source, /初始入库单允许扣货/) assert.match(source, /错误客户不能读取扣货快照/) assert.match(source, /扣货请求同键同载荷重放/) assert.match(source, /扣货同键异载荷返回A0409/) assert.match(source, /扣货入库单从智能装柜候选排除/) assert.match(source, /解除后入库单恢复智能装柜候选/) + assert.match(source, /统一订单列表同步展示扣货字段/) + assert.match(source, /统一订单列表同步恢复扣货字段/) assert.match(source, /summary\?\.holdFlag/) assert.match(source, /summary\?\.currentHoldReason/) assert.doesNotMatch(source, /\.holdCargo\b|\.holdReason\b|\bholdCargo\s*:|\bholdReason\s*:/) }) +test('入库扣货冲突和重放均回查版本及不可变历史零漂移', () => { + assert.match(source, /historyFacts\(replayed\) === heldHistoryFacts/) + assert.match(source, /扣货冲突后版本、原因和历史零漂移/) + assert.match(source, /解除后既有扣货历史身份和内容保持不可变/) + assert.match(source, /historyFactsFrom\(historyRows\(released\)\.slice\(1\)\) === heldHistoryFacts/) + assert.match(source, /historyFacts\(releaseReplay\) === releasedHistoryFacts/) + assert.match(source, /过期版本冲突后解除快照和不可变历史零漂移/) +}) + test('入库扣货写操作统一使用请求头 Idempotency-Key 和 expectedVersion', () => { assert.match(source, /context\.request\('POST', path, body, context\.token, \{ 'Idempotency-Key': key \}\)/) assert.match(source, /expectedVersion: initialVersion/) assert.match(source, /expectedVersion: heldVersion/) + assert.match(source, /inbound-hold-stale/) +}) + +test('入库扣货模块只消费本轮 marker 造数且不读取历史资源或环境凭据', () => { + assert.match(source, /context\.marker/) + assert.match(source, /inboundCode=\$\{encodeURIComponent\(inboundCode\)\}/) + assert.doesNotMatch(source, /process\.env|ADMIN_TEST_|MERCHANT_TEST_|history fixture|historical/i) }) diff --git a/scripts/admin-test-env-modules/inbound-hold.mjs b/scripts/admin-test-env-modules/inbound-hold.mjs index efca25bd1..e12d9b551 100644 --- a/scripts/admin-test-env-modules/inbound-hold.mjs +++ b/scripts/admin-test-env-modules/inbound-hold.mjs @@ -6,6 +6,15 @@ function id(value) { return value == null ? '' : String(value) } function code(response) { return id(response?.body?.code) || 'missing' } function succeeded(response) { return response?.http === 200 && code(response) === '200' } function rows(page) { return Array.isArray(page?.records) ? page.records : [] } +function historyRows(detail) { return Array.isArray(detail?.history) ? detail.history : [] } +function historyFactsFrom(items) { + return JSON.stringify(items.map((item) => ({ + id: id(item?.id), action: item?.action || null, reason: item?.reason || null, + evidenceFileId: id(item?.evidenceFileId) || null, + operatorName: item?.operatorName || null, operatedAt: item?.operatedAt || null, + }))) +} +function historyFacts(detail) { return historyFactsFrom(historyRows(detail)) } function channelGroup(channelCode) { const value = String(channelCode || '').toUpperCase() if (value.startsWith('EXX_')) return 'EXX' @@ -34,13 +43,16 @@ export async function run(context) { const fixture = context.createdResources.orders || {} const tenantId = id(fixture.tenantId) const orderId = id(fixture.orderId) + const orderNo = id(fixture.orderNo) const inboundId = id(fixture.inboundId) const inboundCode = id(fixture.inboundCode) context.assert(MODULE, '复用本轮订单入库单', null, - Boolean(tenantId && orderId && inboundId && inboundCode), - `tenantId=${tenantId || 'missing'}, orderId=${orderId || 'missing'}, inboundId=${inboundId || 'missing'}, inboundCode=${inboundCode || 'missing'}`, + Boolean(tenantId && orderId && orderNo && inboundId && inboundCode), + `tenantId=${tenantId || 'missing'}, orderId=${orderId || 'missing'}, orderNo=${orderNo || 'missing'}, inboundId=${inboundId || 'missing'}, inboundCode=${inboundCode || 'missing'}`, OPTIONS) - if (!tenantId || !orderId || !inboundId || !inboundCode) throw new Error('orders fixture missing inbound hold identity') + if (!tenantId || !orderId || !orderNo || !inboundId || !inboundCode) { + throw new Error('orders fixture missing inbound hold identity') + } const unifiedPath = `/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}` const initialUnifiedResponse = await context.request('GET', unifiedPath) @@ -61,7 +73,7 @@ export async function run(context) { evidence(initialHoldResponse), OPTIONS) context.assert(MODULE, '初始入库单允许扣货', initialHoldResponse, initialHold?.holdFlag === false && initialHold?.canHold === true && initialHold?.canRelease === false - && Array.isArray(initialHold?.history), + && historyRows(initialHold).length === 0, `holdFlag=${initialHold?.holdFlag}, canHold=${initialHold?.canHold}, canRelease=${initialHold?.canRelease}, history=${initialHold?.history?.length ?? 'missing'}`, OPTIONS) const initialVersion = Number(initialHold?.version) @@ -96,18 +108,28 @@ export async function run(context) { OPTIONS) const heldVersion = Number(held?.version) const heldHistorySize = held?.history?.length + const heldHistoryFacts = historyFacts(held) const replayResponse = await write(context, holdPath, holdBody, holdKey) const replayed = context.apiSuccess(MODULE, '扣货请求同键同载荷重放', replayResponse, evidence(replayResponse), OPTIONS) context.assert(MODULE, '扣货幂等重放不增加版本或历史', replayResponse, - Number(replayed?.version) === heldVersion && replayed?.history?.length === heldHistorySize, + Number(replayed?.version) === heldVersion && replayed?.history?.length === heldHistorySize + && historyFacts(replayed) === heldHistoryFacts, `version=${replayed?.version}, history=${replayed?.history?.length ?? 'missing'}`, OPTIONS) const conflictResponse = await write(context, holdPath, { ...holdBody, reason: `${reason}-冲突` }, holdKey) expectCode(context, '扣货同键异载荷返回A0409', conflictResponse, 'A0409') + const heldAfterConflictResponse = await context.request('GET', holdPath) + const heldAfterConflict = context.apiSuccess(MODULE, '回查扣货冲突后的当前快照', heldAfterConflictResponse, + evidence(heldAfterConflictResponse), OPTIONS) + context.assert(MODULE, '扣货冲突后版本、原因和历史零漂移', heldAfterConflictResponse, + heldAfterConflict?.holdFlag === true && heldAfterConflict?.currentHoldReason === reason + && Number(heldAfterConflict?.version) === heldVersion && historyFacts(heldAfterConflict) === heldHistoryFacts, + `version=${heldAfterConflict?.version}, history=${heldAfterConflict?.history?.length ?? 'missing'}`, OPTIONS) + const heldUnifiedResponse = await context.request('GET', unifiedPath) const heldUnified = context.apiSuccess(MODULE, '扣货后读取统一订单', heldUnifiedResponse, evidence(heldUnifiedResponse), OPTIONS) @@ -117,6 +139,17 @@ export async function run(context) { `holdFlag=${heldUnified?.summary?.holdFlag}, mainStatus=${heldUnified?.summary?.mainStatus}`, OPTIONS) + const heldUnifiedListResponse = await context.request('GET', + `/api/platform/v1/orders?tenantId=${encodeURIComponent(tenantId)}&orderNo=${encodeURIComponent(orderNo)}&page=1&size=20`) + const heldUnifiedPage = context.apiSuccess(MODULE, '扣货后读取统一订单列表投影', heldUnifiedListResponse, + evidence(heldUnifiedListResponse), OPTIONS) + const heldUnifiedRow = rows(heldUnifiedPage).find((row) => id(row?.orderId) === orderId) + context.assert(MODULE, '统一订单列表同步展示扣货字段', heldUnifiedListResponse, + heldUnifiedRow?.holdFlag === true && heldUnifiedRow?.currentHoldReason === reason + && heldUnifiedRow?.mainStatus === mainStatus, + `orderId=${orderId}, holdFlag=${heldUnifiedRow?.holdFlag}, mainStatus=${heldUnifiedRow?.mainStatus || 'missing'}`, + OPTIONS) + const heldCandidatesResponse = await context.request('GET', candidatePath) const heldCandidates = context.apiSuccess(MODULE, '扣货后查询智能装柜候选', heldCandidatesResponse, evidence(heldCandidatesResponse), OPTIONS) @@ -141,12 +174,18 @@ export async function run(context) { OPTIONS) const releasedVersion = Number(released?.version) const releasedHistorySize = released?.history?.length + const releasedHistoryFacts = historyFacts(released) + context.assert(MODULE, '解除后既有扣货历史身份和内容保持不可变', releaseResponse, + releasedHistorySize === heldHistorySize + 1 + && historyFactsFrom(historyRows(released).slice(1)) === heldHistoryFacts, + `before=${heldHistorySize}, after=${releasedHistorySize}`, OPTIONS) const releaseReplayResponse = await write(context, releasePath, releaseBody, releaseKey) const releaseReplay = context.apiSuccess(MODULE, '解除请求同键同载荷重放', releaseReplayResponse, evidence(releaseReplayResponse), OPTIONS) context.assert(MODULE, '解除幂等重放不增加版本或历史', releaseReplayResponse, - Number(releaseReplay?.version) === releasedVersion && releaseReplay?.history?.length === releasedHistorySize, + Number(releaseReplay?.version) === releasedVersion && releaseReplay?.history?.length === releasedHistorySize + && historyFacts(releaseReplay) === releasedHistoryFacts, `version=${releaseReplay?.version}, history=${releaseReplay?.history?.length ?? 'missing'}`, OPTIONS) const staleResponse = await write(context, holdPath, @@ -154,6 +193,14 @@ export async function run(context) { `${context.marker}-inbound-hold-stale`.slice(0, 128)) expectCode(context, '过期版本再次扣货返回A0409', staleResponse, 'A0409') + const finalHoldResponse = await context.request('GET', holdPath) + const finalHold = context.apiSuccess(MODULE, '回查过期版本冲突后的扣货快照', finalHoldResponse, + evidence(finalHoldResponse), OPTIONS) + context.assert(MODULE, '过期版本冲突后解除快照和不可变历史零漂移', finalHoldResponse, + finalHold?.holdFlag === false && finalHold?.currentHoldReason == null + && Number(finalHold?.version) === releasedVersion && historyFacts(finalHold) === releasedHistoryFacts, + `version=${finalHold?.version}, history=${finalHold?.history?.length ?? 'missing'}`, OPTIONS) + const finalUnifiedResponse = await context.request('GET', unifiedPath) const finalUnified = context.apiSuccess(MODULE, '解除后读取统一订单', finalUnifiedResponse, evidence(finalUnifiedResponse), OPTIONS) @@ -163,6 +210,17 @@ export async function run(context) { `holdFlag=${finalUnified?.summary?.holdFlag}, mainStatus=${finalUnified?.summary?.mainStatus}`, OPTIONS) + const finalUnifiedListResponse = await context.request('GET', + `/api/platform/v1/orders?tenantId=${encodeURIComponent(tenantId)}&orderNo=${encodeURIComponent(orderNo)}&page=1&size=20`) + const finalUnifiedPage = context.apiSuccess(MODULE, '解除后读取统一订单列表投影', finalUnifiedListResponse, + evidence(finalUnifiedListResponse), OPTIONS) + const finalUnifiedRow = rows(finalUnifiedPage).find((row) => id(row?.orderId) === orderId) + context.assert(MODULE, '统一订单列表同步恢复扣货字段', finalUnifiedListResponse, + finalUnifiedRow?.holdFlag === false && finalUnifiedRow?.currentHoldReason == null + && finalUnifiedRow?.mainStatus === mainStatus, + `orderId=${orderId}, holdFlag=${finalUnifiedRow?.holdFlag}, mainStatus=${finalUnifiedRow?.mainStatus || 'missing'}`, + OPTIONS) + const finalCandidatesResponse = await context.request('GET', candidatePath) const finalCandidates = context.apiSuccess(MODULE, '解除后查询智能装柜候选', finalCandidatesResponse, evidence(finalCandidatesResponse), OPTIONS)