Files
baiyue-firstleg/scripts/admin-test-env-modules/phase3-container-loading.mjs
T

301 lines
21 KiB
JavaScript
Raw Normal View History

const MODULE = 'phase3-container-loading'
const OPTIONS = { scenario: 'phase3-container-smart-loading-pda', deploymentBlocker: true }
const CONTAINERS = '/api/platform/v1/containers'
const SMART = '/api/platform/v1/first-leg/smart-loading'
const PDA = '/api/platform/v1/first-leg/pda-loading'
const FIRST_LEG = '/api/platform/v1/first-leg'
function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
function failure(response) {
return [400, 401, 403, 404, 409, 503].includes(response?.http)
|| (response?.http === 200 && codeOf(response) !== '200')
}
function records(page) { return Array.isArray(page?.records) ? page.records : [] }
async function request(context, method, path, body, headers = {}) {
try {
const response = await fetch(`${context.options.gateway}${path}`, {
method,
headers: {
Accept: 'application/json', Authorization: `Bearer ${context.token}`,
...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers,
},
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000),
})
const raw = await response.text(); let payload = null
try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ }
return { method, path, http: response.status, body: payload,
raw: payload === null ? raw.slice(0, 500) : null,
requestId: payload?.requestId || response.headers.get('x-request-id'),
traceId: payload?.traceId || response.headers.get('x-trace-id') }
} catch (error) {
return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
}
}
function fixture(context) {
return context.createdResources?.orchestrationFixtures?.phase3Container
|| context.createdResources?.inboundMeasurement
|| context.createdResources?.orders
}
export async function run(context) {
const source = fixture(context)
const tenantId = id(source?.tenantId)
const inboundId = id(source?.inboundId)
if (!tenantId || !inboundId || source?.marker !== context.marker) {
context.addStep(MODULE, '取得独立柜次级 INBOUNDED fixture', null, false,
`fixture marker=${source?.marker || 'missing'}, current marker=${context.marker}`, OPTIONS)
return
}
const inboundResponse = await context.request('GET',
`/api/platform/inbounds/${inboundId}?tenantId=${encodeURIComponent(tenantId)}`)
const inbound = context.apiSuccess(MODULE, '读取柜次级独立入库单', inboundResponse,
`tenantId=${tenantId}, inboundId=${inboundId}`, OPTIONS)
const warehouseId = id(inbound?.summary?.warehouseId)
context.assert(MODULE, '柜次候选入库单状态、测量和仓库门禁有效', inboundResponse,
inbound?.summary?.processStatus === 'INBOUNDED' && inbound?.summary?.measureReviewStatus === 'APPROVED'
&& Boolean(warehouseId),
`status=${inbound?.summary?.processStatus || 'missing'}, review=${inbound?.summary?.measureReviewStatus || 'missing'}, warehouseId=${warehouseId || 'missing'}`, OPTIONS)
if (!warehouseId) return
const candidateResponse = await context.request('GET', `${SMART}/candidates?warehouseId=${encodeURIComponent(warehouseId)}`)
const candidates = context.apiSuccess(MODULE, '查询智能装柜真实候选货物', candidateResponse, '', OPTIONS)
const candidate = Array.isArray(candidates)
? candidates.find((item) => id(item.inboundId) === inboundId && Number(item.availablePackageCount) > 0)
: null
context.assert(MODULE, '候选货物归属本轮入库单且剩余箱数有效', candidateResponse,
Boolean(candidate?.inboundDetailId) && id(candidate?.warehouseId) === warehouseId
&& id(candidate?.accountId) === tenantId,
`candidateFound=${Boolean(candidate)}, available=${candidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
if (!candidate?.inboundDetailId) return
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16)
const containerNo = `P3C-${suffix}`.slice(0, 64)
const cabinetNo = `P3I-${suffix}`.slice(0, 64)
const containerPayload = {
containerNo, cabinetNo, warehouseId, warehouseName: inbound?.summary?.warehouseName || null,
billOfLadingNo: `BL-${suffix}`, vesselName: `P3-VESSEL-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64),
containerType: '40HQ', shippingCompanyId: null, shippingCompanyName: `P3-SHIPPING-${suffix}`.slice(0, 128),
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
destinationWarehouseCode: candidate.destinationWarehouseCode || null,
destinationWarehouseName: candidate.destinationWarehouseName || null,
capacityCbm: 100, maxWeightKg: 100000, terminalTruckingCompany: `P3-TRUCK-${suffix}`.slice(0, 128),
loadingAt: '2026-09-02T08:00:00', estimatedSailingAt: '2026-09-03T08:00:00',
estimatedPortArrivalAt: '2026-09-20T08:00:00', estimatedWarehouseArrivalAt: '2026-09-21T08:00:00',
}
const createContainerResponse = await request(context, 'POST', CONTAINERS, containerPayload)
const container = context.apiSuccess(MODULE, '创建 marker 隔离货柜', createContainerResponse,
`containerNo=${containerNo}`, OPTIONS)
const containerId = id(container?.id)
context.assert(MODULE, '货柜归属仓库、路线、版本和编号正确', createContainerResponse,
Boolean(containerId) && container?.containerNo === containerNo && container?.cabinetNo === cabinetNo
&& id(container?.warehouseId) === warehouseId && Number(container?.version) === 0,
`containerId=${containerId || 'missing'}, version=${container?.version ?? 'missing'}`, OPTIONS)
if (!containerId) return
const containerDetailResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
const containerDetail = context.apiSuccess(MODULE, '回查货柜详情', containerDetailResponse, '', OPTIONS)
context.assert(MODULE, '货柜详情保留候选货物路线约束', containerDetailResponse,
containerDetail?.destinationWarehouseCode === (candidate.destinationWarehouseCode || null)
&& containerDetail?.planStatus === 'WAIT_LOADING',
`planStatus=${containerDetail?.planStatus || 'missing'}`, OPTIONS)
const planResponse = await request(context, 'POST', `${SMART}/plans`, { bookingId: containerId, remark: context.marker })
const plan = context.apiSuccess(MODULE, '创建智能装柜计划', planResponse, '', OPTIONS)
const planId = id(plan?.id)
context.assert(MODULE, '装柜计划与货柜关联且处于 EDITING', planResponse,
Boolean(planId) && id(plan?.bookingId) === containerId && plan?.status === 'EDITING'
&& Number(plan?.version) === 0 && Array.isArray(plan?.items) && plan.items.length === 0,
`planId=${planId || 'missing'}, status=${plan?.status || 'missing'}`, OPTIONS)
if (!planId) return
const planReplayResponse = await request(context, 'POST', `${SMART}/plans`, { bookingId: containerId, remark: 'replay' })
const planReplay = context.apiSuccess(MODULE, '重放货柜装柜计划创建', planReplayResponse, '', OPTIONS)
context.assert(MODULE, '同一货柜重放返回同一计划', planReplayResponse,
id(planReplay?.id) === planId, `first=${planId}, replay=${id(planReplay?.id) || 'missing'}`, OPTIONS)
const assignPayload = {
inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1,
expectedVersion: Number(plan.version), operationId: `${context.marker}-container-assign`.slice(0, 128),
}
const assignResponse = await request(context, 'POST', `${SMART}/plans/${planId}/assignments`, assignPayload)
const assigned = context.apiSuccess(MODULE, '人工分配本轮候选货物', assignResponse, '', OPTIONS)
const planItem = assigned?.items?.find?.((item) => id(item.inboundId) === inboundId)
context.assert(MODULE, '分配明细归属、箱数和计划版本正确', assignResponse,
Boolean(planItem?.id) && Number(planItem?.packageCount) === 1 && Number(assigned?.version) === 1,
`itemId=${id(planItem?.id) || 'missing'}, version=${assigned?.version ?? 'missing'}`, OPTIONS)
if (!planItem?.id) return
const assignReplayResponse = await request(context, 'POST', `${SMART}/plans/${planId}/assignments`, assignPayload)
const assignReplay = context.apiSuccess(MODULE, '分配 operationId 幂等重放', assignReplayResponse, '', OPTIONS)
context.assert(MODULE, '重放不重复增加装柜箱数', assignReplayResponse,
assignReplay?.items?.length === 1 && Number(assignReplay.items[0]?.packageCount) === 1
&& Number(assignReplay?.version) === Number(assigned?.version),
`items=${assignReplay?.items?.length ?? 'n/a'}, version=${assignReplay?.version ?? 'missing'}`, OPTIONS)
const staleAssignResponse = await request(context, 'POST', `${SMART}/plans/${planId}/assignments`, {
...assignPayload, expectedVersion: 0, operationId: `${context.marker}-container-stale`.slice(0, 128),
})
context.addStep(MODULE, '旧计划版本分配返回冲突', staleAssignResponse,
staleAssignResponse.http === 200 && ['A0409', '409'].includes(codeOf(staleAssignResponse)),
`HTTP=${staleAssignResponse.http}, code=${codeOf(staleAssignResponse)}`, OPTIONS)
const pushKey = `${context.marker}-container-push`.slice(0, 128)
const pushResponse = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`,
{ expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': pushKey })
const pushed = context.apiSuccess(MODULE, '推送预装并创建 PDA 任务', pushResponse, '', OPTIONS)
context.assert(MODULE, '推送后计划保持 EDITING 且版本递增', pushResponse,
pushed?.status === 'EDITING' && Number(pushed?.version) === Number(assigned.version) + 1,
`status=${pushed?.status || 'missing'}, version=${pushed?.version ?? 'missing'}`, OPTIONS)
if (pushed?.status !== 'EDITING') return
const pushReplayResponse = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`,
{ expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': pushKey })
const pushReplay = context.apiSuccess(MODULE, '推送预装 Idempotency-Key 重放', pushReplayResponse, '', OPTIONS)
context.assert(MODULE, '推送重放保持同一 EDITING 计划', pushReplayResponse,
id(pushReplay?.id) === planId && pushReplay?.status === 'EDITING'
&& Number(pushReplay?.version) === Number(pushed.version),
`planId=${id(pushReplay?.id) || 'missing'}, version=${pushReplay?.version ?? 'missing'}`, OPTIONS)
const preloadPageResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(containerNo)}&page=1&size=20`)
const preloadPage = context.apiSuccess(MODULE, '查询本轮预装清单', preloadPageResponse, '', OPTIONS)
context.assert(MODULE, '预装清单包含本轮已推送 EDITING 计划', preloadPageResponse,
records(preloadPage).some((item) => id(item.planId) === planId && item.status === 'EDITING'),
`records=${records(preloadPage).length}`, OPTIONS)
const taskResponse = await context.request('GET',
`${PDA}/tasks?containerNo=${encodeURIComponent(containerNo)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const task = context.apiSuccess(MODULE, '查询本轮柜次 PDA 拣货任务', taskResponse, '', OPTIONS)
const taskId = id(task?.id)
const pickItem = task?.items?.find?.((item) => id(item.loadingPlanItemId) === id(planItem.id))
context.assert(MODULE, 'PDA 任务、计划明细和待拣箱数关联正确', taskResponse,
Boolean(taskId) && id(task?.loadingPlanId) === planId && task?.taskStatus === 'LOADING'
&& task?.workflowStatus === 'UNASSIGNED'
&& Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1,
`taskId=${taskId || 'missing'}, itemId=${id(pickItem?.id) || 'missing'}`, OPTIONS)
if (!taskId || !pickItem?.id) return
const wrongWarehouseResponse = await context.request('GET',
`${PDA}/tasks?containerNo=${encodeURIComponent(containerNo)}&warehouseId=1`)
context.addStep(MODULE, 'PDA 任务仓库隔离拒绝', wrongWarehouseResponse,
failure(wrongWarehouseResponse), `HTTP=${wrongWarehouseResponse.http}, code=${codeOf(wrongWarehouseResponse)}`, OPTIONS)
const claimResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/claim`, {
expectedAssignmentVersion: Number(task?.assignment?.version || 0),
operationId: `${context.marker}-pda-claim`.slice(0, 128),
})
const claimed = context.apiSuccess(MODULE, '领取本轮柜次 PDA 任务', claimResponse, '', OPTIONS)
context.assert(MODULE, 'PDA 任务只分配给当前登录作业员', claimResponse,
claimed?.workflowStatus === 'IN_PROGRESS' && id(claimed?.assignment?.assigneeId)
&& Number(claimed?.assignment?.version) === 0,
`workflow=${claimed?.workflowStatus || 'missing'}, assigneeId=${id(claimed?.assignment?.assigneeId) || 'missing'}`, OPTIONS)
const claimedPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
const claimedPlan = context.apiSuccess(MODULE, '领取后回查装柜计划', claimedPlanResponse, '', OPTIONS)
context.assert(MODULE, '领取任务后计划进入 LOADING 且版本递增', claimedPlanResponse,
claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) === Number(pushed?.version) + 1,
`status=${claimedPlan?.status || 'missing'}, version=${claimedPlan?.version ?? 'missing'}`, OPTIONS)
2026-08-11 18:43:50 +08:00
const inventoryResponse = await context.request('GET',
`${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
const inventory = context.apiSuccess(MODULE, '读取 PDA 提交前实时库存快照', inventoryResponse, '', OPTIONS)
context.assert(MODULE, '实时库存快照包含稳定令牌和可用箱数', inventoryResponse,
typeof inventory?.snapshotToken === 'string' && inventory.snapshotToken.length === 64
&& inventory?.inbounds?.some?.((item) => Number(item.availablePackageCount) >= 1),
`token=${inventory?.snapshotToken ? 'present' : 'missing'}, inboundCount=${inventory?.inbounds?.length ?? 0}`, OPTIONS)
if (!inventory?.snapshotToken) return
2026-08-11 11:41:41 +08:00
const batchPath = `${PDA}/tasks/${taskId}/batch`
const batchItems = [{ itemId: id(pickItem.id), action: 'PICK', pickedPackageCount: 1,
finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [],
version: Number(pickItem.version), reason: null }]
const staleBatchResponse = await request(context, 'POST', batchPath, {
2026-08-11 18:43:50 +08:00
warehouseId, containerNo, expectedVersion: Number(task.version) + 1,
inventorySnapshotToken: inventory.snapshotToken, items: batchItems,
2026-08-11 11:41:41 +08:00
}, { 'Idempotency-Key': `${context.marker}-pda-batch-stale`.slice(0, 128) })
context.addStep(MODULE, 'PDA batch 旧任务版本返回 A0409', staleBatchResponse,
staleBatchResponse.http === 200 && ['A0409', '409'].includes(codeOf(staleBatchResponse)),
`HTTP=${staleBatchResponse.http}, code=${codeOf(staleBatchResponse)}`, OPTIONS)
2026-08-11 11:41:41 +08:00
const afterFailedBatchResponse = await context.request('GET',
`${PDA}/tasks?containerNo=${encodeURIComponent(containerNo)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const afterFailedBatch = context.apiSuccess(MODULE, '回查失败 batch 后任务快照', afterFailedBatchResponse, '', OPTIONS)
const afterFailedItem = afterFailedBatch?.items?.find?.((item) => id(item.id) === id(pickItem.id))
context.assert(MODULE, 'PDA batch 失败后整批未写入', afterFailedBatchResponse,
Number(afterFailedBatch?.version) === Number(task.version)
&& Number(afterFailedItem?.version) === Number(pickItem.version)
&& Number(afterFailedItem?.pickedPackageCount) === Number(pickItem.pickedPackageCount || 0)
&& afterFailedItem?.itemStatus === pickItem.itemStatus,
`taskVersion=${afterFailedBatch?.version ?? 'missing'}, itemVersion=${afterFailedItem?.version ?? 'missing'}, picked=${afterFailedItem?.pickedPackageCount ?? 'missing'}`, OPTIONS)
2026-08-11 18:43:50 +08:00
const batchPayload = { warehouseId, containerNo, expectedVersion: Number(task.version),
inventorySnapshotToken: inventory.snapshotToken, items: batchItems }
2026-08-11 11:41:41 +08:00
const batchHeaders = { 'Idempotency-Key': `${context.marker}-pda-batch`.slice(0, 128) }
const pickResponse = await request(context, 'POST', batchPath, batchPayload, batchHeaders)
const picked = context.apiSuccess(MODULE, '单次调用 PDA 原子 batch 完成拣货', pickResponse, '', OPTIONS)
const directPdaCompletion = picked?.taskStatus === 'COMPLETED'
&& picked?.workflowStatus === 'COMPLETED'
context.assert(MODULE, '最后一箱拣货后 PDA 任务按已部署契约直接完成', pickResponse,
directPdaCompletion
&& picked?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)),
`taskStatus=${picked?.taskStatus || 'missing'}, workflow=${picked?.workflowStatus || 'missing'}`, OPTIONS)
2026-08-11 11:41:41 +08:00
const pickReplayResponse = await request(context, 'POST', batchPath, batchPayload, batchHeaders)
const pickReplay = context.apiSuccess(MODULE, 'PDA batch Idempotency-Key 幂等重放', pickReplayResponse, '', OPTIONS)
context.assert(MODULE, 'PDA batch 重放保持完成任务和拣货数', pickReplayResponse,
pickReplay?.taskStatus === 'COMPLETED' && pickReplay?.workflowStatus === 'COMPLETED'
&& pickReplay?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)),
`taskStatus=${pickReplay?.taskStatus || 'missing'}`, OPTIONS)
const reviewResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/review`, {
expectedTaskVersion: Number(picked?.version), operationId: `${context.marker}-pda-review`.slice(0, 128),
remark: context.marker,
})
context.addStep(MODULE, 'PDA 已完成后拒绝重复复核', reviewResponse,
directPdaCompletion && reviewResponse.http === 200 && codeOf(reviewResponse) === 'A0409',
`HTTP=${reviewResponse.http}, code=${codeOf(reviewResponse)}`, OPTIONS)
const finalPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
const finalPlan = context.apiSuccess(MODULE, '回查柜次装柜计划终态', finalPlanResponse, '', OPTIONS)
context.assert(MODULE, '装柜计划进入 COMPLETED 且明细已冻结', finalPlanResponse,
finalPlan?.status === 'COMPLETED' && finalPlan?.items?.length === 1 && Boolean(finalPlan.items[0]?.frozenAt),
`status=${finalPlan?.status || 'missing'}, frozen=${Boolean(finalPlan?.items?.[0]?.frozenAt)}`, OPTIONS)
const confirmedPageResponse = await context.request('GET',
`${FIRST_LEG}/loading-lists?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(containerNo)}&page=1&size=20`)
const confirmedPage = context.apiSuccess(MODULE, '查询完成装柜清单', confirmedPageResponse, '', OPTIONS)
context.assert(MODULE, '完成清单分页包含本轮计划', confirmedPageResponse,
records(confirmedPage).some((item) => id(item.planId) === planId && item.status === 'COMPLETED'),
`records=${records(confirmedPage).length}`, OPTIONS)
const confirmedDetailResponse = await context.request('GET',
`${FIRST_LEG}/loading-lists/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const confirmedDetail = context.apiSuccess(MODULE, '查询完成装柜清单详情', confirmedDetailResponse, '', OPTIONS)
context.assert(MODULE, '装柜清单明细、客户归属、冻结和 PDA 历史完整', confirmedDetailResponse,
id(confirmedDetail?.summary?.planId) === planId && confirmedDetail?.summary?.status === 'COMPLETED'
&& confirmedDetail?.items?.some?.((item) => id(item.inboundId) === inboundId
&& id(item.accountId) === tenantId && Boolean(item.frozenAt))
&& confirmedDetail?.histories?.some?.((item) => item.actionType === 'START_LOADING')
&& confirmedDetail?.histories?.some?.((item) => item.actionType === 'PDA_COMPLETE_LOADING'
&& Boolean(item.operatorName) && Boolean(item.occurredAt)),
`items=${confirmedDetail?.items?.length ?? 'n/a'}, histories=${confirmedDetail?.histories?.length ?? 'n/a'}`, OPTIONS)
const immutableResponse = await request(context, 'PATCH',
`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`,
{ containerNo: `MUTATE-${suffix}`.slice(0, 64), voyageNo: 'MUTATE', expectedVersion: Number(finalPlan?.version), operationReason: context.marker },
{ 'Idempotency-Key': `${context.marker}-completed-mutate`.slice(0, 128) })
context.addStep(MODULE, '完成装柜后运输信息不可变', immutableResponse,
immutableResponse.http === 200 && ['A0409', '409'].includes(codeOf(immutableResponse)),
`HTTP=${immutableResponse.http}, code=${codeOf(immutableResponse)}`, OPTIONS)
context.createdResources.phase3ContainerLoading = {
marker: context.marker, tenantId, inboundId, warehouseId, containerBookingId: containerId,
loadingPlanId: planId, pdaTaskId: taskId,
finalStatus: 'COMPLETED',
}
}