From 0919a61661a0bde0c8e823a8661730215a587f65 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 14:27:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../phase3-loading-finalize.mjs | 2 +- .../phase3-order-splitting.mjs | 2 +- .../phase4-overseas-pda.mjs | 8 +- ...se5-overseas-fulfillment-contract.test.mjs | 76 +++- .../phase5-root-fulfillment.mjs | 342 ++++++++++++++++++ 5 files changed, 423 insertions(+), 7 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase5-root-fulfillment.mjs diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 2456d464c..c16d32705 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -107,7 +107,7 @@ async function requestPdf(context, path, body) { } } -async function verifyPalletLabels(context, fixture, loadingPlanId) { +export async function verifyPalletLabels(context, fixture, loadingPlanId) { if (fixture.destinationType !== 'FBA' || fixture.fulfillmentType === 'ONE_CLICK_DROPSHIP') return null const warehouseId = id(fixture.warehouseId) const base = `/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(loadingPlanId)}/pallet-labels` diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index 2e774186b..02ede02a6 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -176,7 +176,7 @@ function bookingPayload(marker, sequence, source, agent, carrier) { estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00' }) } -async function loadOneFragment(context, state) { +export 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', diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 146c65592..35a3a9994 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -278,7 +278,7 @@ export async function uploadPhotos(context, token) { return photos } -async function awaitProducerConsumption(context, producers) { +export async function awaitProducerConsumption(context, producers) { const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))] if (!expected.length) return true const consumed = new Set() @@ -308,7 +308,7 @@ async function awaitProducerConsumption(context, producers) { return expected.every((containerNo) => consumed.has(containerNo)) } -async function convertReceivingContainers(context, producers) { +export async function convertReceivingContainers(context, producers) { for (const producer of producers) { const containerNo = id(producer?.containerNo) const summaryResponse = await context.request('GET', `/api/platform/v1/last-leg/orders/container-summaries?page=1&size=20&containerNo=${encodeURIComponent(containerNo)}`, undefined, context.token, {}) @@ -646,7 +646,7 @@ async function receiveDropship(context, token, producer, inbounds) { return accepted === expectedPending } -async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true) { +export async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true) { if (runAuthorizationProbes && context.token) { const denied = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, context.token, {}) context.addStep(MODULE, '平台管理员访问令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) @@ -970,7 +970,7 @@ export async function run(context) { } } -async function resolveSharedWarehouse(context) { +export async function resolveSharedWarehouse(context) { const path = '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE' const response = await context.request('GET', path) const rows = records(response?.body?.data) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index 6c61baf67..fa5f6287d 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -1,5 +1,5 @@ import test from 'node:test' -import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, verifyDepartureReadiness, verifyPartialOrderCompletion, +import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion, completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer } from './phase5-overseas-fulfillment.mjs' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' @@ -476,3 +476,77 @@ test('source snapshot never treats unknown or incompletely proven raw 404 as zer const context = contextFor(() => { throw new Error('Missing fixed IDs cannot issue HTTP reads') }) assert.equal(await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'stock'), null) }) + + +function representativePalletConservationFixture() { + const quantities = [1, 3, 1, 3, 1] + const codes = ['IN-A', 'IN-A-OTHER', 'IN-B', 'IN-B-OTHER', 'IN-C'] + const references = quantities.map((_, index) => ({ balanceId: String(index + 1), positionId: String(index + 11) })) + const groups = packages => [{ id: 'GROUP', customerId: '100', overseasWarehouseId: 'WH-1', skuCode: 'MIXED', + totalPackages: packages, availablePackages: packages, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, + receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 }] + const snapshot = after => quantities.map((quantity, index) => { + const balanceId = String(index + 1) + const position = { id: String(index + 11), balanceId, customerId: '100', overseasWarehouseId: 'WH-1', + inboundCode: codes[index], palletNo: index < 2 ? 'P-A' : index < 4 ? 'P-B' : 'P-C', + totalPackages: after ? 0 : quantity, availablePackages: after ? 0 : quantity, + holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 } + const leftover = { ...position, id: '99', palletNo: 'UNSELECTED', totalPackages: 3, availablePackages: 3 } + const positions = index === 4 ? [position, leftover] : [position] + const exact = positions.filter(row => ['IN-A', 'IN-B', 'IN-C'].includes(row.inboundCode)) + const total = positions.reduce((sum, row) => sum + row.totalPackages, 0) + return { id: balanceId, totalPackages: exact.reduce((sum, row) => sum + row.totalPackages, 0), + balance: { id: balanceId, totalPackages: total }, positions, globalGroups: groups(after ? 3 : 12) } + }) + const ledger = quantities.map((quantity, index) => ({ id: String(index + 101), customerId: '100', + overseasWarehouseId: 'WH-1', businessType: 'SHIP', businessOrderNo: 'DS-1', outboundOrderNo: 'DS-1', + inboundCode: codes[index], palletNo: index < 2 ? 'P-A' : index < 4 ? 'P-B' : 'P-C', + beforePackages: quantity, deltaPackages: -quantity, afterPackages: 0 })) + return { customerId: '100', warehouseId: 'WH-1', dispatchNo: 'DS-1', expectedPackages: 9, + sourcePositionBalances: references, stockBefore: snapshot(false), stockAfter: snapshot(true), + ledgerBefore: [], ledgerAfter: ledger } +} + +test('G05 MIN inbound-code display does not filter real same-pallet sources: positions9→0/raw12→3/groups12→3/SHIP-9', () => { + const fixture = representativePalletConservationFixture() + const context = contextFor(() => { throw new Error('Offline arithmetic cannot issue HTTP') }) + context.createdResources.phase5OverseasFulfillment = { inventoryProof: {} } + assert.equal(fixture.stockBefore.reduce((sum, row) => sum + row.totalPackages, 0), 6) + assert.equal(fixture.stockAfter.reduce((sum, row) => sum + row.totalPackages, 0), 3) + const result = dispatchStockConservation(context, fixture) + assert.equal(result.passed, true) + assert.deepEqual(result.proof.totals, { positionBefore: 9, positionAfter: 0, rawBefore: 12, rawAfter: 3, + groupBefore: 12, groupAfter: 3, ledgerShipped: 9, expectedPackages: 9 }) + assert.equal(result.proof.ledgerRowChecks.length, 5) + assert.deepEqual(context.assertions.filter(row => !row.passed), []) + const saved = context.createdResources.phase5OverseasFulfillment.inventoryProof + assert.deepEqual(saved.snapshots.beforePick, fixture.stockBefore) + assert.deepEqual(saved.ledgers.afterPick, fixture.ledgerAfter) + assert.ok(JSON.stringify(saved).includes('IN-A-OTHER')) + assert.doesNotMatch(JSON.stringify(saved), /password|accessToken|Bearer|base64/) +}) + +test('G05 failures still independently review and preserve raw, group, each SHIP quantity/source, and unselected positions', () => { + for (const mutate of [ + fixture => { fixture.stockAfter[0].positions[0].id = 'WRONG-POSITION' }, + fixture => { fixture.stockAfter[0].balance.totalPackages = 1 }, + fixture => { for (const row of fixture.stockAfter) row.globalGroups[0].totalPackages = 4 }, + fixture => { fixture.ledgerAfter[0].deltaPackages = -2 }, + fixture => { fixture.ledgerAfter[0].customerId = 'OTHER' }, + fixture => { fixture.ledgerAfter[0].overseasWarehouseId = 'OTHER' }, + fixture => { fixture.ledgerAfter[0].outboundOrderNo = 'OTHER' }, + fixture => { fixture.ledgerAfter[0].palletNo = 'UNSELECTED' }, + fixture => { fixture.stockAfter[4].positions[1].availablePackages = 2 }, + ]) { + const fixture = representativePalletConservationFixture(); mutate(fixture) + const context = contextFor(() => { throw new Error('Offline only') }) + context.createdResources.phase5OverseasFulfillment = { inventoryProof: {} } + const result = dispatchStockConservation(context, fixture) + assert.equal(result.passed, false) + assert.equal(context.assertions.length, 9) + assert.equal(Object.keys(result.proof.checks).length, 8) + assert.equal(result.proof.ledgerRowChecks.length, 5) + assert.deepEqual(context.createdResources.phase5OverseasFulfillment.inventoryProof.snapshots.afterPick, fixture.stockAfter) + assert.deepEqual(context.createdResources.phase5OverseasFulfillment.inventoryProof.ledgers.afterPick, fixture.ledgerAfter) + } +}) diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs new file mode 100644 index 000000000..1b7848ab2 --- /dev/null +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs @@ -0,0 +1,342 @@ +import { loadOneFragment } from './phase3-order-splitting.mjs' +import { verifyPalletLabels } from './phase3-loading-finalize.mjs' +import { stableLoadedCartonMarks } from './phase3-loading.mjs' +import { awaitProducerConsumption, convertReceivingContainers, runReceiving, resolveSharedWarehouse, + resolvePickerAccess, createPicker, disablePicker } from './phase4-overseas-pda.mjs' +import { pickAll, verifyDepartureReadiness, saveReceiptAndComplete } from './phase5-overseas-fulfillment.mjs' +import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs' + +const MODULE = 'phase5-root-fulfillment' +const OPTIONS = { scenario: 'f40-real-root-and-remainder-signatures', deploymentBlocker: true } +const DISPATCH = '/api/platform/v1/last-leg/dispatches' +const PDA = '/api/pda/v1/last-leg/dispatches' +const id = value => value == null ? '' : String(value) +const success = response => response?.http === 200 && String(response?.body?.code) === '200' +const query = (path, values) => `${path}?${new URLSearchParams(values)}` + +export function fulfillmentHistorySnapshot(detail) { + return { + orderId: id(detail?.order?.id), rootOrderId: id(detail?.order?.rootOrderId), + parentOrderId: id(detail?.order?.parentOrderId), role: detail?.order?.orderRole, + status: detail?.order?.status, version: Number(detail?.order?.version), + inboundCode: detail?.order?.inboundCode, orderNo: detail?.order?.orderNo, + rawOrder: detail?.order, + histories: (detail?.statusHistory || []).map(row => ({ ...row, id: id(row.id) })), + } +} +export function validTwoFragmentTree(tree, rootOrderId, childOrderId) { + const rows = Array.isArray(tree?.children) ? tree.children : [] + const loaded = rows.filter(row => row.splitRole === 'LOADED') + const remainder = rows.filter(row => row.splitRole === 'REMAINDER') + return id(tree?.parentOrderId) === id(rootOrderId) && Number(tree.originalPackageCount) === 2 + && Number(tree.cumulativeLoadedPackageCount) === 2 && Number(tree.currentRemainderPackageCount) === 0 + && rows.length === 3 && loaded.length === 2 && remainder.length === 1 + && new Set(loaded.map(row => id(row.sourceOrderId))).size === 2 + && loaded.every(row => id(row.orderId) === id(row.sourceOrderId) && Number(row.packageCount) === 1) + && loaded.some(row => id(row.sourceOrderId) === id(rootOrderId)) + && loaded.some(row => id(row.sourceOrderId) === id(childOrderId)) + && id(remainder[0]?.orderId) === id(childOrderId) && id(remainder[0]?.sourceOrderId) === id(rootOrderId) + && Number(remainder[0]?.packageCount) === 1 +} +export function completionCounts(snapshot) { + return { + own: (snapshot?.histories || []).filter(row => row.action === 'OVERSEAS_DELIVERED').length, + final: (snapshot?.histories || []).filter(row => row.action === 'FULFILLMENT_COMPLETED').length, + } +} +export function validSignatureGate(snapshot, firstSource, allSigned = false) { + const root = completionCounts(snapshot?.root), child = completionCounts(snapshot?.child) + if (allSigned) return snapshot?.root?.status === 'COMPLETED' && snapshot?.child?.status === 'COMPLETED' + && root.own === 1 && root.final === 1 && child.own === 1 && child.final === 0 + && snapshot?.rootInbound?.processStatus === 'DELIVERED' + && snapshot?.rootInbound?.orderStatusSnapshot === 'COMPLETED' + if (firstSource === 'CHILD') return snapshot?.root?.status !== 'COMPLETED' + && snapshot?.child?.status === 'COMPLETED' && root.own === 0 && root.final === 0 + && child.own === 1 && snapshot?.rootInbound?.orderStatusSnapshot !== 'COMPLETED' + return firstSource === 'MASTER' && snapshot?.root?.status !== 'COMPLETED' + && snapshot?.child?.status !== 'COMPLETED' && root.own === 1 && root.final === 0 && child.own === 0 + && snapshot?.rootInbound?.processStatus === 'DELIVERED' + && snapshot?.rootInbound?.orderStatusSnapshot !== 'COMPLETED' +} +export function exactCompletionOutboxProof(evidence, customerId, rootOrderId, sourceOrderIds) { + const expected = [...new Set(sourceOrderIds.map(id))].sort() + const rows = Array.isArray(evidence?.orders) ? evidence.orders : [] + return evidence?.readOnly === true && id(evidence.customerId) === id(customerId) + && id(evidence.rootOrderId) === id(rootOrderId) + && rows.length === expected.length && JSON.stringify(rows.map(row => id(row.orderId)).sort()) === JSON.stringify(expected) + && rows.every(row => id(row.rootOrderId) === id(rootOrderId) && id(row.customerId) === id(customerId) + && row.eventType === 'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED' + && Number(row.count) === 1 && Number(row.publishedCount) === 1 && Number(row.pendingCount) === 0 + && Number(row.failedCount) === 0) +} +function check(context, label, condition, evidence, response = null) { + context.assert(MODULE, label, response, condition, evidence, OPTIONS) + if (!condition) throw new Error(`${MODULE}: ${label}: ${evidence}`) +} +async function api(context, method, label, path, body, token = context.token, headers = {}) { + const response = await context.request(method, path, body, token, headers) + const data = context.apiSuccess(MODULE, label, response, '', OPTIONS) + if (!success(response)) throw new Error(`${MODULE}: ${label}: ${response?.body?.code || response?.http}`) + return data +} +async function stage(context, name) { + const mod = await import(`./${name}.mjs`) + const before = context.steps?.length || 0 + await mod.run(context) + const failures = (context.steps || []).slice(before).filter(step => step.passed === false) + check(context, `F40 ${name} 正式前置没有失败`, failures.length === 0, `failed=${failures.length}`) +} +async function state(context, customerId, rootOrderId, childOrderId, rootInboundId) { + const root = fulfillmentHistorySnapshot(await api(context, 'GET', '读取根订单原始状态与完整历史', + query(`/api/platform/v1/orders/first-leg/${rootOrderId}`, { tenantId: customerId }))) + const child = childOrderId ? fulfillmentHistorySnapshot(await api(context, 'GET', '读取真实余量子订单原始状态与历史', + query(`/api/platform/v1/orders/first-leg/${childOrderId}`, { tenantId: customerId }))) : null + const inbound = rootInboundId ? await api(context, 'GET', '读取根来源 Firstleg 入仓摘要', + query(`/api/platform/inbounds/${rootInboundId}`, { tenantId: customerId })) : null + return { root, child, rootInbound: inbound?.summary || null } +} +async function waitState(read, predicate, attempts = 90) { + let last + for (let index = 0; index < attempts; index++) { + last = await read() + if (predicate(last)) return last + if (index + 1 < attempts) await new Promise(resolve => setTimeout(resolve, 1000)) + } + return last +} +async function domesticLogin(context) { + if (context.pdaToken) return + const username = context.options.pdaUsername, password = context.options.pdaPassword + check(context, 'F40 显式国内 PDA 账号已配置', Boolean(username && password), 'credentialsPresent=true/false') + const login = await api(context, 'POST', '登录正式国内 PDA 会话', '/api/auth/login', { loginId: username, password }, '') + context.pdaToken = login.accessToken + check(context, 'F40 国内 PDA 会话有效', Boolean(context.pdaToken), 'tokenPresent=true/false') +} +async function fragment(context, source, rootOrderId, sequence) { + const summary = source.summary + const result = await loadOneFragment(context, { + tenantId: id(summary.tenantId), rootOrderId, sourceOrderId: id(summary.orderId), + sourceOrderNo: summary.orderNo, customerName: summary.customerName, + channelCode: summary.channelCode, channelName: summary.channelName, + warehouseId: id(summary.warehouseId), warehouseName: summary.warehouseName, + sourcePackageCount: sequence === 0 ? 2 : 1, sequence, + }) + check(context, 'F40 一箱来源通过真实 PDA 完成装柜', Boolean(result?.taskId && result?.sourceInboundId), `sequence=${sequence}`) + const current = await api(context, 'GET', '回查该片真实入仓来源', + query(`/api/platform/inbounds/${result.sourceInboundId}`, { tenantId: id(summary.tenantId) })) + const container = await api(context, 'GET', '回查该片实际柜次身份', `/api/platform/v1/containers/${result.containerId}`) + const task = await api(context, 'GET', '回查该片真实完成 PDA 任务', + query(`/api/platform/v1/first-leg/pda-loading/tasks/${result.taskId}`, { warehouseId: id(summary.warehouseId) })) + check(context, 'F40 来源单实际 LOADED 且 PDA 仅实装一箱', current?.summary?.processStatus === 'LOADED' + && task?.taskStatus === 'COMPLETED' && task.items.reduce((sum, item) => sum + Number(item.pickedPackageCount), 0) === 1, + `source=${current?.summary?.inboundCode}`) + const loading = { + marker: context.marker, tenantId: id(summary.tenantId), orderId: id(summary.orderId), + inboundId: result.sourceInboundId, inboundCode: current.summary.inboundCode, + oceanBookingId: result.oceanBookingId, containerBookingId: result.containerId, + loadingPlanId: result.planId, canonicalPdaTaskId: result.taskId, pickingTaskId: result.taskId, + warehouseId: id(summary.warehouseId), cabinetNo: container.cabinetNo, containerNo: container.containerNo, + destinationType: 'FBA', fulfillmentType: 'STANDARD', destinationWarehouseId: id(current.summary.destinationWarehouseId), + destinationWarehouseCode: current.summary.destinationCode, cartonMarks: stableLoadedCartonMarks(task.items), + finalStatus: 'LOADED', inboundStatus: 'LOADED', finalizeSucceeded: true, + } + context.createdResources.phase3Loading = loading + context.createdResources.orders = { ...(context.createdResources.orders || {}), ...loading, + taxMode: 'BUY_DECLARATION' } + const palletLabelManifest = await verifyPalletLabels(context, loading, result.planId) + check(context, 'F40 来源柜散货托盘码来自真实准备接口', Boolean(palletLabelManifest?.totalCount), `sequence=${sequence}`) + for (const name of ['phase3-customs', 'service-center-flow', 'phase3-port-schedules', + 'phase3-outbound-dispatch', 'phase3-container-documents', 'phase3-carrier-schedule-queries']) await stage(context, name) + const nodes = await import('./phase4-carrier-nodes.mjs') + await nodes.runUntilPickupReady(context) + const pickup = context.createdResources.phase4CarrierPickupReady + check(context, 'F40 来源柜实际节点达到 PICKUP_READY', pickup?.ready === true, `queryId=${pickup?.queryId || 'missing'}`) + return { ...loading, sourceOrderId: loading.orderId, sourceInboundId: loading.inboundId, + sourceStatus: 'LOADED', resourceKey: `${context.marker}:FBA:${loading.containerNo}`, + shippingReady: true, carrierScheduleQueryId: pickup.queryId, carrierLatestActualAt: pickup.latestActualAt, + palletLabelManifest, rootOrderId } +} +async function sendFragment(context, producer, picker, merchantToken, index) { + const customerId = producer.tenantId + const candidates = await api(context, 'GET', '读取该来源入仓码实收排车候选', `${DISPATCH}/candidates?sourceStage=RECEIVED`) + const matches = (Array.isArray(candidates) ? candidates : []).filter(row => row.sourceStage === 'RECEIVED' + && row.inboundCode === producer.inboundCode && id(row.overseasWarehouseId) === id(picker.warehouseId)) + check(context, 'F40 只选择该根履约片的真实实收托盘', matches.length > 0 + && matches.every(row => Number(row.packageCount) > 0), `inboundCode=${producer.inboundCode}, pallets=${matches.length}`) + const dispatch = await api(context, 'POST', '该履约片创建独立真实排车', `${DISPATCH}/pre-arrange`, { + planPalletIds: matches.map(row => id(row.planPalletId)), + }) + const dispatchNo = dispatch.dispatchNo + await api(context, 'PUT', '补齐该片车辆司机', `${DISPATCH}/${dispatchNo}`, { + vehiclePlate: `F40-${index}-${context.marker.slice(-6)}`.slice(0,64), driverName: 'F40验收司机', + driverPhone: '19900000000', carrierName: 'F40测试车队', + estimatedArrivalAt: new Date(Date.now()+86400000).toISOString().slice(0,19), + }) + const transfer = await api(context, 'POST', '客户正式创建该片派送单', + query('/api/merchant/v1/last-leg/delivery-orders', { dispatchNo }), + { idempotencyKey: `${context.marker}-F40-TRANSFER-${index}`, remark: context.marker }, merchantToken) + const deliveries = Array.isArray(transfer.deliveries) ? transfer.deliveries : [] + check(context, 'F40 同目的仓一片对应唯一派送单', deliveries.length === 1 && Boolean(deliveries[0]?.deliveryNo), `count=${deliveries.length}`) + const deliveryPath = query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}`, { customerId }) + const detail = await api(context, 'GET', '核对派送实际 SKU 仅来自本片入仓码', deliveryPath) + check(context, 'F40 派送实际物品来源和一箱数量严格匹配', detail.order?.customerId === customerId + && detail.skus?.length > 0 && detail.skus.every(row => row.inboundCode === producer.inboundCode) + && detail.skus.reduce((sum,row) => sum + Number(row.packageCount),0) === 1, `source=${producer.inboundCode}`) + context.marker = `${context.context.f40BaseMarker}-SEND-${index}` + check(context, 'F40 正式 PDA 拣货完成', await pickAll(context, picker.token, dispatchNo), `dispatch=${dispatchNo}`) + check(context, 'F40 发车前置就绪', await verifyDepartureReadiness(context, picker.token, dispatchNo), `dispatch=${dispatchNo}`) + const departed = await api(context, 'POST', 'F40 正式 PDA 发车', `${PDA}/${dispatchNo}/depart`, + { idempotencyKey: `${context.marker}-DEPART`, remark: context.marker }, picker.token) + check(context, 'F40 正式发车已 DEPARTED', departed.status === 'DEPARTED', `dispatch=${dispatchNo}`) + const outbound = await api(context, 'GET', '回查实际来源出库完成', + query(`/api/platform/v1/last-leg/outbound-orders/${transfer.outboundNo}`, { page:'1',size:'20' })) + check(context,'F40 实际出库单 OUTBOUNDED 且来源入仓码匹配', outbound.outbound?.status === 'OUTBOUNDED' + && outbound.pallets?.length > 0 && outbound.pallets.every(row => row.inboundCode === producer.inboundCode), + `outbound=${transfer.outboundNo}`) + const beforeDelivery = await api(context, 'GET', '读取本片派送推进前版本', deliveryPath) + const delivering = await api(context,'PUT','实际派送推进配送中', + query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}/status`,{customerId}), + {status:'DELIVERING',expectedVersion:Number(beforeDelivery.order.version),remark:context.marker}) + check(context,'F40 派送处于配送中后保存签收',delivering.deliveryStatus === 'DELIVERING',`delivery=${deliveries[0].deliveryNo}`) + const completed = await saveReceiptAndComplete(context, deliveries[0], customerId, index) + check(context, 'F40 实际签收资料驱动派送完成', completed?.order?.status === 'DELIVERED', `delivery=${deliveries[0].deliveryNo}`) + return { producer, dispatchNo, outboundNo: transfer.outboundNo, deliveryNo: deliveries[0].deliveryNo, + receiptVersion: completed.order.version } +} + +/** Explicit TEST orchestrator, called by the parent acceptance context; never synthesizes an event. */ +export async function run(context) { + const proof = context.createdResources.phase5RootFulfillment = { + marker: context.marker, rootOrderId: null, childOrderId: null, customerId: null, + fragments: [], signatures: [], snapshots: [], finalStatus: 'PREPARING', outboxProof: null, + cleanup: 'Fresh completed root/child/cabinet/PDA/receipt audit resources remain as acceptance evidence', + } + const signatureOrder = context.context?.f40SignatureOrder || 'ROOT_FIRST' + check(context,'F40 签收次序为显式正式来源顺序', ['ROOT_FIRST','CHILD_FIRST'].includes(signatureOrder), signatureOrder) + proof.signatureOrder = signatureOrder + const scope = Object.create(context) + scope.marker = `${context.marker}-F40` + scope.context = { ...context.context, orderDestinationType: 'FBA', orderFulfillmentType: 'STANDARD', + orderProfile: { taxMode: 'BUY_DECLARATION', fulfillmentType: 'STANDARD' }, + waitSplitVariant: { skuCount: 1, packageCountPerSku: 2, packageCounts: [2] }, + allowAnyExistingPallet: true, f40BaseMarker: `${context.marker}-F40` } + scope.createdResources = { phase3OrderSplitting: { retainedAuditRecordIds: [] } } + await domesticLogin(scope) + await stage(scope, 'orders') + const orderFixture = { ...scope.createdResources.orders } + await stage(scope, 'inbound-measurement') + const rootOrderId = id(orderFixture.orderId), customerId = id(orderFixture.tenantId) + proof.rootOrderId = rootOrderId; proof.customerId = customerId + const rootInbound = await api(scope, 'GET', '读取两箱根真实 WAIT_SPLIT 来源', + query(`/api/platform/inbounds/${orderFixture.inboundId}`, { tenantId: customerId })) + check(scope, 'F40 根实物数量确为两箱且可分柜', rootInbound.summary?.processStatus === 'WAIT_SPLIT' + && Number(scope.createdResources.inboundMeasurement.actualPackageCount) === 2, `root=${rootOrderId}`) + const first = await fragment(scope, rootInbound, rootOrderId, 0) + proof.fragments.push(first) + const tree = await api(scope, 'GET', '首柜 PDA 完成后读取正式余量树', + query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`, { tenantId: customerId })) + const remainder = tree.children?.filter(row => row.splitRole === 'REMAINDER') || [] + check(scope, 'F40 首柜仅生成一个真实一箱余量子单', remainder.length === 1 + && Number(remainder[0].packageCount) === 1 && Number(tree.currentRemainderPackageCount) === 1, `children=${remainder.length}`) + const childOrderId = id(remainder[0].orderId) + proof.childOrderId = childOrderId + const childDetail = await api(scope, 'GET', '读取实际余量订单身份', + query(`/api/platform/v1/orders/first-leg/${childOrderId}`, { tenantId: customerId })) + check(scope, 'F40 子订单根父身份完整一致', childDetail.order?.orderRole === 'CHILD' + && id(childDetail.order.rootOrderId) === rootOrderId && id(childDetail.order.parentOrderId) === rootOrderId, + `root=${rootOrderId}, child=${childOrderId}`) + const childCandidates = await api(scope, 'GET', '从公开入仓列表定位真实子入仓单', + query('/api/platform/inbounds', { tenantId: customerId, inboundCode: childDetail.order.inboundCode, page:'1',size:'20' })) + const childRows = (childCandidates.records || []).filter(row => id(row.orderId) === childOrderId + && row.inboundCode === childDetail.order.inboundCode) + check(scope, 'F40 子单有唯一独立真实入仓来源', childRows.length === 1, `sources=${childRows.length}`) + const childInbound = await api(scope, 'GET', '读取子单可装柜来源', + query(`/api/platform/inbounds/${childRows[0].id}`, { tenantId: customerId })) + scope.marker = `${scope.context.f40BaseMarker}-CHILD` + const second = await fragment(scope, childInbound, rootOrderId, 1) + proof.fragments.push(second) + const finalTree = await api(scope, 'GET', '两柜装完后核对数量守恒履约树', + query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`, { tenantId: customerId })) + check(scope, 'F40 真实同根两个一箱来源和柜次数量守恒', validTwoFragmentTree(finalTree, rootOrderId, childOrderId) + && first.containerBookingId !== second.containerBookingId, `root=${rootOrderId}, child=${childOrderId}`) + const before = await state(scope, customerId, rootOrderId, childOrderId, first.inboundId) + check(scope, 'F40 尚未签收两片不得出现完成事实', before.root.role === 'MASTER' + && id(before.root.rootOrderId) === rootOrderId && before.root.status !== 'COMPLETED' + && before.child.status !== 'COMPLETED' && completionCounts(before.root).own === 0, 'before-signatures') + proof.snapshots.push({ phase:'BEFORE_SIGNATURES', ...before }) + const warehouse = await resolveSharedWarehouse(scope), access = await resolvePickerAccess(scope) + check(scope, 'F40 实际海外接柜仓和PDA权限有效', Boolean(warehouse?.id && access), 'resourcePresent') + scope.marker = `${scope.context.f40BaseMarker}-PDA` + const picker = await createPicker(scope, 7, warehouse.id, access) + check(scope, 'F40 海外PDA操作员会话有效', Boolean(picker?.token), 'tokenPresent') + picker.warehouseId = warehouse.id + try { + const loginId = scope.options.merchantUsername || process.env.MERCHANT_TEST_USERNAME + const password = scope.options.merchantPassword || process.env.MERCHANT_TEST_PASSWORD + const login = await api(scope, 'POST', 'F40 客户正式登录', '/api/auth/login', { loginId, password }, '') + const profile = await api(scope, 'GET', 'F40 客户会话归属核验', '/api/merchant/v1/iam/tenant/profile', undefined, login.accessToken) + check(scope, 'F40 客户会话属于该根订单客户', id(profile.id) === customerId, 'customerOwned') + scope.createdResources.phase4InboundProducerFixtures = { marker: scope.marker, containers:[first,second] } + scope.createdResources.phase4OverseasPda = { marker:scope.marker, generatedPalletsByInbound:{}, + palletIds:[],locationIds:[],completedContainerFacts:[], completedContainers:[], + overseasWarehouseId:warehouse.id, overseasWarehouseName:warehouse.name, customerId, finalStatus:null } + check(scope, 'F40 两柜真实装柜事件已消费', await awaitProducerConsumption(scope,[first,second]), 'two-producers-consumed') + check(scope, 'F40 两柜真实计划及托盘转换成功', await convertReceivingContainers(scope,[first,second]), 'two-containers-converted') + const signatureSources = signatureOrder === 'CHILD_FIRST' ? [second,first] : [first,second] + for (const [index, producer] of signatureSources.entries()) { + scope.marker = `${scope.context.f40BaseMarker}-RECEIVE-${index}` + scope.createdResources.phase4InboundProducerFixtures.marker = scope.marker + scope.createdResources.phase4OverseasPda.marker = scope.marker + scope.createdResources.phase4OverseasPda.finalStatus = null + scope.pdaContainerSuffix = producer.containerNo + check(scope, 'F40 码头正式提柜前置完成', await prepareTerminalDrayage(scope, producer), `container=${producer.containerNo}`) + await runReceiving(scope,picker.token,null,null,producer,false) + check(scope, 'F40 来源柜 PDA 卸柜实收完成', scope.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED' + && scope.createdResources.phase4OverseasPda.completedContainerFacts.some(row => + id(row.containerBookingId) === id(producer.containerBookingId) && row.containerNo === producer.containerNo), + `container=${producer.containerNo}`) + const signed = await sendFragment(scope,producer,picker,login.accessToken,index) + proof.signatures.push(signed) + const observed = await waitState(() => state(scope,customerId,rootOrderId,childOrderId,first.inboundId), + value => validSignatureGate(value, signatureOrder === 'CHILD_FIRST' ? 'CHILD' : 'MASTER', index === 1)) + proof.snapshots.push({ phase:index === 0 ? 'ONE_SIGNED_ROOT_PENDING' : 'ALL_SIGNED', ...observed }) + check(scope,index === 0 ? 'F40 第一片签收根仍未完成' : 'F40 最后一片签收才完成根及来源摘要且各历史只一次', + validSignatureGate(observed, signatureOrder === 'CHILD_FIRST' ? 'CHILD' : 'MASTER', index === 1), signatureOrder) + } + const stable = await state(scope,customerId,rootOrderId,childOrderId,first.inboundId) + // Existing receipt helper replays the exact original save command before each signature observation. + // Re-saving completed receipts with a fresh business key is also a no-transition operation. + for (const [index,signed] of proof.signatures.entries()) { + scope.marker = `${scope.context.f40BaseMarker}-REPLAY-${index}` + const current = await api(scope,'GET','读取已完成签收文件绑定版本', + query(`/api/platform/v1/last-leg/delivery-orders/${signed.deliveryNo}/documents`,{customerId})) + const documents = (current.categories || []).filter(row => row.files?.length).map(row => ({category:row.category, + fileIds:row.files.map(file => id(file.fileId))})) + await api(scope,'PUT','重复已完成派送文件保存不得重发签收事实', + query(`/api/platform/v1/last-leg/delivery-orders/${signed.deliveryNo}/documents`,{customerId}), + {expectedVersion:Number(current.version),idempotencyKey:`${scope.marker}-SAVE`,bolNo:current.bolNo || null,documents}) + } + const afterReplay = await state(scope,customerId,rootOrderId,childOrderId,first.inboundId) + check(scope,'F40 已完成签收重放根子订单状态版本和历史零新增', + JSON.stringify(stable) === JSON.stringify(afterReplay),'exact-root-child-inbound-snapshot') + proof.snapshots.push({phase:'AFTER_RECEIPT_REPLAY',...afterReplay}) + const reader = context.context?.f40ReadOnlyOutboxEvidence + if (typeof reader === 'function') { + proof.outboxProof = await reader({customerId, rootOrderId, sourceOrderIds:[rootOrderId,childOrderId], + eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED'}) + check(scope,'F40 每个实际来源完成Outbox唯一并已发布', + exactCompletionOutboxProof(proof.outboxProof,customerId,rootOrderId,[rootOrderId,childOrderId]),'exact-root/source/customer/readOnly-proof') + proof.finalStatus='VERIFIED' + } else { + proof.finalStatus='HTTP_VERIFIED_OUTBOX_PENDING' + context.addCoverageGap(MODULE,OPTIONS.scenario,'F40 完成Outbox唯一发布只读证据', + '原始Order状态与历史已验证;仍需对本轮客户/根/子 exact source 完成Outbox做只读计数', + 'missing-outbox-evidence',{deploymentBlocker:true}) + context.addStep(MODULE,'F40 真实完成Outbox唯一发布证据',null,false,'readonly evidence reader missing',OPTIONS) + } + } catch (error) { + proof.finalStatus = 'FAILED' + throw error + } finally { await disablePicker(scope,picker,7) } + return proof +}