895 lines
65 KiB
JavaScript
895 lines
65 KiB
JavaScript
import { publishPlatformLogisticsChannels, resolveOceanBookingAgent } from './ocean-booking-fixture.mjs'
|
|
import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
|
|
import { containerFieldGaps, loadingListFieldGaps, preloadFieldGaps } from './fulfillment-list-field-contract.mjs'
|
|
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
|
|
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
|
|
import { channelGroup } from './smart-loading-channel-group.mjs'
|
|
|
|
const MODULE = 'phase3-container-loading'
|
|
const OPTIONS = { scenario: 'phase3-container-smart-loading-pda', deploymentBlocker: true }
|
|
const CONTAINERS = '/api/platform/v1/containers'
|
|
const DOMESTIC_CONTAINERS = `${CONTAINERS}/domestic`
|
|
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 : [] }
|
|
function containerRecord(value) { return value?.container }
|
|
|
|
export function containerWorkbenchGaps(value) {
|
|
return [
|
|
!value?.container?.id && 'container.id',
|
|
!value?.container?.cabinetNo && 'container.cabinetNo',
|
|
!Number.isInteger(value?.container?.version) && 'container.version',
|
|
!value?.workbenchStage && 'workbenchStage',
|
|
(!value?.statusSummary || typeof value.statusSummary !== 'object') && 'statusSummary',
|
|
!Array.isArray(value?.allowedActions) && 'allowedActions',
|
|
].filter(Boolean)
|
|
}
|
|
|
|
async function waitForCompletedTask(context, taskId, warehouseId, attempts = 45) {
|
|
let response = null
|
|
let task = null
|
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
response = await context.request('GET',
|
|
`${PDA}/tasks/${taskId}?warehouseId=${encodeURIComponent(warehouseId)}`)
|
|
task = response?.http === 200 && codeOf(response) === '200' ? response.body?.data : null
|
|
if (task?.taskStatus === 'COMPLETED' && task?.workflowStatus === 'COMPLETED') {
|
|
return { response, task, attempts: attempt }
|
|
}
|
|
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, 1_000))
|
|
}
|
|
return { response, task, attempts }
|
|
}
|
|
|
|
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 }
|
|
}
|
|
}
|
|
|
|
async function uploadLoadingEvidence(context, taskId, warehouseId, evidenceType, sealNo) {
|
|
const path = `${PDA}/tasks/${taskId}/evidence`
|
|
const form = new FormData()
|
|
const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130])
|
|
form.append('warehouseId', String(warehouseId))
|
|
form.append('expectedVersion', '0')
|
|
form.append('evidenceType', evidenceType)
|
|
if (evidenceType === 'SEAL') form.append('sealNo', sealNo)
|
|
form.append('remark', context.marker)
|
|
form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-${evidenceType.toLowerCase()}.png`)
|
|
try {
|
|
const response = await fetch(`${context.options.gateway}${path}`, {
|
|
method: 'POST',
|
|
headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`,
|
|
'Idempotency-Key': `${context.marker}-cab-evidence-${evidenceType}`.slice(0, 128) },
|
|
body: form,
|
|
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: 'POST', 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: 'POST', 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: 68, maxWeightKg: 19500 }
|
|
const realContainerNo = iso6346ContainerNumber(`${context.marker}-initial`)
|
|
const wrongContainerNo = `TST-${suffix}-02`.slice(0, 64)
|
|
const bookingAgent = await resolveOceanBookingAgent(context, MODULE, 'MATSON', OPTIONS)
|
|
context.assert(MODULE, '放舱订舱使用真实启用供应商', null,
|
|
Boolean(bookingAgent?.id && bookingAgent?.name),
|
|
`supplierId=${id(bookingAgent?.id) || 'missing'}`, OPTIONS)
|
|
if (!bookingAgent?.id || !bookingAgent?.name) return
|
|
const shippingCarrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS)
|
|
context.assert(MODULE, '放舱船公司使用真实启用CARRIER往来单位', null,
|
|
Boolean(shippingCarrier?.id && shippingCarrier?.carrierCode === 'MATSON'),
|
|
`partnerId=${id(shippingCarrier?.id) || 'missing'}`, OPTIONS)
|
|
if (!shippingCarrier?.id) return
|
|
const bookingDraft = {
|
|
customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`,
|
|
channelCode: channelGroup(channelCode), warehouseId,
|
|
billOfLadingNo: `BL-${suffix}`.slice(0, 128),
|
|
shippingCompanyId: id(shippingCarrier.id), shippingCompanyName: shippingCarrier.name, carrierCode: 'MATSON',
|
|
bookingAgentId: id(bookingAgent.id), bookingAgentName: bookingAgent.name,
|
|
vesselName: `验收船-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64),
|
|
departurePortCode: 'CNSZX', departurePortName: '深圳',
|
|
destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX',
|
|
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: [
|
|
{ containerType: primaryRequirement.containerType, containerKind: primaryRequirement.containerKind, quantity: 1,
|
|
capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg },
|
|
{ containerType: '40GP', containerKind: 'FCL', quantity: 1, capacityCbm: 58, maxWeightKg: 19500 },
|
|
],
|
|
}
|
|
context.assert(MODULE, '订舱渠道与仓库快照完整', inboundResponse, Boolean(channelCode && channelName && warehouseName),
|
|
`channelCode=${channelCode || 'missing'}, warehouseId=${warehouseId}, warehouseName=${warehouseName || 'missing'}`, OPTIONS)
|
|
const simulationResponse = await request(context, 'POST', CONTAINERS, {
|
|
channelGroupCode: channelGroup(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?.channelGroupCode === channelGroup(channelCode)
|
|
&& simulation?.channelCode == null
|
|
&& 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, {
|
|
channelGroupCode: channelGroup(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, 'GET',
|
|
`${SMART}/plans/by-booking/${encodeURIComponent(foreignContainerId)}`)
|
|
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),
|
|
})
|
|
const foreignAssigned = foreignAssignResponse.body?.data
|
|
const foreignItem = Array.isArray(foreignAssigned?.items)
|
|
? foreignAssigned.items.find((item) => id(item.inboundId) === inboundId
|
|
&& id(item.inboundDetailId) === id(candidate.inboundDetailId))
|
|
: null
|
|
context.addStep(MODULE, '手工装柜允许两个真实有效渠道之间跨渠道分配', foreignAssignResponse,
|
|
foreignAssignResponse.http === 200 && codeOf(foreignAssignResponse) === '200' && Boolean(foreignItem?.id),
|
|
`primary=${channelCode}, foreign=${foreignChannel.channelCode}, HTTP=${foreignAssignResponse.http}, code=${codeOf(foreignAssignResponse)}`, OPTIONS)
|
|
if (foreignItem?.id) {
|
|
const foreignRemoveResponse = await request(context, 'DELETE',
|
|
`${SMART}/plans/${foreignPlan?.id}/items/${foreignItem.id}`, {
|
|
expectedPlanVersion: Number(foreignAssigned.version), expectedItemVersion: Number(foreignItem.version),
|
|
operationId: `${context.marker}-other-channel-remove`.slice(0, 128),
|
|
})
|
|
context.apiSuccess(MODULE, '跨渠道分配验收后释放入仓码供主流程继续', foreignRemoveResponse, '', OPTIONS)
|
|
}
|
|
}
|
|
|
|
const containerDetailResponse = await context.request('GET', `${DOMESTIC_CONTAINERS}/${containerId}`)
|
|
const containerDetail = containerRecord(context.apiSuccess(MODULE, '回查货柜详情', containerDetailResponse, '', OPTIONS))
|
|
const workbenchGaps = containerWorkbenchGaps(containerDetailResponse.body?.data)
|
|
context.assert(MODULE, '货柜详情返回正式工作台 DTO 而非旧扁平实体', containerDetailResponse,
|
|
workbenchGaps.length === 0, `missing=${workbenchGaps.join('|') || 'none'}`, OPTIONS)
|
|
const pageResponse = await context.request('GET',
|
|
`${DOMESTIC_CONTAINERS}?page=1&size=20&scope=ACTIVE&status=${encodeURIComponent(containerDetail?.planStatus || '')}&keyword=${encodeURIComponent(containerDetail?.cabinetNo || '')}`)
|
|
const page = context.apiSuccess(MODULE, '按货柜页面筛选参数查询本轮模拟柜', pageResponse, '', OPTIONS)
|
|
const ownRows = records(page).filter((item) => id(item?.container?.id) === containerId)
|
|
context.assert(MODULE, '货柜页面列表返回唯一模拟柜和工作台动作状态', pageResponse,
|
|
ownRows.length === 1 && records(page).every((item) => containerWorkbenchGaps(item).length === 0)
|
|
&& ownRows[0]?.container?.cabinetNo === containerDetail?.cabinetNo
|
|
&& ownRows[0]?.container?.planStatus === containerDetail?.planStatus,
|
|
`matches=${ownRows.length}, rows=${records(page).length}`, OPTIONS)
|
|
context.assert(MODULE, '货柜详情保留候选货物路线约束', containerDetailResponse,
|
|
(!containerDetail?.destinationWarehouseCode || !candidate.destinationWarehouseCode
|
|
|| containerDetail.destinationWarehouseCode === candidate.destinationWarehouseCode)
|
|
&& containerDetail?.channelGroupCode === channelGroup(channelCode)
|
|
&& containerDetail?.channelCode == null
|
|
&& ['EDITING', 'WAIT_LOADING'].includes(containerDetail?.planStatus),
|
|
`planStatus=${containerDetail?.planStatus || 'missing'}, channelGroup=${containerDetail?.channelGroupCode || 'missing'}`, OPTIONS)
|
|
|
|
const containerNo = id(containerDetail?.containerNo || containerDetail?.cabinetNo)
|
|
const planResponse = await request(context, 'GET', `${SMART}/plans/by-booking/${encodeURIComponent(containerId)}`)
|
|
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, 'GET', `${SMART}/plans/by-booking/${encodeURIComponent(containerId)}`)
|
|
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 pushRequest = Object.freeze({ path: `${SMART}/plans/${planId}` })
|
|
const pushResponse = await request(context, 'GET', pushRequest.path)
|
|
const pushed = context.apiSuccess(MODULE, '分配后自动建立预装关联且计划可读', pushResponse, '', OPTIONS)
|
|
context.assert(MODULE, '自动关联保持 EDITING 且无需额外推送版本', pushResponse,
|
|
id(pushed?.id) === planId && pushed?.status === 'EDITING' && pushed?.containerNo == null
|
|
&& Number(pushed?.version) === Number(assigned.version),
|
|
`status=${pushed?.status || 'missing'}, version=${pushed?.version ?? 'missing'}`, OPTIONS)
|
|
if (pushed?.status !== 'EDITING') return
|
|
|
|
const pushedDetailResponse = await context.request('GET',
|
|
`${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`)
|
|
const pushedDetail = context.apiSuccess(MODULE, '无号 push 后预装清单可见', pushedDetailResponse, '', OPTIONS)
|
|
const pushedLinksResponse = await context.request('GET', `${SMART}/plans/${planId}/preloads`)
|
|
const pushedLinks = context.apiSuccess(MODULE, '无号 push 自动建立 bridge', pushedLinksResponse, '', OPTIONS)
|
|
const pushedLink = Array.isArray(pushedLinks)
|
|
? pushedLinks.find((item) => id(item.inboundId) === inboundId && id(item.loadingPlanId) === planId) : null
|
|
context.assert(MODULE, 'push 自动 bridge 保持预装和柜次稳定归属', pushedLinksResponse,
|
|
Boolean(pushedLink?.id) && Number(pushedLink?.allocatedPackageCount) === 1,
|
|
`linkId=${id(pushedLink?.id) || 'missing'}, preloadPlanId=${id(pushedLink?.preloadPlanId) || 'missing'}`, OPTIONS)
|
|
if (!pushedLink?.id) return
|
|
const postPushPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
|
|
const postPushPlan = context.apiSuccess(MODULE, '首次 push 后读取事务完成态柜次计划', postPushPlanResponse, '', OPTIONS)
|
|
context.assert(MODULE, '事务完成态计划版本与 bridge 一致且保持 EDITING', postPushPlanResponse,
|
|
postPushPlan?.status === 'EDITING' && Number(postPushPlan?.version) === Number(pushedLink?.loadingPlanVersion)
|
|
&& Number(postPushPlan?.version) >= Number(assigned.version),
|
|
`pushResponseVersion=${pushed?.version ?? 'missing'}, persistedVersion=${postPushPlan?.version ?? 'missing'}, bridgeVersion=${pushedLink?.loadingPlanVersion ?? 'missing'}`, OPTIONS)
|
|
if (postPushPlan?.status !== 'EDITING' || !Number.isInteger(Number(postPushPlan?.version))) return
|
|
const pushedTaskResponse = await context.request('GET',
|
|
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
|
context.addStep(MODULE, '无号 push 成功后 PDA 查询 A0404', pushedTaskResponse,
|
|
pushedTaskResponse.http === 200 && codeOf(pushedTaskResponse) === 'A0404',
|
|
responseEvidence(pushedTaskResponse), OPTIONS)
|
|
|
|
const pushBaseline = projectPushPreloadStability({
|
|
plan: postPushPlan, detail: pushedDetail, links: pushedLinks,
|
|
task: { http: pushedTaskResponse.http, code: codeOf(pushedTaskResponse) },
|
|
})
|
|
const pushReplayResponse = await request(context, 'GET', pushRequest.path)
|
|
context.apiSuccess(MODULE, '重复读取自动关联计划', pushReplayResponse, '', OPTIONS)
|
|
const replayPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
|
|
const replayPlan = context.apiSuccess(MODULE, '重放后回查柜次计划', replayPlanResponse, '', OPTIONS)
|
|
const replayDetailResponse = await context.request('GET',
|
|
`${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`)
|
|
const replayDetail = context.apiSuccess(MODULE, '重放后回查预装历史', replayDetailResponse, '', OPTIONS)
|
|
const replayLinksResponse = await context.request('GET', `${SMART}/plans/${planId}/preloads`)
|
|
const replayLinks = context.apiSuccess(MODULE, '重放后回查 bridge', replayLinksResponse, '', OPTIONS)
|
|
const replayTaskResponse = await context.request('GET',
|
|
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
|
const pushReplaySnapshot = projectPushPreloadStability({
|
|
plan: replayPlan, detail: replayDetail, links: replayLinks,
|
|
task: { http: replayTaskResponse.http, code: codeOf(replayTaskResponse) },
|
|
})
|
|
const pushReplayDiff = diffPushPreloadStability(pushBaseline, pushReplaySnapshot)
|
|
context.assert(MODULE, 'push exact replay 对计划版本历史关联任务零漂移', pushReplayResponse,
|
|
pushReplayDiff.length === 0 && codeOf(replayTaskResponse) === 'A0404',
|
|
`changed=${pushReplayDiff.join('|') || 'none'}; replayTask=${responseEvidence(replayTaskResponse)}`, OPTIONS)
|
|
const pushSameKeyDifferentBody = await request(context, 'GET', pushRequest.path)
|
|
context.addStep(MODULE, '自动关联计划重复读取稳定', pushSameKeyDifferentBody,
|
|
!failure(pushSameKeyDifferentBody), responseEvidence(pushSameKeyDifferentBody), OPTIONS)
|
|
const pushNewKeyDuplicate = await request(context, 'GET', pushRequest.path)
|
|
context.addStep(MODULE, '自动关联计划再次读取稳定', pushNewKeyDuplicate,
|
|
!failure(pushNewKeyDuplicate), responseEvidence(pushNewKeyDuplicate), OPTIONS)
|
|
|
|
const rejectedPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
|
|
const rejectedPlan = context.apiSuccess(MODULE, '非法 push 后回查柜次计划', rejectedPlanResponse, '', OPTIONS)
|
|
const rejectedDetailResponse = await context.request('GET',
|
|
`${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`)
|
|
const rejectedDetail = context.apiSuccess(MODULE, '非法 push 后回查预装历史', rejectedDetailResponse, '', OPTIONS)
|
|
const rejectedLinksResponse = await context.request('GET', `${SMART}/plans/${planId}/preloads`)
|
|
const rejectedLinks = context.apiSuccess(MODULE, '非法 push 后回查 bridge', rejectedLinksResponse, '', OPTIONS)
|
|
const rejectedTaskResponse = await context.request('GET',
|
|
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
|
const rejectedSnapshot = projectPushPreloadStability({
|
|
plan: rejectedPlan, detail: rejectedDetail, links: rejectedLinks,
|
|
task: { http: rejectedTaskResponse.http, code: codeOf(rejectedTaskResponse) },
|
|
})
|
|
const rejectedDiff = diffPushPreloadStability(pushBaseline, rejectedSnapshot)
|
|
context.assert(MODULE, '两类非法 push 后计划版本历史关联任务仍零漂移', rejectedTaskResponse,
|
|
!failure(pushSameKeyDifferentBody) && !failure(pushNewKeyDuplicate)
|
|
&& rejectedDiff.length === 0 && codeOf(rejectedTaskResponse) === 'A0404',
|
|
`changed=${rejectedDiff.join('|') || 'none'}; sameKey=${responseEvidence(pushSameKeyDifferentBody)}; newKey=${responseEvidence(pushNewKeyDuplicate)}; task=${responseEvidence(rejectedTaskResponse)}`, OPTIONS)
|
|
if (rejectedDiff.length !== 0 || codeOf(rejectedTaskResponse) !== 'A0404') return
|
|
|
|
const unlinkedTaskResponse = await context.request('GET',
|
|
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
|
const unlinkedTaskAbsent = unlinkedTaskResponse.http === 200 && codeOf(unlinkedTaskResponse) === 'A0404'
|
|
context.addStep(MODULE, '无号且未关联订舱不生成 PDA 任务', unlinkedTaskResponse,
|
|
unlinkedTaskAbsent, responseEvidence(unlinkedTaskResponse), OPTIONS)
|
|
if (!unlinkedTaskAbsent) return
|
|
|
|
const simulationBeforeLinkResponse = await context.request('GET', `${DOMESTIC_CONTAINERS}/${containerId}`)
|
|
const simulationBeforeLink = containerRecord(context.apiSuccess(MODULE, '预装确认前读取模拟柜最新版本', simulationBeforeLinkResponse, '', OPTIONS))
|
|
if (!Number.isInteger(Number(simulationBeforeLink?.version))) return
|
|
|
|
const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings',
|
|
{ expectedVersion: 0, data: bookingDraft }, { 'Idempotency-Key': `${context.marker}-loading-booking-create` })
|
|
const bookingCreatedDraft = context.apiSuccess(MODULE, '登记含渠道仓库和运输快照的 DRAFT 订舱', bookingCreate, '', OPTIONS)
|
|
const bookingId = id(bookingCreatedDraft?.oceanBooking?.id)
|
|
if (!bookingId) return
|
|
const bookingDocumentForm = new FormData()
|
|
bookingDocumentForm.append('expectedVersion', String(Number(bookingCreatedDraft?.oceanBooking?.version)))
|
|
bookingDocumentForm.append('documentType', 'BOOKING_CONFIRMATION')
|
|
bookingDocumentForm.append('file', new Blob([`${context.marker}-BOOKING_CONFIRMATION`], { type: 'text/plain' }),
|
|
`booking-confirmation-${context.marker}.txt`)
|
|
const bookingDocumentResponse = await context.requestMultipart('POST',
|
|
`/api/platform/v1/ocean-bookings/${bookingId}/documents/upload`, bookingDocumentForm, context.token,
|
|
{ 'Idempotency-Key': `${context.marker}-loading-booking-document`.slice(0, 128) })
|
|
const bookingDocument = context.apiSuccess(MODULE, '上传有效订舱确认文件', bookingDocumentResponse, '', OPTIONS)
|
|
const releaseVersion = Number(bookingDocument?.bookingVersion)
|
|
context.assert(MODULE, '订舱确认文件已关联且版本递增', bookingDocumentResponse,
|
|
Boolean(bookingDocument?.document?.id) && bookingDocument?.document?.documentType === 'BOOKING_CONFIRMATION'
|
|
&& Number.isInteger(releaseVersion),
|
|
`documentId=${id(bookingDocument?.document?.id) || 'missing'}, bookingVersion=${bookingDocument?.bookingVersion ?? 'missing'}`, OPTIONS)
|
|
if (!Number.isInteger(releaseVersion)) return
|
|
const bookingRelease = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
|
|
expectedVersion: releaseVersion, reason: context.marker,
|
|
}, { 'Idempotency-Key': `${context.marker}-loading-booking-release`.slice(0, 128) })
|
|
const bookingCreated = context.apiSuccess(MODULE, '放舱订舱并冻结运输事实', bookingRelease, '', OPTIONS)
|
|
const bookingNo = id(bookingCreated?.oceanBooking?.bookingNo)
|
|
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, '订舱放舱为 RELEASED 且不预生成真实货柜', bookingRelease,
|
|
confirmedBooking?.oceanBooking?.status === 'RELEASED'
|
|
&& Array.isArray(confirmedBooking?.containerRequirements) && confirmedBooking.containerRequirements.length === 2
|
|
&& (!confirmedBooking?.containers || confirmedBooking.containers.length === 0),
|
|
`status=${confirmedBooking?.oceanBooking?.status || 'missing'}, requirements=${confirmedBooking?.containerRequirements?.length ?? 'n/a'}, containers=${confirmedBooking?.containers?.length ?? 0}`, OPTIONS)
|
|
context.assert(MODULE, 'RELEASED 订舱回显完整且一致的冻结运输快照', bookingRelease,
|
|
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, 'RELEASED 订舱进入正式列表', confirmedListResponse,
|
|
records(confirmedList).some((item) => id(item.id) === bookingId && item.status === 'RELEASED'),
|
|
`bookingId=${bookingId}, records=${records(confirmedList).length}`, OPTIONS)
|
|
|
|
const requirement = confirmedBooking?.containerRequirements?.find?.((item) => item.containerType === primaryRequirement.containerType)
|
|
if (!requirement?.id) 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), containerNo: realContainerNo,
|
|
expectedVersion: Number(simulationBeforeLink.version),
|
|
})
|
|
const linkedContainer = context.apiSuccess(MODULE, '首次关联 RELEASED 订舱需求并补真实柜号', 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 && linkedContainer?.containerNo === realContainerNo
|
|
&& id(afterLinkPlan?.id) === planId
|
|
&& beforeLinkItems === afterLinkItems,
|
|
`containerBookingId=${containerId}, cabinetNo=${linkedContainer?.cabinetNo || 'missing'}, loadingPlanId=${id(afterLinkPlan?.id) || 'missing'}`, OPTIONS)
|
|
|
|
const activeContainersResponse = await context.request('GET',
|
|
`${DOMESTIC_CONTAINERS}?page=1&size=50&scope=ACTIVE&keyword=${encodeURIComponent(realContainerNo)}`)
|
|
const activeContainers = context.apiSuccess(MODULE, '关联后按真实柜号回查正常货柜列表', activeContainersResponse, '', OPTIONS)
|
|
const activeRealRows = records(activeContainers).map(containerRecord).filter((item) => item?.containerNo === realContainerNo)
|
|
context.assert(MODULE, '关联后列表仅保留模拟柜原地补全的一条 ACTIVE 操作身份', activeContainersResponse,
|
|
activeRealRows.length === 1 && id(activeRealRows[0]?.id) === containerId,
|
|
`activeIds=${activeRealRows.map((item) => id(item?.id)).join('|') || 'none'}`, OPTIONS)
|
|
const containerGaps = containerFieldGaps(activeRealRows[0], { requireContainerNo: true })
|
|
context.assert(MODULE, '货柜列表关键展示字段来自正式柜次快照且格式完整', activeContainersResponse,
|
|
activeRealRows.length === 1 && containerGaps.length === 0,
|
|
`missing=${containerGaps.join(',') || 'none'}`, OPTIONS)
|
|
|
|
const mergedBookingResponse = await context.request('GET', `/api/platform/v1/ocean-bookings/${bookingId}`)
|
|
const mergedBooking = context.apiSuccess(MODULE, '关联后回查订舱 allocation 当前操作身份', mergedBookingResponse, '', OPTIONS)
|
|
const mergedContainer = mergedBooking?.containers?.find?.((item) => item.containerNo === realContainerNo)
|
|
context.assert(MODULE, '订舱 allocation 原子创建并指向稳定模拟柜身份', mergedBookingResponse,
|
|
id(mergedContainer?.containerBookingId) === containerId
|
|
&& id(mergedContainer?.requirementId) === id(requirement.id)
|
|
&& Boolean(mergedContainer?.allocationId),
|
|
`containerBookingId=${id(mergedContainer?.containerBookingId) || 'missing'}, requirementId=${id(mergedContainer?.requirementId) || 'missing'}, allocationId=${id(mergedContainer?.allocationId) || 'missing'}`, OPTIONS)
|
|
|
|
const duplicateSimulationResponse = await request(context, 'POST', CONTAINERS, {
|
|
channelGroupCode: channelGroup(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), containerNo: realContainerNo,
|
|
expectedVersion: Number(duplicateSimulation?.version),
|
|
})
|
|
context.addStep(MODULE, '已消费订舱需求拒绝再次关联', duplicateLinkResponse,
|
|
failure(duplicateLinkResponse), `HTTP=${duplicateLinkResponse.http}, code=${codeOf(duplicateLinkResponse)}`, OPTIONS)
|
|
|
|
const preloadPageResponse = await context.request('GET',
|
|
`${FIRST_LEG}/loading-plans?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(realContainerNo)}&page=1&size=20`)
|
|
const preloadPage = context.apiSuccess(MODULE, '查询本轮预装清单', preloadPageResponse, '', OPTIONS)
|
|
const preloadRow = records(preloadPage).find((item) => id(item.planId) === planId)
|
|
context.assert(MODULE, '预装清单包含本轮已推送 EDITING 计划', preloadPageResponse,
|
|
records(preloadPage).some((item) => id(item.planId) === planId && item.status === 'EDITING'),
|
|
`records=${records(preloadPage).length}`, OPTIONS)
|
|
const preloadGaps = preloadFieldGaps(preloadRow)
|
|
context.assert(MODULE, '预装清单客户、仓库、箱型、数量重量体积与时间字段完整', preloadPageResponse,
|
|
preloadGaps.length === 0, `missing=${preloadGaps.join(',') || 'none'}`, OPTIONS)
|
|
|
|
const unconfirmedTaskResponse = await context.request('GET',
|
|
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
|
context.addStep(MODULE, '订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务', unconfirmedTaskResponse,
|
|
unconfirmedTaskResponse.http === 200 && codeOf(unconfirmedTaskResponse) === 'A0404',
|
|
responseEvidence(unconfirmedTaskResponse), OPTIONS)
|
|
|
|
const confirmableResponse = await context.request('GET',
|
|
`${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`)
|
|
const confirmable = context.apiSuccess(MODULE, '计划级确认前读取即时版本与就绪门禁', confirmableResponse, '', OPTIONS)
|
|
const confirmableVersion = Number(confirmable?.summary?.version)
|
|
context.assert(MODULE, '真实柜合并放舱后整份计划允许确认且 PDA 尚未创建', confirmableResponse,
|
|
Number.isInteger(confirmableVersion) && confirmable?.summary?.status === 'EDITING'
|
|
&& confirmable?.summary?.readiness?.canConfirm === true
|
|
&& confirmable?.summary?.readiness?.readyForPda === false
|
|
&& confirmable?.summary?.readiness?.pdaTaskId == null,
|
|
`version=${confirmable?.summary?.version ?? 'missing'}, canConfirm=${confirmable?.summary?.readiness?.canConfirm}, pdaTaskId=${id(confirmable?.summary?.readiness?.pdaTaskId) || 'none'}`, OPTIONS)
|
|
if (!Number.isInteger(confirmableVersion) || confirmable?.summary?.readiness?.canConfirm !== true) return
|
|
const pdaOperatorsResponse = await context.request('GET',
|
|
`/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${encodeURIComponent(warehouseId)}`)
|
|
const pdaOperators = context.apiSuccess(MODULE, '查询当前国内头程 PDA 作业员候选', pdaOperatorsResponse, '', OPTIONS)
|
|
const pdaOperator = (Array.isArray(pdaOperators) ? pdaOperators : records(pdaOperators))[0]
|
|
context.assert(MODULE, '存在可用于计划确认的国内头程 PDA 作业员', pdaOperatorsResponse,
|
|
Boolean(pdaOperator?.userId), `operatorId=${id(pdaOperator?.userId) || 'missing'}`, OPTIONS)
|
|
if (!pdaOperator?.userId) return
|
|
const confirmPath = `${FIRST_LEG}/loading-plans/${planId}/confirm`
|
|
const confirmBody = Object.freeze({ expectedVersion: confirmableVersion, assigneeId: Number(pdaOperator.userId) })
|
|
const confirmHeaders = Object.freeze({
|
|
'Idempotency-Key': `${context.marker}-container-plan-confirm`.slice(0, 128),
|
|
})
|
|
const confirmResponse = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders)
|
|
const confirmedPreload = context.apiSuccess(MODULE, '计划级原子确认整份预装并触发唯一 PDA 任务', confirmResponse, '', OPTIONS)
|
|
const confirmedTaskId = id(confirmedPreload?.readiness?.pdaTaskId)
|
|
context.assert(MODULE, '计划确认返回 CONFIRMED、readyForPda 与唯一 taskId', confirmResponse,
|
|
id(confirmedPreload?.planId) === planId && id(confirmedPreload?.containerBookingId) === containerId
|
|
&& confirmedPreload?.confirmationStatus === 'CONFIRMED'
|
|
&& confirmedPreload?.readiness?.confirmationStatus === 'CONFIRMED'
|
|
&& confirmedPreload?.readiness?.readyForPda === true && Boolean(confirmedTaskId)
|
|
&& Number(confirmedPreload?.version) > confirmableVersion,
|
|
`confirmationStatus=${confirmedPreload?.confirmationStatus || 'missing'}, readyForPda=${confirmedPreload?.readiness?.readyForPda}, pdaTaskId=${confirmedTaskId || 'missing'}`, OPTIONS)
|
|
if (!confirmedTaskId || confirmedPreload?.confirmationStatus !== 'CONFIRMED') return
|
|
const confirmReplayResponse = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders)
|
|
context.assert(MODULE, '计划级确认同键同参精确重放同一 taskId 与版本', confirmReplayResponse,
|
|
confirmReplayResponse.http === 200 && codeOf(confirmReplayResponse) === '200'
|
|
&& id(confirmReplayResponse.body?.data?.readiness?.pdaTaskId) === confirmedTaskId
|
|
&& Number(confirmReplayResponse.body?.data?.version) === Number(confirmedPreload.version),
|
|
responseEvidence(confirmReplayResponse), OPTIONS)
|
|
const confirmSameKeyDifferentBody = await request(context, 'POST', confirmPath,
|
|
{ expectedVersion: Number(confirmedPreload.version) }, confirmHeaders)
|
|
context.addStep(MODULE, '计划级确认同 key 异参返回冲突', confirmSameKeyDifferentBody,
|
|
confirmSameKeyDifferentBody.http === 200 && codeOf(confirmSameKeyDifferentBody) === 'A0409',
|
|
responseEvidence(confirmSameKeyDifferentBody), OPTIONS)
|
|
const confirmNewKeyDuplicate = await request(context, 'POST', confirmPath, confirmBody,
|
|
{ 'Idempotency-Key': `${context.marker}-container-plan-confirm-duplicate`.slice(0, 128) })
|
|
context.addStep(MODULE, '已确认计划使用新 key 重复确认返回冲突', confirmNewKeyDuplicate,
|
|
confirmNewKeyDuplicate.http === 200 && codeOf(confirmNewKeyDuplicate) === 'A0409',
|
|
responseEvidence(confirmNewKeyDuplicate), OPTIONS)
|
|
|
|
const confirmedInboundResponse = await context.request('GET',
|
|
`/api/platform/inbounds/${inboundId}?tenantId=${encodeURIComponent(tenantId)}`)
|
|
const confirmedInbound = context.apiSuccess(MODULE, '计划确认后 claim 前回查入库状态', confirmedInboundResponse, '', OPTIONS)
|
|
const confirmedPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
|
|
const confirmedPlan = context.apiSuccess(MODULE, '计划确认后 claim 前回查柜次计划', confirmedPlanResponse, '', OPTIONS)
|
|
context.assert(MODULE, '计划确认后 task 待领取且 claim 前仍 WAIT_SPLIT/EDITING', confirmedPlanResponse,
|
|
confirmedInbound?.summary?.processStatus === 'WAIT_SPLIT' && confirmedPlan?.status === 'EDITING'
|
|
&& Number(confirmedPlan?.version) === Number(confirmedPreload.version),
|
|
`inbound=${confirmedInbound?.summary?.processStatus || 'missing'}, plan=${confirmedPlan?.status || 'missing'}`, OPTIONS)
|
|
if (confirmedInbound?.summary?.processStatus !== 'WAIT_SPLIT' || confirmedPlan?.status !== 'EDITING') return
|
|
|
|
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) && taskId === confirmedTaskId && id(task?.loadingPlanId) === planId
|
|
&& id(task?.containerBookingId) === containerId && Boolean(task?.cabinetNo)
|
|
&& task?.currentContainerNo === realContainerNo && 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 releasedContainerResponse = await context.request('GET', `${DOMESTIC_CONTAINERS}/${containerId}`)
|
|
const releasedContainer = containerRecord(context.apiSuccess(MODULE, '任务生成后回查货柜运输版本', releasedContainerResponse, '', OPTIONS))
|
|
|
|
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 = iso6346ContainerNumber(`${context.marker}-replacement`)
|
|
const replacementResponse = await request(context, 'PATCH',
|
|
`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
|
|
containerNo: replacementContainerNo, voyageNo: `V-${suffix}`.slice(0, 64),
|
|
expectedVersion: Number(confirmedPlan?.version), operationReason: `${context.marker}-replace-container-no`,
|
|
}, { 'Idempotency-Key': `${context.marker}-replace-container-no`.slice(0, 128) })
|
|
const replaced = context.apiSuccess(MODULE, 'PDA 任务创建后替换真实柜号', replacementResponse, '', OPTIONS)
|
|
context.assert(MODULE, '任务创建后真实柜号替换成功', replacementResponse,
|
|
replaced?.summary?.containerNo === replacementContainerNo,
|
|
`containerNo=${replaced?.summary?.containerNo || 'missing'}`, OPTIONS)
|
|
|
|
const clearContainerNoResponse = await request(context, 'PATCH',
|
|
`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
|
|
containerNo: ' ', voyageNo: `V-${suffix}`.slice(0, 64),
|
|
expectedVersion: Number(replaced?.summary?.version), operationReason: `${context.marker}-clear-container-no`,
|
|
}, { 'Idempotency-Key': `${context.marker}-clear-container-no`.slice(0, 128) })
|
|
context.addStep(MODULE, 'PDA 任务创建后清空真实柜号失败', clearContainerNoResponse,
|
|
clearContainerNoResponse.http === 200 && ['A0400', '400'].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 beforeClaimPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
|
|
const beforeClaimPlan = context.apiSuccess(MODULE, '领取任务前读取装柜计划即时版本',
|
|
beforeClaimPlanResponse, '', OPTIONS)
|
|
const beforeClaimPlanVersion = Number(beforeClaimPlan?.version)
|
|
context.assert(MODULE, '领取任务前计划保持 EDITING 且版本有效', beforeClaimPlanResponse,
|
|
beforeClaimPlan?.status === 'EDITING' && Number.isInteger(beforeClaimPlanVersion),
|
|
`status=${beforeClaimPlan?.status || 'missing'}, version=${beforeClaimPlan?.version ?? 'missing'}`, OPTIONS)
|
|
if (beforeClaimPlan?.status !== 'EDITING' || !Number.isInteger(beforeClaimPlanVersion)) return
|
|
|
|
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) > beforeClaimPlanVersion,
|
|
`status=${claimedPlan?.status || 'missing'}, version=${claimedPlan?.version ?? 'missing'}`, OPTIONS)
|
|
|
|
const claimedCorrectionResponse = await request(context, 'PATCH',
|
|
`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
|
|
containerNo: iso6346ContainerNumber(`${context.marker}-claimed-mutate`), voyageNo: 'CLAIMED',
|
|
expectedVersion: Number(claimedPlan?.version), operationReason: `${context.marker}-claimed-mutate`,
|
|
}, { 'Idempotency-Key': `${context.marker}-claimed-mutate`.slice(0, 128) })
|
|
context.addStep(MODULE, 'PDA任务领取后真实柜号纠错被拒绝', claimedCorrectionResponse,
|
|
claimedCorrectionResponse.http === 200 && ['A0409', '409'].includes(codeOf(claimedCorrectionResponse)),
|
|
`HTTP=${claimedCorrectionResponse.http}, code=${codeOf(claimedCorrectionResponse)}`, 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, 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 evidenceTypes = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']
|
|
const sealNo = `SEAL-${suffix}`.slice(0, 64)
|
|
for (const evidenceType of evidenceTypes) {
|
|
const uploadResponse = await uploadLoadingEvidence(context, taskId, warehouseId, evidenceType, sealNo)
|
|
const bound = context.apiSuccess(MODULE, `上传并绑定柜次 ${evidenceType} 真实证据文件`, uploadResponse,
|
|
responseEvidence(uploadResponse), OPTIONS)
|
|
const boundItem = bound?.evidences?.find?.((item) => item.evidenceType === evidenceType && item.status !== 'VOID')
|
|
const evidenceId = id(boundItem?.evidenceId)
|
|
const fileId = id(boundItem?.fileId)
|
|
context.assert(MODULE, `${evidenceType} 有效证据归属稳定且锁码门禁完整`, uploadResponse,
|
|
id(bound?.taskId) === taskId && id(bound?.containerBookingId) === containerId
|
|
&& Boolean(evidenceId) && Boolean(fileId)
|
|
&& boundItem?.remark === context.marker
|
|
&& (evidenceType !== 'SEAL' || boundItem?.sealNo === sealNo),
|
|
`taskId=${id(bound?.taskId) || 'missing'}, containerBookingId=${id(bound?.containerBookingId) || 'missing'}, evidenceId=${evidenceId || 'missing'}, sealNo=${boundItem?.sealNo || 'n/a'}`, OPTIONS)
|
|
if (!evidenceId) return
|
|
|
|
const downloadResponse = await context.requestBinary('GET',
|
|
`${PDA}/tasks/${taskId}/evidence/${encodeURIComponent(evidenceId)}/content?warehouseId=${encodeURIComponent(warehouseId)}`)
|
|
context.assert(MODULE, `下载柜次 ${evidenceType} 证据为非空受控二进制`, downloadResponse,
|
|
downloadResponse.http === 200 && downloadResponse.bytes.length > 0
|
|
&& downloadResponse.contentType.toLowerCase().includes('image/png'),
|
|
`bytes=${downloadResponse.bytes.length}, contentType=${downloadResponse.contentType || 'missing'}`, OPTIONS)
|
|
}
|
|
|
|
const evidenceListResponse = await context.request('GET',
|
|
`${PDA}/tasks/${taskId}/evidence?warehouseId=${encodeURIComponent(warehouseId)}`)
|
|
const evidenceList = context.apiSuccess(MODULE, 'PDA 完成前回查五类有效装柜证据与锁码', evidenceListResponse,
|
|
'', OPTIONS)
|
|
const activeEvidence = Array.isArray(evidenceList?.evidences)
|
|
? evidenceList.evidences.filter((item) => item.status !== 'VOID') : []
|
|
context.assert(MODULE, '五类有效照片和 SEAL 锁码齐全后才调用 PDA complete', evidenceListResponse,
|
|
evidenceTypes.every((type) => activeEvidence.some((item) => item.evidenceType === type))
|
|
&& activeEvidence.some((item) => item.evidenceType === 'SEAL' && item.sealNo === sealNo),
|
|
`types=${[...new Set(activeEvidence.map((item) => item.evidenceType))].join(',') || 'missing'}, sealNo=${sealNo}`, OPTIONS)
|
|
if (!evidenceTypes.every((type) => activeEvidence.some((item) => item.evidenceType === type))
|
|
|| !activeEvidence.some((item) => item.evidenceType === 'SEAL' && item.sealNo === sealNo)) return
|
|
|
|
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 acceptedCompletion = context.apiSuccess(MODULE, 'PDA 提交人工确认装柜完成', completeResponse, '', OPTIONS)
|
|
context.assert(MODULE, '装柜完成命令已接受并进入处理或终态', completeResponse,
|
|
['COMPLETING', 'COMPLETED'].includes(acceptedCompletion?.taskStatus),
|
|
`taskStatus=${acceptedCompletion?.taskStatus || 'missing'}, workflow=${acceptedCompletion?.workflowStatus || 'missing'}`, OPTIONS)
|
|
const completion = await waitForCompletedTask(context, taskId, warehouseId)
|
|
const completed = context.apiSuccess(MODULE, '等待 PDA 装柜完成异步收敛', completion.response,
|
|
`attempts=${completion.attempts}`, OPTIONS)
|
|
const directPdaCompletion = completed?.taskStatus === 'COMPLETED'
|
|
&& completed?.workflowStatus === 'COMPLETED'
|
|
context.assert(MODULE, '人工确认后 PDA 任务进入 COMPLETED', completion.response,
|
|
directPdaCompletion
|
|
&& completed?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)),
|
|
`taskStatus=${completed?.taskStatus || 'missing'}, workflow=${completed?.workflowStatus || 'missing'}, attempts=${completion.attempts}`, 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)
|
|
const confirmedRow = records(confirmedPage).find((item) => id(item.planId) === planId)
|
|
context.assert(MODULE, '完成清单分页包含本轮计划', confirmedPageResponse,
|
|
records(confirmedPage).some((item) => id(item.planId) === planId && item.status === 'COMPLETED'),
|
|
`records=${records(confirmedPage).length}`, OPTIONS)
|
|
const loadingListGaps = loadingListFieldGaps(confirmedRow)
|
|
context.assert(MODULE, '装柜清单柜号、仓库、数量重量体积与时间字段完整', confirmedPageResponse,
|
|
loadingListGaps.length === 0, `missing=${loadingListGaps.join(',') || 'none'}`, 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: iso6346ContainerNumber(`${context.marker}-completed-mutate`), 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, oceanBookingId: bookingId, pdaTaskId: taskId, cabinetNo: stableIdentity.cabinetNo,
|
|
containerNo: replacementContainerNo, pdaIdentityStable: true,
|
|
finalStatus: 'COMPLETED',
|
|
}
|
|
}
|