2026-09-30 14:27:45 +08:00
|
|
|
import { loadOneFragment } from './phase3-order-splitting.mjs'
|
2026-10-02 23:14:24 +08:00
|
|
|
import { freezeOrderCommand } from './orders.mjs'
|
2026-09-30 14:27:45 +08:00
|
|
|
import { verifyPalletLabels } from './phase3-loading-finalize.mjs'
|
|
|
|
|
import { stableLoadedCartonMarks } from './phase3-loading.mjs'
|
2026-10-08 17:07:57 +08:00
|
|
|
import { awaitProducerConsumption, convertReceivingContainers, runReceiving,
|
2026-09-30 14:27:45 +08:00
|
|
|
resolvePickerAccess, createPicker, disablePicker } from './phase4-overseas-pda.mjs'
|
2026-10-23 05:21:30 +08:00
|
|
|
import { pickAll, verifyAutomaticDeparture, saveReceiptAndComplete, readAutomaticDispatchDelivery, dispatchOutboundMatches, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs'
|
2026-10-03 08:13:04 +08:00
|
|
|
import { isDeepStrictEqual } from 'node:util'
|
|
|
|
|
import { assertTestDeliveryFaultProof } from '../test-delivery-fault-proof.mjs'
|
2026-09-30 14:27:45 +08:00
|
|
|
import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs'
|
2026-10-03 06:26:33 +08:00
|
|
|
import { prepareTaxCustoms, verifyTaxDocumentsAfterCompletion } from './phase5-root-tax-customs.mjs'
|
|
|
|
|
import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs'
|
2026-09-30 14:27:45 +08:00
|
|
|
|
|
|
|
|
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'
|
2026-10-20 10:35:52 +08:00
|
|
|
const id = value => {
|
|
|
|
|
if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('F40业务ID精度无效')
|
|
|
|
|
return value == null ? '' : String(value)
|
|
|
|
|
}
|
|
|
|
|
const validVersion = value => (typeof value === 'string' || typeof value === 'number' && Number.isSafeInteger(value))
|
|
|
|
|
&& /^(0|[1-9]\d*)$/.test(String(value))
|
|
|
|
|
const quantity = value => /^(0|[1-9]\d*)$/.test(String(value)) && Number.isSafeInteger(Number(value)) ? Number(value) : null
|
|
|
|
|
const wireVersion = value => {
|
|
|
|
|
if (!validVersion(value)) throw new Error('F40服务器版本缺失或精度无效')
|
|
|
|
|
return value
|
|
|
|
|
}
|
2026-09-30 14:27:45 +08:00
|
|
|
const success = response => response?.http === 200 && String(response?.body?.code) === '200'
|
|
|
|
|
const query = (path, values) => `${path}?${new URLSearchParams(values)}`
|
|
|
|
|
|
2026-10-08 17:07:57 +08:00
|
|
|
/** Resolve only this run's consumed physical sources, never a fixed warehouse ID or list position. */
|
|
|
|
|
export async function resolveF40ReceivingWarehouse(context, producers) {
|
|
|
|
|
check(context, 'F40 选仓仅接受唯一的本轮实装来源', Array.isArray(producers) && producers.length > 0
|
|
|
|
|
&& producers.every(row => row?.containerNo && row?.inboundCode && id(row.tenantId)
|
|
|
|
|
&& id(row.containerBookingId) && id(row.sourceInboundId))
|
|
|
|
|
&& new Set(producers.map(row => id(row.containerBookingId))).size === producers.length
|
|
|
|
|
&& new Set(producers.map(row => id(row.tenantId))).size === 1, 'exact-producer-identities')
|
|
|
|
|
const sources = []
|
|
|
|
|
for (const producer of producers) {
|
2026-10-23 04:48:02 +08:00
|
|
|
const rows = [], seen = new Set(); let total
|
|
|
|
|
for (let page = 1; page <= 100; page++) {
|
|
|
|
|
const data = await api(context, 'GET', 'F40 读取本柜实际尾程来源与接柜仓',
|
|
|
|
|
query('/api/platform/v1/last-leg/orders', { page, size: 200, containerNo: producer.containerNo }))
|
|
|
|
|
check(context, 'F40 来源柜客户入仓身份分页完整且总数稳定', Array.isArray(data?.records)
|
|
|
|
|
&& Number.isSafeInteger(Number(data.total)) && Number(data.total) >= 0
|
|
|
|
|
&& (total === undefined || total === Number(data.total)) && data.records.length <= 200
|
|
|
|
|
&& (data.records.length > 0 || rows.length === Number(data.total)), `page=${page}`)
|
|
|
|
|
total ??= Number(data.total)
|
|
|
|
|
for (const row of data.records) {
|
|
|
|
|
const key = `${id(row.customerId)}:${id(row.id)}`
|
|
|
|
|
check(context, 'F40 来源柜客户入仓身份分页无重复或缺失', Boolean(id(row.id)) && !seen.has(key), `page=${page}`)
|
|
|
|
|
seen.add(key); rows.push(row)
|
|
|
|
|
}
|
|
|
|
|
if (rows.length >= total) break
|
|
|
|
|
}
|
|
|
|
|
check(context, 'F40 来源柜客户入仓身份分页完整', rows.length === total, `rows=${rows.length}, total=${total}`)
|
2026-10-08 17:07:57 +08:00
|
|
|
const matching = rows.filter(row => row.containerNo === producer.containerNo
|
|
|
|
|
&& id(row.sourceContainerBookingId) === id(producer.containerBookingId)
|
|
|
|
|
&& id(row.customerId) === id(producer.tenantId)
|
|
|
|
|
&& id(row.sourceInboundId) === id(producer.sourceInboundId) && row.inboundCode === producer.inboundCode)
|
|
|
|
|
check(context, 'F40 尾程来源柜客户入仓身份唯一且接柜仓明确', matching.length === 1
|
|
|
|
|
&& Boolean(id(matching[0]?.id) && id(matching[0]?.overseasWarehouseId)),
|
|
|
|
|
`container=${producer.containerNo}, exactSources=${matching.length}`)
|
|
|
|
|
sources.push(matching[0])
|
|
|
|
|
}
|
|
|
|
|
const warehouseId = id(sources[0].overseasWarehouseId)
|
|
|
|
|
check(context, 'F40 全部实装来源属于同一实际接柜仓',
|
|
|
|
|
sources.every(row => id(row.overseasWarehouseId) === warehouseId), 'unique-source-receiving-warehouse')
|
|
|
|
|
const page = await api(context, 'GET', 'F40 按来源仓精确核验Warehouse权威收货主档',
|
|
|
|
|
'/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE')
|
|
|
|
|
const rows = Array.isArray(page?.records) ? page.records : []
|
|
|
|
|
const matching = rows.filter(row => id(row.id) === warehouseId)
|
|
|
|
|
const warehouse = matching[0]
|
|
|
|
|
check(context, 'F40 实际接柜仓为唯一启用海外收货仓且编码一致', matching.length === 1
|
|
|
|
|
&& warehouse.warehouseType === 'OVERSEAS' && warehouse.status === 'ACTIVE'
|
|
|
|
|
&& sources.every(row => !row.overseasWarehouseCode || row.overseasWarehouseCode === warehouse.warehouseCode),
|
|
|
|
|
`warehouseId=${warehouseId}`)
|
|
|
|
|
return { id: warehouseId, name: warehouse.warehouseName || warehouse.name,
|
|
|
|
|
code: warehouse.warehouseCode, sourceOrders: sources.map(row => ({ orderId: id(row.id),
|
|
|
|
|
customerId: id(row.customerId), sourceContainerBookingId: id(row.sourceContainerBookingId),
|
|
|
|
|
sourceInboundId: id(row.sourceInboundId), inboundCode: row.inboundCode, containerNo: row.containerNo,
|
|
|
|
|
overseasWarehouseId: id(row.overseasWarehouseId) })) }
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 15:16:39 +08:00
|
|
|
export function f40LocalDateTime(value) { return value.toISOString().slice(0,19) }
|
2026-09-30 17:13:11 +08:00
|
|
|
export function preserveReceivingEvidence(proof, scope) {
|
|
|
|
|
proof.receivingCompletionEvidence = scope.createdResources?.phase4OverseasPda?.receivingCompletionEvidence || {}
|
2026-10-03 06:26:33 +08:00
|
|
|
proof.receivingCandidateEvidence = scope.createdResources?.phase4OverseasPda?.candidateWaitEvidence || []
|
2026-09-30 17:13:11 +08:00
|
|
|
}
|
|
|
|
|
export function validFreshReceiptSave(before, after) {
|
2026-10-20 10:35:52 +08:00
|
|
|
if (![before?.documents?.version, after?.documents?.version, before?.delivery?.order?.version, after?.delivery?.order?.version].every(validVersion)) return false
|
|
|
|
|
const previousVersion = BigInt(before.documents.version)
|
|
|
|
|
const nextVersion = BigInt(after.documents.version)
|
|
|
|
|
if (nextVersion !== previousVersion + 1n
|
|
|
|
|
|| BigInt(before.delivery.order.version) !== previousVersion
|
|
|
|
|
|| BigInt(after.delivery.order.version) !== nextVersion
|
2026-09-30 17:13:11 +08:00
|
|
|
|| before?.delivery?.order?.status !== 'DELIVERED' || !before.delivery.order.signedAt) return false
|
|
|
|
|
const same = (left,right) => JSON.stringify(left) === JSON.stringify(right)
|
|
|
|
|
if (!same(before.documents,{...after.documents,version:before.documents.version})) return false
|
|
|
|
|
const oldOperations = before.delivery.operations, newOperations = after.delivery.operations
|
|
|
|
|
if (!Array.isArray(oldOperations) || !Array.isArray(newOperations)
|
|
|
|
|
|| newOperations.length !== oldOperations.length + 1) return false
|
|
|
|
|
const oldIds = new Set(oldOperations.map(row => id(row.id)))
|
|
|
|
|
const added = newOperations.filter(row => !oldIds.has(id(row.id)))
|
|
|
|
|
if (added.length !== 1 || !id(added[0].id) || added[0].operationNode !== 'SAVE_DOCUMENTS'
|
|
|
|
|
|| added[0].result !== `保存派送资料:${after.documents.uploadedCategoryCount}/3类`
|
|
|
|
|
|| !added[0].operatedAt || !added[0].operatorName
|
|
|
|
|
|| !same(oldOperations,newOperations.filter(row => oldIds.has(id(row.id))))) return false
|
|
|
|
|
if (!same(before.delivery,{...after.delivery,order:{...after.delivery.order,version:before.delivery.order.version},
|
|
|
|
|
operations:oldOperations})) return false
|
|
|
|
|
if (!Array.isArray(before.revisions) || !Array.isArray(after.revisions)
|
|
|
|
|
|| after.revisions.length !== before.revisions.length + 1) return false
|
2026-10-20 10:35:52 +08:00
|
|
|
const addedRevisions = after.revisions.filter(row => validVersion(row.version) && BigInt(row.version) === nextVersion)
|
2026-09-30 17:13:11 +08:00
|
|
|
return addedRevisions.length === 1 && Boolean(addedRevisions[0].savedAt && addedRevisions[0].operatorId)
|
2026-10-20 10:35:52 +08:00
|
|
|
&& same(before.revisions,after.revisions.filter(row => !validVersion(row.version) || BigInt(row.version) !== nextVersion))
|
2026-09-30 17:13:11 +08:00
|
|
|
&& same(addedRevisions[0].snapshot,after.documents)
|
|
|
|
|
}
|
2026-09-30 15:16:39 +08:00
|
|
|
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) }
|
|
|
|
|
}
|
2026-10-20 10:35:52 +08:00
|
|
|
/** 本片只接受正式候选的冻结选择器和FBA逐票来源,不补猜来源版本。 */
|
|
|
|
|
export function f40CreateDispatchBody(marker, warehouseId, customerId, candidates) {
|
|
|
|
|
if (!Array.isArray(candidates) || !candidates.length) throw new Error('F40缺少本片真实候选')
|
|
|
|
|
const ids = new Set(), batchAllocations = []
|
|
|
|
|
const positive = value => /^[1-9]\d*$/.test(id(value))
|
|
|
|
|
const cargo = candidates.map(candidate => {
|
|
|
|
|
const selection = candidate?.selection
|
|
|
|
|
if (!selection || selection.sourceType !== 'INBOUND_PLAN' || !positive(selection.planPalletId)
|
|
|
|
|
|| !positive(selection.warehousePalletId) || selection.returnReceiptId != null || selection.relabelNo != null
|
|
|
|
|
|| candidate.sourceKey !== `PLAN:${id(selection.planPalletId)}` || ids.has(candidate.sourceKey)
|
|
|
|
|
|| !/^[a-f\d]{64}$/i.test(selection.sourceVersion || '') || candidate.sourceVersion !== selection.sourceVersion
|
|
|
|
|
|| id(candidate.customerId) !== id(customerId) || id(candidate.warehouseId) !== id(warehouseId)
|
|
|
|
|
|| quantity(candidate.availablePackages) == null || quantity(candidate.availablePackages) <= 0
|
|
|
|
|
|| candidate.destination?.type !== 'FBA' || !candidate.destination.key
|
|
|
|
|
|| !Array.isArray(candidate.blockReasons) || candidate.blockReasons.length
|
|
|
|
|
|| !Array.isArray(candidate.appointmentBatches)) throw new Error('F40本片候选身份、库存或冻结版本不完整')
|
|
|
|
|
ids.add(candidate.sourceKey)
|
|
|
|
|
if (candidate.appointmentBatches.length) {
|
|
|
|
|
if (candidate.appointmentBatches.length !== 1) throw new Error('F40跨批次缺少本轮真实逐票分配,不推算')
|
|
|
|
|
const batch = candidate.appointmentBatches[0]
|
|
|
|
|
if (!/^FBA_APPOINTMENT:[1-9]\d*$/.test(batch.batchKey || '') || id(batch.customerId) !== id(customerId)
|
|
|
|
|
|| !Array.isArray(batch.sources) || !batch.sources.length
|
|
|
|
|
|| batch.sources.some(source => !/^(BOOKING:.+|ORDER:[1-9]\d*)$/.test(source.sourceKey || '')
|
|
|
|
|
|| source.destinationKey !== candidate.destination.key || quantity(source.packageCount) == null || quantity(source.packageCount) <= 0)
|
|
|
|
|
|| batch.sources.reduce((sum, source) => sum + quantity(source.packageCount), 0) !== quantity(candidate.availablePackages))
|
|
|
|
|
throw new Error('F40预约批次与本片逐票实物不一致')
|
|
|
|
|
for (const source of batch.sources) batchAllocations.push({ planPalletId: id(selection.planPalletId), batchKey: batch.batchKey,
|
|
|
|
|
customerId: id(customerId), sourceKey: source.sourceKey, destinationKey: source.destinationKey,
|
|
|
|
|
packageCount: quantity(source.packageCount), recordVersion: wireVersion(batch.recordVersion) })
|
|
|
|
|
}
|
|
|
|
|
return { sourceType: selection.sourceType, planPalletId: id(selection.planPalletId),
|
|
|
|
|
warehousePalletId: id(selection.warehousePalletId), returnReceiptId: null, relabelNo: null, sourceVersion: selection.sourceVersion }
|
|
|
|
|
})
|
|
|
|
|
return { idempotencyKey: `${marker}-CREATE`.slice(0, 200), warehouseId: id(warehouseId), vehicle: null, driver: null,
|
|
|
|
|
plannedDepartureAt: null, estimatedArrivalAt: null, remark: `${marker}-F40-native`, cargo, batchAllocations }
|
|
|
|
|
}
|
|
|
|
|
/** 调用方可提供只读实测读取器;不按箱数或计费重量构造物理实载值。 */
|
|
|
|
|
export async function resolveF40LoadingMeasurements(context, dispatchNo, producer, warehouseId) {
|
|
|
|
|
const source = context.context?.f40LoadingMeasurements
|
|
|
|
|
if (typeof source === 'function') return source({ environment: 'TEST', marker: context.marker, dispatchNo,
|
|
|
|
|
warehouseId: id(warehouseId), sourceInboundId: id(producer.sourceInboundId), inboundCode: producer.inboundCode })
|
|
|
|
|
return source?.[dispatchNo]
|
2026-09-30 15:16:39 +08:00
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 14:27:45 +08:00
|
|
|
export function fulfillmentHistorySnapshot(detail) {
|
|
|
|
|
return {
|
|
|
|
|
orderId: id(detail?.order?.id), rootOrderId: id(detail?.order?.rootOrderId),
|
|
|
|
|
parentOrderId: id(detail?.order?.parentOrderId), role: detail?.order?.orderRole,
|
2026-10-20 10:35:52 +08:00
|
|
|
status: detail?.order?.status, version: detail?.order?.version,
|
2026-09-30 14:27:45 +08:00
|
|
|
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'
|
|
|
|
|
}
|
2026-10-08 17:07:57 +08:00
|
|
|
// TEST Lastleg runs with TZ=Asia/Shanghai. Its public signedAt is local;
|
|
|
|
|
// the completion event and Order projection preserve the same instant in UTC.
|
|
|
|
|
export function rootCompletionTimeMatches(completedAt, signedTimes) {
|
|
|
|
|
const instant = (value, offset) => {
|
|
|
|
|
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})?$/.test(value)) return NaN
|
|
|
|
|
return Date.parse(value + (/(?:Z|[+-]\d{2}:\d{2})$/.test(value) ? '' : offset))
|
|
|
|
|
}
|
|
|
|
|
if (!Array.isArray(signedTimes) || signedTimes.length === 0) return false
|
|
|
|
|
const signatures = signedTimes.map(value => instant(value, '+08:00'))
|
|
|
|
|
return signatures.every(Number.isFinite) && Number.isFinite(instant(completedAt, 'Z'))
|
|
|
|
|
&& instant(completedAt, 'Z') === Math.max(...signatures)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 14:27:45 +08:00
|
|
|
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)
|
|
|
|
|
}
|
2026-09-30 17:13:11 +08:00
|
|
|
export function validUnloadedRemainder(tree, child, inbound, customerId, 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 Boolean(id(rootOrderId) && id(childOrderId) && id(rootOrderId) !== id(childOrderId))
|
|
|
|
|
&& id(tree?.parentOrderId) === id(rootOrderId) && Number(tree.originalPackageCount) === 2
|
|
|
|
|
&& Number(tree.cumulativeLoadedPackageCount) === 1 && Number(tree.currentRemainderPackageCount) === 1
|
|
|
|
|
&& rows.length === 2 && loaded.length === 1 && remainder.length === 1
|
|
|
|
|
&& id(loaded[0].orderId) === id(rootOrderId) && id(loaded[0].sourceOrderId) === id(rootOrderId)
|
|
|
|
|
&& Number(loaded[0].packageCount) === 1
|
|
|
|
|
&& id(remainder[0].orderId) === id(childOrderId) && id(remainder[0].sourceOrderId) === id(rootOrderId)
|
|
|
|
|
&& Number(remainder[0].packageCount) === 1
|
|
|
|
|
&& id(remainder[0].loadingPlanId) === id(loaded[0].loadingPlanId)
|
|
|
|
|
&& id(remainder[0].containerBookingId) === id(loaded[0].containerBookingId)
|
|
|
|
|
&& Boolean(id(loaded[0].loadingPlanId) && id(loaded[0].containerBookingId))
|
|
|
|
|
&& child?.role === 'CHILD' && child.status === 'APPROVED'
|
|
|
|
|
&& child.orderId === id(childOrderId) && child.rootOrderId === id(rootOrderId)
|
|
|
|
|
&& child.parentOrderId === id(rootOrderId) && id(child.rawOrder?.tenantId) === id(customerId)
|
|
|
|
|
&& completionCounts(child).own === 0 && completionCounts(child).final === 0
|
|
|
|
|
&& Boolean(id(inbound?.id) && inbound?.inboundCode)
|
|
|
|
|
&& id(inbound.orderId) === id(childOrderId) && id(inbound.tenantId) === id(customerId)
|
|
|
|
|
&& inbound.inboundCode === child.inboundCode && inbound.orderNo === child.orderNo
|
|
|
|
|
&& inbound.processStatus === 'WAIT_SPLIT' && inbound.orderStatusSnapshot === 'APPROVED'
|
|
|
|
|
&& Number(inbound.totalPackageCount) === 1 && !inbound.containerNo && !inbound.cabinetNo
|
|
|
|
|
}
|
|
|
|
|
export function validRemainderCancellationGate(snapshot) {
|
|
|
|
|
const root = completionCounts(snapshot?.root), child = completionCounts(snapshot?.child)
|
|
|
|
|
return snapshot?.root?.status === 'COMPLETED' && snapshot?.child?.status === 'CANCELLED'
|
|
|
|
|
&& root.own === 1 && root.final === 1 && child.own === 0 && child.final === 0
|
|
|
|
|
&& snapshot.child.histories.filter(row => row.action === 'CANCELLED').length === 1
|
|
|
|
|
&& snapshot.child.rawOrder?.completedAt == null
|
|
|
|
|
&& snapshot?.rootInbound?.processStatus === 'DELIVERED'
|
|
|
|
|
&& snapshot?.rootInbound?.orderStatusSnapshot === 'COMPLETED'
|
|
|
|
|
}
|
|
|
|
|
export function exactCancelledRemainderOutboxProof(evidence, customerId, rootOrderId, childOrderId) {
|
|
|
|
|
const rows = Array.isArray(evidence?.orders) ? evidence.orders : []
|
|
|
|
|
const root = rows.filter(row => id(row.orderId) === id(rootOrderId))
|
|
|
|
|
const child = rows.filter(row => id(row.orderId) === id(childOrderId))
|
|
|
|
|
return id(rootOrderId) !== id(childOrderId) && rows.length === 2 && root.length === 1 && child.length === 1
|
|
|
|
|
&& exactCompletionOutboxProof({...evidence,orders:root},customerId,rootOrderId,[rootOrderId])
|
|
|
|
|
&& id(child[0].customerId) === id(customerId) && id(child[0].rootOrderId) === id(rootOrderId)
|
|
|
|
|
&& child[0].eventType === 'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED'
|
|
|
|
|
&& ['count','publishedCount','pendingCount','failedCount'].every(field => child[0][field] != null && Number(child[0][field]) === 0)
|
|
|
|
|
}
|
|
|
|
|
function outboxCounts(evidence) {
|
|
|
|
|
return JSON.stringify((evidence?.orders || []).map(row => ({
|
|
|
|
|
orderId:id(row.orderId),customerId:id(row.customerId),rootOrderId:id(row.rootOrderId),eventType:row.eventType,
|
|
|
|
|
count:Number(row.count),publishedCount:Number(row.publishedCount),pendingCount:Number(row.pendingCount),failedCount:Number(row.failedCount),
|
|
|
|
|
})).sort((left,right) => left.orderId.localeCompare(right.orderId)))
|
|
|
|
|
}
|
2026-09-30 14:27:45 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-09-30 17:13:11 +08:00
|
|
|
export async function verifyRootProjections(context, customerId, rootOrderId, merchantToken, snapshot) {
|
|
|
|
|
const completed = snapshot.root.status === 'COMPLETED'
|
|
|
|
|
const ownSigned = completionCounts(snapshot.root).own === 1
|
|
|
|
|
const valid = value => id(value?.summary?.orderId) === id(rootOrderId)
|
|
|
|
|
&& (completed ? value.summary.mainStatus === 'COMPLETED'
|
|
|
|
|
: ownSigned ? value.summary.mainStatus === 'DELIVERED'
|
|
|
|
|
: Boolean(value.summary.mainStatus) && value.summary.mainStatus !== 'COMPLETED')
|
|
|
|
|
const platform = await waitState(() => api(context,'GET','F40 平台统一根订单状态',
|
|
|
|
|
query(`/api/platform/v1/orders/${rootOrderId}`,{tenantId:customerId})),valid)
|
|
|
|
|
const customer = await api(context,'GET','F40 本企业客户统一根订单状态',
|
|
|
|
|
`/api/merchant/v1/orders/${rootOrderId}`,undefined,merchantToken)
|
|
|
|
|
check(context,'F40 平台客户统一状态与原始根完成门禁一致',valid(platform) && valid(customer)
|
|
|
|
|
&& customer.summary.mainStatus === platform.summary.mainStatus,'exact-root-platform-customer-status')
|
|
|
|
|
return {platform:platform.summary,customer:customer.summary}
|
|
|
|
|
}
|
2026-10-03 06:26:33 +08:00
|
|
|
export async function verifyRemainderTaxIdentity(context, evidence, merchantToken) {
|
|
|
|
|
const {taxMode,customerId,rootOrderId,childOrderId,rootOrderNo,rawChild,rootInbound,childInbound}=evidence
|
|
|
|
|
check(context,'F40 余量税制核对范围完整',['BUY_DECLARATION','TAX_REFUND'].includes(taxMode)
|
|
|
|
|
&& Boolean(rootOrderNo && customerId && rootOrderId && childOrderId),'exact-current-remainder-scope')
|
|
|
|
|
check(context,'F40 原始余量子订单继承本轮税制',rawChild?.taxMode===taxMode
|
|
|
|
|
&& id(rawChild.id)===childOrderId && id(rawChild.tenantId)===customerId,
|
|
|
|
|
`expected=${taxMode}, actual=${rawChild?.taxMode || 'missing'}`)
|
|
|
|
|
check(context,'F40 根与余量入仓摘要税制和来源身份一致',rootInbound?.taxMode===taxMode
|
|
|
|
|
&& childInbound?.taxMode===taxMode && id(rootInbound.orderId)===rootOrderId
|
|
|
|
|
&& id(childInbound.orderId)===childOrderId && id(childInbound.tenantId)===customerId,
|
|
|
|
|
`rootTax=${rootInbound?.taxMode || 'missing'}, childTax=${childInbound?.taxMode || 'missing'}`)
|
|
|
|
|
const exactRows = page => Array.isArray(page?.records) && page.records.length===2 && Number(page.total)===2
|
|
|
|
|
&& new Set(page.records.map(row=>id(row.orderId))).size===2
|
|
|
|
|
&& page.records.every(row=>[rootOrderId,childOrderId].includes(id(row.orderId))
|
|
|
|
|
&& id(row.tenantId)===customerId && row.taxMode===taxMode
|
|
|
|
|
&& id(row.relation?.rootOrderId)===rootOrderId
|
|
|
|
|
&& (id(row.orderId)===rootOrderId?row.relation?.role==='PARENT'
|
|
|
|
|
:row.relation?.role==='CHILD'&&id(row.relation?.parentOrderId)===rootOrderId))
|
|
|
|
|
for(const [name,filter] of [['unfiltered',null],['matchingTax',taxMode],
|
|
|
|
|
['otherTax',taxMode==='TAX_REFUND'?'BUY_DECLARATION':'TAX_REFUND']]) {
|
|
|
|
|
const values={orderNo:rootOrderNo,page:'1',size:'200'}
|
|
|
|
|
if(filter)values.taxMode=filter
|
|
|
|
|
evidence[name]=await api(context,'GET',`F40 客户统一订单税制筛选对照${name}`,
|
|
|
|
|
query('/api/merchant/v1/orders',values),undefined,merchantToken)
|
|
|
|
|
check(context,`F40 客户筛选${name}精确覆盖本轮父子身份`,name==='otherTax'
|
|
|
|
|
? Array.isArray(evidence[name]?.records)&&evidence[name].records.length===0&&Number(evidence[name].total)===0
|
|
|
|
|
: exactRows(evidence[name]),`taxMode=${filter || 'unfiltered'}`)
|
|
|
|
|
}
|
|
|
|
|
return evidence
|
|
|
|
|
}
|
2026-09-30 17:13:11 +08:00
|
|
|
export async function cancelUnloadedRemainder(context, proof, customerId, rootOrderId, childOrderId,
|
|
|
|
|
rootInboundId, childInboundId) {
|
|
|
|
|
proof.currentStage = 'REMAINDER_CANCEL_PRECONDITION'
|
|
|
|
|
const before = await state(context,customerId,rootOrderId,childOrderId,rootInboundId)
|
|
|
|
|
const treePath = query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`,{tenantId:customerId})
|
|
|
|
|
const tree = await api(context,'GET','取消前重新核对唯一未消费余量履约树',treePath)
|
|
|
|
|
const childInbound = await api(context,'GET','取消前回查未装余量入仓来源',
|
|
|
|
|
query(`/api/platform/inbounds/${childInboundId}`,{tenantId:customerId}))
|
|
|
|
|
check(context,'F40 仅取消真实签收根下未装 APPROVED 一箱余量', before.root.status === 'APPROVED'
|
|
|
|
|
&& validSignatureGate(before,'MASTER')
|
|
|
|
|
&& validUnloadedRemainder(tree,before.child,childInbound?.summary,customerId,rootOrderId,childOrderId),
|
|
|
|
|
'raw-approved-root/child; one-loaded-root/one-unloaded-remainder')
|
|
|
|
|
proof.snapshots.push({phase:'BEFORE_REMAINDER_CANCELLATION',...before,tree,childInbound:childInbound.summary})
|
|
|
|
|
const cancelPath = query(`/api/platform/v1/orders/first-leg/${childOrderId}/cancel`,{tenantId:customerId})
|
2026-10-02 23:14:24 +08:00
|
|
|
const cancelCommand = freezeOrderCommand({order:before.child.rawOrder}, `${context.context.f40BaseMarker}-cancel-remainder`.slice(0,128),
|
|
|
|
|
{reason:`${context.context.f40BaseMarker} 未装柜余量取消验收`})
|
|
|
|
|
proof.cancelCommand = cancelCommand
|
|
|
|
|
const cancelBody = cancelCommand.body
|
2026-09-30 17:13:11 +08:00
|
|
|
proof.currentStage = 'REMAINDER_CANCELLATION'
|
2026-10-02 23:14:24 +08:00
|
|
|
await api(context,'POST','正式取消未装柜 APPROVED 余量订单',cancelPath,cancelBody,context.token,cancelCommand.headers)
|
2026-09-30 17:13:11 +08:00
|
|
|
const after = await waitState(() => state(context,customerId,rootOrderId,childOrderId,rootInboundId),
|
|
|
|
|
validRemainderCancellationGate)
|
|
|
|
|
check(context,'F40 最后有效未装余量取消后根仅完成一次且子未签收',validRemainderCancellationGate(after),
|
|
|
|
|
'root-own=1/root-final=1/child-cancel=1/child-signature=0')
|
|
|
|
|
const afterTree = await api(context,'GET','取消后保留真实分片数量台账',treePath)
|
|
|
|
|
check(context,'F40 取消没有伪造第二柜实装或改写分片数量',
|
|
|
|
|
JSON.stringify(tree.children) === JSON.stringify(afterTree.children)
|
|
|
|
|
&& Number(afterTree.cumulativeLoadedPackageCount) === 1 && Number(afterTree.currentRemainderPackageCount) === 1,
|
|
|
|
|
'ledger-retains-one-physical-box-and-one-cancelled-remainder')
|
|
|
|
|
proof.snapshots.push({phase:'ROOT_SIGNED_REMAINDER_CANCELLED',...after,tree:afterTree})
|
|
|
|
|
proof.currentStage = 'REMAINDER_CANCEL_REPLAY'
|
|
|
|
|
const reader = context.context?.f40ReadOnlyOutboxEvidence
|
|
|
|
|
const request = {customerId,rootOrderId,sourceOrderIds:[rootOrderId,childOrderId],
|
|
|
|
|
eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED'}
|
|
|
|
|
const beforeOutbox = typeof reader === 'function' ? await waitState(() => reader(request),
|
|
|
|
|
value => exactCancelledRemainderOutboxProof(value,customerId,rootOrderId,childOrderId),30) : null
|
|
|
|
|
if (beforeOutbox) check(context,'F40 取消后唯一根完成Outbox且取消子完成Outbox为零',
|
|
|
|
|
exactCancelledRemainderOutboxProof(beforeOutbox,customerId,rootOrderId,childOrderId),'exact-root-one/cancelled-child-zero')
|
2026-10-02 23:14:24 +08:00
|
|
|
const replay = await context.request('POST',cancelPath,cancelBody,context.token,cancelCommand.headers)
|
|
|
|
|
check(context,'F40 原键原版本取消重放成功',success(replay),'cancel-original-key-replay',replay)
|
|
|
|
|
const conflict = await context.request('POST',cancelPath,{...cancelBody,reason:`${cancelBody.reason}-conflict`},context.token,cancelCommand.headers)
|
|
|
|
|
check(context,'F40 原键不同取消载荷返回 A0409',conflict?.http === 200 && String(conflict?.body?.code) === 'A0409','cancel-payload-conflict',conflict)
|
|
|
|
|
const duplicate = freezeOrderCommand({order:after.child.rawOrder}, `${context.context.f40BaseMarker}-cancel-new-key`.slice(0,128), {reason:cancelBody.reason})
|
|
|
|
|
const rejected = await context.request('POST',cancelPath,duplicate.body,context.token,duplicate.headers)
|
|
|
|
|
check(context,'F40 新键重复取消返回 A0409',rejected?.http === 200 && String(rejected?.body?.code) === 'A0409','cancel-new-key-conflict',rejected)
|
2026-09-30 17:13:11 +08:00
|
|
|
const stable = await state(context,customerId,rootOrderId,childOrderId,rootInboundId)
|
|
|
|
|
const replayTree = await api(context,'GET','取消重放后回查原履约树',treePath)
|
|
|
|
|
check(context,'F40 取消重放订单版本历史根摘要及履约台账零变化',
|
|
|
|
|
JSON.stringify(after) === JSON.stringify(stable) && JSON.stringify(afterTree) === JSON.stringify(replayTree),
|
|
|
|
|
'exact-root/child/history/summary/tree')
|
|
|
|
|
if (beforeOutbox) {
|
|
|
|
|
const afterOutbox = await reader(request)
|
|
|
|
|
check(context,'F40 取消重放完成Outbox计数发布状态零变化',
|
|
|
|
|
exactCancelledRemainderOutboxProof(afterOutbox,customerId,rootOrderId,childOrderId)
|
|
|
|
|
&& outboxCounts(beforeOutbox) === outboxCounts(afterOutbox),'exact-root-child-completion-counts')
|
|
|
|
|
proof.cancelReplayOutbox = {before:beforeOutbox,after:afterOutbox}
|
|
|
|
|
}
|
|
|
|
|
proof.snapshots.push({phase:'AFTER_CANCELLATION_REPLAY',...stable,tree:replayTree})
|
|
|
|
|
return stable
|
|
|
|
|
}
|
2026-09-30 14:27:45 +08:00
|
|
|
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,
|
|
|
|
|
})
|
2026-10-09 17:21:56 +08:00
|
|
|
if (context.context?.prepareDomesticPdaH5 && result?.preparedForH5) return result
|
2026-09-30 14:27:45 +08:00
|
|
|
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) }))
|
2026-09-30 15:16:39 +08:00
|
|
|
const workbench = await api(context, 'GET', '回查该片实际柜次身份', `/api/platform/v1/containers/domestic/${result.containerId}`)
|
|
|
|
|
const container = workbench?.container
|
2026-09-30 14:27:45 +08:00
|
|
|
const task = await api(context, 'GET', '回查该片真实完成 PDA 任务',
|
|
|
|
|
query(`/api/platform/v1/first-leg/pda-loading/tasks/${result.taskId}`, { warehouseId: id(summary.warehouseId) }))
|
2026-09-30 15:16:39 +08:00
|
|
|
check(context,'F40 公开国内柜次详情和实际 PDA 的订舱柜次计划任务身份一致',
|
|
|
|
|
validFragmentCabinetIdentity(workbench,result,task),`containerBookingId=${result.containerId},planId=${result.planId},taskId=${result.taskId}`)
|
2026-09-30 14:27:45 +08:00
|
|
|
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),
|
2026-09-30 15:16:39 +08:00
|
|
|
// Standard FBA resolves the actual receiving inbound prefix; transferred physical box codes remain unchanged.
|
|
|
|
|
receivingCartonMarks:[`${current.summary.inboundCode}#PDA-CONTRACT-1`],
|
2026-09-30 14:27:45 +08:00
|
|
|
finalStatus: 'LOADED', inboundStatus: 'LOADED', finalizeSucceeded: true,
|
|
|
|
|
}
|
|
|
|
|
context.createdResources.phase3Loading = loading
|
|
|
|
|
context.createdResources.orders = { ...(context.createdResources.orders || {}), ...loading,
|
2026-10-03 06:26:33 +08:00
|
|
|
taxMode: context.context.f40TaxMode }
|
2026-09-30 14:27:45 +08:00
|
|
|
const palletLabelManifest = await verifyPalletLabels(context, loading, result.planId)
|
|
|
|
|
check(context, 'F40 来源柜散货托盘码来自真实准备接口', Boolean(palletLabelManifest?.totalCount), `sequence=${sequence}`)
|
2026-10-03 06:26:33 +08:00
|
|
|
if(context.context.f40TaxMode==='TAX_REFUND') await prepareTaxCustoms(context,loading)
|
|
|
|
|
else await stage(context,'phase3-customs')
|
|
|
|
|
for (const name of ['service-center-flow', 'phase3-port-schedules',
|
2026-09-30 14:27:45 +08:00
|
|
|
'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 }
|
|
|
|
|
}
|
2026-10-22 11:56:47 +08:00
|
|
|
const F40_DISPATCH_DEPENDENCIES = { prepareFleetTrip, pickAll, confirmFleetLoading, readAutomaticDispatchDelivery,
|
|
|
|
|
verifyAutomaticDeparture, saveReceiptAndComplete, finishFleetTrip, fulfillmentEvidenceContext }
|
2026-10-20 10:35:52 +08:00
|
|
|
|
|
|
|
|
export async function sendFragment(context, producer, picker, merchantToken, index, overrides = {}) {
|
2026-10-22 11:56:47 +08:00
|
|
|
const { prepareFleetTrip, pickAll, confirmFleetLoading, readAutomaticDispatchDelivery, verifyAutomaticDeparture,
|
2026-10-20 10:35:52 +08:00
|
|
|
saveReceiptAndComplete, finishFleetTrip, fulfillmentEvidenceContext } = { ...F40_DISPATCH_DEPENDENCIES, ...overrides }
|
|
|
|
|
const customerId = id(producer.tenantId), warehouseId = id(picker.warehouseId)
|
2026-09-30 14:27:45 +08:00
|
|
|
context.marker = `${context.context.f40BaseMarker}-SEND-${index}`
|
2026-10-20 10:35:52 +08:00
|
|
|
const rootProof = context.context.f40RootFulfillmentProof
|
|
|
|
|
check(context, 'F40 排车具有本轮根来源生产回执', Boolean(rootProof && producer.inboundCode && id(producer.sourceInboundId)
|
|
|
|
|
&& rootProof.fragments?.some(row => id(row.sourceInboundId) === id(producer.sourceInboundId)
|
|
|
|
|
&& row.inboundCode === producer.inboundCode && id(row.tenantId) === customerId
|
|
|
|
|
&& id(row.containerBookingId) === id(producer.containerBookingId))), 'actual-root-source')
|
|
|
|
|
const dispatchProof = { marker: context.marker, customerId, warehouseId, sourceInboundId: id(producer.sourceInboundId),
|
|
|
|
|
inboundCode: producer.inboundCode, dispatchNo: null, stage: 'PREPARING' }
|
|
|
|
|
;(rootProof.dispatches ||= []).push(dispatchProof)
|
|
|
|
|
// 复用同一已验证的写前落盘/未知停写助手;证据对象也保留在根单证明中。
|
|
|
|
|
context.createdResources.phase5OverseasFulfillment = dispatchProof
|
|
|
|
|
context = await fulfillmentEvidenceContext(context)
|
|
|
|
|
try {
|
2026-10-23 04:48:02 +08:00
|
|
|
const planIds = context.createdResources?.phase4OverseasPda?.generatedPlanIds
|
|
|
|
|
check(context, 'F40 正式候选限定本轮转换计划', Array.isArray(planIds) && planIds.length > 0
|
|
|
|
|
&& planIds.every(value => /^[1-9][0-9]*$/.test(id(value))) && new Set(planIds.map(id)).size === planIds.length,
|
|
|
|
|
'current-converted-plan-scope')
|
2026-10-20 10:35:52 +08:00
|
|
|
const candidates = await api(context, 'GET', '读取该来源入仓码原生实收排车候选',
|
2026-10-23 04:48:02 +08:00
|
|
|
query(`${DISPATCH}/candidates`, { warehouseId, sourceStage: 'RECEIVED', planIds: planIds.map(id).join(','), page: 1, size: 50 }))
|
2026-10-20 10:35:52 +08:00
|
|
|
check(context, 'F40 正式候选列表完整', candidates?.complete === true && Array.isArray(candidates.candidates), 'native-complete-candidate-list')
|
|
|
|
|
const matches = candidates.candidates.filter(row => row.selection?.sourceType === 'INBOUND_PLAN'
|
|
|
|
|
&& row.inboundCode === producer.inboundCode && id(row.customerId) === customerId && id(row.warehouseId) === warehouseId)
|
|
|
|
|
check(context, 'F40 只选择本片完整真实实收候选且总量一箱', candidates?.complete === true && matches.length > 0
|
|
|
|
|
&& matches.reduce((sum, row) => sum + Number(row.availablePackages), 0) === 1, `inboundCode=${producer.inboundCode}, pallets=${matches.length}`)
|
|
|
|
|
dispatchProof.candidates = matches
|
|
|
|
|
const createBody = f40CreateDispatchBody(context.marker, warehouseId, customerId, matches)
|
|
|
|
|
const dispatch = await api(context, 'POST', '该履约片通过原生创建独立真实整车', DISPATCH, createBody)
|
|
|
|
|
const dispatchNo = dispatch?.dispatchNo
|
|
|
|
|
check(context, 'F40 创建返回原生整车业务身份及装车前阶段', Boolean(dispatchNo) && dispatch.status === 'ACTIVE'
|
|
|
|
|
&& dispatch.stage === 'BEFORE_LOADING' && validVersion(dispatch.recordVersion), 'native-create-result')
|
|
|
|
|
dispatchProof.dispatchNo = dispatchNo; dispatchProof.createResult = dispatch
|
|
|
|
|
const createReplay = await api(context, 'POST', 'F40 原生建单原命令重放', DISPATCH, createBody)
|
|
|
|
|
check(context, 'F40 建单重放只恢复原版本及业务结果', createReplay.replayed === true && createReplay.dispatchNo === dispatchNo
|
|
|
|
|
&& String(createReplay.recordVersion) === String(dispatch.recordVersion) && JSON.stringify(createReplay.result) === JSON.stringify(dispatch.result), 'immutable-create-result')
|
|
|
|
|
dispatchProof.action = 'PREPARE_AND_REVIEW'
|
|
|
|
|
const tripProof = await prepareFleetTrip(context, MODULE, dispatchNo, warehouseId)
|
|
|
|
|
dispatchProof.trip = tripProof
|
|
|
|
|
dispatchProof.action = 'PDA_PICK'
|
|
|
|
|
check(context, 'F40 正式 PDA 拣货核平仍待显式装车', await pickAll(context, picker.token, dispatchNo), `dispatch=${dispatchNo}`)
|
|
|
|
|
dispatchProof.action = 'LOADING_COMPLETE'
|
|
|
|
|
const actualMeasurements = await resolveF40LoadingMeasurements(context, dispatchNo, producer, warehouseId)
|
|
|
|
|
await confirmFleetLoading(context, MODULE, tripProof, picker.token, actualMeasurements)
|
2026-10-22 11:56:47 +08:00
|
|
|
dispatchProof.action = 'READ_AUTOMATIC_DELIVERY'
|
|
|
|
|
const transfer = await readAutomaticDispatchDelivery(context, dispatchNo)
|
2026-10-20 10:35:52 +08:00
|
|
|
check(context, 'F40 平台装车后一次安排本片完整客户派送', Boolean(transfer), `dispatch=${dispatchNo}`)
|
|
|
|
|
const deliveries = transfer.deliveries, outbounds = transfer.outbounds
|
|
|
|
|
check(context, 'F40 同客户同目的仓一片对应唯一出库与派送', deliveries?.length === 1 && outbounds?.length === 1
|
|
|
|
|
&& id(deliveries[0].customerId) === customerId && id(outbounds[0].customerId) === customerId
|
|
|
|
|
&& deliveries[0].outboundNo === outbounds[0].outboundNo && Number(deliveries[0].packageCount) === 1
|
|
|
|
|
&& deliveries[0].destination?.key === matches[0].destination.key, 'one-actual-customer-destination')
|
|
|
|
|
const outboundNo = outbounds[0].outboundNo
|
2026-10-22 11:56:47 +08:00
|
|
|
dispatchProof.arrangement = transfer.data?.documents; dispatchProof.outboundNo = outboundNo
|
2026-10-20 10:35:52 +08:00
|
|
|
dispatchProof.deliveryNo = deliveries[0].deliveryNo
|
|
|
|
|
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 派送实际物品来源和一箱数量严格匹配', id(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}`)
|
2026-10-22 11:56:47 +08:00
|
|
|
dispatchProof.action = 'VERIFY_AUTOMATIC_DEPARTURE'
|
|
|
|
|
const ready = await verifyAutomaticDeparture(context, picker.token, dispatchNo)
|
2026-10-20 10:35:52 +08:00
|
|
|
check(context, 'F40 双端当前公共版本发车门禁就绪', Boolean(ready), `dispatch=${dispatchNo}`)
|
2026-10-22 11:56:47 +08:00
|
|
|
check(context, 'F40 自动发车真实出库一箱', Number(ready.data.counts?.shippedPackageCount) === 1, `dispatch=${dispatchNo}`)
|
|
|
|
|
dispatchProof.departure = ready.data; dispatchProof.loadingReplay = tripProof.loadingCommand?.replay; dispatchProof.stage = 'DELIVERING'
|
2026-10-20 10:35:52 +08:00
|
|
|
const outbound = await api(context, 'GET', '回查实际来源出库完成', `/api/platform/v1/last-leg/outbound-orders/${outboundNo}`)
|
2026-10-23 05:21:30 +08:00
|
|
|
check(context,'F40 实际出库单 OUTBOUNDED 且来源入仓码匹配', dispatchOutboundMatches(outbound, outbounds[0], dispatchNo, deliveries)
|
2026-10-20 10:35:52 +08:00
|
|
|
&& outbound.pallets?.length > 0 && outbound.pallets.every(row => row.inboundCode === producer.inboundCode), `outbound=${outboundNo}`)
|
|
|
|
|
const beforeDelivery = await api(context, 'GET', '回查实际发车推进本片派送及其版本', deliveryPath)
|
|
|
|
|
check(context, 'F40 实际发车已推进派送DELIVERING且身份未漂移', beforeDelivery.order?.status === 'DELIVERING'
|
|
|
|
|
&& id(beforeDelivery.order.customerId) === customerId && beforeDelivery.order.outboundNo === outboundNo
|
|
|
|
|
&& validVersion(beforeDelivery.order.version), 'actual-departure-delivery-facts')
|
|
|
|
|
dispatchProof.action = 'SAVE_RECEIPT_AND_EVENT_RECOVERY'
|
|
|
|
|
const completed = await withDeliveryEventRecovery(context, {
|
|
|
|
|
marker: context.context.f40BaseMarker, customerId: id(customerId), deliveryId: id(beforeDelivery.order.id),
|
|
|
|
|
sourceInboundId: id(producer.sourceInboundId), sourceOrderId: id(producer.sourceOrderId), rootOrderId: id(producer.rootOrderId),
|
|
|
|
|
}, id(picker.warehouseId), index, () => 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}`)
|
|
|
|
|
const nativeCompletion = await waitState(() => api(context, 'GET', 'F40 回查本片全部正常签收汇总阶段', `${DISPATCH}/${dispatchNo}`),
|
|
|
|
|
value => value?.status === 'ACTIVE' && value?.stage === 'DELIVERED')
|
|
|
|
|
check(context, 'F40 正常签收自动汇总原生整车DELIVERED', nativeCompletion?.dispatchNo === dispatchNo
|
|
|
|
|
&& nativeCompletion.status === 'ACTIVE' && nativeCompletion.stage === 'DELIVERED', 'actual-normal-signature-completion')
|
|
|
|
|
dispatchProof.normalCompletion = nativeCompletion; dispatchProof.stage = 'DELIVERED'
|
|
|
|
|
const tripClosure = await finishFleetTrip(context, MODULE, tripProof)
|
|
|
|
|
dispatchProof.tripClosure = tripClosure
|
|
|
|
|
await context.checkpoint()
|
|
|
|
|
return { producer, dispatchNo, outboundNo, deliveryNo: deliveries[0].deliveryNo,
|
|
|
|
|
receiptVersion: completed.order.version, signedAt: completed.order.signedAt, receiptSnapshot:completed, tripNo: tripProof.tripNo, tripClosure }
|
|
|
|
|
} catch (error) {
|
|
|
|
|
dispatchProof.stage = 'FAILED'; dispatchProof.failedAction = dispatchProof.action || 'CREATE'
|
|
|
|
|
try { await context.checkpoint() } finally { throw error }
|
|
|
|
|
}
|
2026-09-30 14:27:45 +08:00
|
|
|
}
|
|
|
|
|
|
2026-10-03 08:13:04 +08:00
|
|
|
/** Only the first actual FBA signature is faulted; caller performs the normal receipt command. */
|
|
|
|
|
export async function withDeliveryEventRecovery(context, faultScope, warehouseId, index, sign) {
|
|
|
|
|
const executor = context.context.f40DeliveryEventFaultExecutor
|
|
|
|
|
if (!executor || index !== 0) return sign()
|
2026-10-03 08:20:27 +08:00
|
|
|
const proof = context.context.f40RootFulfillmentProof
|
|
|
|
|
if (!proof) throw Error('F40 parent fulfillment proof required')
|
2026-10-03 08:13:04 +08:00
|
|
|
if (proof.deliveryEventRecovery) throw Error('F40 delivery recovery already attempted')
|
|
|
|
|
const reader = context.context.f40ReadOnlyInventoryEvidence
|
|
|
|
|
if (typeof reader !== 'function') throw Error('F40 complete inventory evidence reader required')
|
|
|
|
|
const record = proof.deliveryEventRecovery = { scope: faultScope, status: 'PREPARING', restored: false }
|
|
|
|
|
const inventoryScope = { marker: faultScope.marker, customerId: faultScope.customerId, warehouseId }
|
|
|
|
|
try {
|
|
|
|
|
record.inventoryBefore = await reader(inventoryScope)
|
|
|
|
|
record.preparationAttempted = true
|
|
|
|
|
await executor.prepare(faultScope)
|
|
|
|
|
record.status = 'SIGNING'
|
|
|
|
|
const result = await sign()
|
|
|
|
|
record.status = 'RECOVERING'
|
|
|
|
|
record.proof = await executor.readProof(faultScope)
|
|
|
|
|
assertTestDeliveryFaultProof(record.proof, faultScope)
|
|
|
|
|
if (record.proof.restored !== true) throw Error('F40 delivery producer restoration missing')
|
|
|
|
|
record.inventoryAfter = await reader(inventoryScope)
|
|
|
|
|
if (record.inventoryBefore?.scope?.coverage !== 'CUSTOMER_WAREHOUSE_COMPLETE'
|
|
|
|
|
|| record.inventoryBefore.scope.customerId !== faultScope.customerId
|
|
|
|
|
|| record.inventoryBefore.scope.warehouseId !== warehouseId
|
|
|
|
|
|| !['balances','positions','ledger'].every(key => Array.isArray(record.inventoryBefore[key]))
|
|
|
|
|
|| !isDeepStrictEqual(record.inventoryBefore, record.inventoryAfter)) throw Error('F40 full inventory drift during delivery recovery')
|
|
|
|
|
record.inventoryUnchanged = true
|
|
|
|
|
record.status = 'VERIFIED'
|
|
|
|
|
return result
|
|
|
|
|
} catch (error) {
|
|
|
|
|
record.status = 'FAILED'
|
|
|
|
|
throw error
|
|
|
|
|
} finally {
|
|
|
|
|
if (record.preparationAttempted) try {
|
|
|
|
|
record.restoration = await executor.cleanup()
|
|
|
|
|
record.restored = record.restoration?.restored === true && record.restoration?.healthy === true && record.restoration?.proxyStopped === true
|
|
|
|
|
if (!record.restored) throw Error('F40 delivery restoration incomplete')
|
|
|
|
|
} catch (error) {
|
|
|
|
|
record.status = 'RESTORATION_FAILED'
|
|
|
|
|
record.restored = false
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 14:27:45 +08:00
|
|
|
/** Explicit TEST orchestrator, called by the parent acceptance context; never synthesizes an event. */
|
|
|
|
|
export async function run(context) {
|
2026-09-30 15:16:39 +08:00
|
|
|
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
|
2026-09-30 14:27:45 +08:00
|
|
|
const proof = context.createdResources.phase5RootFulfillment = {
|
|
|
|
|
marker: context.marker, rootOrderId: null, childOrderId: null, customerId: null,
|
|
|
|
|
fragments: [], signatures: [], snapshots: [], finalStatus: 'PREPARING', outboxProof: null,
|
2026-09-30 15:16:39 +08:00
|
|
|
currentStage:'INITIALIZING', failedStage:null,
|
2026-09-30 14:27:45 +08:00
|
|
|
cleanup: 'Fresh completed root/child/cabinet/PDA/receipt audit resources remain as acceptance evidence',
|
|
|
|
|
}
|
|
|
|
|
const signatureOrder = context.context?.f40SignatureOrder || 'ROOT_FIRST'
|
2026-09-30 17:13:11 +08:00
|
|
|
check(context,'F40 签收次序为显式正式来源顺序',
|
|
|
|
|
['ROOT_FIRST','CHILD_FIRST','ROOT_SIGNED_REMAINDER_CANCELLED'].includes(signatureOrder), signatureOrder)
|
|
|
|
|
const cancelRemainder = signatureOrder === 'ROOT_SIGNED_REMAINDER_CANCELLED'
|
2026-09-30 14:27:45 +08:00
|
|
|
proof.signatureOrder = signatureOrder
|
2026-10-03 06:26:33 +08:00
|
|
|
const taxMode=context.context?.f40TaxMode||'BUY_DECLARATION'
|
|
|
|
|
check(context,'F40 税制必须为显式支持值',['BUY_DECLARATION','TAX_REFUND'].includes(taxMode),taxMode)
|
|
|
|
|
proof.taxMode=taxMode;proof.taxCustoms=[]
|
2026-09-30 14:27:45 +08:00
|
|
|
const scope = Object.create(context)
|
|
|
|
|
scope.marker = `${context.marker}-F40`
|
|
|
|
|
scope.context = { ...context.context, orderDestinationType: 'FBA', orderFulfillmentType: 'STANDARD',
|
2026-10-03 08:20:27 +08:00
|
|
|
f40RootFulfillmentProof: proof,
|
2026-09-30 17:13:11 +08:00
|
|
|
orderStopAtApproved:cancelRemainder,
|
2026-10-03 06:26:33 +08:00
|
|
|
orderProfile: { taxMode, fulfillmentType: 'STANDARD' },f40TaxMode:taxMode,f40TaxEvidence:proof.taxCustoms,
|
2026-09-30 14:27:45 +08:00
|
|
|
waitSplitVariant: { skuCount: 1, packageCountPerSku: 2, packageCounts: [2] },
|
2026-09-30 15:16:39 +08:00
|
|
|
phase4OverseasPdaOperations: { completedFixtures: [] },
|
2026-09-30 14:27:45 +08:00
|
|
|
allowAnyExistingPallet: true, f40BaseMarker: `${context.marker}-F40` }
|
|
|
|
|
scope.createdResources = { phase3OrderSplitting: { retainedAuditRecordIds: [] } }
|
2026-09-30 15:16:39 +08:00
|
|
|
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)
|
2026-10-03 06:26:33 +08:00
|
|
|
if(taxMode==='TAX_REFUND')scope.context.f40TaxMerchantToken=login.accessToken
|
2026-09-30 15:16:39 +08:00
|
|
|
check(scope,'F40 客户会话和本轮客户 fixture 一致', Boolean(expectedCustomerId)
|
|
|
|
|
&& (!credentials.customerId || credentials.customerId === expectedCustomerId),'exact-fixture-customer')
|
2026-09-30 14:27:45 +08:00
|
|
|
await domesticLogin(scope)
|
2026-09-30 15:16:39 +08:00
|
|
|
proof.currentStage = 'ORDER_CREATION'
|
2026-09-30 14:27:45 +08:00
|
|
|
await stage(scope, 'orders')
|
|
|
|
|
const orderFixture = { ...scope.createdResources.orders }
|
2026-09-30 15:16:39 +08:00
|
|
|
check(scope,'F40 正式订单创建客户与本轮登录客户一致', id(orderFixture.tenantId) === expectedCustomerId,
|
|
|
|
|
'actual-order-customer-matches-profile')
|
2026-09-30 17:13:11 +08:00
|
|
|
if (cancelRemainder) {
|
|
|
|
|
const approvedRoot = fulfillmentHistorySnapshot(await api(scope,'GET','取消专项回查新根真实审核状态',
|
|
|
|
|
query(`/api/platform/v1/orders/first-leg/${orderFixture.orderId}`,{tenantId:expectedCustomerId})))
|
|
|
|
|
check(scope,'F40 取消专项新根必须为原始 APPROVED 而非快照推断',approvedRoot.role === 'MASTER'
|
|
|
|
|
&& approvedRoot.status === 'APPROVED' && approvedRoot.orderId === id(orderFixture.orderId)
|
|
|
|
|
&& id(approvedRoot.rawOrder?.tenantId) === expectedCustomerId,'raw-approved-fresh-root')
|
|
|
|
|
proof.snapshots.push({phase:'APPROVED_NEW_ROOT',root:approvedRoot})
|
|
|
|
|
}
|
2026-09-30 15:16:39 +08:00
|
|
|
proof.currentStage = 'DOMESTIC_MEASUREMENT'
|
2026-09-30 14:27:45 +08:00
|
|
|
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}`)
|
2026-09-30 15:16:39 +08:00
|
|
|
proof.currentStage = 'ROOT_LOAD_AND_SHIP'
|
2026-09-30 14:27:45 +08:00
|
|
|
const first = await fragment(scope, rootInbound, rootOrderId, 0)
|
2026-10-09 17:21:56 +08:00
|
|
|
if (scope.context.prepareDomesticPdaH5) {
|
|
|
|
|
check(scope, '国内H5待拣任务已准备且未执行拣货', first?.preparedForH5 === true && Boolean(first.taskId), 'PREPARED_NOT_VERIFIED')
|
|
|
|
|
proof.domesticPdaH5Fixture = first
|
|
|
|
|
proof.finalStatus = 'PREPARED_NOT_VERIFIED'
|
|
|
|
|
proof.currentStage = 'DOMESTIC_PDA_H5_READY'
|
|
|
|
|
proof.cleanup = 'Fresh customer/order/container/task retained for manual H5 execution; no picking, completion or overseas work executed'
|
|
|
|
|
return proof
|
|
|
|
|
}
|
2026-09-30 14:27:45 +08:00
|
|
|
proof.fragments.push(first)
|
2026-09-30 15:16:39 +08:00
|
|
|
proof.currentStage = 'REMAINDER_IDENTITY'
|
2026-09-30 14:27:45 +08:00
|
|
|
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 }))
|
2026-10-03 06:26:33 +08:00
|
|
|
proof.remainderTaxIdentity = {taxMode,customerId,rootOrderId,childOrderId,rootOrderNo:orderFixture.orderNo,
|
|
|
|
|
rawChild:childDetail.order,rootInbound:rootInbound.summary,childInbound:childInbound.summary}
|
|
|
|
|
await verifyRemainderTaxIdentity(scope,proof.remainderTaxIdentity,login.accessToken)
|
2026-09-30 17:13:11 +08:00
|
|
|
let second = null
|
|
|
|
|
if (cancelRemainder) {
|
|
|
|
|
check(scope,'F40 取消专项真实余量未装且原始 APPROVED',
|
|
|
|
|
validUnloadedRemainder(tree,fulfillmentHistorySnapshot(childDetail),childInbound?.summary,
|
|
|
|
|
customerId,rootOrderId,childOrderId),'one-approved-unloaded-remainder')
|
|
|
|
|
proof.unloadedRemainder = {childOrderId,childInboundId:id(childRows[0].id),tree,summary:childInbound.summary}
|
|
|
|
|
} else {
|
|
|
|
|
scope.marker = `${scope.context.f40BaseMarker}-CHILD`
|
|
|
|
|
proof.currentStage = 'CHILD_LOAD_AND_SHIP'
|
|
|
|
|
second = await fragment(scope, childInbound, rootOrderId, 1)
|
|
|
|
|
proof.fragments.push(second)
|
|
|
|
|
}
|
2026-09-30 15:16:39 +08:00
|
|
|
proof.currentStage = 'FULFILLMENT_TREE'
|
2026-09-30 14:27:45 +08:00
|
|
|
const finalTree = await api(scope, 'GET', '两柜装完后核对数量守恒履约树',
|
|
|
|
|
query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`, { tenantId: customerId }))
|
2026-09-30 17:13:11 +08:00
|
|
|
check(scope, 'F40 真实同根实装及余量数量守恒', cancelRemainder
|
|
|
|
|
? validUnloadedRemainder(finalTree,fulfillmentHistorySnapshot(childDetail),childInbound?.summary,
|
|
|
|
|
customerId,rootOrderId,childOrderId)
|
|
|
|
|
: validTwoFragmentTree(finalTree, rootOrderId, childOrderId)
|
|
|
|
|
&& first.containerBookingId !== second.containerBookingId, `root=${rootOrderId}, child=${childOrderId}`)
|
2026-09-30 14:27:45 +08:00
|
|
|
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 })
|
2026-10-08 17:07:57 +08:00
|
|
|
const producers = cancelRemainder ? [first] : [first,second]
|
|
|
|
|
proof.currentStage = 'OVERSEAS_SOURCE_WAREHOUSE'
|
|
|
|
|
check(scope, 'F40 实装来源真实装柜事件已消费', await awaitProducerConsumption(scope,producers), `producers=${producers.length}`)
|
|
|
|
|
const warehouse = await resolveF40ReceivingWarehouse(scope,producers)
|
|
|
|
|
proof.receivingWarehouseResolution = warehouse
|
2026-09-30 15:16:39 +08:00
|
|
|
proof.currentStage = 'OVERSEAS_PDA_IDENTITY'
|
2026-10-08 17:07:57 +08:00
|
|
|
const access = await resolvePickerAccess(scope)
|
2026-09-30 14:27:45 +08:00
|
|
|
check(scope, 'F40 实际海外接柜仓和PDA权限有效', Boolean(warehouse?.id && access), 'resourcePresent')
|
|
|
|
|
scope.marker = `${scope.context.f40BaseMarker}-PDA`
|
|
|
|
|
const picker = await createPicker(scope, 7, warehouse.id, access)
|
2026-09-30 15:16:39 +08:00
|
|
|
try {
|
2026-09-30 14:27:45 +08:00
|
|
|
check(scope, 'F40 海外PDA操作员会话有效', Boolean(picker?.token), 'tokenPresent')
|
|
|
|
|
picker.warehouseId = warehouse.id
|
|
|
|
|
check(scope, 'F40 客户会话属于该根订单客户', id(profile.id) === customerId, 'customerOwned')
|
2026-09-30 17:13:11 +08:00
|
|
|
scope.createdResources.phase4InboundProducerFixtures = { marker: scope.marker, containers:producers }
|
2026-09-30 14:27:45 +08:00
|
|
|
scope.createdResources.phase4OverseasPda = { marker:scope.marker, generatedPalletsByInbound:{},
|
|
|
|
|
palletIds:[],locationIds:[],completedContainerFacts:[], completedContainers:[],
|
2026-09-30 17:13:11 +08:00
|
|
|
overseasWarehouseId:warehouse.id, overseasWarehouseName:warehouse.name, customerId, finalStatus:null,
|
|
|
|
|
receivingCompletionEvidence:{} }
|
|
|
|
|
preserveReceivingEvidence(proof,scope)
|
2026-09-30 15:16:39 +08:00
|
|
|
proof.currentStage = 'OVERSEAS_PLAN_CONVERSION'
|
2026-09-30 17:13:11 +08:00
|
|
|
check(scope, 'F40 实装来源真实计划及托盘转换成功', await convertReceivingContainers(scope,producers), `producers=${producers.length}`)
|
|
|
|
|
const signatureSources = signatureOrder === 'CHILD_FIRST' ? [second,first] : producers
|
2026-09-30 14:27:45 +08:00
|
|
|
for (const [index, producer] of signatureSources.entries()) {
|
2026-09-30 15:16:39 +08:00
|
|
|
proof.currentStage = `OVERSEAS_RECEIVING_${index+1}`
|
2026-09-30 14:27:45 +08:00
|
|
|
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}`)
|
2026-09-30 15:16:39 +08:00
|
|
|
proof.currentStage = `DISPATCH_AND_SIGNATURE_${index+1}`
|
2026-09-30 14:27:45 +08:00
|
|
|
const signed = await sendFragment(scope,producer,picker,login.accessToken,index)
|
|
|
|
|
proof.signatures.push(signed)
|
2026-09-30 15:16:39 +08:00
|
|
|
proof.currentStage = `COMPLETION_GATE_${index+1}`
|
2026-09-30 14:27:45 +08:00
|
|
|
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)
|
2026-09-30 17:13:11 +08:00
|
|
|
proof.snapshots.at(-1).projections = await verifyRootProjections(scope,customerId,rootOrderId,login.accessToken,observed)
|
2026-09-30 14:27:45 +08:00
|
|
|
}
|
2026-09-30 17:13:11 +08:00
|
|
|
if (cancelRemainder) await cancelUnloadedRemainder(scope,proof,customerId,rootOrderId,childOrderId,
|
|
|
|
|
first.inboundId,id(childRows[0].id))
|
2026-09-30 15:16:39 +08:00
|
|
|
proof.currentStage = 'RECEIPT_REPLAY'
|
2026-09-30 14:27:45 +08:00
|
|
|
const stable = await state(scope,customerId,rootOrderId,childOrderId,first.inboundId)
|
2026-09-30 17:13:11 +08:00
|
|
|
proof.finalProjections = await verifyRootProjections(scope,customerId,rootOrderId,login.accessToken,stable)
|
2026-09-30 15:16:39 +08:00
|
|
|
const finalSignedAt = proof.signatures.map(row => row.signedAt).sort().at(-1)
|
2026-10-08 17:07:57 +08:00
|
|
|
check(scope,'F40 根完成时间为实际有效来源签收时间最大值', rootCompletionTimeMatches(stable.root.rawOrder?.completedAt, proof.signatures.map(row => row.signedAt)),
|
2026-09-30 15:16:39 +08:00
|
|
|
`completedAt=${stable.root.rawOrder?.completedAt}, maxSignedAt=${finalSignedAt}`)
|
2026-09-30 17:13:11 +08:00
|
|
|
const reader = context.context?.f40ReadOnlyOutboxEvidence
|
|
|
|
|
const outboxRequest = {customerId,rootOrderId,sourceOrderIds:[rootOrderId,childOrderId],
|
|
|
|
|
eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED'}
|
|
|
|
|
const validOutbox = evidence => cancelRemainder
|
|
|
|
|
? exactCancelledRemainderOutboxProof(evidence,customerId,rootOrderId,childOrderId)
|
|
|
|
|
: exactCompletionOutboxProof(evidence,customerId,rootOrderId,[rootOrderId,childOrderId])
|
|
|
|
|
if (typeof reader === 'function') {
|
|
|
|
|
proof.beforeReceiptReplayOutbox = await waitState(() => reader(outboxRequest),validOutbox)
|
|
|
|
|
check(scope,'F40 重复签收前完成事件各实际来源唯一发布',validOutbox(proof.beforeReceiptReplayOutbox),
|
|
|
|
|
'exact-source-before-receipt-replay')
|
|
|
|
|
}
|
2026-09-30 14:27:45 +08:00
|
|
|
// 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))}))
|
2026-09-30 17:13:11 +08:00
|
|
|
const documentPath = query(`/api/platform/v1/last-leg/delivery-orders/${signed.deliveryNo}/documents`,{customerId})
|
|
|
|
|
const deliveryPath = query(`/api/platform/v1/last-leg/delivery-orders/${signed.deliveryNo}`,{customerId})
|
|
|
|
|
const historyPath = query(`/api/platform/v1/last-leg/delivery-orders/${signed.deliveryNo}/documents/history`,{customerId})
|
|
|
|
|
const readReceipt = async target => {
|
|
|
|
|
target.documents = await api(scope,'GET','F40 回查签收资料完整绑定',documentPath)
|
|
|
|
|
target.delivery = await api(scope,'GET','F40 回查签收派送完整事实',deliveryPath)
|
|
|
|
|
target.revisions = await api(scope,'GET','F40 回查签收资料完整版本历史',historyPath)
|
|
|
|
|
return target
|
|
|
|
|
}
|
|
|
|
|
const beforeFresh = {documents:current,delivery:await api(scope,'GET','F40 新键前冻结派送业务与操作事实',deliveryPath),
|
|
|
|
|
revisions:await api(scope,'GET','F40 新键前冻结资料版本历史',historyPath)}
|
|
|
|
|
check(scope,'F40 新键前原签收事实保持完整稳定',
|
|
|
|
|
JSON.stringify(signed.receiptSnapshot) === JSON.stringify(beforeFresh.delivery),`delivery=${signed.deliveryNo}`)
|
2026-10-20 10:35:52 +08:00
|
|
|
const saveBody = {expectedVersion:wireVersion(current.version),idempotencyKey:`${scope.marker}-SAVE`,bolNo:current.bolNo || null,documents}
|
2026-09-30 17:13:11 +08:00
|
|
|
const receiptProof = {deliveryNo:signed.deliveryNo,beforeFresh,saveBody,afterFresh:{},afterSameKey:{}}
|
|
|
|
|
;(proof.receiptReplayProofs ||= []).push(receiptProof)
|
|
|
|
|
const saved = await api(scope,'PUT','重复已完成派送文件保存不得重发签收事实',
|
2026-09-30 14:27:45 +08:00
|
|
|
query(`/api/platform/v1/last-leg/delivery-orders/${signed.deliveryNo}/documents`,{customerId}),
|
2026-09-30 17:13:11 +08:00
|
|
|
saveBody)
|
|
|
|
|
receiptProof.saved = saved
|
|
|
|
|
const afterFresh = await readReceipt(receiptProof.afterFresh)
|
|
|
|
|
check(scope,'F40 新键资料保存仅递增一次版本及资料审计不得重复完成',
|
|
|
|
|
saved.replayed === false && validFreshReceiptSave(beforeFresh,afterFresh),`delivery=${signed.deliveryNo}`)
|
|
|
|
|
const sameKeyReplay = await api(scope,'PUT','F40 新资料命令原键原请求重放',documentPath,saveBody)
|
|
|
|
|
receiptProof.sameKeyReplay = sameKeyReplay
|
|
|
|
|
await readReceipt(receiptProof.afterSameKey)
|
|
|
|
|
check(scope,'F40 新资料命令原键重放资料版本历史派送业务及操作零漂移',
|
|
|
|
|
sameKeyReplay.replayed === true && JSON.stringify({...sameKeyReplay,replayed:false}) === JSON.stringify(saved)
|
|
|
|
|
&& JSON.stringify(afterFresh) === JSON.stringify(receiptProof.afterSameKey),`delivery=${signed.deliveryNo}`)
|
2026-09-30 14:27:45 +08:00
|
|
|
}
|
|
|
|
|
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})
|
2026-09-30 15:16:39 +08:00
|
|
|
proof.currentStage = 'READ_ONLY_OUTBOX_EVIDENCE'
|
2026-09-30 14:27:45 +08:00
|
|
|
if (typeof reader === 'function') {
|
2026-09-30 17:13:11 +08:00
|
|
|
proof.outboxProof = await reader(outboxRequest)
|
2026-09-30 14:27:45 +08:00
|
|
|
check(scope,'F40 每个实际来源完成Outbox唯一并已发布',
|
2026-09-30 17:13:11 +08:00
|
|
|
validOutbox(proof.outboxProof),
|
|
|
|
|
'exact-root/source/customer/readOnly-proof')
|
|
|
|
|
check(scope,'F40 新键重复签收完成Outbox数量与发布状态零漂移',
|
|
|
|
|
outboxCounts(proof.beforeReceiptReplayOutbox) === outboxCounts(proof.outboxProof),'before-after-receipt-replay-outbox')
|
2026-10-03 06:26:33 +08:00
|
|
|
if(taxMode==='TAX_REFUND')await verifyTaxDocumentsAfterCompletion(scope,proof.taxCustoms,login.accessToken)
|
2026-09-30 14:27:45 +08:00
|
|
|
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'
|
2026-09-30 15:16:39 +08:00
|
|
|
proof.failedStage = proof.currentStage
|
2026-09-30 14:27:45 +08:00
|
|
|
throw error
|
2026-09-30 17:13:11 +08:00
|
|
|
} finally { preserveReceivingEvidence(proof,scope); proof.currentStage = 'PDA_IDENTITY_CLEANUP'; await disablePicker(scope,picker,7) }
|
2026-09-30 15:16:39 +08:00
|
|
|
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'
|
2026-09-30 14:27:45 +08:00
|
|
|
return proof
|
|
|
|
|
}
|