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

1591 lines
124 KiB
JavaScript
Raw Normal View History

import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
2026-08-27 11:42:05 +08:00
import { isPureStatusNoop, visibleLoadingHistories } from './loading-history-noop.mjs'
2026-08-28 09:19:18 +08:00
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
2026-08-24 21:00:22 +08:00
import { createHash } from 'node:crypto'
2026-08-08 21:14:39 +08:00
const MODULE = 'phase3-loading'
const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true }
2026-08-26 11:10:42 +08:00
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || ''
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || ''
2026-08-24 23:19:27 +08:00
const MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司'
2026-09-01 15:43:19 +08:00
const LATE_CONTAINER_GATE_ONLY = process.env.LATE_CONTAINER_GATE_ONLY === '1'
2026-08-08 21:14:39 +08:00
function id(value) { return value == null ? '' : String(value) }
2026-08-24 21:00:22 +08:00
export function markerSuffix(marker) {
const value = String(marker || '')
const digits = value.replace(/\D/g, '').slice(-8).padStart(8, '0')
const digest = createHash('sha256').update(value).digest('hex').slice(0, 8).toUpperCase()
return `${digits}-${digest}`
}
2026-08-08 21:14:39 +08:00
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'}`
}
2026-08-08 21:14:39 +08:00
function failure(response) {
return [400, 401, 403, 404, 409, 503].includes(response?.http)
|| (response?.http === 200 && codeOf(response) !== '200')
}
2026-08-24 23:19:27 +08:00
async function ensureMatsonSupplier(context, records) {
const existing = records.find((value) => value?.id && value?.name === MATSON_SUPPLIER_NAME)
if (existing?.status === 'ACTIVE') return existing
if (existing?.id) {
const activatedResponse = await context.request('PATCH',
`/api/platform/v1/suppliers/${encodeURIComponent(id(existing.id))}/status?tenantId=0`, { status: 'ACTIVE' })
const activated = context.apiSuccess(MODULE, '启用稳定MATSON船司开发夹具', activatedResponse,
`supplierId=${id(existing.id)}`, OPTIONS)
return activated?.id ? activated : null
}
const createdResponse = await context.request('POST', '/api/platform/v1/suppliers?tenantId=0', {
2026-08-28 09:19:18 +08:00
name: MATSON_SUPPLIER_NAME, category: 'BOOKING', carrierCodes: ['MATSON'], region: '开发环境', legalRepresentative: '开发联调',
2026-08-24 23:19:27 +08:00
contactName: '开发联调', phone: '13800000000', email: 'matson-dev-fixture@example.com',
address: '开发环境稳定船司夹具', businessLicenseNo: 'DEV-CARRIER-MATSON', licenseFileId: null,
status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: '三四期开发环境HTTP验收稳定船司夹具',
})
const supplierId = id(context.apiSuccess(MODULE, '创建稳定MATSON船司开发夹具', createdResponse,
`name=${MATSON_SUPPLIER_NAME}`, OPTIONS))
return supplierId ? { id: supplierId, name: MATSON_SUPPLIER_NAME, status: 'ACTIVE' } : null
}
2026-09-02 15:47:13 +08:00
export function stableLoadedCartonMarks(items) {
2026-09-02 16:01:58 +08:00
const groups = new Map()
for (const item of [...(Array.isArray(items) ? items : [])]
2026-08-28 09:19:18 +08:00
.sort((left, right) => id(left?.loadingPlanItemId).localeCompare(id(right?.loadingPlanItemId)))
2026-09-02 16:01:58 +08:00
) {
const groupKey = id(item?.inboundId) || `item:${id(item?.loadingPlanItemId)}`
const group = groups.get(groupKey) || { count: 0, labels: new Set() }
group.count += Math.max(0, Number(item?.pickedPackageCount) || 0)
for (const value of item?.boxCodes || []) {
const label = String(value || '').trim()
if (label) group.labels.add(label)
}
groups.set(groupKey, group)
}
// Settlement transfers the highest carton indexes to the remainder child for the whole
// inbound, not per cargo line. Aggregate task rows by inbound before selecting the retained
// low indexes so the downstream fixture mirrors the persisted container manifest.
return [...groups.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.flatMap(([, group]) => [...group.labels]
.sort((left, right) => left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' }))
.slice(0, group.count))
2026-08-28 09:19:18 +08:00
}
2026-08-08 21:14:39 +08:00
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 uploadEvidence(context, taskId, warehouseId, evidenceType, expectedVersion, keySuffix,
{ sealNo = null, remark = context.marker, fileSuffix = evidenceType } = {}) {
const query = new URLSearchParams({ warehouseId: String(warehouseId), expectedVersion: String(expectedVersion),
evidenceType, remark })
if (sealNo != null) query.set('sealNo', sealNo)
const path = `/api/platform/v1/first-leg/pda-loading/tasks/${taskId}/evidence?${query}`
2026-08-08 21:14:39 +08:00
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])
const fileName = `${context.marker}-${String(fileSuffix).toLowerCase()}.png`
form.append('file', new Blob([png], { type: 'image/png' }), fileName)
2026-08-08 21:14:39 +08:00
try {
const response = await fetch(`${context.options.gateway}${path}`, {
method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`,
'Idempotency-Key': `${context.marker}-p3-evidence-${keySuffix}`.slice(0, 128) },
2026-08-08 21:14:39 +08:00
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 }
}
}
function fixture(context) {
2026-08-12 10:59:39 +08:00
return context.createdResources?.orchestrationFixtures?.phase3Loading
|| context.createdResources?.orchestrationFixtures?.inboundMeasurement
2026-08-08 21:14:39 +08:00
|| context.createdResources?.inboundMeasurement
|| context.createdResources?.orders
}
2026-08-11 20:10:24 +08:00
function inventorySignature(snapshot) {
return JSON.stringify((snapshot?.inbounds || []).map((inbound) => ({
inboundId: id(inbound?.inboundId),
availablePackageCount: Number(inbound?.availablePackageCount),
resources: (inbound?.resources || []).map((resource) => ({
blockId: id(resource?.blockId), slotNo: Number(resource?.slotNo),
reservationId: id(resource?.reservationId), weightResultId: id(resource?.weightResultId),
palletSequence: Number(resource?.palletSequence), palletId: id(resource?.palletId),
locationId: id(resource?.locationId), slotStatus: resource?.slotStatus || null,
2026-08-11 20:10:24 +08:00
sharedOverflow: resource?.sharedOverflow === true,
availablePackageCount: Number(resource?.availablePackageCount),
})),
})))
}
function channelGroup(channelCode) {
const value = String(channelCode || '').toUpperCase()
if (value.startsWith('EXX_')) return 'EXX'
if (value.startsWith('MATSON_CLX_')) return 'MATSON_CLX'
if (value.startsWith('MATSON_MAX_')) return 'MATSON_MAX'
if (value.startsWith('HEDE_')) return 'HEDE'
if (value.startsWith('OA_LA_') || value === 'OA_PARCEL') return 'OA_LA'
if (value.startsWith('OA_CHICAGO_')) return 'OA_CHICAGO'
if (value.startsWith('OA_HOUSTON_')) return 'OA_HOUSTON'
if (value.startsWith('OA_SAVANNAH_')) return 'OA_SAVANNAH'
if (value === 'OA_NEW_YORK_TRUCK') return value
throw new Error(`unsupported smart-loading channel group: ${channelCode}`)
}
2026-08-11 20:10:24 +08:00
function operationsSignature(operations) {
return JSON.stringify((operations || []).map((operation) => ({
id: id(operation?.id), operationId: operation?.operationId || null,
actionType: operation?.actionType || null, operatorId: id(operation?.operatorId),
createdAt: operation?.createdAt || null,
})))
}
2026-08-12 13:24:17 +08:00
function taskItemsSignature(task) {
return JSON.stringify((task?.items || []).map((item) => ({
id: id(item?.id), status: item?.itemStatus || null,
picked: Number(item?.pickedPackageCount), version: Number(item?.version),
})))
}
2026-08-08 21:14:39 +08:00
export async function run(context) {
const source = fixture(context)
const tenantId = id(source?.tenantId)
const inboundId = id(source?.inboundId)
2026-08-24 19:58:35 +08:00
if (!tenantId || !inboundId || source?.marker !== context.marker) {
2026-08-08 21:14:39 +08:00
context.addStep(MODULE, '取得本轮 WAIT_SPLIT 隔离入库单', null, false,
2026-08-24 19:58:35 +08:00
`未找到 orders/inbound-measurement 本轮隔离 fixture,未使用历史入库单;fixtureMarker=${source?.marker || 'missing'}, runMarker=${context.marker}`,
OPTIONS)
2026-08-08 21:14:39 +08:00
return
}
const detailPath = `/api/platform/inbounds/${inboundId}?tenantId=${encodeURIComponent(tenantId)}`
const initialResponse = await context.request('GET', detailPath)
const initial = context.apiSuccess(MODULE, '读取三期隔离入库单状态与版本', initialResponse, `tenantId=${tenantId}, inboundId=${inboundId}`, OPTIONS)
const initialStatus = initial?.summary?.processStatus
const initialVersion = Number(initial?.summary?.version)
context.assert(MODULE, '隔离入库单处于智能分柜门禁状态', initialResponse,
2026-08-13 14:55:50 +08:00
initialStatus === 'WAIT_SPLIT' && Number.isInteger(initialVersion),
2026-08-08 21:14:39 +08:00
`status=${initialStatus || 'missing'}, version=${initial?.summary?.version ?? 'missing'}`, OPTIONS)
2026-08-13 14:55:50 +08:00
if (initialStatus !== 'WAIT_SPLIT' || !Number.isInteger(initialVersion)) return
2026-08-08 21:14:39 +08:00
const base = `/api/platform/v1/inbounds/${inboundId}`
2026-08-24 21:00:22 +08:00
const suffix = markerSuffix(context.marker)
const warehouseId = id(initial?.summary?.warehouseId)
const containerNo = iso6346ContainerNumber(`${context.marker}-phase3-loading`)
const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading'
const pdaBase = '/api/platform/v1/first-leg/pda-loading'
2026-08-20 15:20:05 +08:00
const supplierResponse = await context.request('GET',
2026-08-24 23:19:27 +08:00
'/api/platform/v1/suppliers?tenantId=0&category=BOOKING&page=1&size=200')
const supplierPage = context.apiSuccess(MODULE, '读取订舱代理与船司供应商事实源', supplierResponse, '', OPTIONS)
const supplierRecords = Array.isArray(supplierPage?.records) ? supplierPage.records : []
2026-08-28 09:19:18 +08:00
const supplier = await ensureMatsonSupplier(context, supplierRecords)
2026-08-20 15:20:05 +08:00
context.assert(MODULE, '存在可用于发布订舱的 ACTIVE 订舱代理', supplierResponse,
Boolean(supplier?.id && supplier?.name), `supplierId=${id(supplier?.id) || 'missing'}`, OPTIONS)
if (!supplier?.id) return
2026-08-28 09:19:18 +08:00
const shippingCarrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS)
context.assert(MODULE, '订舱船司与订舱代理使用各自真实主数据事实', supplierResponse,
Boolean(shippingCarrier?.id && shippingCarrier?.carrierCode === 'MATSON'),
2026-08-24 23:19:27 +08:00
`shippingCompanyId=${id(shippingCarrier?.id) || 'missing'}, bookingAgentId=${id(supplier.id)}`, OPTIONS)
if (!shippingCarrier?.id) return
context.createdResources.phase3CarrierSupplier = {
supplierId: id(shippingCarrier.id), name: shippingCarrier.name, status: 'ACTIVE', stableDevFixture: true,
}
2026-08-13 19:19:29 +08:00
const bookingDraft = {
customerId: tenantId, customerName: initial?.summary?.customerName,
2026-08-17 13:24:48 +08:00
warehouseId,
2026-08-14 18:50:07 +08:00
channelCode: initial?.summary?.channelCode,
billOfLadingNo: `P3BL-${suffix}`.slice(0, 128),
2026-08-28 09:19:18 +08:00
shippingCompanyId: id(shippingCarrier.id), shippingCompanyName: shippingCarrier.name, carrierCode: 'MATSON',
2026-08-20 15:20:05 +08:00
bookingAgentId: id(supplier.id), bookingAgentName: supplier.name,
vesselName: `P3VESSEL-${suffix}`.slice(0, 128), voyageNo: `P3V-${suffix}`.slice(0, 64),
2026-08-13 19:19:29 +08:00
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',
2026-08-13 19:19:29 +08:00
remark: context.marker,
parties: [{ partyType: 'SHIPPER', partyName: `P3-SHIPPER-${suffix}` },
{ partyType: 'CONSIGNEE', partyName: `P3-CONSIGNEE-${suffix}` },
{ partyType: 'NOTIFY_PARTY', partyName: `P3-NOTIFY-${suffix}` }],
2026-08-18 16:09:52 +08:00
containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76,
2026-08-17 13:24:48 +08:00
maxWeightKg: 28000 }],
2026-08-13 19:19:29 +08:00
}
const simulationResponse = await request(context, 'POST', '/api/platform/v1/containers', {
2026-08-17 13:24:48 +08:00
channelCode: initial?.summary?.channelCode, warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, '新增三期内部模拟柜', simulationResponse, '', OPTIONS)
const containerBookingId = id(simulation?.id)
context.assert(MODULE, '三期模拟柜不要求真实柜号或订舱', simulationResponse,
Boolean(containerBookingId) && Boolean(simulation?.cabinetNo) && simulation?.containerNo == null
&& simulation?.oceanBookingId == null,
`containerBookingId=${containerBookingId || 'missing'}, cabinetNo=${simulation?.cabinetNo || 'missing'}`, OPTIONS)
if (!containerBookingId) return
2026-08-13 19:19:29 +08:00
const candidateResponse = await context.request('GET', `${smartLoadingBase}/candidates?bookingId=${encodeURIComponent(containerBookingId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const candidates = context.apiSuccess(MODULE, '查询本轮入库单柜次装柜候选', candidateResponse, '', OPTIONS)
2026-09-04 17:17:08 +08:00
const inboundCandidates = Array.isArray(candidates) ? candidates.filter((item) => id(item.inboundId) === inboundId
&& Number(item.availablePackageCount) > 0) : []
let candidate = inboundCandidates[0]
let dropshipCandidates = []
if (source?.fulfillmentType === 'ONE_CLICK_DROPSHIP') {
const group = channelGroup(initial?.summary?.channelCode)
const candidatePath = `${smartLoadingBase}/inbound-candidates?channelGroupCodes=${encodeURIComponent(group)}&warehouseId=${encodeURIComponent(warehouseId)}&customerId=${encodeURIComponent(tenantId)}&inboundCode=${encodeURIComponent(initial?.summary?.inboundCode || source?.inboundCode || '')}&orderStatuses=WAIT_SPLIT&page=1&size=20`
const palletCandidatesResponse = await context.request('GET', candidatePath)
const palletCandidatePage = context.apiSuccess(MODULE, '查询一件代发整托装柜候选', palletCandidatesResponse, '', OPTIONS)
dropshipCandidates = Array.isArray(palletCandidatePage?.records)
? palletCandidatePage.records.filter((item) => id(item.inboundId) === inboundId
&& id(item.palletId) && item.unitType === 'SEALED_PALLET' && item.indivisible === true) : []
candidate = dropshipCandidates[0]
context.assert(MODULE, '一件代发候选只按已封托托盘返回', palletCandidatesResponse,
dropshipCandidates.length > 0, `inboundId=${inboundId}, pallets=${dropshipCandidates.length}`, OPTIONS)
}
context.assert(MODULE, '柜次装柜候选与入库单、客户和仓库一致', candidateResponse,
Boolean(candidate?.inboundDetailId || id(candidate?.palletId)) && id(candidate?.accountId) === tenantId
&& id(candidate?.warehouseId) === warehouseId,
`candidate=${Boolean(candidate)}, warehouseId=${warehouseId || 'missing'}`, OPTIONS)
if ((!candidate?.inboundDetailId && !id(candidate?.palletId)) || !warehouseId) return
const loadingPlanResponse = await request(context, 'GET',
`${smartLoadingBase}/plans/by-booking/${encodeURIComponent(containerBookingId)}`)
const loadingPlan = context.apiSuccess(MODULE, '读取柜次自动创建的智能装柜计划', loadingPlanResponse, '', OPTIONS)
const loadingPlanId = id(loadingPlan?.id)
if (!loadingPlanId) return
2026-09-04 17:17:08 +08:00
const dropshipAssignment = source?.fulfillmentType === 'ONE_CLICK_DROPSHIP'
const initialAssignmentCount = dropshipAssignment
? dropshipCandidates.filter((item) => id(item.palletId) === id(candidate.palletId))
.reduce((total, item) => total + Number(item.availablePackageCount || 0), 0) : 1
2026-09-04 17:17:08 +08:00
const assignmentPath = dropshipAssignment
? `${smartLoadingBase}/plans/${loadingPlanId}/pallet-assignments`
2026-09-04 17:17:08 +08:00
: `${smartLoadingBase}/plans/${loadingPlanId}/assignments`
const assignmentBody = dropshipAssignment
? { palletId: id(candidate.palletId), expectedVersion: Number(loadingPlan.version),
operationId: `${context.marker}-${loadingPlanId}-bridge-assign-pallet`.slice(0, 128) }
2026-09-04 17:17:08 +08:00
: { inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: initialAssignmentCount,
expectedVersion: Number(loadingPlan.version), operationId: `${context.marker}-${loadingPlanId}-bridge-assign`.slice(0, 128) }
const assignmentResponse = await request(context, 'POST', assignmentPath, assignmentBody)
const assignedLoadingPlan = context.apiSuccess(MODULE, '将本轮入库货物分配到柜次计划', assignmentResponse, '', OPTIONS)
context.assert(MODULE, '柜次计划明细和版本真实持久化', assignmentResponse,
2026-09-04 17:17:08 +08:00
assignedLoadingPlan?.items?.filter?.((item) => id(item.inboundId) === inboundId)
.reduce((total, item) => total + Number(item.packageCount || 0), 0) === initialAssignmentCount
&& Number(assignedLoadingPlan?.version) >= 1,
`loadingPlanId=${loadingPlanId}, assignedPackages=${initialAssignmentCount}, version=${assignedLoadingPlan?.version ?? 'missing'}`, OPTIONS)
2026-08-18 16:39:39 +08:00
const assignedItem = assignedLoadingPlan?.items?.find?.((item) => id(item.inboundId) === inboundId)
const orderRemarks = source || {}
2026-08-18 16:39:39 +08:00
context.assert(MODULE, '装柜明细客户备注与最新内部备注来源精确且不冒用货物备注', assignmentResponse,
assignedItem?.customerRemark === orderRemarks.customerRemark
&& assignedItem?.internalRemark === orderRemarks.internalRemark
&& assignedItem?.internalRemark !== orderRemarks.cargoRemark,
`customerRemark=${assignedItem?.customerRemark || 'missing'}, internalRemark=${assignedItem?.internalRemark || 'missing'}, cargoRemark=${orderRemarks.cargoRemark || 'missing'}`, OPTIONS)
if (!assignedItem?.id || !Number.isInteger(Number(assignedItem.version))) return
const warehouseRemark = `${context.marker}-WAREHOUSE-REMARK`
const warehouseRemarkPath = `${smartLoadingBase}/plans/${loadingPlanId}/items/${id(assignedItem.id)}/warehouse-remark`
const warehouseRemarkBody = Object.freeze({ warehouseRemark,
expectedPlanVersion: Number(assignedLoadingPlan.version), expectedItemVersion: Number(assignedItem.version),
operationId: `${context.marker}-${loadingPlanId}-warehouse-remark`.slice(0, 128) })
2026-08-18 16:39:39 +08:00
const remarkResponse = await request(context, 'PATCH', warehouseRemarkPath, warehouseRemarkBody)
const remarkedPlan = context.apiSuccess(MODULE, 'EDITING 装柜明细保存仓库备注', remarkResponse, '', OPTIONS)
const remarkedItem = remarkedPlan?.items?.find?.((item) => id(item.id) === id(assignedItem.id))
context.assert(MODULE, '仓库备注保存后计划与明细版本递增且三备注并存', remarkResponse,
remarkedItem?.warehouseRemark === warehouseRemark
&& Number(remarkedPlan?.version) > Number(assignedLoadingPlan.version)
&& Number(remarkedItem?.version) > Number(assignedItem.version)
&& remarkedItem?.customerRemark === orderRemarks.customerRemark
&& remarkedItem?.internalRemark === orderRemarks.internalRemark,
`planVersion=${remarkedPlan?.version ?? 'missing'}, itemVersion=${remarkedItem?.version ?? 'missing'}`, OPTIONS)
if (!remarkedItem?.id) return
const remarkReplayResponse = await request(context, 'PATCH', warehouseRemarkPath, warehouseRemarkBody)
const replayedRemarkPlan = context.apiSuccess(MODULE, '仓库备注相同 operationId 与原请求精确重放', remarkReplayResponse, '', OPTIONS)
const replayedRemarkItem = replayedRemarkPlan?.items?.find?.((item) => id(item.id) === id(assignedItem.id))
context.assert(MODULE, '仓库备注精确重放不重复递增版本或历史事实', remarkReplayResponse,
Number(replayedRemarkPlan?.version) === Number(remarkedPlan?.version)
&& Number(replayedRemarkItem?.version) === Number(remarkedItem?.version)
&& replayedRemarkItem?.warehouseRemark === warehouseRemark,
`planVersion=${replayedRemarkPlan?.version ?? 'missing'}, itemVersion=${replayedRemarkItem?.version ?? 'missing'}`, OPTIONS)
const staleRemarkResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${id(assignedItem.id)}/warehouse-remark`, {
warehouseRemark: `${warehouseRemark}-STALE`, expectedPlanVersion: Number(assignedLoadingPlan.version),
expectedItemVersion: Number(assignedItem.version), operationId: `${context.marker}-${loadingPlanId}-warehouse-remark-stale`.slice(0, 128),
2026-08-18 16:39:39 +08:00
})
context.addStep(MODULE, '仓库备注旧版本 A0409 且零写入', staleRemarkResponse,
staleRemarkResponse.http === 200 && codeOf(staleRemarkResponse) === 'A0409',
`HTTP=${staleRemarkResponse.http}, code=${codeOf(staleRemarkResponse)}`, OPTIONS)
const afterStaleRemarkResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const afterStaleRemarkPlan = context.apiSuccess(MODULE, '仓库备注旧版本冲突后回查计划', afterStaleRemarkResponse, '', OPTIONS)
const afterStaleRemarkItem = afterStaleRemarkPlan?.items?.find?.((item) => id(item.id) === id(remarkedItem.id))
context.assert(MODULE, '仓库备注旧版本冲突后版本与备注事实零漂移', afterStaleRemarkResponse,
Number(afterStaleRemarkPlan?.version) === Number(remarkedPlan?.version)
&& Number(afterStaleRemarkItem?.version) === Number(remarkedItem?.version)
&& afterStaleRemarkItem?.warehouseRemark === warehouseRemark,
`planVersion=${afterStaleRemarkPlan?.version ?? 'missing'}, itemVersion=${afterStaleRemarkItem?.version ?? 'missing'}`, OPTIONS)
const clearRemarkResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${id(remarkedItem.id)}/warehouse-remark`, {
warehouseRemark: ' ', expectedPlanVersion: Number(remarkedPlan.version),
expectedItemVersion: Number(remarkedItem.version), operationId: `${context.marker}-${loadingPlanId}-warehouse-remark-clear`.slice(0, 128),
2026-08-18 16:39:39 +08:00
})
const clearedPlan = context.apiSuccess(MODULE, 'EDITING 装柜明细空白仓库备注归一化为 null', clearRemarkResponse, '', OPTIONS)
const clearedItem = clearedPlan?.items?.find?.((item) => id(item.id) === id(remarkedItem.id))
context.assert(MODULE, '清空仓库备注不影响客户与内部备注来源', clearRemarkResponse,
clearedItem?.warehouseRemark == null && clearedItem?.customerRemark === orderRemarks.customerRemark
&& clearedItem?.internalRemark === orderRemarks.internalRemark,
`warehouseRemark=${clearedItem?.warehouseRemark ?? 'null'}`, OPTIONS)
if (!clearedItem?.id) return
const restoreRemarkResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${id(clearedItem.id)}/warehouse-remark`, {
warehouseRemark, expectedPlanVersion: Number(clearedPlan.version), expectedItemVersion: Number(clearedItem.version),
operationId: `${context.marker}-${loadingPlanId}-warehouse-remark-restore`.slice(0, 128),
2026-08-18 16:39:39 +08:00
})
const persistedRemarkPlan = context.apiSuccess(MODULE, '恢复真实仓库备注供装柜完成事件下发', restoreRemarkResponse, '', OPTIONS)
if (!Number.isInteger(Number(persistedRemarkPlan?.version))) return
const pendingPreloadResponse = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`)
const pendingPreload = context.apiSuccess(MODULE, '智能排货自动建立 PENDING 预装聚合', pendingPreloadResponse, '', OPTIONS)
const planId = id(pendingPreload?.id)
context.assert(MODULE, '推送前入库单保持 WAIT_SPLIT 且预装聚合为 PENDING', pendingPreloadResponse,
Boolean(planId) && pendingPreload?.status === 'PENDING' && Number.isInteger(Number(pendingPreload?.version)),
`preloadPlanId=${planId || 'missing'}, status=${pendingPreload?.status || 'missing'}`, OPTIONS)
2026-08-08 21:14:39 +08:00
if (!planId) return
const pushRequest = Object.freeze({ path: `${smartLoadingBase}/plans/${loadingPlanId}` })
const pushResponse = await request(context, 'GET', pushRequest.path)
const pushedLoadingPlan = context.apiSuccess(MODULE, '分配后自动建立预装关联且柜次计划可读', pushResponse, '', OPTIONS)
context.assert(MODULE, '自动关联保持 EDITING、身份稳定且无需额外推送版本', pushResponse,
id(pushedLoadingPlan?.id) === loadingPlanId && pushedLoadingPlan?.status === 'EDITING'
&& pushedLoadingPlan?.containerNo == null
&& Number(pushedLoadingPlan?.version) === Number(persistedRemarkPlan.version),
`status=${pushedLoadingPlan?.status || 'missing'}, version=${pushedLoadingPlan?.version ?? 'missing'}`, OPTIONS)
if (pushedLoadingPlan?.status !== 'EDITING') return
const pushedDetailResponse = await context.request('GET',
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const pushedDetail = context.apiSuccess(MODULE, '无号推送后预装清单正式可见', pushedDetailResponse, '', OPTIONS)
2026-08-27 11:42:05 +08:00
const pushedHistories = visibleLoadingHistories(pushedDetail?.histories)
context.assert(MODULE, '自动预装不保留旧 PUSH_PRELOAD 历史且无纯状态 no-op', pushedDetailResponse,
pushedHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length === 0
2026-08-27 11:42:05 +08:00
&& !(pushedDetail?.histories || []).some(isPureStatusNoop),
`raw=${pushedDetail?.histories?.length ?? 'invalid'}, effective=${pushedHistories.length}, push=${pushedHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length}`,
OPTIONS)
const pushedLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`)
const pushedLinks = context.apiSuccess(MODULE, '无号推送自动建立 bridge 关联', pushedLinksResponse, '', OPTIONS)
const link = Array.isArray(pushedLinks) ? pushedLinks.find((item) => id(item.inboundId) === inboundId) : null
context.assert(MODULE, 'push 自动建立唯一预装关联且分配身份稳定', pushedLinksResponse,
Boolean(link?.id) && id(link?.loadingPlanId) === loadingPlanId && id(link?.preloadPlanId) === planId
&& Number(link?.allocatedPackageCount) === 1,
`linkId=${id(link?.id) || 'missing'}, preloadPlanId=${id(link?.preloadPlanId) || 'missing'}`, OPTIONS)
if (!link?.id) return
const postPushPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const postPushPlan = context.apiSuccess(MODULE, '首次 push 后读取事务完成态柜次计划', postPushPlanResponse, '', OPTIONS)
context.assert(MODULE, '事务完成态计划版本与 bridge 一致且保持 EDITING', postPushPlanResponse,
postPushPlan?.status === 'EDITING' && Number(postPushPlan?.version) === Number(link?.loadingPlanVersion)
&& Number(postPushPlan?.version) > Number(assignedLoadingPlan.version),
`pushResponseVersion=${pushedLoadingPlan?.version ?? 'missing'}, persistedVersion=${postPushPlan?.version ?? 'missing'}, bridgeVersion=${link?.loadingPlanVersion ?? 'missing'}`, OPTIONS)
if (postPushPlan?.status !== 'EDITING' || !Number.isInteger(Number(postPushPlan?.version))) return
const missingContainerTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const missingContainerTaskAbsent = missingContainerTaskResponse.http === 200 && codeOf(missingContainerTaskResponse) === 'A0404'
context.addStep(MODULE, '无号 push 成功后 PDA 查询明确 A0404', missingContainerTaskResponse,
missingContainerTaskAbsent,
`HTTP=${missingContainerTaskResponse.http}, code=${codeOf(missingContainerTaskResponse)}, requestId=${missingContainerTaskResponse.requestId || 'missing'}, traceId=${missingContainerTaskResponse.traceId || 'missing'}`, OPTIONS)
if (!missingContainerTaskAbsent) return
const replayBaseline = projectPushPreloadStability({
plan: postPushPlan, detail: pushedDetail, links: pushedLinks,
task: { http: missingContainerTaskResponse.http, code: codeOf(missingContainerTaskResponse) },
})
const pushReplayResponse = await request(context, 'GET', pushRequest.path)
context.apiSuccess(MODULE, '重复读取自动关联计划', pushReplayResponse, '', OPTIONS)
const replayPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const replayPlan = context.apiSuccess(MODULE, '重放后回查柜次计划', replayPlanResponse, '', OPTIONS)
const replayDetailResponse = await context.request('GET',
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const replayDetail = context.apiSuccess(MODULE, '重放后回查预装历史', replayDetailResponse, '', OPTIONS)
2026-08-27 11:42:05 +08:00
const replayHistories = visibleLoadingHistories(replayDetail?.histories)
context.assert(MODULE, 'push 同幂等请求重放历史零新增', replayDetailResponse,
replayHistories.length === pushedHistories.length
&& replayHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length === 0,
`before=${pushedHistories.length}, after=${replayHistories.length}, legacyPush=${replayHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length}`,
2026-08-27 11:42:05 +08:00
OPTIONS)
const replayLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`)
const replayLinks = context.apiSuccess(MODULE, '重放后回查 bridge', replayLinksResponse, '', OPTIONS)
const replayTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const replaySnapshot = projectPushPreloadStability({
plan: replayPlan, detail: replayDetail, links: replayLinks,
task: { http: replayTaskResponse.http, code: codeOf(replayTaskResponse) },
})
const replayDiff = diffPushPreloadStability(replayBaseline, replaySnapshot)
context.assert(MODULE, 'push exact replay 对计划版本历史关联和任务零漂移', pushReplayResponse,
replayDiff.length === 0 && codeOf(replayTaskResponse) === 'A0404',
`changed=${replayDiff.join('|') || 'none'}; replayTask=${responseEvidence(replayTaskResponse)}`, OPTIONS)
const sameKeyDifferentBody = await request(context, 'GET', pushRequest.path)
context.addStep(MODULE, '自动关联计划重复读取稳定', sameKeyDifferentBody,
!failure(sameKeyDifferentBody), `HTTP=${sameKeyDifferentBody.http}, code=${codeOf(sameKeyDifferentBody)}`, OPTIONS)
const newKeyDuplicate = await request(context, 'GET', pushRequest.path)
context.addStep(MODULE, '自动关联计划再次读取稳定', newKeyDuplicate,
!failure(newKeyDuplicate), `HTTP=${newKeyDuplicate.http}, code=${codeOf(newKeyDuplicate)}`, OPTIONS)
const rejectedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const rejectedPlan = context.apiSuccess(MODULE, '非法 push 后回查柜次计划', rejectedPlanResponse, '', OPTIONS)
const rejectedDetailResponse = await context.request('GET',
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const rejectedDetail = context.apiSuccess(MODULE, '非法 push 后回查预装历史', rejectedDetailResponse, '', OPTIONS)
2026-08-27 11:42:05 +08:00
const rejectedHistories = visibleLoadingHistories(rejectedDetail?.histories)
const rejectedLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`)
const rejectedLinks = context.apiSuccess(MODULE, '非法 push 后回查 bridge', rejectedLinksResponse, '', OPTIONS)
const rejectedTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const rejectedSnapshot = projectPushPreloadStability({
plan: rejectedPlan, detail: rejectedDetail, links: rejectedLinks,
task: { http: rejectedTaskResponse.http, code: codeOf(rejectedTaskResponse) },
})
const rejectedDiff = diffPushPreloadStability(replayBaseline, rejectedSnapshot)
context.assert(MODULE, '两类非法 push 后计划版本历史关联和任务仍零漂移', rejectedTaskResponse,
!failure(sameKeyDifferentBody) && !failure(newKeyDuplicate)
2026-08-27 11:42:05 +08:00
&& rejectedDiff.length === 0 && codeOf(rejectedTaskResponse) === 'A0404'
&& rejectedHistories.length === pushedHistories.length
&& rejectedHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length === 0,
`changed=${rejectedDiff.join('|') || 'none'}; sameKey=${responseEvidence(sameKeyDifferentBody)}; newKey=${responseEvidence(newKeyDuplicate)}; task=${responseEvidence(rejectedTaskResponse)}`, OPTIONS)
if (rejectedDiff.length !== 0 || codeOf(rejectedTaskResponse) !== 'A0404') return
const unlinkedTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
2026-08-17 13:24:48 +08:00
context.addStep(MODULE, '无号且未关联订舱仍无 PDA 任务', unlinkedTaskResponse,
unlinkedTaskResponse.http === 200 && codeOf(unlinkedTaskResponse) === 'A0404',
`HTTP=${unlinkedTaskResponse.http}, code=${codeOf(unlinkedTaskResponse)}`, OPTIONS)
const bookingCreateResponse = await request(context, 'POST', '/api/platform/v1/ocean-bookings',
{ expectedVersion: 0, data: bookingDraft },
{ 'Idempotency-Key': `${context.marker}-p3-booking-create`.slice(0, 128) })
2026-08-18 16:09:52 +08:00
const bookingDraftResponse = context.apiSuccess(MODULE, '创建三期 DRAFT 订舱(不生成真实柜)', bookingCreateResponse, '', OPTIONS)
const bookingId = id(bookingDraftResponse?.oceanBooking?.id)
const draftVersion = Number(bookingDraftResponse?.oceanBooking?.version)
context.assert(MODULE, '三期 DRAFT 订舱返回稳定身份和初始版本', bookingCreateResponse,
Boolean(bookingId) && bookingDraftResponse?.oceanBooking?.status === 'DRAFT'
&& Number.isInteger(draftVersion) && draftVersion >= 0,
`bookingId=${bookingId || 'missing'}, status=${bookingDraftResponse?.oceanBooking?.status || 'missing'}, version=${bookingDraftResponse?.oceanBooking?.version ?? 'missing'}`, OPTIONS)
if (!bookingId || !Number.isInteger(draftVersion) || draftVersion < 0) return
const bookingDocumentKey = `${context.marker}-p3-booking-document`.slice(0, 128)
const bookingDocumentForm = () => {
const form = new FormData()
form.append('expectedVersion', String(draftVersion))
form.append('documentType', 'BOOKING_CONFIRMATION')
form.append('file', new Blob([`${context.marker}-BOOKING_CONFIRMATION`], { type: 'text/plain' }),
`booking-confirmation-${context.marker}.txt`)
return form
}
const bookingDocumentResponse = await context.requestMultipart('POST',
`/api/platform/v1/ocean-bookings/${bookingId}/documents/upload`, bookingDocumentForm(), context.token,
{ 'Idempotency-Key': bookingDocumentKey })
const bookingDocument = context.apiSuccess(MODULE, '三期订舱上传当前有效订舱确认文件', bookingDocumentResponse, '', OPTIONS)
const releaseVersion = Number(bookingDocument?.bookingVersion)
context.assert(MODULE, '订舱文件归属当前订舱且上传递增版本', bookingDocumentResponse,
Boolean(bookingDocument?.document?.id) && id(bookingDocument?.document?.oceanBookingId) === bookingId
&& bookingDocument?.document?.documentType === 'BOOKING_CONFIRMATION'
&& bookingDocument?.document?.valid === true && Number.isInteger(releaseVersion)
&& releaseVersion > draftVersion,
`documentId=${id(bookingDocument?.document?.id) || 'missing'}, oceanBookingId=${id(bookingDocument?.document?.oceanBookingId) || 'missing'}, draftVersion=${draftVersion}, bookingVersion=${bookingDocument?.bookingVersion ?? 'missing'}`, OPTIONS)
if (!bookingId || !Number.isInteger(releaseVersion) || releaseVersion <= draftVersion) return
const bookingDocumentReplayResponse = await context.requestMultipart('POST',
`/api/platform/v1/ocean-bookings/${bookingId}/documents/upload`, bookingDocumentForm(), context.token,
{ 'Idempotency-Key': bookingDocumentKey })
const bookingDocumentReplay = context.apiSuccess(MODULE, '订舱文件同键同载荷重放', bookingDocumentReplayResponse, '', OPTIONS)
context.assert(MODULE, '订舱文件精确重放不新增文件或版本', bookingDocumentReplayResponse,
id(bookingDocumentReplay?.document?.id) === id(bookingDocument?.document?.id)
&& id(bookingDocumentReplay?.document?.oceanBookingId) === bookingId
&& Number(bookingDocumentReplay?.bookingVersion) === releaseVersion,
`firstDocumentId=${id(bookingDocument?.document?.id) || 'missing'}, replayDocumentId=${id(bookingDocumentReplay?.document?.id) || 'missing'}, firstVersion=${releaseVersion}, replayVersion=${bookingDocumentReplay?.bookingVersion ?? 'missing'}`, OPTIONS)
const bookingAfterDocumentResponse = await context.request('GET', `/api/platform/v1/ocean-bookings/${bookingId}`)
const bookingAfterDocument = context.apiSuccess(MODULE, '回查订舱文件有效归属', bookingAfterDocumentResponse, '', OPTIONS)
const activeBookingDocuments = (bookingAfterDocument?.documents || []).filter((document) => document?.valid === true)
context.assert(MODULE, '同键重放后当前订舱仅保留一条目标有效文件', bookingAfterDocumentResponse,
activeBookingDocuments.filter((document) => id(document?.id) === id(bookingDocument?.document?.id)
&& id(document?.oceanBookingId) === bookingId && document?.documentType === 'BOOKING_CONFIRMATION').length === 1,
`activeDocuments=${activeBookingDocuments.length}, targetDocumentId=${id(bookingDocument?.document?.id) || 'missing'}`, OPTIONS)
2026-08-18 16:09:52 +08:00
const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`,
{ expectedVersion: releaseVersion, reason: context.marker },
2026-08-18 16:09:52 +08:00
{ 'Idempotency-Key': `${context.marker}-p3-booking-release`.slice(0, 128) })
const bookingConfirmed = context.apiSuccess(MODULE, '三期订舱 RELEASED 冻结运输快照', releaseResponse, '', OPTIONS)
const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, bookingConfirmed?.oceanBooking?.[field]]))
const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '')
const frozenTransportMatches = frozenTransportValid
&& transportFields.every((field) => String(frozenTransport[field]) === String(bookingDraft[field]))
2026-08-18 16:09:52 +08:00
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HC')
context.assert(MODULE, 'RELEASED 订舱回显完整冻结运输快照且无真实柜号', releaseResponse,
Boolean(bookingId) && bookingConfirmed?.oceanBooking?.status === 'RELEASED' && frozenTransportMatches
&& Boolean(requirement?.id) && (!bookingConfirmed?.containers || bookingConfirmed.containers.length === 0),
`bookingId=${bookingId || 'missing'}, status=${bookingConfirmed?.oceanBooking?.status || 'missing'}`, OPTIONS)
2026-08-18 16:09:52 +08:00
if (!bookingId || !requirement?.id || !frozenTransportMatches) return
const latestContainerResponse = await context.request('GET', `/api/platform/v1/containers/domestic/${containerBookingId}`)
const latestWorkbench = context.apiSuccess(MODULE, '合并前读取模拟柜最新版本', latestContainerResponse, '', OPTIONS)
const latestContainer = latestWorkbench?.container
const linkResponse = await request(context, 'POST', `/api/platform/v1/containers/${containerBookingId}/ocean-booking-link`, {
2026-09-01 15:43:19 +08:00
oceanBookingId: bookingId, requirementId: id(requirement.id),
...(LATE_CONTAINER_GATE_ONLY ? {} : { containerNo }),
2026-08-18 16:09:52 +08:00
expectedVersion: Number(latestContainer?.version),
})
2026-09-01 15:43:19 +08:00
const linked = context.apiSuccess(MODULE, LATE_CONTAINER_GATE_ONLY
? '无号模拟柜原地关联三期 RELEASED 订舱需求'
: '模拟柜原地关联三期 RELEASED 订舱需求并补真实柜号', linkResponse, '', OPTIONS)
context.assert(MODULE, '订舱关联保持柜次计划与运输身份', linkResponse,
id(linked?.id) === containerBookingId && linked?.cabinetNo === simulation.cabinetNo
2026-09-01 15:43:19 +08:00
&& (LATE_CONTAINER_GATE_ONLY ? linked?.containerNo == null : linked?.containerNo === containerNo)
&& id(linked?.oceanBookingId) === bookingId,
`containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}`, OPTIONS)
if (id(linked?.id) !== containerBookingId) return
2026-08-18 16:09:52 +08:00
context.assert(MODULE, '三期订舱 RELEASED 后保持模拟柜关联身份和运输快照', releaseResponse,
bookingConfirmed?.oceanBooking?.status === 'RELEASED'
2026-09-01 15:43:19 +08:00
&& id(linked?.oceanBookingId) === bookingId
&& (LATE_CONTAINER_GATE_ONLY ? linked?.containerNo == null : linked?.containerNo === containerNo),
2026-08-18 16:09:52 +08:00
`status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, containerBookingId=${containerBookingId}`, OPTIONS)
2026-08-14 22:21:24 +08:00
const unconfirmedTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
context.addStep(MODULE, '订舱 RELEASED 但预装未 CONFIRMED 仍无 PDA 任务', unconfirmedTaskResponse,
unconfirmedTaskResponse.http === 200 && codeOf(unconfirmedTaskResponse) === 'A0404',
`HTTP=${unconfirmedTaskResponse.http}, code=${codeOf(unconfirmedTaskResponse)}`, OPTIONS)
2026-08-08 21:14:39 +08:00
const confirmableResponse = await context.request('GET',
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const confirmable = context.apiSuccess(MODULE, '计划级确认前读取即时版本与就绪门禁', confirmableResponse, '', OPTIONS)
const confirmableVersion = Number(confirmable?.summary?.version)
2026-08-17 13:24:48 +08:00
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 confirmPath = `/api/platform/v1/first-leg/loading-plans/${loadingPlanId}/confirm`
const confirmBody = Object.freeze({ expectedVersion: confirmableVersion })
const confirmHeaders = Object.freeze({ 'Idempotency-Key': `${context.marker}-p3-plan-confirm`.slice(0, 128) })
const confirm = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders)
const confirmed = context.apiSuccess(MODULE, '计划级原子确认整份预装并触发唯一 PDA 任务', confirm, '', OPTIONS)
const confirmedTaskId = id(confirmed?.readiness?.pdaTaskId)
context.assert(MODULE, '计划确认返回 CONFIRMED、readyForPda 与唯一 taskId', confirm,
id(confirmed?.planId) === loadingPlanId && id(confirmed?.containerBookingId) === containerBookingId
&& confirmed?.confirmationStatus === 'CONFIRMED'
&& confirmed?.readiness?.confirmationStatus === 'CONFIRMED'
&& confirmed?.readiness?.readyForPda === true && Boolean(confirmedTaskId)
&& Number(confirmed?.version) > confirmableVersion,
`confirmationStatus=${confirmed?.confirmationStatus || 'missing'}, readyForPda=${confirmed?.readiness?.readyForPda}, pdaTaskId=${confirmedTaskId || 'missing'}`, OPTIONS)
if (!confirmedTaskId || confirmed?.confirmationStatus !== 'CONFIRMED') return
const confirmReplay = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders)
context.assert(MODULE, '计划级确认同键同参精确重放同一 taskId 与版本', confirmReplay,
confirmReplay.http === 200 && codeOf(confirmReplay) === '200'
&& id(confirmReplay.body?.data?.readiness?.pdaTaskId) === confirmedTaskId
&& Number(confirmReplay.body?.data?.version) === Number(confirmed.version),
`HTTP=${confirmReplay.http}, code=${codeOf(confirmReplay)}, requestId=${confirmReplay.requestId || 'missing'}, traceId=${confirmReplay.traceId || 'missing'}`, OPTIONS)
const confirmSameKeyDifferentBody = await request(context, 'POST', confirmPath,
{ expectedVersion: Number(confirmed.version) }, confirmHeaders)
context.addStep(MODULE, '计划级确认同 key 异参返回冲突', confirmSameKeyDifferentBody,
confirmSameKeyDifferentBody.http === 200 && codeOf(confirmSameKeyDifferentBody) === 'A0409',
`HTTP=${confirmSameKeyDifferentBody.http}, code=${codeOf(confirmSameKeyDifferentBody)}, requestId=${confirmSameKeyDifferentBody.requestId || 'missing'}, traceId=${confirmSameKeyDifferentBody.traceId || 'missing'}`, OPTIONS)
const confirmNewKeyDuplicate = await request(context, 'POST', confirmPath, confirmBody,
{ 'Idempotency-Key': `${context.marker}-p3-plan-confirm-duplicate`.slice(0, 128) })
context.addStep(MODULE, '已确认计划使用新 key 重复确认返回冲突', confirmNewKeyDuplicate,
confirmNewKeyDuplicate.http === 200 && codeOf(confirmNewKeyDuplicate) === 'A0409',
`HTTP=${confirmNewKeyDuplicate.http}, code=${codeOf(confirmNewKeyDuplicate)}, requestId=${confirmNewKeyDuplicate.requestId || 'missing'}, traceId=${confirmNewKeyDuplicate.traceId || 'missing'}`, OPTIONS)
const confirmedInboundResponse = await context.request('GET', detailPath)
const confirmedInbound = context.apiSuccess(MODULE, '计划确认后 claim 前回查入库状态', confirmedInboundResponse, '', OPTIONS)
const confirmedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
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(confirmed.version),
`inbound=${confirmedInbound?.summary?.processStatus || 'missing'}, plan=${confirmedPlan?.status || 'missing'}`, OPTIONS)
if (confirmedInbound?.summary?.processStatus !== 'WAIT_SPLIT' || confirmedPlan?.status !== 'EDITING') return
2026-08-08 21:14:39 +08:00
const canonicalTaskResponse = await context.request('GET',
2026-08-13 19:19:29 +08:00
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const canonicalTask = context.apiSuccess(MODULE, '读取柜次级 PDA 唯一事实任务', canonicalTaskResponse, '', OPTIONS)
const canonicalTaskId = id(canonicalTask?.id)
const taskDetailPath = `${pdaBase}/tasks/${canonicalTaskId}?warehouseId=${encodeURIComponent(warehouseId)}`
const canonicalItem = canonicalTask?.items?.find?.((item) => id(item.inboundId) === inboundId)
2026-09-04 17:17:08 +08:00
const canonicalPickItems = dropshipAssignment
? canonicalTask?.items?.filter?.((item) => id(item.inboundId) === inboundId) || []
: canonicalItem ? [canonicalItem] : []
context.assert(MODULE, '柜次 PDA 任务与桥接入库明细一致', canonicalTaskResponse,
Boolean(canonicalTaskId) && canonicalTaskId === confirmedTaskId && id(canonicalTask?.loadingPlanId) === loadingPlanId
&& id(canonicalTask?.containerBookingId) === containerBookingId
2026-09-04 17:17:08 +08:00
&& canonicalTask?.workflowStatus === 'UNASSIGNED' && canonicalPickItems.length > 0,
`taskId=${canonicalTaskId || 'missing'}, itemCount=${canonicalPickItems.length}`, OPTIONS)
if (!canonicalTaskId || !canonicalPickItems.length) return
const beforeClaimPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const beforeClaimPlan = context.apiSuccess(MODULE, '领取前读取柜次计划即时版本', beforeClaimPlanResponse, '', OPTIONS)
const beforeClaimPlanVersion = Number(beforeClaimPlan?.version)
if (!Number.isInteger(beforeClaimPlanVersion)) return
const claim = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/claim`, {
expectedAssignmentVersion: Number(canonicalTask?.assignment?.version || 0),
operationId: `${context.marker}-bridge-claim`.slice(0, 128),
})
const claimed = context.apiSuccess(MODULE, '领取柜次 PDA 任务', claim, '', OPTIONS)
context.assert(MODULE, '柜次 PDA 任务绑定当前作业员', claim,
claimed?.workflowStatus === 'IN_PROGRESS' && Boolean(claimed?.assignment?.assigneeId),
`workflow=${claimed?.workflowStatus || 'missing'}`, OPTIONS)
const claimedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const claimedPlan = context.apiSuccess(MODULE, '领取后回查柜次计划', claimedPlanResponse, '', OPTIONS)
context.assert(MODULE, '领取 PDA 任务后计划进入 LOADING 且版本递增', claimedPlanResponse,
claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) > beforeClaimPlanVersion,
`status=${claimedPlan?.status || 'missing'}, version=${claimedPlan?.version ?? 'missing'}`, OPTIONS)
2026-08-11 18:43:50 +08:00
const inventoryResponse = await context.request('GET',
`${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
const inventory = context.apiSuccess(MODULE, '读取 PDA 提交前实时库存快照', inventoryResponse, '', OPTIONS)
2026-08-11 20:10:24 +08:00
const inventoryInbound = inventory?.inbounds?.find?.((item) => id(item.inboundId) === inboundId)
const inventoryIdentityVerified = id(inventory?.taskId) === canonicalTaskId
2026-08-13 20:00:30 +08:00
&& id(inventory?.loadingPlanId) === loadingPlanId
&& id(inventory?.containerBookingId) === containerBookingId
2026-08-11 20:10:24 +08:00
&& Number(inventory?.taskVersion) === Number(claimed?.version)
2026-09-01 15:43:19 +08:00
&& id(inventory?.cabinetNo)
&& (LATE_CONTAINER_GATE_ONLY ? inventory?.currentContainerNo == null : inventory?.currentContainerNo === containerNo)
2026-08-13 20:00:30 +08:00
&& id(inventory?.warehouseId) === warehouseId
2026-08-11 20:10:24 +08:00
&& Boolean(inventory?.capturedAt) && typeof inventory?.snapshotToken === 'string'
&& inventory.snapshotToken.length === 64 && Number(inventoryInbound?.availablePackageCount) >= 1
&& (inventoryInbound?.resources || []).every((resource) => id(resource?.blockId)
&& Number(resource?.slotNo) >= 1 && id(resource?.reservationId) && id(resource?.locationId)
&& ['RESERVED_EMPTY', 'BOUND'].includes(resource?.slotStatus)
2026-08-11 20:10:24 +08:00
&& Number(resource?.availablePackageCount) >= 0)
&& Number(inventoryInbound?.availablePackageCount) === (inventoryInbound?.resources || [])
.reduce((total, resource) => total + Number(resource?.availablePackageCount || 0), 0)
context.assert(MODULE, '实时库存快照归属、版本、资源数量和稳定令牌完整', inventoryResponse,
inventoryIdentityVerified,
`taskId=${id(inventory?.taskId) || 'missing'}, taskVersion=${inventory?.taskVersion ?? 'missing'}, warehouseId=${id(inventory?.warehouseId) || 'missing'}, token=${inventory?.snapshotToken ? 'present' : 'missing'}, available=${inventoryInbound?.availablePackageCount ?? 'missing'}`, OPTIONS)
2026-08-11 18:43:50 +08:00
if (!inventory?.snapshotToken) return
2026-08-11 20:10:24 +08:00
const repeatedInventoryResponse = await context.request('GET',
`${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
const repeatedInventory = context.apiSuccess(MODULE, '未发生库存变化时重复读取实时库存快照', repeatedInventoryResponse, '', OPTIONS)
const inventorySnapshotVerified = inventoryIdentityVerified
&& repeatedInventory?.snapshotToken === inventory.snapshotToken
&& Number(repeatedInventory?.taskVersion) === Number(inventory.taskVersion)
&& inventorySignature(repeatedInventory) === inventorySignature(inventory)
context.assert(MODULE, '未变化库存返回稳定 token、版本和数量快照', repeatedInventoryResponse,
inventorySnapshotVerified,
`sameToken=${repeatedInventory?.snapshotToken === inventory.snapshotToken}, taskVersion=${repeatedInventory?.taskVersion ?? 'missing'}`, OPTIONS)
const inventoryBlockSnapshots = (inventory?.inbounds || []).map((entry) => ({
inboundId: id(entry?.inboundId),
inboundCode: canonicalTask?.items?.find?.((item) => id(item?.inboundId) === id(entry?.inboundId))?.inboundCode || null,
resources: (entry?.resources || []).map((resource) => ({
blockId: id(resource?.blockId), slotNo: Number(resource?.slotNo),
reservationId: id(resource?.reservationId), locationId: id(resource?.locationId),
sharedOverflow: resource?.sharedOverflow === true,
})),
})).filter((entry) => entry.inboundId && entry.resources.length > 0)
const inventoryBlockIdentityVerified = inventoryBlockSnapshots.length > 0
&& inventoryBlockSnapshots.every((entry) => new Set(entry.resources.map((resource) => resource.blockId)).size === 1)
context.assert(MODULE, 'PDA 库存快照按入仓单返回唯一整票库位块身份', inventoryResponse,
inventoryBlockIdentityVerified,
`inbounds=${inventoryBlockSnapshots.length}, blocks=${inventoryBlockSnapshots.map((entry) => entry.resources[0]?.blockId).join(',')}`, OPTIONS)
2026-08-11 20:10:24 +08:00
2026-08-11 11:41:41 +08:00
const batchPath = `${pdaBase}/tasks/${canonicalTaskId}/batch`
const batchKey = `${context.marker}-bridge-batch`.slice(0, 128)
2026-08-25 13:59:58 +08:00
const skipBatchKey = `${context.marker}-exception-skip-batch`.slice(0, 128)
const partialBatchKey = `${context.marker}-exception-partial-batch`.slice(0, 128)
const dynamicBatchKey = `${context.marker}-dynamic-pick`.slice(0, 128)
2026-09-04 17:17:08 +08:00
const dropshipWholePallet = source?.fulfillmentType === 'ONE_CLICK_DROPSHIP'
const expectedBatchOperationIds = dropshipWholePallet
? [batchKey]
: [batchKey, skipBatchKey, partialBatchKey, dynamicBatchKey]
2026-08-11 11:41:41 +08:00
const batchPayload = {
2026-08-13 20:00:30 +08:00
warehouseId, loadingPlanId, containerBookingId,
2026-08-11 11:41:41 +08:00
expectedVersion: Number(claimed?.version),
2026-08-11 18:43:50 +08:00
inventorySnapshotToken: inventory.snapshotToken,
2026-09-04 17:17:08 +08:00
items: canonicalPickItems.map((item) => ({
itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount),
version: Number(item.version),
})),
2026-08-11 11:41:41 +08:00
}
2026-08-11 11:41:41 +08:00
const staleBatch = await request(context, 'POST', batchPath,
{ ...batchPayload, expectedVersion: Number(batchPayload.expectedVersion) + 1 },
{ 'Idempotency-Key': `${context.marker}-bridge-batch-stale`.slice(0, 128) })
context.addStep(MODULE, 'PDA 原子批次旧任务版本返回冲突', staleBatch,
staleBatch.http === 200 && codeOf(staleBatch) === 'A0409',
`HTTP=${staleBatch.http}, code=${codeOf(staleBatch)}`, OPTIONS)
const beforeInvalidTaskResponse = await context.request('GET', taskDetailPath)
2026-08-12 12:35:29 +08:00
const beforeInvalidTask = context.apiSuccess(MODULE, '非法混合批次前读取 PDA 全量任务基线',
beforeInvalidTaskResponse, '', OPTIONS)
const beforeInvalidOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`)
const beforeInvalidOperations = context.apiSuccess(MODULE, '非法混合批次前读取 PDA 操作历史基线',
beforeInvalidOperationsResponse, '', OPTIONS)
2026-08-11 11:41:41 +08:00
const invalidBatch = await request(context, 'POST', batchPath, {
...batchPayload,
items: [...batchPayload.items, {
itemId: '999999999999999999', action: 'SKIP', version: 0, reason: 'forged-item-must-rollback',
}],
}, { 'Idempotency-Key': `${context.marker}-bridge-batch-rollback`.slice(0, 128) })
context.addStep(MODULE, 'PDA 原子批次任一非法明细时整批拒绝', invalidBatch,
invalidBatch.http === 200 && codeOf(invalidBatch) === 'A0409',
`HTTP=${invalidBatch.http}, code=${codeOf(invalidBatch)}`, OPTIONS)
const unchangedAfterInvalidResponse = await context.request('GET', taskDetailPath)
2026-08-11 11:41:41 +08:00
const unchangedAfterInvalid = context.apiSuccess(MODULE, '回查失败批次后的 PDA 任务快照', unchangedAfterInvalidResponse, '', OPTIONS)
2026-08-12 12:35:29 +08:00
const afterInvalidOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`)
const afterInvalidOperations = context.apiSuccess(MODULE, '非法混合批次后回查 PDA 操作历史',
afterInvalidOperationsResponse, '', OPTIONS)
context.assert(MODULE, '合法明细加非法明细失败后任务、全部明细和操作历史零写入', unchangedAfterInvalidResponse,
unchangedAfterInvalid?.taskStatus === beforeInvalidTask?.taskStatus
&& Number(unchangedAfterInvalid?.version) === Number(beforeInvalidTask?.version)
2026-08-12 13:24:17 +08:00
&& taskItemsSignature(unchangedAfterInvalid) === taskItemsSignature(beforeInvalidTask)
2026-08-12 12:35:29 +08:00
&& operationsSignature(afterInvalidOperations) === operationsSignature(beforeInvalidOperations),
2026-08-12 13:24:17 +08:00
`taskVersion=${unchangedAfterInvalid?.version ?? 'missing'}, items=${taskItemsSignature(unchangedAfterInvalid)}, operations=${afterInvalidOperations?.length ?? 'missing'}`, OPTIONS)
2026-08-11 11:41:41 +08:00
const merchantLogin = await context.request('POST', '/api/auth/login', {
2026-09-03 18:13:00 +08:00
loginId: MERCHANT_USERNAME, password: MERCHANT_PASSWORD,
2026-08-11 11:41:41 +08:00
}, '')
const merchantToken = merchantLogin.http === 200 && codeOf(merchantLogin) === '200'
? merchantLogin.body?.data?.accessToken : null
context.addStep(MODULE, '取得客户侧隔离令牌用于 PDA 批次越权验证', merchantLogin,
Boolean(merchantToken), `username=${MERCHANT_USERNAME}`, OPTIONS)
const customerIsolatedBatch = merchantToken
? await request(context, 'POST', batchPath, batchPayload, {
Authorization: `Bearer ${merchantToken}`,
'Idempotency-Key': `${context.marker}-bridge-batch-customer`.slice(0, 128),
})
2026-08-11 11:41:41 +08:00
: merchantLogin
context.addStep(MODULE, '客户身份无权提交平台 PDA 原子批次', customerIsolatedBatch,
customerIsolatedBatch.http === 403 && codeOf(customerIsolatedBatch) === 'A0403'
&& Boolean(customerIsolatedBatch.requestId) && Boolean(customerIsolatedBatch.traceId),
`HTTP=${customerIsolatedBatch.http}, code=${codeOf(customerIsolatedBatch)}, requestId=${customerIsolatedBatch.requestId || 'missing'}, traceId=${customerIsolatedBatch.traceId || 'missing'}`, OPTIONS)
2026-08-11 20:10:24 +08:00
const customerIsolatedSnapshot = merchantToken
? await request(context, 'GET',
`${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`,
undefined, { Authorization: `Bearer ${merchantToken}` })
: merchantLogin
context.addStep(MODULE, '客户身份无权读取平台 PDA 实时库存快照', customerIsolatedSnapshot,
customerIsolatedSnapshot.http === 403 && codeOf(customerIsolatedSnapshot) === 'A0403'
&& Boolean(customerIsolatedSnapshot.requestId) && Boolean(customerIsolatedSnapshot.traceId),
`HTTP=${customerIsolatedSnapshot.http}, code=${codeOf(customerIsolatedSnapshot)}, requestId=${customerIsolatedSnapshot.requestId || 'missing'}, traceId=${customerIsolatedSnapshot.traceId || 'missing'}`, OPTIONS)
2026-08-11 11:41:41 +08:00
const currentAssigneeId = Number(claimed?.assignment?.assigneeId)
const reassigned = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/assign`, {
assigneeId: currentAssigneeId + 1000000, assigneeName: `${context.marker}-other-picker`,
expectedAssignmentVersion: Number(claimed?.assignment?.version),
operationId: `${context.marker}-bridge-assign-other`.slice(0, 128),
})
const otherAssignment = context.apiSuccess(MODULE, '将 PDA 任务改派给另一作业员', reassigned, '', OPTIONS)
const operatorIsolatedBatch = await request(context, 'POST', batchPath, batchPayload,
{ 'Idempotency-Key': `${context.marker}-bridge-batch-other-operator`.slice(0, 128) })
context.addStep(MODULE, '非任务作业员提交 PDA 原子批次返回冲突', operatorIsolatedBatch,
operatorIsolatedBatch.http === 200 && codeOf(operatorIsolatedBatch) === 'A0409',
`HTTP=${operatorIsolatedBatch.http}, code=${codeOf(operatorIsolatedBatch)}`, OPTIONS)
const restoredAssignmentResponse = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/assign`, {
assigneeId: currentAssigneeId, assigneeName: claimed?.assignment?.assigneeName || 'admin',
expectedAssignmentVersion: Number(otherAssignment?.assignment?.version),
operationId: `${context.marker}-bridge-assign-restore`.slice(0, 128),
})
const restoredAssignment = context.apiSuccess(MODULE, '恢复 PDA 任务到当前作业员', restoredAssignmentResponse, '', OPTIONS)
context.assert(MODULE, '作业员隔离验证后任务归属恢复', restoredAssignmentResponse,
id(restoredAssignment?.assignment?.assigneeId) === id(currentAssigneeId),
`assigneeId=${id(restoredAssignment?.assignment?.assigneeId) || 'missing'}`, OPTIONS)
const canonicalPickPath = dropshipWholePallet
? `${pdaBase}/tasks/${canonicalTaskId}/pallets/pick`
: batchPath
const canonicalPickPayload = dropshipWholePallet
? { warehouseId, palletNo: candidate.palletNo, expectedTaskVersion: Number(claimed?.version), operationId: batchKey }
: batchPayload
const canonicalPick = await request(context, 'POST', canonicalPickPath, canonicalPickPayload,
2026-08-11 11:41:41 +08:00
{ 'Idempotency-Key': batchKey })
const pickedCanonical = context.apiSuccess(MODULE,
dropshipWholePallet ? '扫描一件代发封托托盘并整体拣货' : '原子提交柜次 PDA 整批拣货',
canonicalPick, '', OPTIONS)
2026-08-13 20:00:30 +08:00
const awaitingReview = pickedCanonical?.taskStatus === 'LOADING'
&& pickedCanonical?.workflowStatus === 'AWAITING_REVIEW'
context.assert(MODULE, 'PDA 原子批次完成拣货并等待人工确认', canonicalPick,
awaitingReview && pickedCanonical?.items?.every?.((item) => item.itemStatus !== 'PENDING'),
`taskStatus=${pickedCanonical?.taskStatus || 'missing'}, workflow=${pickedCanonical?.workflowStatus || 'missing'}`, OPTIONS)
const canonicalBatchReplay = await request(context, 'POST', canonicalPickPath, canonicalPickPayload,
2026-08-11 11:41:41 +08:00
{ 'Idempotency-Key': batchKey })
const atomicBatchReplayVerified = canonicalBatchReplay.http === 200 && codeOf(canonicalBatchReplay) === '200'
2026-08-11 11:41:41 +08:00
&& id(canonicalBatchReplay.body?.data?.id) === canonicalTaskId
2026-08-13 20:00:30 +08:00
&& canonicalBatchReplay.body?.data?.taskStatus === 'LOADING'
&& canonicalBatchReplay.body?.data?.workflowStatus === 'AWAITING_REVIEW'
&& Number(canonicalBatchReplay.body?.data?.version) === Number(pickedCanonical?.version)
2026-08-13 20:00:30 +08:00
context.assert(MODULE, 'PDA 原子批次同参同键重放保持待人工确认', canonicalBatchReplay,
atomicBatchReplayVerified,
2026-08-11 11:41:41 +08:00
`HTTP=${canonicalBatchReplay.http}, code=${codeOf(canonicalBatchReplay)}, taskVersion=${canonicalBatchReplay.body?.data?.version ?? 'missing'}`, OPTIONS)
2026-08-17 15:41:42 +08:00
const preFinalOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`)
const preFinalOperations = context.apiSuccess(MODULE, '回查下游门禁前拣货审计', preFinalOperationsResponse, '', OPTIONS)
const canonicalPickAction = dropshipWholePallet ? 'PICK_PALLET' : 'BATCH'
context.assert(MODULE, '最终确认前仅保留领取和拣货审计且无 COMPLETE_LOADING', preFinalOperationsResponse,
2026-08-17 15:41:42 +08:00
Array.isArray(preFinalOperations)
&& preFinalOperations.some((item) => item.actionType === 'CLAIM'
2026-08-12 13:24:17 +08:00
&& id(item.operatorId) && Boolean(item.createdAt))
&& preFinalOperations.some((item) => item.actionType === canonicalPickAction
2026-08-12 13:24:17 +08:00
&& id(item.operatorId) && Boolean(item.createdAt))
2026-08-17 15:41:42 +08:00
&& !preFinalOperations.some((item) => item.actionType === 'COMPLETE_LOADING')
&& !preFinalOperations.some((item) => item.actionType === 'REVIEW'),
`actions=${Array.isArray(preFinalOperations) ? preFinalOperations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
2026-08-12 13:24:17 +08:00
const pickedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const pickedPlan = context.apiSuccess(MODULE, '首笔真实拣货后回查柜次计划', pickedPlanResponse, '', OPTIONS)
2026-08-17 15:41:42 +08:00
context.assert(MODULE, '下游门禁前柜次计划保持 LOADING 且等待最终完成', pickedPlanResponse,
pickedPlan?.status === 'LOADING' && pickedPlan?.finalCompletionStatus === 'IN_PROGRESS'
&& id(pickedPlan?.pdaTaskId) === canonicalTaskId && pickedPlan?.pdaTaskStatus === 'LOADING',
`status=${pickedPlan?.status || 'missing'}, version=${pickedPlan?.version ?? 'missing'}`, OPTIONS)
2026-09-01 15:43:19 +08:00
if (LATE_CONTAINER_GATE_ONLY) {
const missingOperationId = `${context.marker}-late-container-missing-complete`.slice(0, 128)
const missingComplete = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`, {
expectedTaskVersion: Number(pickedCanonical?.version), operationId: missingOperationId,
remark: `${context.marker}-missing-container`,
})
context.addStep(MODULE, '无号拣货完成后 PDA complete 精确返回 A0409', missingComplete,
missingComplete.http === 200 && codeOf(missingComplete) === 'A0409'
&& String(missingComplete.body?.message || '').includes('真实集装箱号'),
`${responseEvidence(missingComplete)}, message=${missingComplete.body?.message || 'missing'}`, OPTIONS)
const transport = await request(context, 'PATCH',
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
containerNo, voyageNo: bookingDraft.voyageNo, expectedVersion: Number(pickedPlan?.version),
operationReason: null,
}, { 'Idempotency-Key': `${context.marker}-late-container-fill`.slice(0, 128) })
const filled = context.apiSuccess(MODULE, 'LOADING 阶段通过预装运输信息首次补录真实柜号', transport,
responseEvidence(transport), OPTIONS)
context.assert(MODULE, '首次补号保持计划与柜次身份且柜号规范化落盘', transport,
id(filled?.summary?.planId) === loadingPlanId
&& id(filled?.summary?.containerBookingId) === containerBookingId
&& filled?.summary?.status === 'LOADING' && filled?.summary?.containerNo === containerNo,
`planId=${id(filled?.summary?.planId) || 'missing'}, containerBookingId=${id(filled?.summary?.containerBookingId) || 'missing'}, containerNo=${filled?.summary?.containerNo || 'missing'}`, OPTIONS)
const afterFillTaskResponse = await context.request('GET', taskDetailPath)
const afterFillTask = context.apiSuccess(MODULE, '首次补号后回读原 PDA 任务', afterFillTaskResponse, '', OPTIONS)
context.assert(MODULE, '补号不替换 task/plan/cabinet 稳定身份', afterFillTaskResponse,
id(afterFillTask?.id) === canonicalTaskId && id(afterFillTask?.loadingPlanId) === loadingPlanId
&& id(afterFillTask?.containerBookingId) === containerBookingId
&& afterFillTask?.currentContainerNo === containerNo,
`taskId=${id(afterFillTask?.id) || 'missing'}, currentContainerNo=${afterFillTask?.currentContainerNo || 'missing'}`, OPTIONS)
const acceptedOperationId = `${context.marker}-late-container-accepted-complete`.slice(0, 128)
const acceptedComplete = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`, {
expectedTaskVersion: Number(afterFillTask?.version), operationId: acceptedOperationId,
remark: `${context.marker}-container-filled`,
})
context.assert(MODULE, '补号后 complete 已越过柜号门禁并受理异步命令', acceptedComplete,
acceptedComplete.http === 200 && codeOf(acceptedComplete) === '200'
&& ['PROCESSING', 'FAILED', 'COMPLETED'].includes(acceptedComplete.body?.data?.workflowStatus),
`${responseEvidence(acceptedComplete)}, workflow=${acceptedComplete.body?.data?.workflowStatus || 'missing'}`, OPTIONS)
context.assert(MODULE, '独立柜未创建拖车单也不阻塞 PDA 装柜完成命令', acceptedComplete,
acceptedComplete.http === 200 && codeOf(acceptedComplete) === '200',
`containerBookingId=${containerBookingId}, oceanBookingId=${bookingId}, ${responseEvidence(acceptedComplete)}`, OPTIONS)
2026-09-01 15:43:19 +08:00
let terminalTask = acceptedComplete.body?.data
let terminalResponse = acceptedComplete
for (let attempt = 0; attempt < 10 && terminalTask?.workflowStatus === 'PROCESSING'; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 500))
terminalResponse = await context.request('GET', taskDetailPath)
if (terminalResponse.http === 200 && codeOf(terminalResponse) === '200') terminalTask = terminalResponse.body?.data
}
const nextStage = terminalTask?.completionCommand?.failureStage || null
const nextCode = terminalTask?.completionCommand?.failureCode || null
const nextMessage = String(terminalTask?.completionCommand?.failureMessage || '')
context.assert(MODULE, '补号后已越过柜号门禁并允许进入柜级证据门禁', terminalResponse,
!nextMessage.includes('集装箱号')
&& (terminalTask?.workflowStatus !== 'PROCESSING'
|| (nextStage === 'CABINET_EVIDENCE' && nextCode === 'A0409')),
2026-09-01 15:43:19 +08:00
`workflow=${terminalTask?.workflowStatus || 'missing'}, stage=${terminalTask?.completionCommand?.failureStage || 'none'}, code=${terminalTask?.completionCommand?.failureCode || 'none'}, message=${terminalTask?.completionCommand?.failureMessage || 'none'}`, OPTIONS)
context.createdResources.phase3LateContainerGate = {
marker: context.marker, tenantId, inboundId, containerBookingId, cabinetNo: simulation.cabinetNo,
oceanBookingId: bookingId, loadingPlanId, taskId: canonicalTaskId, containerNo,
missingComplete: responseEvidence(missingComplete), fill: responseEvidence(transport),
acceptedComplete: responseEvidence(acceptedComplete), finalWorkflow: terminalTask?.workflowStatus || null,
nextFailureStage: nextStage, nextFailureCode: nextCode,
2026-09-01 15:43:19 +08:00
}
return
}
const preload = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`)
const preloadData = context.apiSuccess(MODULE, '回查已确认预装清单', preload, '', OPTIONS)
2026-08-17 15:41:42 +08:00
context.assert(MODULE, 'PDA 最终完成前预装清单仍为 CONFIRMED 且归属稳定', preload,
id(preloadData?.id) === planId && id(preloadData?.inboundId) === inboundId && id(preloadData?.tenantId) === tenantId
&& preloadData?.status === 'CONFIRMED',
`planId=${id(preloadData?.id) || 'missing'}, items=${preloadData?.items?.length ?? 'n/a'}`, OPTIONS)
const loadingResponse = await context.request('GET', detailPath)
const loading = context.apiSuccess(MODULE, '读取装柜中入库单版本', loadingResponse, '', OPTIONS)
const loadingVersion = Number(loading?.summary?.version)
context.assert(MODULE, '入库单已进入 LOADING 且版本有效', loadingResponse,
loading?.summary?.processStatus === 'LOADING' && Number.isInteger(loadingVersion),
`status=${loading?.summary?.processStatus || 'missing'}, version=${loading?.summary?.version ?? 'missing'}`, OPTIONS)
if (!Number.isInteger(loadingVersion)) return
2026-08-13 20:00:30 +08:00
context.addStep(MODULE, '柜次 PDA 作为现场作业唯一事实源', null, true,
'本场景不调用旧入库级 picking/start 写接口,避免客户端汇总数量覆盖柜次任务事实', OPTIONS)
const evidenceTypes = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']
const evidenceItems = []
const evidencePath = `${pdaBase}/tasks/${canonicalTaskId}/evidence`
const normalizedEvidence = (item) => item ? { ...item, id: id(item.evidenceId) } : null
for (const evidenceType of evidenceTypes) {
const sealNo = evidenceType === 'SEAL' ? `SEAL-${suffix}` : null
const upload = await uploadEvidence(context, canonicalTaskId, warehouseId, evidenceType, 0,
evidenceType, { sealNo })
const evidenceData = context.apiSuccess(MODULE, `通过 PDA task 原子上传并绑定 ${evidenceType} 证据`, upload, '', OPTIONS)
const evidenceItem = normalizedEvidence(evidenceData?.evidences?.find?.((item) =>
item.evidenceType === evidenceType && item.status !== 'VOID'))
const fileId = id(evidenceItem?.fileId)
context.assert(MODULE, `${evidenceType} 证据归属 task、柜次、marker 和版本`, upload,
id(evidenceData?.taskId) === canonicalTaskId
&& id(evidenceData?.containerBookingId) === containerBookingId
&& Boolean(evidenceItem?.id) && Boolean(fileId)
&& evidenceItem?.fileName === `${context.marker}-${evidenceType.toLowerCase()}.png`
&& evidenceItem?.remark === context.marker && Number(evidenceItem?.version) === 0
&& (evidenceType !== 'SEAL' || evidenceItem?.sealNo === sealNo),
`taskId=${id(evidenceData?.taskId) || 'missing'}, containerBookingId=${id(evidenceData?.containerBookingId) || 'missing'}, evidenceId=${id(evidenceItem?.id) || 'missing'}`, OPTIONS)
if (!evidenceItem?.id) return
evidenceItems.push(evidenceItem)
2026-08-12 12:35:29 +08:00
if (evidenceType === 'PHOTO_STEP_1') {
const replay = await uploadEvidence(context, canonicalTaskId, warehouseId, evidenceType, 0,
evidenceType)
const replayData = context.apiSuccess(MODULE, '装柜证据 task 上传同键同载荷重放', replay, '', OPTIONS)
const replayItems = replayData?.evidences?.filter?.((item) => item.evidenceType === evidenceType) || []
context.assert(MODULE, '证据 task 上传重放返回同一记录且不重复追加', replay,
replayItems.length === 1 && id(replayItems[0]?.evidenceId) === id(evidenceItem.id),
`evidenceId=${id(replayItems[0]?.evidenceId) || 'missing'}, count=${replayItems.length}`, OPTIONS)
const conflict = await uploadEvidence(context, canonicalTaskId, warehouseId, evidenceType, 0,
evidenceType, { remark: `${context.marker}-different` })
context.addStep(MODULE, '装柜证据 task 上传同键异载荷返回冲突', conflict,
2026-08-12 12:35:29 +08:00
conflict.http === 200 && codeOf(conflict) === 'A0409'
&& Boolean(conflict.requestId) && Boolean(conflict.traceId),
`HTTP=${conflict.http}, code=${codeOf(conflict)}, requestId=${conflict.requestId || 'missing'}, traceId=${conflict.traceId || 'missing'}`, OPTIONS)
}
const download = await context.requestBinary('GET',
`${evidencePath}/${encodeURIComponent(evidenceItem.id)}/content?warehouseId=${encodeURIComponent(warehouseId)}`)
context.assert(MODULE, `下载本轮 ${evidenceType} 证据文件`, download,
download.http === 200 && download.contentType.toLowerCase().includes('image/png') && download.bytes.length > 0,
`bytes=${download.bytes.length}, contentType=${download.contentType || 'missing'}`, OPTIONS)
if (evidenceType === 'PHOTO_STEP_4') {
2026-08-28 09:19:18 +08:00
context.addStep(MODULE, '缺铅封照片和锁码时不提前占用异步完成命令', null, true,
'complete 为持久异步命令;证据未齐场景只冻结现场事实,最终阶段再提交唯一完成命令', OPTIONS)
}
}
const firstPhoto = evidenceItems.find((item) => item.evidenceType === 'PHOTO_STEP_1')
const sealEvidence = evidenceItems.find((item) => item.evidenceType === 'SEAL')
2026-08-10 11:28:57 +08:00
const appended = await uploadEvidence(context, canonicalTaskId, warehouseId, 'PHOTO_STEP_1',
Number(firstPhoto?.version), 'PHOTO_STEP_1_APPEND', { fileSuffix: 'PHOTO_STEP_1_APPEND' })
const appendedData = context.apiSuccess(MODULE, '追加同类型第二张 task 装柜证据', appended, '', OPTIONS)
const appendedItems = appendedData?.evidences?.filter?.((item) => item.evidenceType === 'PHOTO_STEP_1') || []
const appendedActive = normalizedEvidence(appendedItems.find((item) => item.status !== 'VOID'))
const appendedFileId = id(appendedActive?.fileId)
context.assert(MODULE, '同类型装柜证据追加保留历史并唯一激活新图', appended,
2026-08-10 11:28:57 +08:00
Boolean(appendedFileId) && appendedItems.length >= 2
&& appendedItems.some((item) => id(item.fileId) === id(firstPhoto?.fileId) && item.status === 'VOID')
&& id(appendedActive?.fileId) === appendedFileId,
2026-08-10 11:28:57 +08:00
`photoStep1Count=${appendedItems.length}, original=${id(firstPhoto?.fileId) || 'missing'}, appended=${appendedFileId || 'missing'}`, OPTIONS)
const wrongType = await uploadEvidence(context, canonicalTaskId, warehouseId, 'PHOTO_STEP_9', 0,
'wrong-type', { fileSuffix: 'wrong-type' })
context.addStep(MODULE, '不在白名单的证据类型不可上传绑定', wrongType, failure(wrongType),
`HTTP=${wrongType.http}, code=${codeOf(wrongType)}`, OPTIONS)
2026-08-08 21:14:39 +08:00
const forged = await context.request('GET',
`${evidencePath}/999999999999999999/content?warehouseId=${encodeURIComponent(warehouseId)}`)
context.addStep(MODULE, '伪造装柜 evidenceId 无法读取任务证据内容', forged,
(forged.http === 200 && codeOf(forged) === 'A0404') || forged.http === 404,
2026-08-08 21:14:39 +08:00
`HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS)
const beforeMissingSealResponse = await context.request('GET',
`${evidencePath}?warehouseId=${encodeURIComponent(warehouseId)}`)
const beforeMissingSeal = beforeMissingSealResponse.body?.data?.evidences || []
const missingSealText = await uploadEvidence(context, canonicalTaskId, warehouseId, 'SEAL',
Number(sealEvidence?.version), 'missing-seal-text', { sealNo: ' ', fileSuffix: 'missing-seal-text' })
2026-08-10 11:28:57 +08:00
context.addStep(MODULE, '带锁码照片缺少锁码文本时拒绝绑定', missingSealText, failure(missingSealText),
`HTTP=${missingSealText.http}, code=${codeOf(missingSealText)}`, OPTIONS)
const afterMissingSealResponse = await context.request('GET',
`${evidencePath}?warehouseId=${encodeURIComponent(warehouseId)}`)
context.assert(MODULE, '缺锁码文本失败后 task 证据零漂移', afterMissingSealResponse,
JSON.stringify(afterMissingSealResponse.body?.data?.evidences || []) === JSON.stringify(beforeMissingSeal),
`before=${beforeMissingSeal.length}, after=${afterMissingSealResponse.body?.data?.evidences?.length ?? 'missing'}`, OPTIONS)
2026-08-10 11:28:57 +08:00
const voidTarget = appendedActive
2026-08-10 11:28:57 +08:00
const voidResponse = voidTarget
? await request(context, 'POST', `${evidencePath}/${encodeURIComponent(id(voidTarget.id))}/void`,
{ warehouseId, expectedVersion: Number(voidTarget.version), reason: `${context.marker}-wrong-photo` })
2026-08-10 11:28:57 +08:00
: appended
const voidData = context.apiSuccess(MODULE, '作废错误装柜证据并保留审计', voidResponse, '', OPTIONS)
context.assert(MODULE, '错图作废后原记录保留且不再计入有效证据', voidResponse,
Boolean(voidTarget) && voidData?.evidences?.some?.((item) => id(item.evidenceId) === id(voidTarget.id)
&& item.status === 'VOID' && Boolean(item.updatedAt)),
2026-08-10 11:28:57 +08:00
`evidenceId=${id(voidTarget?.id) || 'missing'}, status=${voidTarget?.status || 'missing'}`, OPTIONS)
const restoredPhotoResponse = await uploadEvidence(context, canonicalTaskId, warehouseId, 'PHOTO_STEP_1', 0,
'PHOTO_STEP_1_RESTORE', { fileSuffix: 'PHOTO_STEP_1_RESTORE' })
const restoredPhotoData = context.apiSuccess(MODULE, '错图作废后恢复 PHOTO_STEP_1 有效证据', restoredPhotoResponse, '', OPTIONS)
const restoredPhoto = normalizedEvidence(restoredPhotoData?.evidences?.find?.((item) =>
item.evidenceType === 'PHOTO_STEP_1' && item.status !== 'VOID'))
context.assert(MODULE, 'PHOTO_STEP_1 恢复后旧记录保留且新记录唯一有效', restoredPhotoResponse,
Boolean(restoredPhoto?.id) && restoredPhotoData?.evidences?.some?.((item) =>
id(item.evidenceId) === id(voidTarget?.id) && item.status === 'VOID'),
`restoredEvidenceId=${id(restoredPhoto?.id) || 'missing'}`, OPTIONS)
2026-08-12 12:35:29 +08:00
const missingTypeTarget = evidenceItems.find((item) => item.evidenceType === 'PHOTO_STEP_4')
const missingTypePath = `${evidencePath}/${encodeURIComponent(id(missingTypeTarget?.id))}/void`
2026-08-12 12:35:29 +08:00
const staleMissingTypeVoid = missingTypeTarget
? await request(context, 'POST', missingTypePath,
{ warehouseId, expectedVersion: Number(missingTypeTarget.version) + 1, reason: `${context.marker}-stale-version` })
2026-08-12 12:35:29 +08:00
: voidResponse
context.addStep(MODULE, '装柜证据作废旧版本返回冲突', staleMissingTypeVoid,
Boolean(missingTypeTarget) && staleMissingTypeVoid.http === 200 && codeOf(staleMissingTypeVoid) === 'A0409',
`HTTP=${staleMissingTypeVoid.http}, code=${codeOf(staleMissingTypeVoid)}`, OPTIONS)
const missingTypeVoidBody = {
warehouseId, expectedVersion: Number(missingTypeTarget?.version), reason: `${context.marker}-temporary-missing-type`,
2026-08-12 12:35:29 +08:00
}
const missingTypeVoid = missingTypeTarget
? await request(context, 'POST', missingTypePath, missingTypeVoidBody)
2026-08-12 12:35:29 +08:00
: voidResponse
const missingTypeVoidData = context.apiSuccess(MODULE, '临时作废唯一 PHOTO_STEP_4 证据', missingTypeVoid, '', OPTIONS)
context.assert(MODULE, '临时作废后 PHOTO_STEP_4 不再计入有效证据', missingTypeVoid,
Boolean(missingTypeTarget) && missingTypeVoidData?.evidences?.some?.((item) =>
id(item.evidenceId) === id(missingTypeTarget.id) && item.status === 'VOID'),
2026-08-12 12:35:29 +08:00
`evidenceId=${id(missingTypeTarget?.id) || 'missing'}`, OPTIONS)
const missingTypeVoidReplay = missingTypeTarget
? await request(context, 'POST', missingTypePath, missingTypeVoidBody)
2026-08-12 12:35:29 +08:00
: voidResponse
context.assert(MODULE, '已作废 task 证据重复作废返回冲突', missingTypeVoidReplay,
missingTypeVoidReplay.http === 200 && codeOf(missingTypeVoidReplay) === 'A0409',
2026-08-12 12:35:29 +08:00
`HTTP=${missingTypeVoidReplay.http}, code=${codeOf(missingTypeVoidReplay)}`, OPTIONS)
const missingTypeVoidConflict = missingTypeTarget
? await request(context, 'POST', missingTypePath,
{ ...missingTypeVoidBody, reason: `${context.marker}-different-void-reason` })
2026-08-12 12:35:29 +08:00
: voidResponse
context.addStep(MODULE, '已作废 task 证据异参重试仍返回冲突', missingTypeVoidConflict,
2026-08-12 12:35:29 +08:00
missingTypeVoidConflict.http === 200 && codeOf(missingTypeVoidConflict) === 'A0409',
`HTTP=${missingTypeVoidConflict.http}, code=${codeOf(missingTypeVoidConflict)}`, OPTIONS)
const duplicateVoid = missingTypeTarget
? await request(context, 'POST', missingTypePath, missingTypeVoidBody)
2026-08-12 12:35:29 +08:00
: voidResponse
context.addStep(MODULE, '已作废证据使用新请求重复作废返回冲突', duplicateVoid,
duplicateVoid.http === 200 && codeOf(duplicateVoid) === 'A0409',
`HTTP=${duplicateVoid.http}, code=${codeOf(duplicateVoid)}`, OPTIONS)
2026-08-28 09:19:18 +08:00
context.addStep(MODULE, '作废唯一证据类型后不提前占用异步完成命令', null, true,
'PHOTO_STEP_4 已真实作废并回读;补传恢复后才允许最终阶段提交唯一完成命令', OPTIONS)
2026-08-12 12:35:29 +08:00
const replacementUpload = await uploadEvidence(context, canonicalTaskId, warehouseId, 'PHOTO_STEP_4', 0,
'PHOTO_STEP_4_RESTORE', { fileSuffix: 'PHOTO_STEP_4_RESTORE' })
const replacementData = context.apiSuccess(MODULE, '补传恢复 PHOTO_STEP_4 有效证据', replacementUpload, '', OPTIONS)
const replacementItem = normalizedEvidence(replacementData?.evidences?.find?.((item) =>
item.evidenceType === 'PHOTO_STEP_4' && item.status !== 'VOID'))
const replacementFileId = id(replacementItem?.fileId)
context.assert(MODULE, '补传后五类有效证据恢复且旧 VOID 记录保留', replacementUpload,
2026-08-12 12:35:29 +08:00
Boolean(replacementFileId)
&& replacementData?.evidences?.some?.((item) => item.evidenceType === 'PHOTO_STEP_4'
2026-08-12 12:35:29 +08:00
&& id(item.fileId) === replacementFileId && item.status !== 'VOID')
&& replacementData?.evidences?.some?.((item) => id(item.evidenceId) === id(missingTypeTarget?.id)
2026-08-12 12:35:29 +08:00
&& item.status === 'VOID'),
`replacementFileId=${replacementFileId || 'missing'}`, OPTIONS)
const customerEvidence = merchantToken
? await request(context, 'GET', `${evidencePath}?warehouseId=${encodeURIComponent(warehouseId)}`,
2026-08-12 12:35:29 +08:00
undefined, { Authorization: `Bearer ${merchantToken}` })
: merchantLogin
context.addStep(MODULE, '客户身份无权访问平台装柜证据', customerEvidence,
customerEvidence.http === 403 && codeOf(customerEvidence) === 'A0403'
&& Boolean(customerEvidence.requestId) && Boolean(customerEvidence.traceId),
`HTTP=${customerEvidence.http}, code=${codeOf(customerEvidence)}, requestId=${customerEvidence.requestId || 'missing'}, traceId=${customerEvidence.traceId || 'missing'}`, OPTIONS)
const wrongWarehouseEvidence = await context.request('GET',
`${evidencePath}?warehouseId=${encodeURIComponent(Number(warehouseId) + 1)}`)
context.addStep(MODULE, '错误仓库作用域不可读取本轮 task 装柜证据', wrongWarehouseEvidence,
wrongWarehouseEvidence.http === 200 && codeOf(wrongWarehouseEvidence) === 'A0403',
`HTTP=${wrongWarehouseEvidence.http}, code=${codeOf(wrongWarehouseEvidence)}`, OPTIONS)
const wrongTaskEvidence = await context.request('GET',
`${pdaBase}/tasks/999999999999999999/evidence?warehouseId=${encodeURIComponent(warehouseId)}`)
context.addStep(MODULE, '错误 taskId 不可读取本轮装柜证据', wrongTaskEvidence,
wrongTaskEvidence.http === 200 && codeOf(wrongTaskEvidence) === 'A0404',
`HTTP=${wrongTaskEvidence.http}, code=${codeOf(wrongTaskEvidence)}`, OPTIONS)
2026-08-12 12:35:29 +08:00
const evidenceList = await context.request('GET',
`${evidencePath}?warehouseId=${encodeURIComponent(warehouseId)}`)
const evidenceListData = context.apiSuccess(MODULE, '回查五类装柜证据列表', evidenceList, '', OPTIONS)
const activeEvidenceList = evidenceListData?.evidences?.filter?.((item) => item.status !== 'VOID') || []
context.assert(MODULE, '五类装柜证据完整且铅封号持久化', evidenceList,
id(evidenceListData?.taskId) === canonicalTaskId
&& id(evidenceListData?.containerBookingId) === containerBookingId
&& evidenceTypes.every((type) => activeEvidenceList.some((item) => item.evidenceType === type))
&& activeEvidenceList.some((item) => item.evidenceType === 'SEAL' && item.sealNo === `SEAL-${suffix}`),
2026-08-08 21:14:39 +08:00
`evidences=${evidenceListData?.evidences?.length ?? 'n/a'}`, OPTIONS)
const immutablePhoto = restoredPhoto
const postLoadedFileId = null
2026-08-17 15:41:42 +08:00
const incrementalFixture = context.createdResources?.orchestrationFixtures?.phase3LoadingIncremental
const incrementalInboundId = id(incrementalFixture?.inboundId)
2026-08-25 13:59:58 +08:00
let skipBatchVerified = false
let partialBatchVerified = false
let partialBlockSnapshot = null
2026-08-17 15:41:42 +08:00
let dynamicIncrementVerified = false
2026-09-04 17:17:08 +08:00
if (dropshipWholePallet) {
context.addStep(MODULE, '一件代发整托生产者不执行可拆分动态增量场景', null, true,
'逐箱封托事实必须整托守恒;SKIP、PARTIAL 和动态增删改由 STANDARD 生产者覆盖', OPTIONS)
} else if (!incrementalInboundId || incrementalFixture?.marker !== context.marker || incrementalInboundId === inboundId) {
2026-08-17 15:41:42 +08:00
context.addStep(MODULE, '取得本轮独立动态增量入库单', null, false,
`inboundId=${incrementalInboundId || 'missing'}, primaryInboundId=${inboundId}, marker=${incrementalFixture?.marker || 'missing'}`, OPTIONS)
} else {
const dynamicCandidatesResponse = await context.request('GET',
`${smartLoadingBase}/candidates?bookingId=${encodeURIComponent(containerBookingId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const dynamicCandidates = context.apiSuccess(MODULE, '查询同柜动态增量候选', dynamicCandidatesResponse, '', OPTIONS)
2026-08-25 09:52:49 +08:00
const markerOwnedDynamicCandidates = Array.isArray(dynamicCandidates) ? dynamicCandidates.filter((item) =>
id(item?.inboundId) === incrementalInboundId && Number(item?.availablePackageCount) > 0) : []
const dynamicCandidate = markerOwnedDynamicCandidates[0]
const skippedExceptionCandidate = markerOwnedDynamicCandidates[1]
const partialExceptionCandidate = markerOwnedDynamicCandidates.find((item, index) =>
index > 1 && Number(item?.availablePackageCount) >= 2)
2026-08-17 15:41:42 +08:00
context.assert(MODULE, '动态增量候选保持客户、仓库和渠道隔离', dynamicCandidatesResponse,
Boolean(dynamicCandidate?.inboundDetailId) && id(dynamicCandidate?.accountId) === tenantId
&& id(dynamicCandidate?.warehouseId) === warehouseId
&& dynamicCandidate?.channelCode === initial?.summary?.channelCode,
`inboundId=${incrementalInboundId}, detailId=${id(dynamicCandidate?.inboundDetailId) || 'missing'}, available=${dynamicCandidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
2026-08-25 09:52:49 +08:00
context.assert(MODULE, '本轮增量 fixture 提供独立 SKIP 与 PARTIAL 合法货物明细', dynamicCandidatesResponse,
Boolean(skippedExceptionCandidate?.inboundDetailId) && Boolean(partialExceptionCandidate?.inboundDetailId)
&& id(skippedExceptionCandidate?.inboundDetailId) !== id(partialExceptionCandidate?.inboundDetailId)
&& id(dynamicCandidate?.inboundDetailId) !== id(skippedExceptionCandidate?.inboundDetailId)
&& id(dynamicCandidate?.inboundDetailId) !== id(partialExceptionCandidate?.inboundDetailId),
`candidateCount=${markerOwnedDynamicCandidates.length}, skipDetailId=${id(skippedExceptionCandidate?.inboundDetailId) || 'missing'}, partialDetailId=${id(partialExceptionCandidate?.inboundDetailId) || 'missing'}`, OPTIONS)
if (skippedExceptionCandidate?.inboundDetailId && partialExceptionCandidate?.inboundDetailId) {
const beforeSkipPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const beforeSkipPlan = context.apiSuccess(MODULE, 'SKIP 异常场景前读取计划即时版本', beforeSkipPlanResponse, '', OPTIONS)
const skipAssignmentResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/assignments`, {
inboundId: incrementalInboundId, inboundDetailId: id(skippedExceptionCandidate.inboundDetailId), packageCount: 1,
expectedVersion: Number(beforeSkipPlan?.version), operationId: `${context.marker}-exception-skip-assign`.slice(0, 128),
})
const skipAssignedPlan = context.apiSuccess(MODULE, '加入本轮待跳拣异常明细', skipAssignmentResponse, '', OPTIONS)
const skipPlanItem = skipAssignedPlan?.items?.find?.((item) =>
id(item?.inboundDetailId) === id(skippedExceptionCandidate.inboundDetailId))
const skipTaskResponse = await context.request('GET', taskDetailPath)
const skipTask = context.apiSuccess(MODULE, '读取待跳拣异常 PDA 明细', skipTaskResponse, '', OPTIONS)
const skipTaskItem = skipTask?.items?.find?.((item) => id(item?.loadingPlanItemId) === id(skipPlanItem?.id))
const skipInventoryResponse = await context.request('GET',
`${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
const skipInventory = context.apiSuccess(MODULE, '跳拣前刷新实时库存快照', skipInventoryResponse, '', OPTIONS)
const skipReason = `${context.marker}-箱唛破损待复核`
const skipBatchResponse = await request(context, 'POST', batchPath, {
warehouseId, loadingPlanId, containerBookingId,
expectedVersion: Number(skipTask?.version), inventorySnapshotToken: skipInventory?.snapshotToken,
items: [{ itemId: id(skipTaskItem?.id), action: 'SKIP', version: Number(skipTaskItem?.version),
reason: skipReason }],
2026-08-25 13:59:58 +08:00
}, { 'Idempotency-Key': skipBatchKey })
2026-08-25 09:52:49 +08:00
const skippedTask = context.apiSuccess(MODULE, 'SKIP 携带异常原因并原子解决待拣明细', skipBatchResponse, '', OPTIONS)
const skippedTaskItem = skippedTask?.items?.find?.((item) => id(item?.id) === id(skipTaskItem?.id))
2026-08-25 13:59:58 +08:00
skipBatchVerified = context.assert(MODULE, '合法 SKIP 保存零实拣事实并恢复 AWAITING_REVIEW', skipBatchResponse,
2026-08-25 09:52:49 +08:00
Boolean(skipReason.trim()) && skippedTaskItem?.itemStatus === 'SKIPPED'
&& Number(skippedTaskItem?.pickedPackageCount) === 0
&& skippedTask?.workflowStatus === 'AWAITING_REVIEW' && skippedTask?.taskStatus === 'LOADING',
`itemStatus=${skippedTaskItem?.itemStatus || 'missing'}, picked=${skippedTaskItem?.pickedPackageCount ?? 'missing'}, workflow=${skippedTask?.workflowStatus || 'missing'}`, OPTIONS)
const beforePartialPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const beforePartialPlan = context.apiSuccess(MODULE, 'PARTIAL 异常场景前读取计划即时版本', beforePartialPlanResponse, '', OPTIONS)
const partialAssignmentResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/assignments`, {
inboundId: incrementalInboundId, inboundDetailId: id(partialExceptionCandidate.inboundDetailId), packageCount: 2,
expectedVersion: Number(beforePartialPlan?.version), operationId: `${context.marker}-exception-partial-assign`.slice(0, 128),
})
const partialAssignedPlan = context.apiSuccess(MODULE, '加入本轮部分拣货异常明细', partialAssignmentResponse, '', OPTIONS)
const partialPlanItem = partialAssignedPlan?.items?.find?.((item) =>
id(item?.inboundDetailId) === id(partialExceptionCandidate.inboundDetailId))
const unresolvedTaskResponse = await context.request('GET', taskDetailPath)
const unresolvedTask = context.apiSuccess(MODULE, '读取存在未解决异常的 PDA 任务', unresolvedTaskResponse, '', OPTIONS)
const unresolvedTaskItem = unresolvedTask?.items?.find?.((item) =>
id(item?.loadingPlanItemId) === id(partialPlanItem?.id))
const operationsBeforeUnresolvedCompleteResponse = await context.request('GET',
`${pdaBase}/tasks/${canonicalTaskId}/operations`)
const operationsBeforeUnresolvedComplete = context.apiSuccess(MODULE,
'未解决异常完成前读取 PDA 操作审计基线', operationsBeforeUnresolvedCompleteResponse, '', OPTIONS)
const unresolvedComplete = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`, {
expectedTaskVersion: Number(unresolvedTask?.version),
operationId: `${context.marker}-complete-with-unresolved`.slice(0, 128),
remark: `${context.marker}-must-reject-unresolved`,
}, { 'Idempotency-Key': `${context.marker}-complete-with-unresolved-key`.slice(0, 128) })
context.addStep(MODULE, '存在未解决异常明细时禁止确认装柜完成', unresolvedComplete,
unresolvedComplete.http === 200 && codeOf(unresolvedComplete) === 'A0409'
&& String(unresolvedComplete.body?.message || '').includes('尚未处理'),
responseEvidence(unresolvedComplete), OPTIONS)
const afterUnresolvedCompleteTaskResponse = await context.request('GET', taskDetailPath)
const afterUnresolvedCompleteTask = context.apiSuccess(MODULE,
'未解决异常完成失败后回查 PDA 任务', afterUnresolvedCompleteTaskResponse, '', OPTIONS)
const operationsAfterUnresolvedCompleteResponse = await context.request('GET',
`${pdaBase}/tasks/${canonicalTaskId}/operations`)
const operationsAfterUnresolvedComplete = context.apiSuccess(MODULE,
'未解决异常完成失败后回查 PDA 操作审计', operationsAfterUnresolvedCompleteResponse, '', OPTIONS)
context.assert(MODULE, '未解决异常完成失败后 task/items/operation 全部零写入', afterUnresolvedCompleteTaskResponse,
Number(afterUnresolvedCompleteTask?.version) === Number(unresolvedTask?.version)
&& taskItemsSignature(afterUnresolvedCompleteTask) === taskItemsSignature(unresolvedTask)
&& operationsSignature(operationsAfterUnresolvedComplete) === operationsSignature(operationsBeforeUnresolvedComplete),
`taskVersion=${afterUnresolvedCompleteTask?.version ?? 'missing'}, operations=${operationsAfterUnresolvedComplete?.length ?? 'missing'}`, OPTIONS)
const partialInventoryResponse = await context.request('GET',
`${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
const partialInventory = context.apiSuccess(MODULE, '部分拣货前刷新实时库存快照', partialInventoryResponse, '', OPTIONS)
const partialInventoryInbound = partialInventory?.inbounds?.find?.((item) =>
id(item?.inboundId) === incrementalInboundId)
const partialResources = (partialInventoryInbound?.resources || []).map((resource) => ({
blockId: id(resource?.blockId), slotNo: Number(resource?.slotNo),
reservationId: id(resource?.reservationId), locationId: id(resource?.locationId),
sharedOverflow: resource?.sharedOverflow === true,
}))
partialBlockSnapshot = {
inboundId: incrementalInboundId,
inboundCode: unresolvedTaskItem?.inboundCode || null,
resources: partialResources,
}
context.assert(MODULE, 'PARTIAL 前余货属于唯一整票库位块', partialInventoryResponse,
partialResources.length > 0
&& new Set(partialResources.map((resource) => resource.blockId)).size === 1,
`inboundId=${incrementalInboundId}, resources=${partialResources.length}, blockId=${partialResources[0]?.blockId || 'missing'}`, OPTIONS)
2026-08-25 09:52:49 +08:00
const partialBatchResponse = await request(context, 'POST', batchPath, {
warehouseId, loadingPlanId, containerBookingId,
expectedVersion: Number(afterUnresolvedCompleteTask?.version),
inventorySnapshotToken: partialInventory?.snapshotToken,
items: [{ itemId: id(unresolvedTaskItem?.id), action: 'PARTIAL', finalPickedPackageCount: 1,
2026-09-04 12:18:36 +08:00
version: Number(unresolvedTaskItem?.version) }],
2026-08-25 13:59:58 +08:00
}, { 'Idempotency-Key': partialBatchKey })
2026-08-25 09:52:49 +08:00
const partialTask = context.apiSuccess(MODULE, '合法 PARTIAL 解决未完成明细', partialBatchResponse, '', OPTIONS)
const partialTaskItem = partialTask?.items?.find?.((item) => id(item?.id) === id(unresolvedTaskItem?.id))
2026-08-25 13:59:58 +08:00
partialBatchVerified = context.assert(MODULE, '部分拣货数量守恒并恢复最终可确认状态', partialBatchResponse,
2026-08-25 09:52:49 +08:00
partialTaskItem?.itemStatus === 'PARTIAL' && Number(partialTaskItem?.plannedPackageCount) === 2
&& Number(partialTaskItem?.pickedPackageCount) === 1
&& partialTask?.workflowStatus === 'AWAITING_REVIEW' && partialTask?.taskStatus === 'LOADING',
`itemStatus=${partialTaskItem?.itemStatus || 'missing'}, planned=${partialTaskItem?.plannedPackageCount ?? 'missing'}, picked=${partialTaskItem?.pickedPackageCount ?? 'missing'}, workflow=${partialTask?.workflowStatus || 'missing'}`, OPTIONS)
const exceptionOverviewResponse = await context.request('GET',
`/api/platform/v1/containers/${encodeURIComponent(containerBookingId)}/overview`)
const exceptionOverview = context.apiSuccess(MODULE,
'回查柜次 PDA 异常原因与已解决进度', exceptionOverviewResponse, '', OPTIONS)
context.assert(MODULE, 'SKIP 异常原因持久化且 PARTIAL 后无未解决明细', exceptionOverviewResponse,
exceptionOverview?.pda?.exceptionReasons?.includes?.(skipReason)
&& Number(exceptionOverview?.pda?.unresolvedItemCount) === 0
&& exceptionOverview?.pda?.workflowStatus === 'AWAITING_REVIEW',
`exceptionReasons=${JSON.stringify(exceptionOverview?.pda?.exceptionReasons || [])}, unresolved=${exceptionOverview?.pda?.unresolvedItemCount ?? 'missing'}, workflow=${exceptionOverview?.pda?.workflowStatus || 'missing'}`, OPTIONS)
2026-08-25 09:52:49 +08:00
}
2026-08-17 15:41:42 +08:00
if (dynamicCandidate?.inboundDetailId) {
const planBeforeIncrementResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const planBeforeIncrement = context.apiSuccess(MODULE, '读取 LOADING 动态增量前计划', planBeforeIncrementResponse, '', OPTIONS)
const taskBeforeIncrementResponse = await context.request('GET', taskDetailPath)
2026-08-17 15:41:42 +08:00
const taskBeforeIncrement = context.apiSuccess(MODULE, '读取 LOADING 动态增量前任务', taskBeforeIncrementResponse, '', OPTIONS)
const evidenceBeforeIncrement = JSON.stringify(evidenceListData?.evidences || [])
const incrementResponse = await request(context, 'POST',
`${smartLoadingBase}/plans/${loadingPlanId}/assignments`, {
inboundId: incrementalInboundId, inboundDetailId: id(dynamicCandidate.inboundDetailId), packageCount: 1,
expectedVersion: Number(planBeforeIncrement?.version),
operationId: `${context.marker}-dynamic-assign`.slice(0, 128),
})
2026-08-17 15:41:42 +08:00
const incrementedPlan = context.apiSuccess(MODULE, 'LOADING 阶段向同一 PDA task 增加入库单', incrementResponse, '', OPTIONS)
const incrementalPlanItems = (incrementedPlan?.items || []).filter((item) =>
id(item?.inboundId) === incrementalInboundId
&& id(item?.inboundDetailId) === id(dynamicCandidate.inboundDetailId))
const incrementalPlanItem = incrementalPlanItems.length === 1 ? incrementalPlanItems[0] : null
context.assert(MODULE, '动态新增计划明细按 inboundDetailId 唯一定位', incrementResponse,
incrementalPlanItems.length === 1 && id(incrementalPlanItem?.inboundDetailId) === id(dynamicCandidate.inboundDetailId),
`inboundId=${incrementalInboundId}, inboundDetailId=${id(dynamicCandidate.inboundDetailId)}, matches=${incrementalPlanItems.length}, planItemId=${id(incrementalPlanItem?.id) || 'missing'}`, OPTIONS)
2026-08-18 16:39:39 +08:00
const preservedRemarkItem = incrementedPlan?.items?.find?.((item) => id(item?.id) === id(assignedItem.id))
const incrementedTaskResponse = await context.request('GET', taskDetailPath)
2026-08-17 15:41:42 +08:00
const incrementedTask = context.apiSuccess(MODULE, '增量后回查稳定 PDA 四元身份', incrementedTaskResponse, '', OPTIONS)
const incrementalTaskItem = incrementedTask?.items?.find?.((item) =>
id(item?.loadingPlanItemId) === id(incrementalPlanItem?.id))
const evidenceAfterIncrementResponse = await context.request('GET',
`${evidencePath}?warehouseId=${encodeURIComponent(warehouseId)}`)
2026-08-17 15:41:42 +08:00
const evidenceAfterIncrement = context.apiSuccess(MODULE, '增量后回查既有五类证据', evidenceAfterIncrementResponse, '', OPTIONS)
context.assert(MODULE, '增量保持稳定四元身份、LOADING 不回退并撤回最终完成门禁', incrementResponse,
id(incrementedPlan?.id) === loadingPlanId && incrementedPlan?.status === 'LOADING'
&& incrementedPlan?.finalCompletionStatus === 'IN_PROGRESS'
&& id(incrementedPlan?.pdaTaskId) === canonicalTaskId && incrementedPlan?.pdaTaskStatus === 'LOADING'
&& Number(incrementedPlan?.version) > Number(planBeforeIncrement?.version)
&& Number(incrementedPlan?.pdaTaskVersion) > Number(taskBeforeIncrement?.version)
&& Number(incrementalPlanItem?.version) === 0 && Number(incrementalPlanItem?.pickedPackageCount) === 0
&& Number(incrementalPlanItem?.minPackageCount) === 1
2026-08-18 16:39:39 +08:00
&& incrementalPlanItem?.warehouseRemark == null
&& preservedRemarkItem?.warehouseRemark === warehouseRemark
2026-08-17 15:41:42 +08:00
&& incrementalPlanItem?.canAdjust === true && incrementalPlanItem?.canRemove === true
&& id(incrementedTask?.loadingPlanId) === loadingPlanId
&& id(incrementedTask?.containerBookingId) === containerBookingId
&& incrementedTask?.workflowStatus === 'IN_PROGRESS'
&& JSON.stringify(evidenceAfterIncrement?.evidences || []) === evidenceBeforeIncrement,
`planVersion=${incrementedPlan?.version ?? 'missing'}, taskVersion=${incrementedTask?.version ?? 'missing'}, workflow=${incrementedTask?.workflowStatus || 'missing'}`, OPTIONS)
if (incrementalTaskItem?.id) {
const staleIncrementBatch = await request(context, 'POST', batchPath, {
warehouseId, loadingPlanId, containerBookingId,
expectedVersion: Number(incrementedTask?.version),
inventorySnapshotToken: batchPayload.inventorySnapshotToken,
2026-08-17 15:41:42 +08:00
items: [{ itemId: id(incrementalTaskItem.id), action: 'PICK', pickedPackageCount: 1,
2026-09-04 12:18:36 +08:00
version: Number(incrementalTaskItem.version) }],
2026-08-17 15:41:42 +08:00
}, { 'Idempotency-Key': `${context.marker}-dynamic-stale-inventory`.slice(0, 128) })
context.addStep(MODULE, '增量后旧 inventory token 明确 A0409 并要求刷新', staleIncrementBatch,
staleIncrementBatch.http === 200 && codeOf(staleIncrementBatch) === 'A0409'
&& String(staleIncrementBatch.body?.message || '').includes('库存已变化,请刷新后重试'),
responseEvidence(staleIncrementBatch), OPTIONS)
const taskAfterStaleResponse = await context.request('GET', taskDetailPath)
2026-08-17 15:41:42 +08:00
const taskAfterStale = context.apiSuccess(MODULE, '旧 token 冲突后回查增量任务', taskAfterStaleResponse, '', OPTIONS)
const planAfterStaleResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const planAfterStale = context.apiSuccess(MODULE, '旧 token 冲突后回查增量计划', planAfterStaleResponse, '', OPTIONS)
context.assert(MODULE, '增量旧 token 冲突后 plan/task/items 零写入', taskAfterStaleResponse,
Number(taskAfterStale?.version) === Number(incrementedTask?.version)
&& taskItemsSignature(taskAfterStale) === taskItemsSignature(incrementedTask)
&& Number(planAfterStale?.version) === Number(incrementedPlan?.version),
`taskVersion=${taskAfterStale?.version ?? 'missing'}, planVersion=${planAfterStale?.version ?? 'missing'}`, OPTIONS)
const removeResponse = await request(context, 'DELETE',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(incrementalPlanItem.id))}`, {
expectedPlanVersion: Number(incrementedPlan.version), expectedItemVersion: Number(incrementalPlanItem.version),
operationId: `${context.marker}-dynamic-remove-unpicked`.slice(0, 128),
})
const removedPlan = context.apiSuccess(MODULE, '未拣动态增量明细允许删除', removeResponse, '', OPTIONS)
context.assert(MODULE, '删除未拣项递增计划和任务版本且稳定身份不变', removeResponse,
!removedPlan?.items?.some?.((item) => id(item?.id) === id(incrementalPlanItem.id))
&& Number(removedPlan?.version) > Number(incrementedPlan?.version)
&& Number(removedPlan?.pdaTaskVersion) > Number(incrementedPlan?.pdaTaskVersion)
&& id(removedPlan?.pdaTaskId) === canonicalTaskId && removedPlan?.status === 'LOADING',
`planVersion=${removedPlan?.version ?? 'missing'}, taskVersion=${removedPlan?.pdaTaskVersion ?? 'missing'}`, OPTIONS)
const reassignResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/assignments`, {
inboundId: incrementalInboundId, inboundDetailId: id(dynamicCandidate.inboundDetailId), packageCount: 1,
expectedVersion: Number(removedPlan?.version), operationId: `${context.marker}-dynamic-reassign`.slice(0, 128),
})
const reassignedPlan = context.apiSuccess(MODULE, '删除后重新加入同一动态入库单', reassignResponse, '', OPTIONS)
let effectiveDynamicPlan = reassignedPlan
const reassignedPlanItems = (reassignedPlan?.items || []).filter((item) =>
id(item?.inboundId) === incrementalInboundId
&& id(item?.inboundDetailId) === id(dynamicCandidate.inboundDetailId))
let reassignedPlanItem = reassignedPlanItems.length === 1 ? reassignedPlanItems[0] : null
context.assert(MODULE, '重新加入动态明细仍按 inboundDetailId 唯一定位', reassignResponse,
reassignedPlanItems.length === 1
&& id(reassignedPlanItem?.inboundDetailId) === id(dynamicCandidate.inboundDetailId),
`inboundId=${incrementalInboundId}, inboundDetailId=${id(dynamicCandidate.inboundDetailId)}, matches=${reassignedPlanItems.length}, planItemId=${id(reassignedPlanItem?.id) || 'missing'}`, OPTIONS)
2026-09-04 15:06:36 +08:00
const authoritativeTotal = Number(reassignedPlanItem?.totalPackageCount)
const assignedPackageCount = Number(reassignedPlanItem?.packageCount)
const authoritativeRemaining = Number(reassignedPlanItem?.remainingPackageCount)
context.assert(MODULE, '计划详情由服务端返回权威总箱数和剩余箱数', reassignResponse,
Number.isInteger(authoritativeTotal) && authoritativeTotal >= assignedPackageCount
&& authoritativeRemaining === authoritativeTotal - assignedPackageCount,
`totalPackageCount=${reassignedPlanItem?.totalPackageCount ?? 'missing'}, packageCount=${reassignedPlanItem?.packageCount ?? 'missing'}, remainingPackageCount=${reassignedPlanItem?.remainingPackageCount ?? 'missing'}`,
OPTIONS)
const assignedByBookingResponse = await context.request('GET',
`${smartLoadingBase}/plans/by-booking/${encodeURIComponent(containerBookingId)}`)
const assignedByBooking = context.apiSuccess(MODULE, '计划已有明细后按柜次读取智能装柜详情',
assignedByBookingResponse, '', OPTIONS)
const assignedByBookingItem = assignedByBooking?.items?.find?.((item) =>
id(item?.id) === id(reassignedPlanItem?.id))
context.assert(MODULE, '按柜次详情返回同一计划及权威箱数且数量守恒', assignedByBookingResponse,
id(assignedByBooking?.id) === loadingPlanId
&& Number(assignedByBooking?.version) === Number(reassignedPlan?.version)
&& Number(assignedByBookingItem?.packageCount) === assignedPackageCount
&& Number(assignedByBookingItem?.totalPackageCount) === authoritativeTotal
&& Number(assignedByBookingItem?.remainingPackageCount) === authoritativeRemaining,
`loadingPlanId=${id(assignedByBooking?.id) || 'missing'}, packageCount=${assignedByBookingItem?.packageCount ?? 'missing'}, totalPackageCount=${assignedByBookingItem?.totalPackageCount ?? 'missing'}, remainingPackageCount=${assignedByBookingItem?.remainingPackageCount ?? 'missing'}`,
OPTIONS)
2026-09-04 15:06:36 +08:00
if (reassignedPlanItem?.id && Number.isInteger(authoritativeTotal) && authoritativeTotal > 0) {
const overAuthorityResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(reassignedPlanItem.id))}`, {
packageCount: authoritativeTotal + 1, expectedPlanVersion: Number(reassignedPlan?.version),
expectedItemVersion: Number(reassignedPlanItem.version),
operationId: `${context.marker}-dynamic-over-authority`.slice(0, 128),
})
context.addStep(MODULE, '调整箱数超过服务端权威总箱数返回A0422', overAuthorityResponse,
overAuthorityResponse.http === 200 && codeOf(overAuthorityResponse) === 'A0422',
2026-09-04 15:06:36 +08:00
responseEvidence(overAuthorityResponse), OPTIONS)
const afterOverAuthorityResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const afterOverAuthority = context.apiSuccess(MODULE, '超权威总箱数失败后回查计划',
afterOverAuthorityResponse, '', OPTIONS)
const afterOverAuthorityItem = afterOverAuthority?.items?.find?.((item) =>
id(item?.id) === id(reassignedPlanItem.id))
context.assert(MODULE, '超权威总箱数失败后计划和明细零写入', afterOverAuthorityResponse,
Number(afterOverAuthority?.version) === Number(reassignedPlan?.version)
&& Number(afterOverAuthorityItem?.version) === Number(reassignedPlanItem.version)
&& Number(afterOverAuthorityItem?.packageCount) === assignedPackageCount
&& Number(afterOverAuthorityItem?.totalPackageCount) === authoritativeTotal
&& Number(afterOverAuthorityItem?.remainingPackageCount) === authoritativeRemaining,
`planVersion=${afterOverAuthority?.version ?? 'missing'}, itemVersion=${afterOverAuthorityItem?.version ?? 'missing'}, packageCount=${afterOverAuthorityItem?.packageCount ?? 'missing'}`,
OPTIONS)
}
2026-08-17 15:41:42 +08:00
context.assert(MODULE, '动态增量 fixture 至少保留两件可用数量供调整与最小已拣门禁', dynamicCandidatesResponse,
Number(dynamicCandidate?.availablePackageCount) >= 2,
`availablePackageCount=${dynamicCandidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
if (Number(dynamicCandidate?.availablePackageCount) >= 2 && reassignedPlanItem?.id) {
const adjustResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(reassignedPlanItem.id))}`, {
packageCount: 2, expectedPlanVersion: Number(reassignedPlan?.version),
expectedItemVersion: Number(reassignedPlanItem.version),
operationId: `${context.marker}-dynamic-adjust`.slice(0, 128),
})
const adjustedPlan = context.apiSuccess(MODULE, '未拣动态增量明细允许调整数量', adjustResponse, '', OPTIONS)
const adjustedItem = adjustedPlan?.items?.find?.((item) => id(item?.id) === id(reassignedPlanItem.id))
context.assert(MODULE, '调整数量递增 plan/task/item 版本并保持稳定任务', adjustResponse,
Number(adjustedPlan?.version) > Number(reassignedPlan?.version)
&& Number(adjustedPlan?.pdaTaskVersion) > Number(reassignedPlan?.pdaTaskVersion)
&& Number(adjustedItem?.version) > Number(reassignedPlanItem?.version)
2026-08-18 16:39:39 +08:00
&& Number(adjustedItem?.packageCount) === 2 && adjustedItem?.warehouseRemark === reassignedPlanItem?.warehouseRemark
&& id(adjustedPlan?.pdaTaskId) === canonicalTaskId,
2026-08-17 15:41:42 +08:00
`planVersion=${adjustedPlan?.version ?? 'missing'}, taskVersion=${adjustedPlan?.pdaTaskVersion ?? 'missing'}, itemVersion=${adjustedItem?.version ?? 'missing'}`, OPTIONS)
const decreaseResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(adjustedItem?.id))}`, {
packageCount: 1, expectedPlanVersion: Number(adjustedPlan?.version),
expectedItemVersion: Number(adjustedItem?.version),
operationId: `${context.marker}-dynamic-adjust-down`.slice(0, 128),
})
const decreasedPlan = context.apiSuccess(MODULE, '未拣动态增量明细允许向下调整数量',
decreaseResponse, '', OPTIONS)
const decreasedItem = decreasedPlan?.items?.find?.((item) => id(item?.id) === id(adjustedItem?.id))
context.assert(MODULE, '向下调整后版本递增且权威总数与剩余数同步', decreaseResponse,
Number(decreasedPlan?.version) > Number(adjustedPlan?.version)
&& Number(decreasedItem?.version) > Number(adjustedItem?.version)
&& Number(decreasedItem?.packageCount) === 1
&& Number(decreasedItem?.totalPackageCount) === authoritativeTotal
&& Number(decreasedItem?.remainingPackageCount) === authoritativeTotal - 1
&& id(decreasedPlan?.pdaTaskId) === canonicalTaskId,
`planVersion=${decreasedPlan?.version ?? 'missing'}, itemVersion=${decreasedItem?.version ?? 'missing'}, packageCount=${decreasedItem?.packageCount ?? 'missing'}, remainingPackageCount=${decreasedItem?.remainingPackageCount ?? 'missing'}`,
OPTIONS)
const restoreAdjustedResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(decreasedItem?.id))}`, {
packageCount: 2, expectedPlanVersion: Number(decreasedPlan?.version),
expectedItemVersion: Number(decreasedItem?.version),
operationId: `${context.marker}-dynamic-adjust-restore`.slice(0, 128),
})
const restoredAdjustedPlan = context.apiSuccess(MODULE, '向下调整验证后恢复两箱供已拣下限校验',
restoreAdjustedResponse, '', OPTIONS)
const restoredAdjustedItem = restoredAdjustedPlan?.items?.find?.((item) => id(item?.id) === id(decreasedItem?.id))
context.assert(MODULE, '恢复后版本递增且数量回到两箱', restoreAdjustedResponse,
Number(restoredAdjustedPlan?.version) > Number(decreasedPlan?.version)
&& Number(restoredAdjustedItem?.version) > Number(decreasedItem?.version)
&& Number(restoredAdjustedItem?.packageCount) === 2
&& Number(restoredAdjustedItem?.totalPackageCount) === authoritativeTotal
&& Number(restoredAdjustedItem?.remainingPackageCount) === authoritativeTotal - 2
&& id(restoredAdjustedPlan?.pdaTaskId) === canonicalTaskId,
`planVersion=${restoredAdjustedPlan?.version ?? 'missing'}, itemVersion=${restoredAdjustedItem?.version ?? 'missing'}, packageCount=${restoredAdjustedItem?.packageCount ?? 'missing'}`,
OPTIONS)
effectiveDynamicPlan = restoredAdjustedPlan
reassignedPlanItem = restoredAdjustedItem
2026-08-17 15:41:42 +08:00
}
const refreshedDynamicTaskResponse = await context.request('GET', taskDetailPath)
2026-08-17 15:41:42 +08:00
const refreshedDynamicTask = context.apiSuccess(MODULE, '重新增量后刷新 PDA task detail', refreshedDynamicTaskResponse, '', OPTIONS)
const refreshedDynamicTaskItem = refreshedDynamicTask?.items?.find?.((item) =>
id(item?.loadingPlanItemId) === id(reassignedPlanItem?.id))
const refreshedDynamicInventoryResponse = await context.request('GET',
`${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
const refreshedDynamicInventory = context.apiSuccess(MODULE, '重新增量后刷新 inventory snapshot', refreshedDynamicInventoryResponse, '', OPTIONS)
const dynamicBatchResponse = await request(context, 'POST', batchPath, {
warehouseId, loadingPlanId, containerBookingId,
expectedVersion: Number(refreshedDynamicTask?.version),
inventorySnapshotToken: refreshedDynamicInventory?.snapshotToken,
items: [{ itemId: id(refreshedDynamicTaskItem?.id), action: 'PICK', pickedPackageCount: Number(reassignedPlanItem?.packageCount),
2026-09-04 12:18:36 +08:00
version: Number(refreshedDynamicTaskItem?.version) }],
2026-08-25 13:59:58 +08:00
}, { 'Idempotency-Key': dynamicBatchKey })
2026-08-17 15:41:42 +08:00
const dynamicPickedTask = context.apiSuccess(MODULE, '刷新后完成动态新增项拣货', dynamicBatchResponse, '', OPTIONS)
context.assert(MODULE, '动态新增项拣完后同一任务重新进入 AWAITING_REVIEW', dynamicBatchResponse,
id(dynamicPickedTask?.id) === canonicalTaskId && dynamicPickedTask?.workflowStatus === 'AWAITING_REVIEW'
&& dynamicPickedTask?.taskStatus === 'LOADING'
&& dynamicPickedTask?.items?.every?.((item) => item.itemStatus !== 'PENDING'),
`taskId=${id(dynamicPickedTask?.id) || 'missing'}, workflow=${dynamicPickedTask?.workflowStatus || 'missing'}`, OPTIONS)
const pickedDynamicPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const pickedDynamicPlan = context.apiSuccess(MODULE, '动态新增项拣完后回查 minPackageCount', pickedDynamicPlanResponse, '', OPTIONS)
const pickedDynamicPlanItem = pickedDynamicPlan?.items?.find?.((item) => id(item?.id) === id(reassignedPlanItem?.id))
const beforePickedMutationTaskResponse = await context.request('GET', taskDetailPath)
2026-08-17 15:41:42 +08:00
const beforePickedMutationTask = context.apiSuccess(MODULE, '已拣项负向前读取任务快照', beforePickedMutationTaskResponse, '', OPTIONS)
const reducePickedResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(pickedDynamicPlanItem?.id))}`, {
packageCount: Number(pickedDynamicPlanItem?.minPackageCount) - 1,
2026-08-17 15:41:42 +08:00
expectedPlanVersion: Number(pickedDynamicPlan?.version), expectedItemVersion: Number(pickedDynamicPlanItem?.version),
operationId: `${context.marker}-dynamic-reduce-picked`.slice(0, 128),
})
context.addStep(MODULE, '已拣项减量低于 minPackageCount 返回 A0409', reducePickedResponse,
reducePickedResponse.http === 200 && codeOf(reducePickedResponse) === 'A0409', responseEvidence(reducePickedResponse), OPTIONS)
const deletePickedResponse = await request(context, 'DELETE',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(pickedDynamicPlanItem?.id))}`, {
expectedPlanVersion: Number(pickedDynamicPlan?.version), expectedItemVersion: Number(pickedDynamicPlanItem?.version),
operationId: `${context.marker}-dynamic-delete-picked`.slice(0, 128),
})
context.addStep(MODULE, '已拣项删除返回 A0409', deletePickedResponse,
deletePickedResponse.http === 200 && codeOf(deletePickedResponse) === 'A0409', responseEvidence(deletePickedResponse), OPTIONS)
const afterPickedMutationPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const afterPickedMutationPlan = context.apiSuccess(MODULE, '已拣项负向后回查计划', afterPickedMutationPlanResponse, '', OPTIONS)
const afterPickedMutationTaskResponse = await context.request('GET', taskDetailPath)
2026-08-17 15:41:42 +08:00
const afterPickedMutationTask = context.apiSuccess(MODULE, '已拣项负向后回查任务', afterPickedMutationTaskResponse, '', OPTIONS)
context.assert(MODULE, '已拣项删除和减量失败后 plan/task/items 零写入', afterPickedMutationPlanResponse,
Number(afterPickedMutationPlan?.version) === Number(pickedDynamicPlan?.version)
&& Number(afterPickedMutationTask?.version) === Number(beforePickedMutationTask?.version)
&& taskItemsSignature(afterPickedMutationTask) === taskItemsSignature(beforePickedMutationTask),
`planVersion=${afterPickedMutationPlan?.version ?? 'missing'}, taskVersion=${afterPickedMutationTask?.version ?? 'missing'}`, OPTIONS)
dynamicIncrementVerified = dynamicPickedTask?.workflowStatus === 'AWAITING_REVIEW'
&& Number(pickedDynamicPlanItem?.minPackageCount) === Number(reassignedPlanItem?.packageCount)
&& Number(effectiveDynamicPlan?.version) <= Number(pickedDynamicPlan?.version)
}
}
}
2026-08-24 11:49:31 +08:00
// 报关协同在本模块之后执行,最终 COMPLETE_LOADING 只由 phase3-loading-finalize 发起。
// 当前服务端明确不以拖车作为前置门禁,因此这里不得用一次预期失败的写请求维持 LOADING。
const stagedDetailResponse = await context.request('GET', detailPath)
const stagedDetail = context.apiSuccess(MODULE, '下游协同前读取装柜中入库状态', stagedDetailResponse, '', OPTIONS)
const stagedTaskResponse = await context.request('GET', taskDetailPath)
const stagedTask = context.apiSuccess(MODULE, '下游协同前读取待最终完成 PDA 任务', stagedTaskResponse, '', OPTIONS)
const stagedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const stagedPlan = context.apiSuccess(MODULE, '下游协同前读取装柜中计划', stagedPlanResponse, '', OPTIONS)
context.assert(MODULE, '报关与最终完成前保持 LOADING 且不提前写 COMPLETE_LOADING', stagedDetailResponse,
stagedDetail?.summary?.processStatus === 'LOADING'
&& stagedTask?.taskStatus === 'LOADING'
&& stagedTask?.workflowStatus === 'AWAITING_REVIEW'
&& stagedPlan?.status === 'LOADING',
`inboundStatus=${stagedDetail?.summary?.processStatus || 'missing'}, taskStatus=${stagedTask?.taskStatus || 'missing'}, workflow=${stagedTask?.workflowStatus || 'missing'}, planStatus=${stagedPlan?.status || 'missing'}`, OPTIONS)
2026-08-08 21:14:39 +08:00
context.createdResources.phase3Loading = {
2026-08-15 11:05:58 +08:00
tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId,
cartonMark: source?.boxCode || source?.cartonMark || null,
2026-09-02 15:47:13 +08:00
cartonMarks: stableLoadedCartonMarks(stagedTask?.items),
fulfillmentType: source?.fulfillmentType || 'STANDARD',
skuCodes: [...new Set([...(Array.isArray(source?.skuCodes) ? source.skuCodes : []),
2026-09-04 17:17:08 +08:00
...(!dropshipWholePallet && Array.isArray(incrementalFixture?.skuCodes) ? incrementalFixture.skuCodes : [])])],
skuLines: [...(Array.isArray(source?.skuLines) ? source.skuLines : []),
2026-09-04 17:17:08 +08:00
...(!dropshipWholePallet && Array.isArray(incrementalFixture?.skuLines) ? incrementalFixture.skuLines : [])],
2026-08-24 19:58:35 +08:00
destinationType: initial?.summary?.destinationType || source?.destinationType || null,
destinationWarehouseId: id(initial?.summary?.destinationWarehouseId || source?.destinationWarehouseId),
destinationWarehouseCode: initial?.summary?.destinationCode || source?.destinationWarehouseCode || null,
2026-08-15 11:05:58 +08:00
evidenceIds: evidenceItems.map((item) => id(item.id)),
2026-08-12 10:59:39 +08:00
voidedEvidenceId: id(voidTarget?.id), evidenceTypes, sealNo: `SEAL-${suffix}`,
2026-08-12 12:35:29 +08:00
missingTypeVoidedEvidenceId: id(missingTypeTarget?.id), replacementEvidenceFileId: replacementFileId,
immutableEvidenceId: id(immutablePhoto?.id), immutableEvidenceVersion: Number(immutablePhoto?.version),
2026-08-12 12:35:29 +08:00
postLoadedFileId,
2026-08-14 10:09:46 +08:00
oceanBookingId: bookingId, loadingPlanId, containerBookingId, containerNo, warehouseId,
canonicalPdaTaskId: canonicalTaskId,
pickingTaskId: canonicalTaskId, inboundVersion: loadingVersion,
2026-08-17 15:41:42 +08:00
atomicBatchVerified: awaitingReview && atomicBatchReplayVerified,
inventorySnapshotVerified: inventorySnapshotVerified && atomicBatchReplayVerified,
inventoryBlockIdentityVerified, inventoryBlockSnapshots,
partialBlockSnapshot,
2026-08-25 13:59:58 +08:00
skipBatchVerified, partialBatchVerified, dynamicIncrementVerified,
expectedBatchOperationIds, incrementalInboundId,
2026-08-24 11:49:31 +08:00
pdaTaskVersion: Number(stagedTask?.version),
2026-08-17 15:41:42 +08:00
marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'LOADING', finalStatus: 'AWAITING_REVIEW',
}
2026-08-08 21:14:39 +08:00
}
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'