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' 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 } 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 } } } async function ensureCompletionEvidence(context, tenantId, inboundId, keyBase) { 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]) for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) { const uploadPath = `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}?evidenceType=${encodeURIComponent(evidenceType)}` const form = new FormData() form.append('file', new Blob([png], { type: 'image/png' }), `${keyBase}-${evidenceType}.png`) const rawUpload = await fetch(`${context.options.gateway}${uploadPath}`, { method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, body: form, signal: AbortSignal.timeout(60_000), }) const uploadBody = await rawUpload.json() const upload = { method: 'POST', path: uploadPath, http: rawUpload.status, body: uploadBody, requestId: uploadBody?.requestId || rawUpload.headers.get('x-request-id'), traceId: uploadBody?.traceId || rawUpload.headers.get('x-trace-id') } const file = context.apiSuccess(MODULE, `${keyBase} 上传 ${evidenceType} 装柜证据`, upload, '', OPTIONS) const fileId = id(file?.id || file?.fileId) if (!fileId) return false const bind = await request(context, 'POST', `/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, { expectedVersion: 0, evidenceType, fileId, sealNo: evidenceType === 'SEAL' ? `SEAL-${keyBase}`.slice(0, 64) : null, remark: context.marker, }, { 'Idempotency-Key': `${keyBase}-evidence-${evidenceType}`.slice(0, 128) }) const bound = context.apiSuccess(MODULE, `${keyBase} 绑定 ${evidenceType} 装柜证据`, bind, '', OPTIONS) if (!bound) return false } return true } 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, agent) { const suffix = `${marker}-${sequence}`.replace(/[^A-Za-z0-9]/g, '').slice(-18) return oceanBookingDraft({ marker }, suffix, { customerId: source.tenantId, customerName: source.customerName, channelCode: source.channelCode, warehouseId: source.warehouseId, warehouseName: source.warehouseName, }, { vesselName: `SPLIT-VESSEL-${suffix}`.slice(0, 128), voyageNo: `SV-${sequence}`.slice(0, 64), shippingCompanyId: id(agent.id), shippingCompanyName: agent.name, bookingAgentId: id(agent.id), bookingAgentName: agent.name, 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 const operationBase = `${context.marker}-chain-${sequence}`.slice(0, 100) const agentResponse = await context.request('GET', '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20') const agentPage = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 读取 ACTIVE 订舱代理`, agentResponse, '', OPTIONS) const agent = agentPage?.records?.find?.((item) => item?.id && item?.name) if (!agent) return null const simulationResponse = await request(context, 'POST', CONTAINERS, { 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) const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', { expectedVersion: 0, data: bookingPayload(context.marker, sequence, state, agent), }, { '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 releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/release`, { expectedVersion: Number(bookingCreated?.oceanBooking?.version), reason: context.marker, }, { 'Idempotency-Key': `${operationBase}-booking-release`.slice(0, 128) }) const bookingConfirmed = context.apiSuccess(MODULE, `放舱 ${sourceOrderNo} 的订舱`, releaseResponse, '', OPTIONS) context.assert(MODULE, `${sourceOrderNo} 订舱为 RELEASED 且不预生成真实柜`, releaseResponse, bookingConfirmed?.oceanBooking?.status === 'RELEASED' && (!bookingConfirmed?.containers || bookingConfirmed.containers.length === 0), `status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, containers=${bookingConfirmed?.containers?.length ?? 0}`, OPTIONS) const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HC') const realContainerNo = `SPLIT-${context.marker.replace(/\D/g, '').slice(-10)}-${sequence}`.slice(0, 64) if (!requirement?.id) return null const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, { oceanBookingId, requirementId: id(requirement.id), containerNo: realContainerNo, 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 transportStable = linked?.containerNo === realContainerNo && id(linked?.id) === containerId && linked?.cabinetNo === simulation.cabinetNo context.assert(MODULE, `${sourceOrderNo} 合并后真实柜号落在稳定模拟柜身份`, linkResponse, transportStable, `containerBookingId=${containerId}, containerNo=${linked?.containerNo || 'missing'}, cabinetNo=${linked?.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?.containers?.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}, containers=${bookingDetail?.containers?.length ?? 'n/a'}`, OPTIONS) if (!allocationMatches || id(bookingDetail?.oceanBooking?.id) !== oceanBookingId) return null const createdTransport = bookingTransportSnapshot(bookingConfirmed?.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 const releasedAllocationMatches = bookingDetail?.containers?.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 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', `${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, taskId === confirmedTaskId && 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 labelResponse = await context.requestBinary('POST', `/api/platform/inbounds/${encodeURIComponent(id(candidate.inboundId))}/carton-label-pdf?tenantId=${encodeURIComponent(tenantId)}`) context.assert(MODULE, `${sourceOrderNo} 进入 LOADING 前生成可交接箱唛池`, labelResponse, labelResponse.http === 200 && String(labelResponse.contentType || '').toLowerCase().includes('pdf') && Number(labelResponse.bytes?.length) > 0, `HTTP=${labelResponse.http}, contentType=${labelResponse.contentType || 'missing'}, bytes=${labelResponse.bytes?.length ?? 0}`, OPTIONS) if (labelResponse.http !== 200 || !Number(labelResponse.bytes?.length)) return null 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) } if (!await ensureCompletionEvidence(context, tenantId, id(candidate.inboundId), operationBase)) return null 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) const initialSourceRows = Array.isArray(initialSliceRows) ? initialSliceRows.filter((row) => id(row.sourceOrderId) === orderId) : [] context.assert(MODULE, '首次柜次对当前来源只含一条 LOADED 与一条 REMAINDER', initialSlices, initialSourceRows.length === 2 && initialSourceRows.some((row) => row.splitRole === 'LOADED' && id(row.childOrderId) === id(row.sourceOrderId)) && initialSourceRows.some((row) => row.splitRole === 'REMAINDER' && id(row.childOrderId) !== id(row.sourceOrderId)), `sourceRows=${initialSourceRows.length}, containerRows=${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 链路已全部完成且无活动余量。', }) }