This commit is contained in:
2026-09-30 15:16:39 +08:00
parent 0919a61661
commit 22584e7fde
15 changed files with 994 additions and 45 deletions
@@ -14,6 +14,40 @@ 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 f40LocalDateTime(value) { return value.toISOString().slice(0,19) }
export function resolveF40MerchantCredentials(context) {
const fixture = context.context?.merchantQualificationFixture
if (!fixture) {
const source = context.options
if (!source?.merchantUsername || !source?.merchantPassword) return null
return { username:source.merchantUsername, password:source.merchantPassword, customerId:null }
}
if (!fixture.username || !fixture.password || !id(fixture.tenantId)) return null
return { username:fixture.username, password:fixture.password, customerId:id(fixture.tenantId) }
}
export function f40TransferBody(marker, index, vehicle, now = new Date()) {
return {
idempotencyKey:`${marker}-F40-TRANSFER-${index}`,
plannedDeliveryAt:f40LocalDateTime(new Date(now.getTime()+172800000)),
deliveryMethod:'TRUCK_PALLET', waybillNo:`${marker}-WAYBILL-${index}`.slice(0,64),
carrierName:vehicle.carrierName, pickupMethod:'WAREHOUSE_PICKUP', loadingType:'PALLET',
vehicleType:'TRUCK', vehicleSize:'15托', driverName:vehicle.driverName,
driverPhone:vehicle.driverPhone, vehiclePlate:vehicle.vehiclePlate,
appointmentAccount:'SELF', sellerName:null, remark:`${marker}-phase5-transfer`,
fees:[{feeType:'DELIVERY_FEE',feeName:'派送费',direction:'RECEIVABLE',currency:'USD',amount:10,
remark:`${marker}-acceptance`}],
}
}
export function validFragmentCabinetIdentity(workbench, fragment, task) {
const container = workbench?.container
return id(container?.id) === id(fragment?.containerId)
&& Boolean(container?.cabinetNo && container?.containerNo)
&& id(container?.oceanBookingId) === id(fragment?.oceanBookingId)
&& id(task?.id) === id(fragment?.taskId)
&& id(task?.containerBookingId) === id(fragment?.containerId)
&& id(task?.loadingPlanId) === id(fragment?.planId)
}
export function fulfillmentHistorySnapshot(detail) {
return {
orderId: id(detail?.order?.id), rootOrderId: id(detail?.order?.rootOrderId),
@@ -124,9 +158,12 @@ async function fragment(context, source, rootOrderId, 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 workbench = await api(context, 'GET', '回查该片实际柜次身份', `/api/platform/v1/containers/domestic/${result.containerId}`)
const container = workbench?.container
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 公开国内柜次详情和实际 PDA 的订舱柜次计划任务身份一致',
validFragmentCabinetIdentity(workbench,result,task),`containerBookingId=${result.containerId},planId=${result.planId},taskId=${result.taskId}`)
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}`)
@@ -138,6 +175,8 @@ async function fragment(context, source, rootOrderId, sequence) {
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),
// Standard FBA resolves the actual receiving inbound prefix; transferred physical box codes remain unchanged.
receivingCartonMarks:[`${current.summary.inboundCode}#PDA-CONTRACT-1`],
finalStatus: 'LOADED', inboundStatus: 'LOADED', finalizeSucceeded: true,
}
context.createdResources.phase3Loading = loading
@@ -167,14 +206,15 @@ async function sendFragment(context, producer, picker, merchantToken, index) {
planPalletIds: matches.map(row => id(row.planPalletId)),
})
const dispatchNo = dispatch.dispatchNo
await api(context, 'PUT', '补齐该片车辆司机', `${DISPATCH}/${dispatchNo}`, {
const vehicle = {
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),
})
estimatedArrivalAt: f40LocalDateTime(new Date(Date.now()+86400000)),
}
await api(context, 'PUT', '补齐该片车辆司机', `${DISPATCH}/${dispatchNo}`, vehicle)
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)
f40TransferBody(context.marker,index,vehicle), 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 })
@@ -200,15 +240,29 @@ async function sendFragment(context, producer, picker, merchantToken, index) {
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}`)
check(context,'F40 签收完成记录真实签收时间', Boolean(completed.order.signedAt), `delivery=${deliveries[0].deliveryNo}`)
return { producer, dispatchNo, outboundNo: transfer.outboundNo, deliveryNo: deliveries[0].deliveryNo,
receiptVersion: completed.order.version }
receiptVersion: completed.order.version, signedAt: completed.order.signedAt }
}
/** Explicit TEST orchestrator, called by the parent acceptance context; never synthesizes an event. */
export async function run(context) {
try { return await runRootFulfillment(context) }
catch (error) {
const proof = context.createdResources?.phase5RootFulfillment
if (proof) {
proof.finalStatus = 'FAILED'
proof.failedStage ||= proof.currentStage || 'INITIALIZING'
}
throw error
}
}
async function runRootFulfillment(context) {
const initialStepCount = context.steps?.length || 0
const proof = context.createdResources.phase5RootFulfillment = {
marker: context.marker, rootOrderId: null, childOrderId: null, customerId: null,
fragments: [], signatures: [], snapshots: [], finalStatus: 'PREPARING', outboxProof: null,
currentStage:'INITIALIZING', failedStage:null,
cleanup: 'Fresh completed root/child/cabinet/PDA/receipt audit resources remain as acceptance evidence',
}
const signatureOrder = context.context?.f40SignatureOrder || 'ROOT_FIRST'
@@ -219,11 +273,26 @@ export async function run(context) {
scope.context = { ...context.context, orderDestinationType: 'FBA', orderFulfillmentType: 'STANDARD',
orderProfile: { taxMode: 'BUY_DECLARATION', fulfillmentType: 'STANDARD' },
waitSplitVariant: { skuCount: 1, packageCountPerSku: 2, packageCounts: [2] },
phase4OverseasPdaOperations: { completedFixtures: [] },
allowAnyExistingPallet: true, f40BaseMarker: `${context.marker}-F40` }
scope.createdResources = { phase3OrderSplitting: { retainedAuditRecordIds: [] } }
proof.currentStage = 'CUSTOMER_IDENTITY'
const credentials = resolveF40MerchantCredentials(scope)
check(scope,'F40 本轮客户完整凭据有效', Boolean(credentials), 'complete-credential-pair')
const login = await api(scope,'POST','F40 本轮客户正式登录','/api/auth/login',
{loginId:credentials.username,password:credentials.password},'')
const profile = await api(scope,'GET','F40 本轮客户会话归属核验',
'/api/merchant/v1/iam/tenant/profile',undefined,login.accessToken)
const expectedCustomerId = id(profile.id)
check(scope,'F40 客户会话和本轮客户 fixture 一致', Boolean(expectedCustomerId)
&& (!credentials.customerId || credentials.customerId === expectedCustomerId),'exact-fixture-customer')
await domesticLogin(scope)
proof.currentStage = 'ORDER_CREATION'
await stage(scope, 'orders')
const orderFixture = { ...scope.createdResources.orders }
check(scope,'F40 正式订单创建客户与本轮登录客户一致', id(orderFixture.tenantId) === expectedCustomerId,
'actual-order-customer-matches-profile')
proof.currentStage = 'DOMESTIC_MEASUREMENT'
await stage(scope, 'inbound-measurement')
const rootOrderId = id(orderFixture.orderId), customerId = id(orderFixture.tenantId)
proof.rootOrderId = rootOrderId; proof.customerId = customerId
@@ -231,8 +300,10 @@ export async function run(context) {
query(`/api/platform/inbounds/${orderFixture.inboundId}`, { tenantId: customerId }))
check(scope, 'F40 根实物数量确为两箱且可分柜', rootInbound.summary?.processStatus === 'WAIT_SPLIT'
&& Number(scope.createdResources.inboundMeasurement.actualPackageCount) === 2, `root=${rootOrderId}`)
proof.currentStage = 'ROOT_LOAD_AND_SHIP'
const first = await fragment(scope, rootInbound, rootOrderId, 0)
proof.fragments.push(first)
proof.currentStage = 'REMAINDER_IDENTITY'
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') || []
@@ -253,8 +324,10 @@ export async function run(context) {
const childInbound = await api(scope, 'GET', '读取子单可装柜来源',
query(`/api/platform/inbounds/${childRows[0].id}`, { tenantId: customerId }))
scope.marker = `${scope.context.f40BaseMarker}-CHILD`
proof.currentStage = 'CHILD_LOAD_AND_SHIP'
const second = await fragment(scope, childInbound, rootOrderId, 1)
proof.fragments.push(second)
proof.currentStage = 'FULFILLMENT_TREE'
const finalTree = await api(scope, 'GET', '两柜装完后核对数量守恒履约树',
query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`, { tenantId: customerId }))
check(scope, 'F40 真实同根两个一箱来源和柜次数量守恒', validTwoFragmentTree(finalTree, rootOrderId, childOrderId)
@@ -264,26 +337,25 @@ export async function run(context) {
&& 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 })
proof.currentStage = 'OVERSEAS_PDA_IDENTITY'
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)
try {
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 }
proof.currentStage = 'OVERSEAS_PLAN_CONVERSION'
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()) {
proof.currentStage = `OVERSEAS_RECEIVING_${index+1}`
scope.marker = `${scope.context.f40BaseMarker}-RECEIVE-${index}`
scope.createdResources.phase4InboundProducerFixtures.marker = scope.marker
scope.createdResources.phase4OverseasPda.marker = scope.marker
@@ -295,15 +367,21 @@ export async function run(context) {
&& scope.createdResources.phase4OverseasPda.completedContainerFacts.some(row =>
id(row.containerBookingId) === id(producer.containerBookingId) && row.containerNo === producer.containerNo),
`container=${producer.containerNo}`)
proof.currentStage = `DISPATCH_AND_SIGNATURE_${index+1}`
const signed = await sendFragment(scope,producer,picker,login.accessToken,index)
proof.signatures.push(signed)
proof.currentStage = `COMPLETION_GATE_${index+1}`
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)
}
proof.currentStage = 'RECEIPT_REPLAY'
const stable = await state(scope,customerId,rootOrderId,childOrderId,first.inboundId)
const finalSignedAt = proof.signatures.map(row => row.signedAt).sort().at(-1)
check(scope,'F40 根完成时间为实际两片签收时间最大值', stable.root.rawOrder?.completedAt === finalSignedAt,
`completedAt=${stable.root.rawOrder?.completedAt}, maxSignedAt=${finalSignedAt}`)
// 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()) {
@@ -320,6 +398,7 @@ export async function run(context) {
check(scope,'F40 已完成签收重放根子订单状态版本和历史零新增',
JSON.stringify(stable) === JSON.stringify(afterReplay),'exact-root-child-inbound-snapshot')
proof.snapshots.push({phase:'AFTER_RECEIPT_REPLAY',...afterReplay})
proof.currentStage = 'READ_ONLY_OUTBOX_EVIDENCE'
const reader = context.context?.f40ReadOnlyOutboxEvidence
if (typeof reader === 'function') {
proof.outboxProof = await reader({customerId, rootOrderId, sourceOrderIds:[rootOrderId,childOrderId],
@@ -336,7 +415,15 @@ export async function run(context) {
}
} catch (error) {
proof.finalStatus = 'FAILED'
proof.failedStage = proof.currentStage
throw error
} finally { await disablePicker(scope,picker,7) }
} finally { proof.currentStage = 'PDA_IDENTITY_CLEANUP'; await disablePicker(scope,picker,7) }
if (proof.finalStatus === 'VERIFIED') {
const failedSteps = (context.steps || []).slice(initialStepCount).filter(row => row.passed === false)
if (failedSteps.length) proof.finalStatus = 'FAILED'
check(context,'F40 正式链、重放及临时PDA停用所有检查均通过', failedSteps.length === 0,
`failed=${failedSteps.length}`)
}
proof.currentStage = 'FINISHED'
return proof
}