|
|
|
@@ -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
|
|
|
|
|
}
|