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

507 lines
34 KiB
JavaScript

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'
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
}
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)
}
function bookingPayload(marker, sequence, source) {
const suffix = `${marker}-${sequence}`.replace(/[^A-Za-z0-9]/g, '').slice(-18)
return {
customerId: source.tenantId, customerName: source.customerName,
channelCode: source.channelCode,
warehouseId: source.warehouseId, warehouseName: source.warehouseName,
shippingCompanyId: null, shippingCompanyName: `SPLIT-SHIPPING-${suffix}`.slice(0, 128),
bookingAgentId: null, bookingAgentName: `SPLIT-AGENT-${suffix}`.slice(0, 128),
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', remark: marker,
parties: [{ partyType: 'SHIPPER', partyName: `SPLIT-SHIPPER-${suffix}` },
{ partyType: 'CONSIGNEE', partyName: `SPLIT-CONSIGNEE-${suffix}` },
{ partyType: 'NOTIFY_PARTY', partyName: `SPLIT-NOTIFY-${suffix}` }],
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 100,
maxWeightKg: 100000, quantity: 1 }],
}
}
async function loadOneFragment(context, state) {
const { tenantId, rootOrderId, sourceOrderId, sourceOrderNo, warehouseId, sourcePackageCount, sequence } = state
const operationBase = `${context.marker}-chain-${sequence}`.slice(0, 100)
const simulationResponse = await request(context, 'POST', CONTAINERS, {
channelCode: state.channelCode, warehouseId, containerType: '40HQ', capacityCbm: 100, maxWeightKg: 100000,
})
const simulation = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
const containerId = id(simulation?.id)
if (!containerId) return null
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(containerId)
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 bookingSubmit = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/submit`, {
expectedVersion: Number(bookingCreated?.oceanBooking?.version), reason: context.marker,
}, { 'Idempotency-Key': `${operationBase}-booking-submit` })
const bookingSubmitted = context.apiSuccess(MODULE, `提交 ${sourceOrderNo} 的订舱`, bookingSubmit, '', OPTIONS)
const bookingConfirm = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/confirm`, {
expectedVersion: Number(bookingSubmitted?.oceanBooking?.version), reason: context.marker,
}, { 'Idempotency-Key': `${operationBase}-booking-confirm` })
const bookingConfirmed = context.apiSuccess(MODULE, `确认 ${sourceOrderNo} 的真实订舱`, bookingConfirm, '', OPTIONS)
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ')
if (!requirement?.id) return null
const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
oceanBookingId, requirementId: id(requirement.id), 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)
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)
const containerDetailResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
const containerDetail = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 的自动柜次`, containerDetailResponse, '', OPTIONS)
const realContainerNo = `SPLIT-${operationBase.replace(/[^A-Za-z0-9]/g, '').slice(-18)}`.slice(0, 64)
const transportResponse = await request(context, 'PATCH', `${CONTAINERS}/${encodeURIComponent(containerId)}/transport`, {
containerNo: realContainerNo, voyageNo: `V-${sequence}`,
version: Number(containerDetail?.version), reason: context.marker,
})
const transported = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 补充真实柜号并开放 PDA`, transportResponse, '', OPTIONS)
const transportStable = transported?.containerNo === realContainerNo && id(transported?.id) === containerId
&& transported?.cabinetNo === simulation.cabinetNo
context.assert(MODULE, `${sourceOrderNo} 真实柜号已绑定到稳定柜次`, transportResponse,
transportStable,
`containerBookingId=${containerId}, containerNo=${transported?.containerNo || 'missing'}, cabinetNo=${transported?.cabinetNo || 'missing'}`, OPTIONS)
if (!transportStable) return null
const bookingPath = `/api/platform/v1/ocean-bookings/${encodeURIComponent(oceanBookingId)}`
let bookingDetailResponse = await context.request('GET', bookingPath)
let bookingDetail = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 柜次所属真实订舱`, bookingDetailResponse, '', OPTIONS)
const allocationMatches = bookingDetail?.containerAllocations?.some?.((item) =>
id(item?.containerBookingId) === containerId)
context.assert(MODULE, `${sourceOrderNo} 订舱分配保持稳定柜次`, bookingDetailResponse,
id(bookingDetail?.oceanBooking?.id) === oceanBookingId && allocationMatches,
`oceanBookingId=${id(bookingDetail?.oceanBooking?.id) || 'missing'}, containerBookingId=${containerId}, allocations=${bookingDetail?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
if (!allocationMatches || id(bookingDetail?.oceanBooking?.id) !== oceanBookingId) return null
if (bookingDetail.oceanBooking.status === 'CONFIRMED') {
const releaseResponse = await request(context, 'POST', `${bookingPath}/release`, {
expectedVersion: Number(bookingDetail.oceanBooking.version),
billOfLadingNo: `BL-${operationBase}`.slice(0, 128),
vesselName: `SPLIT-VESSEL-${sequence}`.slice(0, 128), voyageNo: `SV-${sequence}`.slice(0, 64),
estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00: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)
}
const releasedAllocationMatches = bookingDetail?.containerAllocations?.some?.((item) =>
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
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
const taskResponse = await context.request('GET',
`${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,
Boolean(taskId) && id(task?.loadingPlanId) === planId && id(task?.containerBookingId) === containerId
&& 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`,
})
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
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: [],
}],
}
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)
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) {
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)
}
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)
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)
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)
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,
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,
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 链路已全部完成且无活动余量。',
})
}