diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index d49d1196e..595d4cfa8 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -113,6 +113,10 @@ node scripts/admin-test-env-acceptance.mjs --gateway --ove 计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口仍为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:保留同柜 `RELEASED` 订舱、bridge 与五类 ACTIVE 证据门禁;拖车状态和数量不是前置门禁。主事务提交后,零条有效拖车直接跳过,恰有一条时在独立事务尝试推进 `COMPLETED`,多条或并发冲突只记录拖车同步失败,不回滚已完成的 task/plan/inbound。PDA 结算可能先递增来源入库单版本或创建实装分片,完成动作必须在结算后重新锁定实际完成行并使用当前版本;HTTP finalizer 会回查 full/partial 履约分片,失败时保存 `requestId/traceId` 并断言 task/plan/inbound/operation 零漂移。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。 +动态增量还会读取计划明细中的服务端 `totalPackageCount/remainingPackageCount`,断言 +`remaining = total - assigned`;随后尝试把箱数调整为权威总数加一,必须得到 `A0400`,并回查 +plan/item 版本、分配箱数及总数投影全部零漂移。脚本不从当前已分配数量反推订单总箱数。 + `phase3-pda-convergence` 同时回查 `/tasks/pending` 与稳定 task detail:两者的 `workflowStatus`、`itemCount`、`pendingItemCount` 必须来自同一任务明细事实。全拣任务必须在两处均为 `AWAITING_REVIEW`,可执行证据 GET,但派生 `actions=[]`,不得重复发送 batch;操作历史必须恰有一次有效 `BATCH`。若 pending 仍按 assignment 返回 `IN_PROGRESS`,该轮以真实 HTTP 失败阻断,不由脚本或前端改写状态。 部署后需要定向验证该完整链时,模块顺序固定为: @@ -124,7 +128,8 @@ node scripts/admin-test-env-acceptance.mjs --modules merchant-qualification-fixt 整票库位块专项可在只跑 `orders,inbound-measurement` 时追加 `--verify-pallet-location-guide true`。它在测量仍可编辑、正式提交前核对 `palletLocationGuide` 计数、每槽 `blockId/slotNo/reservationId/locationCode/slotStatus`、 -称重结果按 `weightResultId` 显式绑定槽位,并真实验证删除图片后原 reservation/location +称重结果按 `weightResultId` 显式绑定槽位;每确认一张称重图片都会回查一次,后续扩容只能追加槽位, +既有 `blockId/slotNo/reservationId/locationId/sharedOverflow` 不得漂移。脚本还会真实验证删除图片后原 reservation/location 保留且转为 `RESERVED_EMPTY`,再上传替代图片并确认复用原槽后继续提交与审核。脚本只观测自然出现的共享 A0; 不会为制造普通位不足批量停用库位。 diff --git a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs index e8baafcf0..71d2dec73 100644 --- a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs @@ -64,6 +64,9 @@ test('opt-in HTTP acceptance verifies whole-ticket slot identity and delete-to-e assert.match(source, /verifyPalletLocationGuide/) assert.match(source, /expectedWeightResultIds\.every/) assert.match(source, /asId\(row\.reservationId\) === asId\(slot\.reservationId\)/) + assert.match(source, /逐张确认后回查整票库位块/) + assert.match(source, /扩容只追加且既有槽身份不漂移/) + assert.match(source, /verified\.slots\.length >= previous\.slots\.length && priorSlotsStable/) assert.match(source, /\['RESERVED_EMPTY', 'BOUND', 'TRANSFERRED', 'RELEASED'\]/) assert.match(source, /deleteSucceeded/) assert.match(source, /retainedSlot\?\.slotStatus === 'RESERVED_EMPTY'/) diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 44180ab1b..3b182d4ce 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -129,6 +129,7 @@ async function createConfirmedWeightEvidence(context, { }) { if (!Array.isArray(palletIds) || palletIds.length < 1) return null const confirmedResults = [] + const guideEvolution = [] for (let index = 0; index < palletIds.length; index += 1) { const palletId = palletIds[index] const uploadResponse = await context.requestMultipart('POST', @@ -180,6 +181,40 @@ async function createConfirmedWeightEvidence(context, { id: asId(created.id), palletId, netWeightKg, grossWeightKg, version: Number(confirmed?.version), }) + + if (context.options?.verifyPalletLocationGuide) { + const guideResponse = await context.request('GET', + `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement?${query(tenantId)}`) + const guideWorkspace = context.apiSuccess(MODULE, + `逐张确认后回查整票库位块 ${index + 1}/${palletIds.length}`, guideResponse, + `resultId=${asId(created.id)}, confirmedCount=${confirmedResults.length}`, OPTIONS) + const verified = verifyPalletLocationGuide(context, guideResponse, guideWorkspace, + confirmedResults.map((result) => result.id), `第${index + 1}张确认后`) + const previous = guideEvolution.at(-1) + if (previous) { + const priorSlotsStable = previous.slots.every((prior) => { + const current = verified.slots.find((slot) => Number(slot?.slotNo) === Number(prior?.slotNo)) + return current + && asId(current.reservationId) === asId(prior.reservationId) + && asId(current.locationId) === asId(prior.locationId) + && String(current.locationCode || '') === String(prior.locationCode || '') + && Boolean(current.sharedOverflow) === Boolean(prior.sharedOverflow) + }) + context.assert(MODULE, `第${index + 1}张确认后扩容只追加且既有槽身份不漂移`, guideResponse, + asId(verified.guide?.blockId) === previous.blockId + && verified.slots.length >= previous.slots.length && priorSlotsStable, + `beforeBlock=${previous.blockId || 'missing'}, afterBlock=${asId(verified.guide?.blockId) || 'missing'}, beforeSlots=${previous.slots.length}, afterSlots=${verified.slots.length}`, + OPTIONS) + } + guideEvolution.push({ + blockId: asId(verified.guide?.blockId), + slots: verified.slots.map((slot) => ({ + slotNo: Number(slot?.slotNo), reservationId: asId(slot?.reservationId), + locationId: asId(slot?.locationId), locationCode: String(slot?.locationCode || ''), + sharedOverflow: Boolean(slot?.sharedOverflow), + })), + }) + } } const workspaceResponse = await context.request('GET', @@ -200,7 +235,9 @@ async function createConfirmedWeightEvidence(context, { context.assert(MODULE, '图片张数决定托数且净重汇总已落地', workspaceResponse, persisted, `confirmedImages=${confirmedRows.length}, expectedPallets=${palletIds.length}, grossWeight=${grossWeightKg}, netWeight=${netWeightKg}, actualGross=${workspace?.actualGrossWeightKg ?? 'missing'}, actualNet=${workspace?.actualNetWeightKg ?? 'missing'}`, OPTIONS) - return persisted ? { count: confirmedRows.length, grossWeightKg, netWeightKg, confirmedResults } : null + return persisted + ? { count: confirmedRows.length, grossWeightKg, netWeightKg, confirmedResults, guideEvolution } + : null } function verifyPalletLocationGuide(context, response, workspace, expectedWeightResultIds, name) { diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 5f774d6b6..5ba1a6556 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1328,6 +1328,38 @@ export async function run(context) { reassignedPlanItems.length === 1 && id(reassignedPlanItem?.inboundDetailId) === id(dynamicCandidate.inboundDetailId), `inboundId=${incrementalInboundId}, inboundDetailId=${id(dynamicCandidate.inboundDetailId)}, matches=${reassignedPlanItems.length}, planItemId=${id(reassignedPlanItem?.id) || 'missing'}`, OPTIONS) + const authoritativeTotal = Number(reassignedPlanItem?.totalPackageCount) + const assignedPackageCount = Number(reassignedPlanItem?.packageCount) + const authoritativeRemaining = Number(reassignedPlanItem?.remainingPackageCount) + context.assert(MODULE, '计划详情由服务端返回权威总箱数和剩余箱数', reassignResponse, + Number.isInteger(authoritativeTotal) && authoritativeTotal >= assignedPackageCount + && authoritativeRemaining === authoritativeTotal - assignedPackageCount, + `totalPackageCount=${reassignedPlanItem?.totalPackageCount ?? 'missing'}, packageCount=${reassignedPlanItem?.packageCount ?? 'missing'}, remainingPackageCount=${reassignedPlanItem?.remainingPackageCount ?? 'missing'}`, + OPTIONS) + if (reassignedPlanItem?.id && Number.isInteger(authoritativeTotal) && authoritativeTotal > 0) { + const overAuthorityResponse = await request(context, 'PATCH', + `${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(reassignedPlanItem.id))}`, { + packageCount: authoritativeTotal + 1, expectedPlanVersion: Number(reassignedPlan?.version), + expectedItemVersion: Number(reassignedPlanItem.version), + operationId: `${context.marker}-dynamic-over-authority`.slice(0, 128), + }) + context.addStep(MODULE, '调整箱数超过服务端权威总箱数返回A0400', overAuthorityResponse, + overAuthorityResponse.http === 200 && codeOf(overAuthorityResponse) === 'A0400', + responseEvidence(overAuthorityResponse), OPTIONS) + const afterOverAuthorityResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) + const afterOverAuthority = context.apiSuccess(MODULE, '超权威总箱数失败后回查计划', + afterOverAuthorityResponse, '', OPTIONS) + const afterOverAuthorityItem = afterOverAuthority?.items?.find?.((item) => + id(item?.id) === id(reassignedPlanItem.id)) + context.assert(MODULE, '超权威总箱数失败后计划和明细零写入', afterOverAuthorityResponse, + Number(afterOverAuthority?.version) === Number(reassignedPlan?.version) + && Number(afterOverAuthorityItem?.version) === Number(reassignedPlanItem.version) + && Number(afterOverAuthorityItem?.packageCount) === assignedPackageCount + && Number(afterOverAuthorityItem?.totalPackageCount) === authoritativeTotal + && Number(afterOverAuthorityItem?.remainingPackageCount) === authoritativeRemaining, + `planVersion=${afterOverAuthority?.version ?? 'missing'}, itemVersion=${afterOverAuthorityItem?.version ?? 'missing'}, packageCount=${afterOverAuthorityItem?.packageCount ?? 'missing'}`, + OPTIONS) + } context.assert(MODULE, '动态增量 fixture 至少保留两件可用数量供调整与最小已拣门禁', dynamicCandidatesResponse, Number(dynamicCandidate?.availablePackageCount) >= 2, `availablePackageCount=${dynamicCandidate?.availablePackageCount ?? 'missing'}`, OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index cbca07372..b5dbf0a4b 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -520,6 +520,10 @@ test('phase3 loading proves mixed-line rollback across task, all items and opera test('phase3 loading proves stale inventory zero-write and defers final completion audit', () => { const loading = source('./phase3-loading.mjs') + assert.match(loading, /计划详情由服务端返回权威总箱数和剩余箱数/) + assert.match(loading, /authoritativeRemaining === authoritativeTotal - assignedPackageCount/) + assert.match(loading, /调整箱数超过服务端权威总箱数返回A0400/) + assert.match(loading, /超权威总箱数失败后计划和明细零写入/) assert.match(loading, /taskItemsSignature\(afterStaleTask\) === taskItemsSignature\(beforeMutationTask\)/) assert.match(loading, /动态新增计划明细按 inboundDetailId 唯一定位/) assert.match(loading, /重新加入动态明细仍按 inboundDetailId 唯一定位/) diff --git a/scripts/admin-test-env-modules/resource-allocation-recent-contract.test.mjs b/scripts/admin-test-env-modules/resource-allocation-recent-contract.test.mjs new file mode 100644 index 000000000..81acd3a0c --- /dev/null +++ b/scripts/admin-test-env-modules/resource-allocation-recent-contract.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' + +function source(name) { + return readFileSync(new URL(name, import.meta.url), 'utf8') +} + +test('measurement HTTP acceptance preserves whole-ticket slot identities across confirmations', () => { + const measurement = source('./inbound-measurement.mjs') + assert.match(measurement, /逐张确认后回查整票库位块/) + assert.match(measurement, /扩容只追加且既有槽身份不漂移/) + assert.match(measurement, /asId\(verified\.guide\?\.blockId\) === previous\.blockId/) + assert.match(measurement, /verified\.slots\.length >= previous\.slots\.length && priorSlotsStable/) + assert.match(measurement, /asId\(current\.reservationId\) === asId\(prior\.reservationId\)/) + assert.match(measurement, /asId\(current\.locationId\) === asId\(prior\.locationId\)/) +}) + +test('smart-loading HTTP acceptance uses authoritative totals and proves over-allocation zero drift', () => { + const loading = source('./phase3-loading.mjs') + assert.match(loading, /计划详情由服务端返回权威总箱数和剩余箱数/) + assert.match(loading, /authoritativeRemaining === authoritativeTotal - assignedPackageCount/) + assert.match(loading, /packageCount: authoritativeTotal \+ 1/) + assert.match(loading, /调整箱数超过服务端权威总箱数返回A0400/) + assert.match(loading, /Number\(afterOverAuthority\?\.version\) === Number\(reassignedPlan\?\.version\)/) + assert.match(loading, /Number\(afterOverAuthorityItem\?\.remainingPackageCount\) === authoritativeRemaining/) +})