This commit is contained in:
2026-09-04 15:06:36 +08:00
parent 8a0366af5b
commit e23c5dcb7b
6 changed files with 110 additions and 2 deletions
+6 -1
View File
@@ -113,6 +113,10 @@ node scripts/admin-test-env-acceptance.mjs --gateway <development-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;
不会为制造普通位不足批量停用库位。
@@ -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'/)
@@ -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) {
@@ -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)
@@ -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 唯一定位/)
@@ -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/)
})