import { publishPlatformLogisticsChannels } from './ocean-booking-fixture.mjs' 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 responseEvidence(response) { return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` } 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 } } } export function selectContainerLoadingFixture(context) { const candidates = [ context.createdResources?.orchestrationFixtures?.phase3Container, context.createdResources?.inboundMeasurement, context.createdResources?.orders, ].filter(Boolean) // Finance and downstream modules temporarily switch context.marker for // their own isolated runs. Never select the older CAB fixture merely because // it is first in the object; ownership is the marker contract. return candidates.find((candidate) => candidate.marker === context.marker) || candidates[0] } export function selectPlatformChannelIsolationPair(catalog, marker, originWarehouseId, primaryChannelCode) { const catalogMarker = id(catalog?.marker) const catalogWarehouseId = id(catalog?.originWarehouseId) const sourcePath = id(catalog?.sourcePath) if (catalogMarker !== id(marker) || catalogWarehouseId !== id(originWarehouseId) || !sourcePath.startsWith('/api/platform/')) { return { pair: null, reason: 'BLOCKED_FIXTURE: missing current-marker platform channel catalog for origin warehouse' } } const channels = [] const seen = new Set() for (const value of Array.isArray(catalog?.channels) ? catalog.channels : []) { const channelCode = id(value?.channelCode).trim() const channelName = id(value?.channelName).trim() const status = id(value?.status).trim().toUpperCase() if (!channelCode || !channelName || (status && status !== 'ACTIVE') || seen.has(channelCode)) continue seen.add(channelCode) channels.push({ channelCode, channelName }) } const primary = channels.find((value) => value.channelCode === id(primaryChannelCode)) const foreign = channels.find((value) => value.channelCode !== id(primaryChannelCode)) if (!primary || !foreign) { return { pair: null, reason: `BLOCKED_FIXTURE: ACTIVE platform channels=${channels.length}, distinct pair unavailable` } } return { pair: { primary, foreign }, reason: null } } export async function run(context) { const source = selectContainerLoadingFixture(context) const tenantId = id(source?.tenantId) const inboundId = id(source?.inboundId) if (!tenantId || !inboundId || source?.marker !== context.marker) { context.addStep(MODULE, '取得独立柜次级 WAIT_SPLIT 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 === 'WAIT_SPLIT' && inbound?.summary?.measureReviewStatus === 'APPROVED' && Boolean(warehouseId), `status=${inbound?.summary?.processStatus || 'missing'}, review=${inbound?.summary?.measureReviewStatus || 'missing'}, warehouseId=${warehouseId || 'missing'}`, OPTIONS) if (!warehouseId) return const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16) const channelCode = id(inbound?.summary?.channelCode || source?.channelCode) const warehouseName = inbound?.summary?.warehouseName || source?.warehouseName if (!channelCode || !warehouseName) return const channelCatalog = await publishPlatformLogisticsChannels(context, warehouseId, MODULE) const selectedChannel = channelCatalog.channels.find((value) => value.channelCode === channelCode) context.assert(MODULE, '入库渠道存在于当前起运仓 ACTIVE 平台渠道目录', inboundResponse, Boolean(selectedChannel), `originWarehouseId=${warehouseId}, channelCode=${channelCode}, catalog=${channelCatalog.channels.map((value) => value.channelCode).join('|') || 'none'}`, OPTIONS) if (!selectedChannel) return const channelName = selectedChannel.channelName const primaryRequirement = { containerType: '40HQ', containerKind: 'FCL', capacityCbm: 100, maxWeightKg: 100000, quantity: 1 } const bookingDraft = { customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`, channelCode, warehouseId, warehouseName, billOfLadingNo: `BL-${suffix}`.slice(0, 128), shippingCompanyId: null, shippingCompanyName: null, bookingAgentId: null, bookingAgentName: null, vesselName: `验收船-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64), departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00', remark: context.marker, parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, { partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` }, { partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` }], containerRequirements: [primaryRequirement, { containerType: '20GP', containerKind: 'FCL', capacityCbm: 28, maxWeightKg: 22000, quantity: 1 }], } context.assert(MODULE, '订舱渠道与仓库快照完整', inboundResponse, Boolean(channelCode && channelName && warehouseName), `channelCode=${channelCode || 'missing'}, warehouseId=${warehouseId}, warehouseName=${warehouseName || 'missing'}`, OPTIONS) const simulationResponse = await request(context, 'POST', CONTAINERS, { channelCode, warehouseId, containerType: primaryRequirement.containerType, capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg, }) const simulation = context.apiSuccess(MODULE, '新增内部模拟柜', simulationResponse, `warehouseId=${warehouseId}, channelCode=${channelCode}`, OPTIONS) const containerId = id(simulation?.id) context.assert(MODULE, '模拟柜由服务端生成稳定身份且不伪造真实柜号或订舱', simulationResponse, Boolean(containerId) && Boolean(simulation?.cabinetNo) && simulation?.containerNo == null && simulation?.oceanBookingId == null && simulation?.channelCode === channelCode && id(simulation?.warehouseId) === warehouseId, `containerBookingId=${containerId || 'missing'}, cabinetNo=${simulation?.cabinetNo || 'missing'}, oceanBookingId=${id(simulation?.oceanBookingId) || 'null'}`, OPTIONS) if (!containerId) return const candidateResponse = await context.request('GET', `${SMART}/candidates?bookingId=${encodeURIComponent(containerId)}&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 channelIsolation = selectPlatformChannelIsolationPair( context.createdResources?.platformLogisticsChannels, context.marker, warehouseId, channelCode) if (!channelIsolation.pair) { context.addStep(MODULE, '两个真实有效渠道的柜次隔离 fixture', null, false, `${channelIsolation.reason}; originWarehouseId=${warehouseId}, primaryChannelCode=${channelCode}`, OPTIONS) } else { const foreignChannel = channelIsolation.pair.foreign const foreignContainerResponse = await request(context, 'POST', CONTAINERS, { channelCode: foreignChannel.channelCode, warehouseId, containerType: primaryRequirement.containerType, capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg, }) const foreignContainer = context.apiSuccess(MODULE, '新增真实异渠道模拟柜', foreignContainerResponse, '', OPTIONS) const foreignContainerId = id(foreignContainer?.id) if (!foreignContainerId) return const foreignPlanResponse = await request(context, 'POST', `${SMART}/plans`, { bookingId: foreignContainerId, remark: `${context.marker}-other-channel`, }) const foreignPlan = context.apiSuccess(MODULE, '创建真实异渠道智能装柜计划', foreignPlanResponse, '', OPTIONS) const foreignAssignResponse = await request(context, 'POST', `${SMART}/plans/${foreignPlan?.id}/assignments`, { inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1, expectedVersion: Number(foreignPlan?.version), operationId: `${context.marker}-other-channel-assign`.slice(0, 128), }) context.addStep(MODULE, '两个真实有效渠道之间拒绝跨渠道装柜', foreignAssignResponse, foreignAssignResponse.http === 200 && ['A0422', '422'].includes(codeOf(foreignAssignResponse)), `primary=${channelCode}, foreign=${foreignChannel.channelCode}, HTTP=${foreignAssignResponse.http}, code=${codeOf(foreignAssignResponse)}`, OPTIONS) } const containerDetailResponse = await context.request('GET', `${CONTAINERS}/${containerId}`) const containerDetail = context.apiSuccess(MODULE, '回查货柜详情', containerDetailResponse, '', OPTIONS) context.assert(MODULE, '货柜详情保留候选货物路线约束', containerDetailResponse, (!containerDetail?.destinationWarehouseCode || !candidate.destinationWarehouseCode || containerDetail.destinationWarehouseCode === candidate.destinationWarehouseCode) && containerDetail?.channelCode === channelCode && ['EDITING', 'WAIT_LOADING'].includes(containerDetail?.planStatus), `planStatus=${containerDetail?.planStatus || 'missing'}, channel=${containerDetail?.channelCode || 'missing'}`, OPTIONS) const containerNo = id(containerDetail?.containerNo || containerDetail?.cabinetNo) 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 bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', { expectedVersion: 0, data: bookingDraft }, { 'Idempotency-Key': `${context.marker}-loading-booking-create` }) const bookingCreated = context.apiSuccess(MODULE, '登记含渠道仓库和运输快照的已确认真实订舱', bookingCreate, '', OPTIONS) const bookingId = id(bookingCreated?.oceanBooking?.id) const bookingNo = id(bookingCreated?.oceanBooking?.bookingNo) if (!bookingId) return const confirmedBooking = bookingCreated const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt'] const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, confirmedBooking?.oceanBooking?.[field]])) const frozenTransportMatches = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '' && String(frozenTransport[field]) === String(bookingDraft[field])) context.assert(MODULE, '一次登记直接返回 CONFIRMED 且尚未占用模拟柜', bookingCreate, confirmedBooking?.oceanBooking?.status === 'CONFIRMED' && Array.isArray(confirmedBooking?.containerAllocations) && confirmedBooking.containerAllocations.length === 0, `status=${confirmedBooking?.oceanBooking?.status || 'missing'}, allocations=${confirmedBooking?.containerAllocations?.length ?? 'n/a'}`, OPTIONS) context.assert(MODULE, '已确认订舱回显完整且一致的冻结运输快照', bookingCreate, frozenTransportMatches, transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', '), OPTIONS) if (!frozenTransportMatches) return const confirmedListResponse = await context.request('GET', `/api/platform/v1/ocean-bookings?page=1&size=20&keyword=${encodeURIComponent(bookingNo)}`) const confirmedList = context.apiSuccess(MODULE, '按真实订舱号查询正式列表', confirmedListResponse, '', OPTIONS) context.assert(MODULE, 'CONFIRMED 后真实订舱进入正式列表', confirmedListResponse, records(confirmedList).some((item) => id(item.id) === bookingId && item.status === 'CONFIRMED'), `bookingId=${bookingId}, records=${records(confirmedList).length}`, OPTIONS) const requirement = confirmedBooking?.containerRequirements?.find?.((item) => item.containerType === primaryRequirement.containerType) const wrongTypeRequirement = confirmedBooking?.containerRequirements?.find?.((item) => item.containerType !== primaryRequirement.containerType) if (!requirement?.id || !wrongTypeRequirement?.id) return const wrongTypeLinkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, { oceanBookingId: bookingId, requirementId: id(wrongTypeRequirement.id), expectedVersion: Number(containerDetail.version), }) context.addStep(MODULE, '箱型不匹配拒绝关联真实订舱柜', wrongTypeLinkResponse, failure(wrongTypeLinkResponse), `HTTP=${wrongTypeLinkResponse.http}, code=${codeOf(wrongTypeLinkResponse)}`, OPTIONS) const emptySimulationResponse = await request(context, 'POST', CONTAINERS, { channelCode, warehouseId, containerType: primaryRequirement.containerType, capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg, }) let emptySimulation = context.apiSuccess(MODULE, '新增空模拟柜验证解绑换绑', emptySimulationResponse, '', OPTIONS) const emptyContainerId = id(emptySimulation?.id) if (!emptyContainerId) return const emptyLink = await request(context, 'POST', `${CONTAINERS}/${emptyContainerId}/ocean-booking-link`, { oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(emptySimulation.version), }) emptySimulation = context.apiSuccess(MODULE, '空模拟柜首次关联真实订舱', emptyLink, responseEvidence(emptyLink), OPTIONS) if (!emptySimulation) return const emptyUnlink = await request(context, 'DELETE', `${CONTAINERS}/${emptyContainerId}/ocean-booking-link`, { expectedVersion: Number(emptySimulation.version), reason: context.marker, }) emptySimulation = context.apiSuccess(MODULE, '空模拟柜允许解除关联', emptyUnlink, responseEvidence(emptyUnlink), OPTIONS) if (!emptySimulation) return const emptyRelink = await request(context, 'POST', `${CONTAINERS}/${emptyContainerId}/ocean-booking-link`, { oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(emptySimulation.version), }) emptySimulation = context.apiSuccess(MODULE, '空模拟柜解绑后允许重新关联', emptyRelink, responseEvidence(emptyRelink), OPTIONS) if (!emptySimulation) return const emptyFinalUnlink = await request(context, 'DELETE', `${CONTAINERS}/${emptyContainerId}/ocean-booking-link`, { expectedVersion: Number(emptySimulation.version), reason: `${context.marker}-release-capacity`, }) const emptyReleased = context.apiSuccess(MODULE, '空模拟柜最终解绑释放真实订舱容量', emptyFinalUnlink, responseEvidence(emptyFinalUnlink), OPTIONS) if (!emptyReleased) return const beforeLinkItems = JSON.stringify(assigned.items?.map((item) => ({ id: id(item.id), inboundId: id(item.inboundId), packageCount: Number(item.packageCount) })) || []) const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, { oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(containerDetail.version), }) const linkedContainer = context.apiSuccess(MODULE, '首次关联真实已确认订舱柜', linkResponse, responseEvidence(linkResponse), OPTIONS) if (!linkedContainer) return const afterLinkPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`) const afterLinkPlan = context.apiSuccess(MODULE, '关联后回查原装柜计划与明细', afterLinkPlanResponse, '', OPTIONS) const afterLinkItems = JSON.stringify(afterLinkPlan?.items?.map((item) => ({ id: id(item.id), inboundId: id(item.inboundId), packageCount: Number(item.packageCount) })) || []) context.assert(MODULE, '关联前后内部柜次、计划和排货明细身份稳定', linkResponse, id(linkedContainer?.id) === containerId && linkedContainer?.cabinetNo === simulation.cabinetNo && id(linkedContainer?.oceanBookingId) === bookingId && id(afterLinkPlan?.id) === planId && beforeLinkItems === afterLinkItems, `containerBookingId=${containerId}, cabinetNo=${linkedContainer?.cabinetNo || 'missing'}, loadingPlanId=${id(afterLinkPlan?.id) || 'missing'}`, OPTIONS) const duplicateSimulationResponse = await request(context, 'POST', CONTAINERS, { channelCode, warehouseId, containerType: primaryRequirement.containerType, capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg, }) const duplicateSimulation = context.apiSuccess(MODULE, '新增重复容量占用负向模拟柜', duplicateSimulationResponse, '', OPTIONS) const duplicateLinkResponse = await request(context, 'POST', `${CONTAINERS}/${id(duplicateSimulation?.id)}/ocean-booking-link`, { oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(duplicateSimulation?.version), }) context.addStep(MODULE, '同一真实订舱箱型容量拒绝重复关联', duplicateLinkResponse, failure(duplicateLinkResponse), `HTTP=${duplicateLinkResponse.http}, code=${codeOf(duplicateLinkResponse)}`, OPTIONS) const unlinkAfterLoadingResponse = await request(context, 'DELETE', `${CONTAINERS}/${containerId}/ocean-booking-link`, { expectedVersion: Number(linkedContainer.version), reason: context.marker, }) context.addStep(MODULE, '已有排货后拒绝解绑或换绑', unlinkAfterLoadingResponse, failure(unlinkAfterLoadingResponse), `HTTP=${unlinkAfterLoadingResponse.http}, code=${codeOf(unlinkAfterLoadingResponse)}`, OPTIONS) const missingContainerPushResponse = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`, { expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': `${context.marker}-container-push-without-container-no`.slice(0, 128) }) const missingContainerPushRejected = failure(missingContainerPushResponse) context.addStep(MODULE, '无真实柜号拒绝推送预装计划', missingContainerPushResponse, missingContainerPushRejected, `HTTP=${missingContainerPushResponse.http}, code=${codeOf(missingContainerPushResponse)}, requestId=${missingContainerPushResponse.requestId || 'missing'}, traceId=${missingContainerPushResponse.traceId || 'missing'}`, OPTIONS) if (!missingContainerPushRejected) return const gatedTaskResponse = await context.request('GET', `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) const gatedTaskAbsent = failure(gatedTaskResponse) || !id(gatedTaskResponse?.body?.id || gatedTaskResponse?.body?.data?.id) context.addStep(MODULE, '无真实柜号推送失败后不生成 PDA 任务', gatedTaskResponse, gatedTaskAbsent, `HTTP=${gatedTaskResponse.http}, code=${codeOf(gatedTaskResponse)}, task=${id(gatedTaskResponse?.body?.id || gatedTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS) if (!gatedTaskAbsent) return const transportResponse = await request(context, 'PATCH', `${CONTAINERS}/${containerId}/transport`, { containerNo: `TST-${suffix}`.slice(0, 64), voyageNo: `V-${suffix}`.slice(0, 64), version: Number(linkedContainer.version), reason: context.marker, }) const transported = context.apiSuccess(MODULE, '在放舱前绑定本轮真实箱号', transportResponse, '', OPTIONS) context.assert(MODULE, '绑定后柜次保留本轮箱号', transportResponse, transported?.containerNo === `TST-${suffix}`.slice(0, 64) && id(transported?.id) === containerId && transported?.cabinetNo === linkedContainer.cabinetNo, `containerNo=${transported?.containerNo || 'missing'}, containerBookingId=${id(transported?.id) || 'missing'}, loadingPlanId=${planId}`, OPTIONS) if (transported?.containerNo !== `TST-${suffix}`.slice(0, 64)) return const confirmedTaskResponse = await context.request('GET', `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) const confirmedTaskAbsent = failure(confirmedTaskResponse) || !id(confirmedTaskResponse?.body?.id || confirmedTaskResponse?.body?.data?.id) context.addStep(MODULE, '订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务', confirmedTaskResponse, confirmedTaskAbsent, `HTTP=${confirmedTaskResponse.http}, code=${codeOf(confirmedTaskResponse)}, task=${id(confirmedTaskResponse?.body?.id || confirmedTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS) if (!confirmedTaskAbsent) return const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, { expectedVersion: Number(confirmedBooking?.oceanBooking?.version), ...frozenTransport, reason: context.marker, }, { 'Idempotency-Key': `${context.marker}-loading-booking-release`.slice(0, 128) }) const releasedBooking = context.apiSuccess(MODULE, '订舱携带已冻结运输快照放舱并满足 PDA 门禁', releaseResponse, '', OPTIONS) context.assert(MODULE, '放舱后订舱进入 RELEASED 且柜次分配保持稳定', releaseResponse, releasedBooking?.oceanBooking?.status === 'RELEASED' && releasedBooking?.containerAllocations?.some?.((item) => id(item.containerBookingId) === containerId) && transportFields.every((field) => String(releasedBooking?.oceanBooking?.[field]) === String(frozenTransport[field])), `status=${releasedBooking?.oceanBooking?.status || 'missing'}, containerBookingId=${containerId}, transportFrozen=${transportFields.every((field) => String(releasedBooking?.oceanBooking?.[field]) === String(frozenTransport[field]))}`, OPTIONS) if (releasedBooking?.oceanBooking?.status !== 'RELEASED') return const releasedContainerResponse = await context.request('GET', `${CONTAINERS}/${containerId}`) const releasedContainer = context.apiSuccess(MODULE, '放舱后回查货柜运输版本', releasedContainerResponse, '', 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, '柜号与 RELEASED 订舱就绪后推送预装计划', 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?loadingPlanId=${encodeURIComponent(planId)}&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 && id(task?.containerBookingId) === containerId && Boolean(task?.cabinetNo) && task?.currentContainerNo === `TST-${suffix}`.slice(0, 64) && 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 stableIdentity = { taskId, loadingPlanId: id(task.loadingPlanId), containerBookingId: id(task.containerBookingId), cabinetNo: id(task.cabinetNo), } const beforeReplacementSnapshotResponse = await context.request('GET', `${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`) const beforeReplacementSnapshot = context.apiSuccess(MODULE, '换柜号前读取 PDA 稳定库存快照', beforeReplacementSnapshotResponse, '', OPTIONS) const replacementContainerNo = `TST-R-${suffix}`.slice(0, 64) const replacementResponse = await request(context, 'PATCH', `${CONTAINERS}/${containerId}/transport`, { containerNo: replacementContainerNo, voyageNo: `V-${suffix}`.slice(0, 64), version: Number(releasedContainer?.version), reason: `${context.marker}-replace-container-no`, }) const replaced = context.apiSuccess(MODULE, 'PDA 任务创建后替换真实柜号', replacementResponse, '', OPTIONS) context.assert(MODULE, '任务创建后真实柜号替换成功', replacementResponse, replaced?.containerNo === replacementContainerNo, `containerNo=${replaced?.containerNo || 'missing'}`, OPTIONS) const clearContainerNoResponse = await request(context, 'PATCH', `${CONTAINERS}/${containerId}/transport`, { containerNo: null, voyageNo: `V-${suffix}`.slice(0, 64), version: Number(replaced?.version), reason: `${context.marker}-clear-container-no`, }) context.addStep(MODULE, 'PDA 任务创建后清空真实柜号失败', clearContainerNoResponse, clearContainerNoResponse.http === 200 && ['A0409', '409'].includes(codeOf(clearContainerNoResponse)), `HTTP=${clearContainerNoResponse.http}, code=${codeOf(clearContainerNoResponse)}`, OPTIONS) const replacedTaskResponse = await context.request('GET', `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) const replacedTask = context.apiSuccess(MODULE, '换柜号后按稳定计划身份回查 PDA 任务', replacedTaskResponse, '', OPTIONS) const afterReplacementSnapshotResponse = await context.request('GET', `${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`) const afterReplacementSnapshot = context.apiSuccess(MODULE, '换柜号后读取 PDA 稳定库存快照', afterReplacementSnapshotResponse, '', OPTIONS) context.assert(MODULE, '换柜号不改变任务计划柜次柜次号和库存快照身份', replacedTaskResponse, id(replacedTask?.id) === stableIdentity.taskId && id(replacedTask?.loadingPlanId) === stableIdentity.loadingPlanId && id(replacedTask?.containerBookingId) === stableIdentity.containerBookingId && id(replacedTask?.cabinetNo) === stableIdentity.cabinetNo && replacedTask?.currentContainerNo === replacementContainerNo && id(afterReplacementSnapshot?.taskId) === stableIdentity.taskId && id(afterReplacementSnapshot?.loadingPlanId) === stableIdentity.loadingPlanId && id(afterReplacementSnapshot?.containerBookingId) === stableIdentity.containerBookingId && id(afterReplacementSnapshot?.cabinetNo) === stableIdentity.cabinetNo && afterReplacementSnapshot?.snapshotToken === beforeReplacementSnapshot?.snapshotToken, `taskId=${id(replacedTask?.id) || 'missing'}, cabinetNo=${replacedTask?.cabinetNo || 'missing'}, snapshotStable=${afterReplacementSnapshot?.snapshotToken === beforeReplacementSnapshot?.snapshotToken}`, OPTIONS) const wrongWarehouseResponse = await context.request('GET', `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&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) 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 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, { warehouseId, loadingPlanId: planId, containerBookingId: containerId, expectedVersion: Number(task.version) + 1, inventorySnapshotToken: inventory.snapshotToken, items: batchItems, }, { '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) const afterFailedBatchResponse = await context.request('GET', `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&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) const batchPayload = { warehouseId, loadingPlanId: planId, containerBookingId: containerId, expectedVersion: Number(task.version), inventorySnapshotToken: inventory.snapshotToken, items: batchItems } 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 awaitingReview = picked?.taskStatus === 'LOADING' && picked?.workflowStatus === 'AWAITING_REVIEW' context.assert(MODULE, '最后一箱拣货后 PDA 任务等待人工确认装柜完成', pickResponse, awaitingReview && picked?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)), `taskStatus=${picked?.taskStatus || 'missing'}, workflow=${picked?.workflowStatus || 'missing'}`, OPTIONS) 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 === 'LOADING' && pickReplay?.workflowStatus === 'AWAITING_REVIEW' && pickReplay?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)), `taskStatus=${pickReplay?.taskStatus || 'missing'}`, OPTIONS) const completePath = `${PDA}/tasks/${taskId}/complete` const completePayload = { expectedTaskVersion: Number(picked?.version), operationId: `${context.marker}-pda-complete`.slice(0, 128), remark: context.marker, } const completeResponse = await request(context, 'POST', completePath, completePayload) const completed = context.apiSuccess(MODULE, 'PDA 人工确认装柜完成', completeResponse, '', OPTIONS) const directPdaCompletion = completed?.taskStatus === 'COMPLETED' && completed?.workflowStatus === 'COMPLETED' context.assert(MODULE, '人工确认后 PDA 任务进入 COMPLETED', completeResponse, directPdaCompletion && completed?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)), `taskStatus=${completed?.taskStatus || 'missing'}, workflow=${completed?.workflowStatus || 'missing'}`, OPTIONS) const operationsResponse = await request(context, 'GET', `${PDA}/tasks/${taskId}/operations`) const operations = context.apiSuccess(MODULE, '回查完成后的 PDA 操作审计', operationsResponse, '', OPTIONS) context.assert(MODULE, 'PDA 完成审计保留领取、原子批次和人工完成记录', operationsResponse, Array.isArray(operations) && operations.some((item) => item.actionType === 'CLAIM' && Boolean(item.operatorId) && Boolean(item.createdAt)) && operations.some((item) => item.actionType === 'BATCH' && Boolean(item.operatorId) && Boolean(item.createdAt)) && operations.some((item) => item.actionType === 'COMPLETE_LOADING' && Boolean(item.operatorId) && Boolean(item.createdAt)) && !operations.some((item) => item.actionType === 'REVIEW'), `actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS) const reviewResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/review`, { expectedTaskVersion: Number(completed?.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, cabinetNo: stableIdentity.cabinetNo, containerNo: replacementContainerNo, pdaIdentityStable: true, finalStatus: 'COMPLETED', } }