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

558 lines
37 KiB
JavaScript
Raw Normal View History

const MODULE = 'phase3-order-splitting'
const SCENARIO = 'source-loaded-single-remainder-chain'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin'
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!'
const CONTAINERS = '/api/platform/v1/containers'
const SMART = '/api/platform/v1/first-leg/smart-loading'
const PDA = '/api/platform/v1/first-leg/pda-loading'
2026-08-15 11:51:15 +08:00
import { oceanBookingDraft } from './ocean-booking-fixture.mjs'
function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
function isPositiveId(value) { return /^[1-9][0-9]*$/.test(id(value)) }
function isConflict(response) {
return response?.http === 409
|| (response?.http === 200 && ['A0409', '409'].includes(codeOf(response)))
}
function isForbidden(response) {
return response?.http === 403
|| (response?.http === 200 && ['A0403', '403'].includes(codeOf(response)))
}
function hasTrace(response) {
return typeof response?.requestId === 'string' && response.requestId.length > 0
&& typeof response?.traceId === 'string' && response.traceId.length > 0
}
2026-08-15 12:12:34 +08:00
const BOOKING_TRANSPORT_FIELDS = [
'billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt',
]
function bookingTransportSnapshot(value) {
return Object.fromEntries(BOOKING_TRANSPORT_FIELDS.map((field) => [field, value?.[field] ?? null]))
}
function completeBookingTransport(snapshot) {
return BOOKING_TRANSPORT_FIELDS.every((field) => typeof snapshot?.[field] === 'string'
&& snapshot[field].trim().length > 0)
}
function sameBookingTransport(left, right) {
return BOOKING_TRANSPORT_FIELDS.every((field) => left?.[field] === right?.[field])
}
async function request(context, method, path, body, headers = {}, token = context.token) {
try {
const response = await fetch(`${context.options.gateway}${path}`, {
method,
headers: {
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${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 below */ }
return {
method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null,
requestId: payload?.requestId || response.headers.get('x-request-id'),
traceId: payload?.traceId || response.headers.get('x-trace-id'),
}
} catch (error) {
return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
}
}
function treePath(orderId, tenantId) {
return `/api/platform/v1/orders/${encodeURIComponent(orderId)}/fulfillment-tree?tenantId=${encodeURIComponent(tenantId)}`
}
function activeRemainders(tree) {
const rows = Array.isArray(tree?.children) ? tree.children : []
const consumedSources = new Set(rows.filter((row) => row.splitRole === 'LOADED').map((row) => id(row.sourceOrderId)))
return rows.filter((row) => row.splitRole === 'REMAINDER' && !consumedSources.has(id(row.orderId)))
}
function treeShape(tree, rootOrderId) {
if (!tree || id(tree.parentOrderId) !== rootOrderId || !isPositiveId(tree.parentOrderId)
|| typeof tree.parentOrderNo !== 'string' || !Number.isInteger(Number(tree.parentVersion))
|| !Array.isArray(tree.children) || !Number.isInteger(Number(tree.originalPackageCount))
|| !Number.isInteger(Number(tree.cumulativeLoadedPackageCount))
|| !Number.isInteger(Number(tree.currentRemainderPackageCount))) return false
const rootNo = tree.parentOrderNo
const nestedCode = /-[0-9]+-[0-9]+$/
return tree.children.every((row) => {
const role = row?.splitRole
const sourceCarriesLoaded = role !== 'LOADED' || id(row.orderId) === id(row.sourceOrderId)
const remainderShape = role !== 'REMAINDER'
|| (id(row.orderId) !== id(row.sourceOrderId) && Number.isInteger(Number(row.splitSequence))
&& Number(row.splitSequence) > 0 && row.orderNo === `${rootNo}-${Number(row.splitSequence)}`)
return ['LOADED', 'REMAINDER'].includes(role)
&& isPositiveId(row.orderId) && isPositiveId(row.sourceOrderId)
&& typeof row.orderNo === 'string' && !nestedCode.test(row.orderNo)
&& Number.isInteger(Number(row.packageCount)) && Number(row.packageCount) > 0
&& isPositiveId(row.loadingPlanId) && isPositiveId(row.containerBookingId)
&& sourceCarriesLoaded && remainderShape
})
}
function quantityConserved(tree) {
const rows = Array.isArray(tree?.children) ? tree.children : []
const loadedRows = rows.filter((row) => row.splitRole === 'LOADED')
const remainders = rows.filter((row) => row.splitRole === 'REMAINDER')
const rootOriginal = Number(tree?.originalPackageCount)
const rootNo = tree?.parentOrderNo
return loadedRows.every((loaded) => {
const remainder = remainders.find((row) => id(row.sourceOrderId) === id(loaded.sourceOrderId)
&& id(row.loadingPlanId) === id(loaded.loadingPlanId)
&& id(row.containerBookingId) === id(loaded.containerBookingId))
const sourceCount = id(loaded.sourceOrderId) === id(tree.parentOrderId)
? rootOriginal
: Number(remainders.find((row) => id(row.orderId) === id(loaded.sourceOrderId))?.packageCount)
return Number.isInteger(sourceCount) && sourceCount > 0
&& sourceCount === Number(loaded.packageCount) + Number(remainder?.packageCount || 0)
&& (id(loaded.sourceOrderId) === id(tree.parentOrderId) || loaded.orderNo.startsWith(`${rootNo}-`))
})
}
function snapshotMatches(tree, original, loaded, remaining, activeSuffix) {
const active = activeRemainders(tree)
const activeMatches = activeSuffix == null
? active.length === 0
: active.length === 1 && active[0].orderNo === `${tree.parentOrderNo}-${activeSuffix}`
&& Number(active[0].packageCount) === remaining
return Number(tree.originalPackageCount) === original
&& Number(tree.cumulativeLoadedPackageCount) === loaded
&& Number(tree.currentRemainderPackageCount) === remaining
&& Number(tree.allocatedPackageCount) === loaded
&& Number(tree.remainingPackageCount) === remaining
&& original === loaded + remaining
&& activeMatches && quantityConserved(tree)
}
2026-08-14 10:09:46 +08:00
function bookingPayload(marker, sequence, source) {
const suffix = `${marker}-${sequence}`.replace(/[^A-Za-z0-9]/g, '').slice(-18)
2026-08-15 11:51:15 +08:00
return oceanBookingDraft({ marker }, suffix, {
customerId: source.tenantId, customerName: source.customerName, channelCode: source.channelCode,
2026-08-14 10:09:46 +08:00
warehouseId: source.warehouseId, warehouseName: source.warehouseName,
2026-08-15 11:51:15 +08:00
}, { vesselName: `SPLIT-VESSEL-${suffix}`.slice(0, 128), voyageNo: `SV-${sequence}`.slice(0, 64),
estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00' })
}
async function loadOneFragment(context, state) {
const { tenantId, rootOrderId, sourceOrderId, sourceOrderNo, warehouseId, sourcePackageCount, sequence } = state
2026-08-14 10:09:46 +08:00
const operationBase = `${context.marker}-chain-${sequence}`.slice(0, 100)
const simulationResponse = await request(context, 'POST', CONTAINERS, {
2026-08-17 13:24:48 +08:00
channelCode: state.channelCode, warehouseId, containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
const containerId = id(simulation?.id)
if (!containerId) return null
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(containerId)
2026-08-14 10:09:46 +08:00
const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', {
expectedVersion: 0, data: bookingPayload(context.marker, sequence, state),
}, { 'Idempotency-Key': `${operationBase}-booking-create` })
const bookingCreated = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 创建渠道仓库一致的订舱`, bookingCreate, '', OPTIONS)
const oceanBookingId = id(bookingCreated?.oceanBooking?.id)
if (!oceanBookingId) return null
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(oceanBookingId)
const bookingConfirmed = bookingCreated
context.assert(MODULE, `${sourceOrderNo} 一次登记直接返回 CONFIRMED`, bookingCreate,
bookingConfirmed?.oceanBooking?.status === 'CONFIRMED'
2026-08-17 13:24:48 +08:00
&& bookingConfirmed?.containers?.length === 1,
`status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, containers=${bookingConfirmed?.containers?.length ?? 'n/a'}`, OPTIONS)
const requirement = bookingConfirmed?.containers?.find?.((item) => item.containerType === '40HC')
const realContainerNo = requirement?.containerNo
if (!requirement?.containerBookingId || !realContainerNo) return null
const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
2026-08-17 13:24:48 +08:00
realContainerBookingId: id(requirement.containerBookingId), expectedVersion: Number(simulation.version),
})
const linked = context.apiSuccess(MODULE, `关联 ${sourceOrderNo} 的模拟柜与真实订舱`, linkResponse, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 关联后保持稳定内部柜次`, linkResponse,
isPositiveId(containerId) && id(linked?.id) === containerId && linked?.cabinetNo === simulation.cabinetNo,
`oceanBookingId=${oceanBookingId}, containerBookingId=${containerId}`, OPTIONS)
2026-08-14 10:09:46 +08:00
const candidatesPath = `${SMART}/candidates?bookingId=${encodeURIComponent(containerId)}&warehouseId=${encodeURIComponent(warehouseId)}`
const candidatesResponse = await context.request('GET', candidatesPath)
const candidates = context.apiSuccess(MODULE, `查询余量 ${sourceOrderNo} 的真实装柜候选`, candidatesResponse, '', OPTIONS)
const candidate = Array.isArray(candidates)
? candidates.find((row) => id(row.orderId) === sourceOrderId && id(row.accountId) === tenantId)
: null
context.assert(MODULE, `余量 ${sourceOrderNo} 初始已拣为0且可用箱数正确`, candidatesResponse,
Boolean(candidate?.inboundId) && Boolean(candidate?.inboundDetailId)
&& Number(candidate?.availablePackageCount) === sourcePackageCount,
`orderId=${sourceOrderId}, available=${candidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
if (!candidate?.inboundId || !candidate?.inboundDetailId) return null
const createPlan = await request(context, 'POST', `${SMART}/plans`, { bookingId: containerId, remark: context.marker })
const plan = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 创建装柜计划`, createPlan, '', OPTIONS)
const planId = id(plan?.id)
if (!planId) return null
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(planId)
const assignPayload = {
inboundId: id(candidate.inboundId), inboundDetailId: id(candidate.inboundDetailId), packageCount: 1,
expectedVersion: Number(plan.version), operationId: `${operationBase}-assign`,
}
const assign = await request(context, 'POST', `${SMART}/plans/${planId}/assignments`, assignPayload)
const assigned = context.apiSuccess(MODULE, `从 ${sourceOrderNo} 分配1箱`, assign, '', OPTIONS)
const planItem = assigned?.items?.find?.((row) => id(row.inboundId) === id(candidate.inboundId))
context.assert(MODULE, `来源 ${sourceOrderNo} 仅承接本次1箱`, assign,
Boolean(planItem?.id) && Number(planItem?.packageCount) === 1,
`planItemId=${id(planItem?.id) || 'missing'}, packages=${planItem?.packageCount ?? 'missing'}`, OPTIONS)
if (!planItem?.id) return null
const staleAssign = await request(context, 'POST', `${SMART}/plans/${planId}/assignments`, {
...assignPayload, expectedVersion: 0, operationId: `${operationBase}-stale`,
})
context.addStep(MODULE, `余量 ${sourceOrderNo} 的旧计划版本被拒绝`, staleAssign,
isConflict(staleAssign) && hasTrace(staleAssign),
`HTTP=${staleAssign.http}, code=${codeOf(staleAssign)}, requestId=${staleAssign.requestId || 'missing'}, traceId=${staleAssign.traceId || 'missing'}`, OPTIONS)
2026-08-17 13:24:48 +08:00
const transportStable = linked?.containerNo === realContainerNo && id(linked?.id) === containerId
&& linked?.cabinetNo === simulation.cabinetNo
context.assert(MODULE, `${sourceOrderNo} 合并后真实柜号落在稳定模拟柜身份`, linkResponse,
2026-08-14 22:59:10 +08:00
transportStable,
2026-08-17 13:24:48 +08:00
`containerBookingId=${containerId}, containerNo=${linked?.containerNo || 'missing'}, cabinetNo=${linked?.cabinetNo || 'missing'}`, OPTIONS)
2026-08-14 22:59:10 +08:00
if (!transportStable) return null
2026-08-14 23:21:47 +08:00
const bookingPath = `/api/platform/v1/ocean-bookings/${encodeURIComponent(oceanBookingId)}`
let bookingDetailResponse = await context.request('GET', bookingPath)
let bookingDetail = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 柜次所属真实订舱`, bookingDetailResponse, '', OPTIONS)
2026-08-17 13:24:48 +08:00
const allocationMatches = bookingDetail?.containers?.some?.((item) =>
2026-08-14 23:21:47 +08:00
id(item?.containerBookingId) === containerId)
context.assert(MODULE, `${sourceOrderNo} 订舱分配保持稳定柜次`, bookingDetailResponse,
id(bookingDetail?.oceanBooking?.id) === oceanBookingId && allocationMatches,
2026-08-17 13:24:48 +08:00
`oceanBookingId=${id(bookingDetail?.oceanBooking?.id) || 'missing'}, containerBookingId=${containerId}, containers=${bookingDetail?.containers?.length ?? 'n/a'}`, OPTIONS)
2026-08-14 23:21:47 +08:00
if (!allocationMatches || id(bookingDetail?.oceanBooking?.id) !== oceanBookingId) return null
2026-08-15 12:12:34 +08:00
const createdTransport = bookingTransportSnapshot(bookingCreated?.oceanBooking)
const persistedTransport = bookingTransportSnapshot(bookingDetail?.oceanBooking)
const transportFrozen = completeBookingTransport(createdTransport)
&& completeBookingTransport(persistedTransport)
&& sameBookingTransport(createdTransport, persistedTransport)
context.assert(MODULE, `${sourceOrderNo} 创建响应与详情五项运输字段冻结一致`, bookingDetailResponse,
transportFrozen,
BOOKING_TRANSPORT_FIELDS.map((field) => `${field}=${persistedTransport[field] || 'missing'}`).join(', '), OPTIONS)
if (!transportFrozen) return null
2026-08-14 23:21:47 +08:00
if (bookingDetail.oceanBooking.status === 'CONFIRMED') {
const releaseResponse = await request(context, 'POST', `${bookingPath}/release`, {
expectedVersion: Number(bookingDetail.oceanBooking.version),
2026-08-15 12:12:34 +08:00
billOfLadingNo: persistedTransport.billOfLadingNo,
vesselName: persistedTransport.vesselName,
voyageNo: persistedTransport.voyageNo,
estimatedSailingAt: persistedTransport.estimatedSailingAt,
estimatedArrivalAt: persistedTransport.estimatedArrivalAt,
2026-08-14 23:21:47 +08:00
reason: context.marker,
}, { 'Idempotency-Key': `${operationBase}-booking-release`.slice(0, 128) })
context.apiSuccess(MODULE, `放舱 ${sourceOrderNo} 的真实订舱并满足 PDA 门禁`, releaseResponse, '', OPTIONS)
bookingDetailResponse = await context.request('GET', bookingPath)
bookingDetail = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 的 RELEASED 订舱`, bookingDetailResponse, '', OPTIONS)
}
2026-08-17 13:24:48 +08:00
const releasedAllocationMatches = bookingDetail?.containers?.some?.((item) =>
2026-08-14 23:21:47 +08:00
id(item?.containerBookingId) === containerId)
context.assert(MODULE, `${sourceOrderNo} 仅在 RELEASED 且柜次分配稳定后推送`, bookingDetailResponse,
bookingDetail?.oceanBooking?.status === 'RELEASED' && releasedAllocationMatches,
`status=${bookingDetail?.oceanBooking?.status || 'missing'}, containerBookingId=${containerId}`, OPTIONS)
if (bookingDetail?.oceanBooking?.status !== 'RELEASED' || !releasedAllocationMatches) return null
2026-08-14 22:59:10 +08:00
const push = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`,
{ expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': `${operationBase}-push` })
const pushed = context.apiSuccess(MODULE, `推送 ${sourceOrderNo} 的预装计划`, push, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 补真实柜号后计划保持 EDITING`, push,
pushed?.status === 'EDITING', `status=${pushed?.status || 'missing'}`, OPTIONS)
if (pushed?.status !== 'EDITING') return null
2026-08-15 17:40:01 +08:00
const unconfirmedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
context.addStep(MODULE, `${sourceOrderNo} 计划确认前不生成 PDA 任务`, unconfirmedTaskResponse,
unconfirmedTaskResponse.http === 200 && codeOf(unconfirmedTaskResponse) === 'A0404',
`HTTP=${unconfirmedTaskResponse.http}, code=${codeOf(unconfirmedTaskResponse)}, requestId=${unconfirmedTaskResponse.requestId || 'missing'}, traceId=${unconfirmedTaskResponse.traceId || 'missing'}`, OPTIONS)
const confirmableResponse = await context.request('GET',
`/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(planId)}?warehouseId=${encodeURIComponent(warehouseId)}`)
const confirmable = context.apiSuccess(MODULE, `读取 ${sourceOrderNo} 计划级确认即时版本`, confirmableResponse, '', OPTIONS)
const confirmableVersion = Number(confirmable?.summary?.version)
context.assert(MODULE, `${sourceOrderNo} 放舱后整计划允许确认且尚未就绪 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 null
const confirmPath = `/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(planId)}/confirm`
const confirmBody = Object.freeze({ expectedVersion: confirmableVersion })
const confirmHeaders = Object.freeze({ 'Idempotency-Key': `${operationBase}-plan-confirm`.slice(0, 128) })
const confirmResponse = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders)
const confirmed = context.apiSuccess(MODULE, `计划级确认 ${sourceOrderNo} 并生成唯一 PDA 任务`, confirmResponse, '', OPTIONS)
const confirmedTaskId = id(confirmed?.readiness?.pdaTaskId)
context.assert(MODULE, `${sourceOrderNo} 计划确认返回 CONFIRMED 与 readyForPda`, confirmResponse,
id(confirmed?.planId) === planId && id(confirmed?.containerBookingId) === containerId
&& 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 null
const confirmReplay = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders)
context.assert(MODULE, `${sourceOrderNo} 计划确认同键同参精确重放`, 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 taskResponse = await context.request('GET',
2026-08-14 10:09:46 +08:00
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const task = context.apiSuccess(MODULE, `读取 ${sourceOrderNo} 的 PDA 任务`, taskResponse, '', OPTIONS)
const taskId = id(task?.id)
const pickItem = task?.items?.find?.((row) => id(row.loadingPlanItemId) === id(planItem.id))
context.assert(MODULE, `余量 ${sourceOrderNo} 不继承已拣数量`, taskResponse,
2026-08-15 17:40:01 +08:00
taskId === confirmedTaskId && id(task?.loadingPlanId) === planId && id(task?.containerBookingId) === containerId
2026-08-14 10:09:46 +08:00
&& task?.currentContainerNo === realContainerNo && Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1
&& Number(pickItem?.pickedPackageCount) === 0,
`taskId=${taskId || 'missing'}, picked=${pickItem?.pickedPackageCount ?? 'missing'}, planned=${pickItem?.plannedPackageCount ?? 'missing'}`, OPTIONS)
if (!taskId || !pickItem?.id) return null
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(taskId)
const claim = await request(context, 'POST', `${PDA}/tasks/${taskId}/claim`, {
expectedAssignmentVersion: 0, operationId: `${operationBase}-claim`,
})
2026-08-14 10:57:05 +08:00
const claimed = context.apiSuccess(MODULE, `领取 ${sourceOrderNo} 的 PDA 任务`, claim, '', OPTIONS)
const claimedItem = claimed?.items?.find?.((row) => id(row.id) === id(pickItem.id)) || pickItem
const inventoryResponse = await context.request('GET',
`${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
const inventory = context.apiSuccess(MODULE, `冻结 ${sourceOrderNo} 的 PDA 库存快照`, inventoryResponse, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 库存快照绑定稳定任务身份`, inventoryResponse,
id(inventory?.taskId) === taskId && id(inventory?.loadingPlanId) === planId
&& id(inventory?.containerBookingId) === containerId && id(inventory?.warehouseId) === warehouseId
&& Number(inventory?.taskVersion) === Number(claimed?.version)
&& typeof inventory?.snapshotToken === 'string' && inventory.snapshotToken.length === 64,
`taskId=${id(inventory?.taskId) || 'missing'}, taskVersion=${inventory?.taskVersion ?? 'missing'}, loadingPlanId=${id(inventory?.loadingPlanId) || 'missing'}, containerBookingId=${id(inventory?.containerBookingId) || 'missing'}`, OPTIONS)
if (!inventory?.snapshotToken) return null
2026-08-14 10:57:05 +08:00
const batchPath = `${PDA}/tasks/${taskId}/batch`
const batchKey = `${operationBase}-batch`.slice(0, 128)
const batchPayload = {
warehouseId, loadingPlanId: planId, containerBookingId: containerId,
expectedVersion: Number(inventory.taskVersion), inventorySnapshotToken: inventory.snapshotToken,
items: [{
itemId: id(claimedItem.id), action: 'PICK', pickedPackageCount: 1,
version: Number(claimedItem.version), releaseMode: 'MANUAL', locationIds: [],
}],
}
2026-08-14 10:57:05 +08:00
const staleBatch = await request(context, 'POST', batchPath, {
...batchPayload, expectedVersion: Number(inventory.taskVersion) + 1,
}, { 'Idempotency-Key': `${operationBase}-batch-stale`.slice(0, 128) })
context.addStep(MODULE, `余量 ${sourceOrderNo} 的旧任务版本被拒绝且不结算`, staleBatch,
isConflict(staleBatch) && hasTrace(staleBatch),
`HTTP=${staleBatch.http}, code=${codeOf(staleBatch)}, requestId=${staleBatch.requestId || 'missing'}, traceId=${staleBatch.traceId || 'missing'}`, OPTIONS)
const batch = await request(context, 'POST', batchPath, batchPayload, { 'Idempotency-Key': batchKey })
const picked = context.apiSuccess(MODULE, `原子提交 ${sourceOrderNo} 的完整 PDA 草稿`, batch, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 批次拣出1箱并等待人工确认`, batch,
picked?.taskStatus === 'LOADING' && picked?.workflowStatus === 'AWAITING_REVIEW'
&& picked?.items?.some?.((row) => id(row.id) === id(claimedItem.id)
&& Number(row.pickedPackageCount) === 1 && Number(row.plannedPackageCount) === 1),
`workflow=${picked?.workflowStatus || 'missing'}, taskStatus=${picked?.taskStatus || 'missing'}`, OPTIONS)
2026-08-14 10:57:05 +08:00
const replay = await request(context, 'POST', batchPath, batchPayload, { 'Idempotency-Key': batchKey })
const replayed = context.apiSuccess(MODULE, `${sourceOrderNo} PDA 批次幂等重放`, replay, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 批次重放不重复累计`, replay,
id(replayed?.id) === taskId && Number(replayed?.version) === Number(picked?.version)
&& replayed?.items?.some?.((row) => id(row.id) === id(claimedItem.id)
&& Number(row.pickedPackageCount) === 1),
`taskId=${id(replayed?.id) || 'missing'}, taskVersion=${replayed?.version ?? 'missing'}`, OPTIONS)
if (sourcePackageCount > 1) {
2026-08-14 10:57:05 +08:00
const differentReplay = await request(context, 'POST', batchPath, {
...batchPayload,
items: [{ ...batchPayload.items[0], pickedPackageCount: 2 }],
}, { 'Idempotency-Key': batchKey })
context.addStep(MODULE, `${sourceOrderNo} 同幂等键异参被拒绝`, differentReplay,
isConflict(differentReplay) && hasTrace(differentReplay),
`HTTP=${differentReplay.http}, code=${codeOf(differentReplay)}, requestId=${differentReplay.requestId || 'missing'}, traceId=${differentReplay.traceId || 'missing'}`, OPTIONS)
}
2026-08-14 10:57:05 +08:00
const completePath = `${PDA}/tasks/${taskId}/complete`
const completePayload = {
expectedTaskVersion: Number(picked?.version), operationId: `${operationBase}-complete`.slice(0, 128),
remark: context.marker,
}
const complete = await request(context, 'POST', completePath, completePayload)
const completed = context.apiSuccess(MODULE, `人工确认 ${sourceOrderNo} 装柜完成并结算余量`, complete, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 只在 complete 后进入完成态`, complete,
completed?.taskStatus === 'COMPLETED' && completed?.workflowStatus === 'COMPLETED'
&& completed?.items?.some?.((row) => id(row.id) === id(claimedItem.id)
&& Number(row.pickedPackageCount) === 1 && Number(row.plannedPackageCount) === 1),
`workflow=${completed?.workflowStatus || 'missing'}, taskStatus=${completed?.taskStatus || 'missing'}`, OPTIONS)
if (completed?.taskStatus !== 'COMPLETED' || completed?.workflowStatus !== 'COMPLETED') return null
const completeReplay = await request(context, 'POST', completePath, completePayload)
const completedReplay = context.apiSuccess(MODULE, `${sourceOrderNo} PDA 完成幂等重放`, completeReplay, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 完成重放保持同一已结算任务`, completeReplay,
id(completedReplay?.id) === taskId && Number(completedReplay?.version) === Number(completed?.version)
&& completedReplay?.taskStatus === 'COMPLETED' && completedReplay?.workflowStatus === 'COMPLETED',
`taskId=${id(completedReplay?.id) || 'missing'}, taskVersion=${completedReplay?.version ?? 'missing'}`, OPTIONS)
const treeResponse = await context.request('GET', treePath(rootOrderId, tenantId))
const tree = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 拆分后的根聚合`, treeResponse, '', OPTIONS)
2026-08-14 10:09:46 +08:00
const candidatesAfter = await context.request('GET', candidatesPath)
const candidateRowsAfter = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 不可重复消费`, candidatesAfter, '', OPTIONS)
context.assert(MODULE, `来源 ${sourceOrderNo} 同时最多成功拆分一次`, candidatesAfter,
Array.isArray(candidateRowsAfter) && !candidateRowsAfter.some((row) => id(row.orderId) === sourceOrderId),
`sourceOrderId=${sourceOrderId}, stillCandidate=${Array.isArray(candidateRowsAfter) && candidateRowsAfter.some((row) => id(row.orderId) === sourceOrderId)}`, OPTIONS)
2026-08-14 10:09:46 +08:00
return { tree, treeResponse, oceanBookingId, containerId, planId, taskId, sourceInboundId: id(candidate.inboundId) }
}
export async function run(context) {
const fixture = context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading
const tenantId = id(fixture?.tenantId)
const inboundId = id(fixture?.inboundId)
const warehouseId = id(fixture?.warehouseId)
const containerBookingId = id(fixture?.containerBookingId)
if (!tenantId || !inboundId || !warehouseId || !containerBookingId || fixture?.marker !== context.marker) {
context.addStep(MODULE, '取得本轮 marker 隔离拆分 fixture', null, false,
`marker=${fixture?.marker || 'missing'}, inboundId=${inboundId || 'missing'}, warehouseId=${warehouseId || 'missing'}`, OPTIONS)
return
}
const inboundPath = `/api/platform/inbounds/${encodeURIComponent(inboundId)}?tenantId=${encodeURIComponent(tenantId)}`
const inboundResponse = await context.request('GET', inboundPath)
const inbound = context.apiSuccess(MODULE, '读取本轮来源入库单', inboundResponse,
`tenantId=${tenantId}, inboundId=${inboundId}`, OPTIONS)
const orderId = id(inbound?.summary?.orderId)
2026-08-14 10:09:46 +08:00
const channelCode = id(inbound?.summary?.channelCode || fixture?.channelCode)
const channelName = inbound?.summary?.channelName || fixture?.channelName || channelCode
const warehouseName = inbound?.summary?.warehouseName || fixture?.warehouseName
const customerName = inbound?.summary?.customerName || `验收客户-${tenantId}`
context.assert(MODULE, '来源入库单归属本轮客户与根订单', inboundResponse,
2026-08-14 10:09:46 +08:00
isPositiveId(orderId) && id(inbound?.summary?.tenantId) === tenantId
&& Boolean(channelCode && channelName && warehouseName),
`orderId=${orderId || 'missing'}, tenantId=${id(inbound?.summary?.tenantId) || 'missing'}, channelCode=${channelCode || 'missing'}, warehouseName=${warehouseName || 'missing'}`, OPTIONS)
if (!orderId || !channelCode || !channelName || !warehouseName) return
const firstTreeResponse = await context.request('GET', treePath(orderId, tenantId))
const firstTree = context.apiSuccess(MODULE, '查询首次拆分后的根履约树', firstTreeResponse,
`orderId=${orderId}, tenantId=${tenantId}`, OPTIONS)
context.assert(MODULE, '新批次由来源单承接已装且只创建一个余量子单', firstTreeResponse,
treeShape(firstTree, orderId) && snapshotMatches(firstTree, 4, 1, 3, 1)
&& firstTree.children.filter((row) => row.splitRole === 'LOADED').length === 1
&& firstTree.children.filter((row) => row.splitRole === 'REMAINDER').length === 1,
`original=${firstTree?.originalPackageCount ?? 'missing'}, loaded=${firstTree?.cumulativeLoadedPackageCount ?? 'missing'}, remaining=${firstTree?.currentRemainderPackageCount ?? 'missing'}`, OPTIONS)
if (!treeShape(firstTree, orderId)) return
const firstLoaded = firstTree.children.find((row) => row.splitRole === 'LOADED')
context.assert(MODULE, '新拆分未生成 LOADED 子单且完成目标保持来源单', firstTreeResponse,
id(firstLoaded?.orderId) === orderId && id(firstLoaded?.sourceOrderId) === orderId
&& firstLoaded?.orderNo === firstTree.parentOrderNo && Number(firstLoaded?.packageCount) === 1,
`loadedOrderId=${id(firstLoaded?.orderId) || 'missing'}, sourceOrderId=${id(firstLoaded?.sourceOrderId) || 'missing'}`, OPTIONS)
const merchantLogin = await context.request('POST', '/api/auth/login', {
username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD,
}, '')
const merchantAuth = context.apiSuccess(MODULE, '客户账号登录验证隔离与权限', merchantLogin, '', OPTIONS)
const merchantToken = merchantAuth?.accessToken
if (merchantToken) {
const merchantTreeResponse = await context.request('GET',
`/api/merchant/v1/orders/${encodeURIComponent(orderId)}/fulfillment-tree`, undefined, merchantToken)
const merchantTree = context.apiSuccess(MODULE, '客户查询本客户根履约树', merchantTreeResponse, '', OPTIONS)
context.assert(MODULE, '客户履约树与平台数量聚合一致', merchantTreeResponse,
treeShape(merchantTree, orderId) && snapshotMatches(merchantTree, 4, 1, 3, 1),
`children=${merchantTree?.children?.length ?? 'missing'}`, OPTIONS)
const forbidden = await request(context, 'GET', treePath(orderId, tenantId), undefined, {}, merchantToken)
context.addStep(MODULE, '客户账号无权调用平台父子订单接口', forbidden,
isForbidden(forbidden) && hasTrace(forbidden),
`HTTP=${forbidden.http}, code=${codeOf(forbidden)}, requestId=${forbidden.requestId || 'missing'}, traceId=${forbidden.traceId || 'missing'}`, OPTIONS)
}
const wrongTenant = (BigInt(tenantId) + 1n).toString()
const wrongTree = await context.request('GET', treePath(orderId, wrongTenant))
context.addStep(MODULE, '根履约树跨客户访问拒绝且返回追踪信息', wrongTree,
wrongTree.http === 200 && codeOf(wrongTree) !== '200' && hasTrace(wrongTree),
`HTTP=${wrongTree.http}, code=${codeOf(wrongTree)}, requestId=${wrongTree.requestId || 'missing'}, traceId=${wrongTree.traceId || 'missing'}`, OPTIONS)
const initialSlices = await context.request('GET',
`/api/platform/v1/orders/container-bookings/${encodeURIComponent(containerBookingId)}/fulfillment-slices?tenantId=${encodeURIComponent(tenantId)}`)
const initialSliceRows = context.apiSuccess(MODULE, '按首次柜次查询来源与余量分片', initialSlices, '', OPTIONS)
context.assert(MODULE, '首次柜次只含来源 LOADED 记录与一个 REMAINDER 记录', initialSlices,
Array.isArray(initialSliceRows) && initialSliceRows.length === 2
&& initialSliceRows.some((row) => row.splitRole === 'LOADED' && id(row.childOrderId) === id(row.sourceOrderId))
&& initialSliceRows.some((row) => row.splitRole === 'REMAINDER' && id(row.childOrderId) !== id(row.sourceOrderId)),
`rows=${Array.isArray(initialSliceRows) ? initialSliceRows.length : 'missing'}`, OPTIONS)
context.createdResources.phase3OrderSplitting = {
marker: context.marker, tenantId, rootOrderId: orderId, rootOrderNo: firstTree.parentOrderNo,
retainedAuditRecordIds: [], activeRemainderCount: activeRemainders(firstTree).length,
finalLoadedPackageCount: Number(firstTree.cumulativeLoadedPackageCount),
finalRemainingPackageCount: Number(firstTree.currentRemainderPackageCount), cleaned: false,
cleanupNote: '公开 API 不允许删除订单/柜次/PDA审计记录;只有全部分片完成且无活动余量时标记业务清理完成。',
}
let tree = firstTree
for (const step of [
{ sourcePackageCount: 3, expectedLoaded: 2, expectedRemaining: 2, sourceSuffix: 1, activeSuffix: 2 },
{ sourcePackageCount: 2, expectedLoaded: 3, expectedRemaining: 1, sourceSuffix: 2, activeSuffix: 3 },
{ sourcePackageCount: 1, expectedLoaded: 4, expectedRemaining: 0, sourceSuffix: 3, activeSuffix: null },
]) {
const active = activeRemainders(tree)
const source = active[0]
context.assert(MODULE, `链式拆分来源固定为根编号 -${step.sourceSuffix}`, null,
active.length === 1 && source?.orderNo === `${tree.parentOrderNo}-${step.sourceSuffix}`,
`active=${active.map((row) => row.orderNo).join(',') || 'none'}`, OPTIONS)
if (!source) return
const result = await loadOneFragment(context, {
tenantId, rootOrderId: orderId, sourceOrderId: id(source.orderId), sourceOrderNo: source.orderNo,
2026-08-14 10:09:46 +08:00
customerName, channelCode, channelName, warehouseId, warehouseName,
sourcePackageCount: step.sourcePackageCount, sequence: step.sourceSuffix,
})
if (!result?.tree) return
tree = result.tree
Object.assign(context.createdResources.phase3OrderSplitting, {
activeRemainderCount: activeRemainders(tree).length,
finalLoadedPackageCount: Number(tree.cumulativeLoadedPackageCount),
finalRemainingPackageCount: Number(tree.currentRemainderPackageCount),
})
context.assert(MODULE, `完成第 ${step.sourceSuffix + 1} 个分片后根数量守恒`, result.treeResponse,
treeShape(tree, orderId)
&& snapshotMatches(tree, 4, step.expectedLoaded, step.expectedRemaining, step.activeSuffix),
`original=${tree.originalPackageCount}, loaded=${tree.cumulativeLoadedPackageCount}, remaining=${tree.currentRemainderPackageCount}`, OPTIONS)
}
const remainderCodes = tree.children.filter((row) => row.splitRole === 'REMAINDER').map((row) => row.orderNo)
context.assert(MODULE, '余量子单始终按根编号全局递增且禁止嵌套编号', null,
JSON.stringify(remainderCodes) === JSON.stringify([
`${tree.parentOrderNo}-1`, `${tree.parentOrderNo}-2`, `${tree.parentOrderNo}-3`,
]) && remainderCodes.every((code) => !/-[0-9]+-[0-9]+$/.test(code)),
`codes=${remainderCodes.join(',')}`, OPTIONS)
context.assert(MODULE, '最后一次全量装完不再生成子单且根整体完成', null,
activeRemainders(tree).length === 0 && Number(tree.currentRemainderPackageCount) === 0
&& Number(tree.cumulativeLoadedPackageCount) === Number(tree.originalPackageCount),
`loaded=${tree.cumulativeLoadedPackageCount}, remaining=${tree.currentRemainderPackageCount}`, OPTIONS)
const nestedSearch = await context.request('GET',
`/api/platform/v1/orders?keyword=${encodeURIComponent(`${tree.parentOrderNo}-1-1`)}&page=1&size=20`)
const nestedPage = context.apiSuccess(MODULE, '搜索非法嵌套编号不命中本轮子单', nestedSearch, '', OPTIONS)
context.assert(MODULE, '不存在根编号 -1-1 的嵌套子单', nestedSearch,
Array.isArray(nestedPage?.records) && !nestedPage.records.some((row) => id(row.id) === orderId
|| String(row.orderNo || '').startsWith(`${tree.parentOrderNo}-1-1`)),
`records=${nestedPage?.records?.length ?? 'missing'}`, OPTIONS)
Object.assign(context.createdResources.phase3OrderSplitting, {
activeRemainderCount: activeRemainders(tree).length,
finalLoadedPackageCount: Number(tree.cumulativeLoadedPackageCount),
finalRemainingPackageCount: Number(tree.currentRemainderPackageCount),
cleaned: activeRemainders(tree).length === 0,
cleanupNote: '公开 API 不允许删除已完成订单/柜次/PDA审计记录;本轮 marker 链路已全部完成且无活动余量。',
})
}