This commit is contained in:
2026-09-15 14:18:18 +08:00
parent 6a2075fe4b
commit a474ca4d93
35 changed files with 751 additions and 228 deletions
+8
View File
@@ -37,6 +37,14 @@ export async function run(context) {
## 可用上下文
`mixed-container-receiving.mjs` 通过总入口 `--modules mixed-container-receiving` 执行,依赖客户资质准备。新建一票双托一件代发、一票普通 FBA、一票普通海外仓订单,固定同一起运仓和渠道,经正式接口混装同柜、完成国内装柜和船节点、按柜生成海外计划。使用本轮 PDA 身份交替执行 FBA 累计收货、代发整托收货、普通海外仓逐箱收货;每步核对全部票进度、同键重放和未齐正常完成拒绝。终点为 `MIXED_CONTAINER_INBOUND_COMPLETED`,不创建后续出库或派送,不代表仓储转运单混装。报告复用 `createdResources.dropshipPalletMatrix`;失败保留阻断,成功数据保留,finally 停用本轮 PDA 操作员。真实运行须环境授权,离线合同 `mixed-container-receiving-contract.test.mjs` 不能代替真实 HTTP。
一件代发矩阵出库前运行 `dropship-outbound-edge-cases.mjs` 的候选查询探针:先模拟平台首次打开页面只传海外仓,再传客户筛选,要求本轮每票全部托盘、箱数、仓位及 SKU 事实一致,并核对仓库与指定客户隔离。缺托、错误客户/仓库、A0400 或筛选后仓位漂移均阻断;候选达到服务端上限导致本轮数据不可见也不能按空场景通过。该探针不证明跨客户混柜造数已覆盖。
一件代发矩阵在每个剩余实物托盘收货前核对待收箱数,并以 `allowDifference=false` 尝试正常完成:必须返回 A0409,回查会话事实不变后才继续收货。这不覆盖显式差异完成,不得把正常完成门禁解读成系统禁止一切差异结案。
每托首次收货及重复扫描后,矩阵按本轮已提交的来源托盘、箱唛和入仓码独立计算期望进度,回查柜级及每票的总箱数、实收、待收三项。柜级总数正确但串票、重复行、缺票或逐票待收未刷新仍判失败。离线验证器包含三票交替进度样本,但该样本不等于三类混柜真实 HTTP 验收。
`dropship-browser-fixture.mjs` 通过总入口 `--modules dropship-browser-fixture` 执行,自动依赖客户资质准备;复用矩阵生产者创建一票四箱两托,完成国内装柜、船节点和按柜生成海外入库计划,明确停在 `READY_FOR_BROWSER_RECEIVING`。只准备新测试数据,不调用海外 PDA 收货或出库、不创建海外操作员;后续由浏览器页面验收。报告仍保存于 `createdResources.dropshipPalletMatrix`,准备通过不代表全流程通过,不自动删除数据。
`dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账。随后从本轮逐票库存候选创建一件代发专用出库,携带原始箱唛由 PDA 拣货、完成,核对终态、幂等和可用库存扣减;finally 停用本轮操作员。出库重放按业务身份和数据库毫秒时间精度核对。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;脚本覆盖不等于真实 HTTP 已通过,须检查当轮报告和库存审计。
@@ -5,6 +5,30 @@ const MODULE = 'dropship-pallet-matrix'
const OPTIONS = { scenario: 'dropship-outbound-edge-cases', deploymentBlocker: true }
const BASE = '/api/platform/v1/last-leg/outbound-orders'
// Expected progress comes from submitted fixture operations, never the server response.
export function receivingProgressMatches(snapshot, expected) {
const count = value => (typeof value === 'number' || typeof value === 'string' && /^\d+$/.test(value))
&& Number.isSafeInteger(Number(value)) && Number(value) >= 0 ? Number(value) : NaN
if (!Array.isArray(snapshot?.inbounds) || !Array.isArray(expected) || !expected.length) return false
if (new Set(expected.map(row => row.inboundCode)).size !== expected.length
|| snapshot.inbounds.length !== expected.length
|| new Set(snapshot.inbounds.map(row => row.inboundCode)).size !== expected.length) return false
let total = 0
let received = 0
for (const row of expected) {
if (!Number.isInteger(row.total) || !Number.isInteger(row.received)
|| row.total <= 0 || row.received < 0 || row.received > row.total) return false
const actual = snapshot.inbounds.find(item => item.inboundCode === row.inboundCode)
if (!actual || count(actual.totalPackages) !== row.total
|| count(actual.receivedPackages) !== row.received
|| count(actual.pendingPackages) !== row.total - row.received) return false
total += row.total
received += row.received
}
return count(snapshot.totalPackages) === total && count(snapshot.receivedPackages) === received
&& count(snapshot.pendingPackages) === total - received
}
function record(edge, name, response, passed, evidence = '') {
edge.context.assert(MODULE, `${edge.label}${name}`, response, passed, evidence, OPTIONS)
}
@@ -25,6 +49,41 @@ async function taskSnapshot(edge) {
return edge.call('边界测试回查出库冻结事实', 'GET', `${BASE}/${edge.task.outboundNo}`)
}
export async function outboundCandidateGuards(edge) {
const warehouse = encodeURIComponent(edge.warehouseId)
const customer = encodeURIComponent(edge.customerId)
const paths = [
['平台首次查询不传客户', `${BASE}/one-click-dropship/candidates?warehouseId=${warehouse}`],
['平台指定客户查询', `${BASE}/one-click-dropship/candidates?customerId=${customer}&warehouseId=${warehouse}`],
]
const snapshots = []
for (const [name, path] of paths) {
const rows = await edge.call(name, 'GET', path)
edge.check(`${edge.label}${name}返回候选数组`, null, Array.isArray(rows))
edge.check(`${edge.label}${name}仓库隔离`, null,
rows.every(row => String(row.overseasWarehouseId) === String(edge.warehouseId)))
if (name === '平台指定客户查询') {
edge.check(`${edge.label}指定客户不返回其他客户库存`, null,
rows.every(row => String(row.customerId) === String(edge.customerId)))
}
const own = rows.filter(row => edge.group.some(x => x.source.inboundCode === row.inboundCode))
for (const record of edge.group) {
const selected = own.filter(row => row.inboundCode === record.source.inboundCode)
const expectedPallets = [...new Set(record.task.pallets.map(p => p.palletNo))].sort()
const actualPallets = [...new Set(selected.map(row => row.palletNo))].sort()
edge.check(`${record.label}${name}全部托盘与箱数完整`, null,
JSON.stringify(expectedPallets) === JSON.stringify(actualPallets)
&& selected.reduce((sum, row) => sum + Number(row.availablePackages), 0) === record.expectedCartonMarks.length
&& selected.every(row => String(row.customerId) === String(edge.customerId)))
}
snapshots.push(own.map(row => ({ inbound: row.inboundCode, pallet: row.palletNo,
position: String(row.inventoryPositionId), sku: row.skuCode, packages: Number(row.availablePackages) }))
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))))
}
edge.check(`${edge.label}客户筛选前后本轮库存事实一致`, null,
JSON.stringify(snapshots[0]) === JSON.stringify(snapshots[1]))
}
export async function outboundCreationGuards(edge) {
const before = await candidates(edge)
const first = edge.body.items[0]
@@ -3,7 +3,78 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
import { sameOutboundTask } from './dropship-pallet-matrix.mjs'
import { probePartialPick } from './dropship-outbound-edge-cases.mjs'
import { receivingProgressMatches, outboundCandidateGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs'
test('receiving verifier follows three independent inbound codes through interleaved progress', () => {
const expected = [{ inboundCode: 'FBA', total: 5, received: 0 },
{ inboundCode: 'DROPSHIP', total: 4, received: 0 }, { inboundCode: 'OVERSEAS', total: 3, received: 0 }]
for (const [index, count] of [[0, 2], [1, 1], [2, 3], [0, 3], [1, 3]]) {
expected[index].received += count
const snapshot = { totalPackages: 12, receivedPackages: expected.reduce((sum, x) => sum + x.received, 0),
pendingPackages: expected.reduce((sum, x) => sum + x.total - x.received, 0),
inbounds: expected.map(x => ({ inboundCode: x.inboundCode, totalPackages: x.total,
receivedPackages: x.received, pendingPackages: x.total - x.received })) }
assert.equal(receivingProgressMatches(snapshot, expected), true)
const stale = structuredClone(snapshot)
stale.inbounds[index].pendingPackages += 1
assert.equal(receivingProgressMatches(stale, expected), false)
const swapped = structuredClone(snapshot)
swapped.inbounds[0].receivedPackages += 1
swapped.inbounds[1].receivedPackages -= 1
assert.equal(receivingProgressMatches(swapped, expected), false, 'correct container total cannot conceal wrong ownership')
const duplicate = structuredClone(snapshot)
duplicate.inbounds[2] = duplicate.inbounds[0]
assert.equal(receivingProgressMatches(duplicate, expected), false)
}
assert.equal(receivingProgressMatches({ inbounds: [] }, []), false)
})
test('receiving verifier does not interpret absent or malformed progress as zero', () => {
const expected = [{ inboundCode: 'IN', total: 4, received: 0 }]
const good = { totalPackages: 4, receivedPackages: 0, pendingPackages: 4,
inbounds: [{ inboundCode: 'IN', totalPackages: 4, receivedPackages: 0, pendingPackages: 4 }] }
for (const value of [null, undefined, '', false, -1, 0.5]) {
const snapshot = structuredClone(good)
snapshot.inbounds[0].receivedPackages = value
assert.equal(receivingProgressMatches(snapshot, expected), false)
}
assert.equal(receivingProgressMatches(good, expected), true)
})
function candidateProbe(transform = rows => rows) {
const rows = ['P1', 'P2'].map((palletNo, index) => ({ palletNo, inventoryPositionId: index + 1,
inboundCode: 'IN-1', skuCode: 'SKU', availablePackages: 2, customerId: 'C1', overseasWarehouseId: 'W1' }))
const paths = []
return { paths, edge: { label: 'MULTI', customerId: 'C1', warehouseId: 'W1',
group: [{ label: 'MULTI', source: { inboundCode: 'IN-1' },
task: { pallets: [{ palletNo: 'P1' }, { palletNo: 'P2' }] }, expectedCartonMarks: ['a', 'b', 'c', 'd'] }],
check: (name, _response, passed) => assert.ok(passed, name),
call: async (_name, method, path) => {
assert.equal(method, 'GET'); paths.push(path)
return transform(structuredClone(rows), path)
},
} }
}
test('candidate probe sends the actual platform query without customer and checks both pallets', async () => {
const { edge, paths } = candidateProbe()
await outboundCandidateGuards(edge)
assert.equal(new URL(paths[0], 'https://example.invalid').searchParams.has('customerId'), false)
assert.equal(new URL(paths[1], 'https://example.invalid').searchParams.get('customerId'), 'C1')
})
for (const [name, transform] of [
['missing second pallet', rows => rows.slice(0, 1)],
['wrong warehouse', rows => rows.map(row => ({ ...row, overseasWarehouseId: 'W2' }))],
['wrong customer', rows => rows.map(row => ({ ...row, customerId: 'C2' }))],
['bad request response', () => ({ code: 'A0400', message: 'Bad request' })],
['candidate identity drift after filtering', (rows, path) => path.includes('customerId=')
? rows.map(row => ({ ...row, inventoryPositionId: row.inventoryPositionId + 10 })) : rows],
]) {
test(`candidate probe fails closed on ${name}`, async () => {
await assert.rejects(outboundCandidateGuards(candidateProbe(transform).edge))
})
}
test('browser fixture is explicit and stops before overseas receipt or outbound', () => {
assert.deepEqual(normalizeModuleOrder(['dropship-browser-fixture']), ['merchant-qualification-fixture', 'dropship-browser-fixture'])
@@ -47,6 +118,21 @@ test('outbound replay preserves business identity and database millisecond times
const source = readFileSync(new URL('./dropship-pallet-matrix.mjs', import.meta.url), 'utf8')
const edges = readFileSync(new URL('./dropship-outbound-edge-cases.mjs', import.meta.url), 'utf8')
test('matrix runs candidate probes before selecting inventory or creating outbound', () => {
const probe = source.indexOf('await outboundCandidateGuards(')
assert.ok(probe > 0)
assert.ok(probe < source.indexOf("const all = await call('读取专用出库可用库存'"))
})
test('each remaining pallet tests normal completion rejection and zero drift before receiving', () => {
const loop = source.slice(source.indexOf('for (const [index, sourcePalletNo] of sourceNos.entries())'),
source.indexOf("check(`${label}整柜实收总箱数守恒`"))
assert.match(loop, /allowDifference: false/)
assert.match(loop, /premature.body\?\.code === 'A0409'/)
assert.match(loop, /JSON.stringify\(unchanged\) === JSON.stringify\(before\)/)
assert.ok(loop.indexOf('JSON.stringify(unchanged)') < loop.indexOf("const received = await call"))
})
test('outbound edge probes preserve failures and require zero drift before continuing', () => {
assert.match(edges, /deploymentBlocker: true/)
assert.match(edges, /四箱任务先拣一箱成功/)
@@ -11,7 +11,8 @@ import { run as completeCustoms } from './phase3-customs.mjs'
import { run as verifyCarrierQuery } from './phase3-carrier-schedule-queries.mjs'
import { runUntilPickupReady } from './phase4-carrier-nodes.mjs'
import { resolvePickerAccess, createPicker, disablePicker, uploadPhotos } from './phase4-overseas-pda.mjs'
import { outboundCreationGuards, outboundTaskGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs'
import { receivingProgressMatches, outboundCandidateGuards, outboundCreationGuards, outboundTaskGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs'
import { receiveMixedContainer } from './mixed-container-receiving.mjs'
const MODULE = 'dropship-pallet-matrix'
const BASE = '/api/platform/v1/first-leg/pda-dropship'
@@ -37,7 +38,11 @@ export async function prepareBrowserFixture(context) {
return runWorkflow(context, true)
}
async function runWorkflow(context, stopBeforeOverseasPda) {
export async function runMixedContainer(context) {
return runWorkflow(context, false, true)
}
async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = false) {
const marker = context.marker
const savedOptions = context.options.prepareDropshipManualCount
const savedContext = { ...context.context }
@@ -48,25 +53,40 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
context.assert(MODULE, name, response, passed, details, OPTIONS)
if (!passed) throw new Error(name)
}
async function prepare(label, palletCount, mode = {}) {
async function prepare(label, palletCount, mode = {}, destinationType = null) {
context.marker = `${marker}-${label}`
for (const key of ['orders', 'inboundMeasurement', 'ocrInbound', 'testInbound', 'dropshipManualCount']) {
delete context.createdResources[key]
}
for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key]
context.context.orderDestinationType = 'OVERSEAS'
context.context.orderFulfillmentType = 'ONE_CLICK_DROPSHIP'
context.context.orderDestinationType = destinationType || 'OVERSEAS'
context.context.orderFulfillmentType = destinationType ? 'STANDARD' : 'ONE_CLICK_DROPSHIP'
context.context.orderProfile = { taxMode: 'BUY_DECLARATION', pickupMethod: 'SELF_EXPRESS', containerMode: 'LCL' }
context.context.waitSplitVariant = { skuCount: 1, packageCountPerSku: 4, packageCounts: [4], palletCount }
if (label.startsWith('MULTI-MERGE-')) {
context.context.waitSplitVariant.skuCodes = [`SKU-SHARED-${marker}`.slice(0, 64)]
}
context.options.prepareDropshipManualCount = true
context.options.prepareDropshipManualCount = !destinationType
const start = context.steps.length
await context.executeModule(`${MODULE}-${label}-order`, () => createOrder(context))
await context.executeModule(`${MODULE}-${label}-measurement`, () => measure(context))
check(`${label}本轮订单和测量前置全部通过`, null,
!context.steps.slice(start).some((step) => !step.passed))
if (destinationType) {
const measurement = { ...context.createdResources.inboundMeasurement }
const order = { ...context.createdResources.orders }
const response = await context.request('GET',
`/api/platform/inbounds/${measurement.inboundId}?tenantId=${measurement.tenantId}`)
const detail = context.apiSuccess(MODULE, `${label}普通订单待分柜快照`, response, '', OPTIONS)
check(`${label}普通订单四箱待分柜且类型未漂移`, response,
detail?.summary?.processStatus === 'WAIT_SPLIT' && measurement.actualPackageCount === 4
&& order.fulfillmentType === 'STANDARD' && order.destinationType === destinationType)
const record = { label, order, measurement, summary: detail.summary, task: null,
source: { inboundId: String(measurement.inboundId), inboundCode: detail.summary.inboundCode },
expectedCartonMarks: Array.from({ length: 4 }, (_, index) => `${detail.summary.inboundCode}#4-${index + 1}`) }
records.push(record)
return record
}
const source = context.createdResources.dropshipManualCount
check(`${label}存在本轮未清点任务`, null, Boolean(source?.taskId))
const task = await completeDropshipPalletization(context, {
@@ -112,11 +132,11 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
check(`${label}${name}成功`, response, response?.body?.code === '200' && Boolean(value))
return value
}
const pallets = [...new Map(group.flatMap((item) => item.task.pallets)
const pallets = [...new Map(group.flatMap((item) => item.task?.pallets || [])
.map((pallet) => [String(pallet.palletId), pallet])).values()]
for (const record of group) {
const fee = await ensureInitialCalculated(context, { ...record.measurement,
tenantId: String(record.task.tenantId), inboundId: record.source.inboundId,
tenantId: String(record.order.tenantId), inboundId: record.source.inboundId,
finalStatus: 'APPROVED', processStatus: record.summary.processStatus }, OPTIONS)
check(`${record.label}初始费用已计算`, null, Boolean(fee))
}
@@ -136,14 +156,14 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
check(`${label}${name}没有混入其他订单`, null,
detail.items?.every((item) => expectedIds.has(String(item.inboundId))))
check(`${label}${name}整托汇总不重复计算箱数`, null,
plan.items?.length === pallets.length
(mixedContainer || plan.items?.length === pallets.length)
&& plan.items.reduce((sum, item) => sum + Number(item.packageCount), 0)
=== group.reduce((sum, record) => sum + record.expectedCartonMarks.length, 0))
for (const record of group) {
const actual = detail.items.filter((item) => String(item.inboundId) === record.source.inboundId)
check(`${record.label}${name}分配箱数与托盘身份正确`, null,
actual.reduce((sum, item) => sum + Number(item.packageCount), 0) === record.expectedCartonMarks.length
&& actual.every((item) => record.task.pallets.some((pallet) => String(pallet.palletId) === String(item.palletId))))
&& (!record.task || actual.every((item) => record.task.pallets.some((pallet) => String(pallet.palletId) === String(item.palletId)))))
}
}
for (const pallet of pallets) {
@@ -152,7 +172,16 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
operationId: `${context.marker}-add-${pallet.palletId}`,
})
}
if (mixedContainer) {
for (const record of group.filter(item => !item.task)) {
plan = await command(`${record.label}普通订单按整票箱数加入`, 'POST', `${path}/inbound-assignments`, {
inboundId: record.source.inboundId, packageCount: record.expectedCartonMarks.length,
expectedVersion: Number(plan.version), operationId: `${context.marker}-add-${record.label}`,
})
}
}
await conserve('手工加入后')
if (!mixedContainer) {
for (const pallet of pallets) {
plan = await command('按物理托盘完整移除', 'DELETE', `${path}/pallet-assignments/${pallet.palletId}`, {
expectedPlanVersion: Number(plan.version), operationId: `${context.marker}-remove-${pallet.palletId}`,
@@ -164,13 +193,14 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
customerId: String(group[0].task.tenantId), inboundCode: group[0].source.inboundCode,
})
await conserve('智能推荐后')
}
for (const record of group) record.loading = { cabinet, plan, allocationVerified: true }
const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS)
const agent = await resolveOceanBookingAgent(context, MODULE, 'MATSON', OPTIONS)
check(`${label}真实船司和订舱代理齐全`, null, Boolean(carrier?.id && agent?.id))
const suffix = markerSuffix(context.marker)
const draft = oceanBookingDraft(context, suffix, {
customerId: String(group[0].task.tenantId), customerName: summary.customerName,
customerId: String(group[0].order.tenantId), customerName: summary.customerName,
warehouseId: String(summary.warehouseId), channelCode: summary.channelCode,
}, { carrierCode: 'MATSON', shippingCompanyId: String(carrier.id), shippingCompanyName: carrier.name,
bookingAgentId: String(agent.id), bookingAgentName: agent.name })
@@ -211,10 +241,14 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
warehouseId: String(summary.warehouseId), loadingPlanId: String(plan.id), containerBookingId: String(cabinet.id),
expectedVersion: Number(claimed.version), inventorySnapshotToken: inventory.snapshotToken,
items: claimed.items.map((item) => ({ itemId: String(item.id), version: Number(item.version),
action: 'PICK_PALLET', palletNo: item.palletNo })),
...(item.unitType === 'SEALED_PALLET'
? { action: 'PICK_PALLET', palletNo: item.palletNo }
: { action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount) }) })),
}
check(`${label}PDA全部任务均为封托单元`, null,
claimed.items.length > 0 && claimed.items.every((item) => item.unitType === 'SEALED_PALLET'))
claimed.items.length > 0 && (mixedContainer
? claimed.items.some(item => item.unitType === 'SEALED_PALLET') && claimed.items.some(item => item.unitType !== 'SEALED_PALLET')
: claimed.items.every((item) => item.unitType === 'SEALED_PALLET')))
const picked = await command('整托拣货', 'POST', `${pda}/batch`, batch,
{ 'Idempotency-Key': `${context.marker}-pick` })
check(`${label}整托拣货等待最终确认`, null, picked.workflowStatus === 'AWAITING_REVIEW')
@@ -236,7 +270,7 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
`status=${finalTask?.taskStatus}, attempts=${terminal.attempts}`)
for (const record of group) {
const inbound = await command(`${record.label}回查入仓单实装状态`, 'GET',
`/api/platform/inbounds/${record.source.inboundId}?tenantId=${record.task.tenantId}`)
`/api/platform/inbounds/${record.source.inboundId}?tenantId=${record.order.tenantId}`)
check(`${record.label}装柜完成回写`, null, inbound.summary?.processStatus === 'LOADED')
record.loading = { cabinet: linked, plan, task: finalTask, completed, booking: released,
allocationVerified: true, loadingVerified: true, containerNo }
@@ -245,10 +279,10 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
async function progressShipping(group) {
const record = group[0]
context.marker = `${marker}-${record.label}-SHIPPING`
context.createdResources.orders = { ...record.order, tenantId: String(record.task.tenantId),
context.createdResources.orders = { ...record.order, tenantId: String(record.order.tenantId),
inboundId: record.source.inboundId, warehouseId: String(record.summary.warehouseId) }
context.createdResources.phase3Loading = {
marker: context.marker, tenantId: String(record.task.tenantId), orderId: record.order.orderId,
marker: context.marker, tenantId: String(record.order.tenantId), orderId: record.order.orderId,
inboundId: record.source.inboundId, warehouseId: String(record.summary.warehouseId),
containerBookingId: String(record.loading.cabinet.id), loadingPlanId: String(record.loading.plan.id),
oceanBookingId: String(record.loading.booking.oceanBooking.id),
@@ -270,7 +304,7 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
String(inbound.inboundId) === String(item.source.inboundId) && inbound.inboundStatus === 'LOADED'))
&& detail.timeline?.some((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'CONFIRM_DISPATCH'))
context.createdResources.phase3OutboundDispatch = { marker: context.marker, containerBookingId: bookingId,
tenantId: String(record.task.tenantId), confirmedStatus: aggregate.dispatchStatus }
tenantId: String(record.order.tenantId), confirmedStatus: aggregate.dispatchStatus }
}
for (const [name, action] of [['customs', completeCustoms], ['departure', confirmDomesticDeparture],
['carrier-query', verifyCarrierQuery], ['pickup-ready', runUntilPickupReady]]) {
@@ -309,7 +343,7 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
check(`${label}跨区事件逐票建单完整`, null,
summary?.orders?.length === group.length && group.every((record) =>
summary.orders.some((order) => order.inboundCode === record.source.inboundCode
&& String(order.customerId) === String(record.task.tenantId))))
&& String(order.customerId) === String(record.order.tenantId))))
const preview = await call('入库计划预览', 'GET', `${base}/inbound-plans/container-preview?sourceContainerBookingId=${bookingId}`)
check(`${label}全柜入库计划就绪`, null, preview.ready === true && Number(preview.totalOrderCount) === group.length)
const body = { sourceContainerBookingId: bookingId, snapshotVersion: Number(preview.snapshotVersion),
@@ -321,10 +355,10 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
const matching = plans.filter((plan) => plan.lines?.some((line) => line.inboundCode === record.source.inboundCode))
check(`${record.label}计划精确关联来源入仓码`, null, matching.length === 1)
const target = matching[0]
const expectedNos = record.task.pallets.map((pallet) => pallet.palletNo).sort()
const expectedNos = record.task?.pallets.map((pallet) => pallet.palletNo).sort()
const actualNos = (target.pallets || []).map((pallet) => pallet.palletNo).sort()
check(`${record.label}来源托盘完整绑定海外真实资源`, null,
JSON.stringify(expectedNos) === JSON.stringify(actualNos)
(expectedNos ? JSON.stringify(expectedNos) === JSON.stringify(actualNos) : actualNos.length > 0)
&& target.pallets.every((pallet) => pallet.warehousePalletId && pallet.warehouseLocationId))
record.overseas = { plan: target, warehouseId: String(preview.overseasWarehouseId),
summary: summary.orders.find((order) => order.inboundCode === record.source.inboundCode),
@@ -373,9 +407,35 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
const signed = await call('照片绑定并签收', 'POST', `${base}/current/sign`,
{ photos, idempotencyKey: `${context.marker}-sign` })
check(`${label}签收成功`, null, signed.status === 'SIGNED')
if (mixedContainer) {
const completed = await receiveMixedContainer({ group, call, check, marker: context.marker,
request: (method, path, body) => context.requestOverseas(method, path, body, {}, picker.token) })
for (const item of group) {
const stock = await call(`${item.label}混柜完成后按入仓码查询库存`, 'POST',
'/api/pda/v1/last-leg/inventory/resolve', { code: item.source.inboundCode })
check(`${item.label}库存归属和真实托盘绑定正确且箱数核平`, null,
stock.queryCode === item.source.inboundCode && Array.isArray(stock.items) && stock.items.length > 0
&& stock.items.every(row => row.inboundCode === item.source.inboundCode
&& String(row.customerId) === String(item.order.tenantId)
&& row.locationId && item.overseas.plan.pallets.some(p => String(p.warehousePalletId) === String(row.palletId)))
&& stock.items.reduce((sum, row) => sum + Number(row.availablePackages), 0) === item.expectedCartonMarks.length)
item.overseas.receiving = { completed, stock, receivingVerified: true }
}
return
}
const sourceNos = [...new Set(group.flatMap((item) => item.task.pallets.map((pallet) => pallet.palletNo)))]
const receivedPallets = new Set()
let accepted = 0
for (const [index, sourcePalletNo] of sourceNos.entries()) {
const before = await call('未齐收货前回查柜级事实', 'GET', `${base}/current`)
check(`${label}剩余托盘待收箱数准确`, null,
Number(before.pendingPackages) === group.reduce((sum, item) => sum + item.expectedCartonMarks.length, 0) - accepted)
const premature = await context.requestOverseas('POST', `${base}/current/complete`,
{ idempotencyKey: `${context.marker}-premature-${index}`, allowDifference: false }, {}, picker.token)
check(`${label}未收齐不得按正常完成提交`, premature, premature.body?.code === 'A0409')
const unchanged = await call('未齐完成被拒后回查柜级事实', 'GET', `${base}/current`)
check(`${label}未齐完成拒绝后事实不变且可继续收货`, null,
JSON.stringify(unchanged) === JSON.stringify(before))
const body = { sourcePalletNo, idempotencyKey: `${context.marker}-pallet-${index}` }
const received = await call('按来源实物托盘整托收货', 'POST', `${base}/current/receipts/pallet`, body)
accepted += Number(received.acceptedPackages)
@@ -385,6 +445,14 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
{ sourcePalletNo, idempotencyKey: `${context.marker}-duplicate-${index}` })
check(`${label}同托重复扫描不重复收货`, null,
duplicate.result === 'ALREADY_RECEIVED' && Number(duplicate.acceptedPackages) === 0)
receivedPallets.add(sourcePalletNo)
const progress = await call('每托收货及重扫后回查逐票进度', 'GET', `${base}/current`)
const expected = group.map(record => ({ inboundCode: record.source.inboundCode,
total: record.expectedCartonMarks.length,
received: new Set(record.task.pallets.filter(p => receivedPallets.has(p.palletNo))
.flatMap(p => p.cartons || []).filter(c => String(c.inboundId) === String(record.source.inboundId))
.map(c => c.boxCode)).size }))
check(`${label}每托实收不串票且柜级与逐票剩余同步`, null, receivingProgressMatches(progress, expected))
}
check(`${label}整柜实收总箱数守恒`, null,
accepted === group.reduce((sum, item) => sum + item.expectedCartonMarks.length, 0))
@@ -416,6 +484,7 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
return value
}
const candidatePath = `${base}/one-click-dropship/candidates?customerId=${customerId}&warehouseId=${warehouseId}`
await outboundCandidateGuards({ context, group, customerId, warehouseId, label, check, call })
const all = await call('读取专用出库可用库存', 'GET', candidatePath)
const selected = all.filter((candidate) => group.some((record) => record.source.inboundCode === candidate.inboundCode))
const inputs = selected.map((candidate) => {
@@ -480,6 +549,24 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
for (const record of group) record.outbound = { task: completed, selected, detail: outbound, completed: true }
}
try {
if (mixedContainer) {
const dropship = await prepare('MIXED-DROPSHIP', 2)
await verify(dropship, 2)
context.context.waitSplitRoute = { originWarehouseName: dropship.order.receivingWarehouseName,
channelCode: dropship.order.channelCode }
const fba = await prepare('MIXED-FBA', 1, {}, 'FBA')
const overseas = await prepare('MIXED-OVERSEAS', 1, {}, 'OVERSEAS')
const group = [dropship, fba, overseas]
check('混柜三票属于同一起运仓和渠道组', null, group.every(r =>
String(r.summary.warehouseId) === String(dropship.summary.warehouseId)
&& channelGroup(r.summary.channelCode) === channelGroup(dropship.summary.channelCode)))
await allocate(group)
await progressShipping(group)
await convertOverseas(group)
await receiveOverseas(group)
context.createdResources.dropshipPalletMatrix.stage = 'MIXED_CONTAINER_INBOUND_COMPLETED'
return
}
if (stopBeforeOverseasPda) {
const record = await prepare('BROWSER-MULTI', 2)
await verify(record, 2)
@@ -0,0 +1,84 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
import { mixedReceivingSchedule, receiveMixedContainer } from './mixed-container-receiving.mjs'
function fixtures() {
const record = (type, fulfillmentType = 'STANDARD') => ({ label: type,
order: { destinationType: type, fulfillmentType }, source: { inboundId: type, inboundCode: type },
expectedCartonMarks: [1, 2, 3, 4].map(i => `${type}#4-${i}`),
overseas: { plan: { pallets: [{ palletNo: `PAL-${type}` }] } } })
const d = record('DROPSHIP', 'ONE_CLICK_DROPSHIP')
d.task = { pallets: [{ palletNo: 'DP-1', cartons: [{ inboundId: 'DROPSHIP', boxCode: 'DROPSHIP#4-1' }] },
{ palletNo: 'DP-2', cartons: [2, 3, 4].map(i => ({ inboundId: 'DROPSHIP', boxCode: `DROPSHIP#4-${i}` })) }] }
return [d, record('FBA'), record('OVERSEAS')]
}
test('mixed receiving is registered with prerequisites, not transfer or downstream delivery', () => {
assert.deepEqual(normalizeModuleOrder(['mixed-container-receiving']), ['merchant-qualification-fixture', 'mixed-container-receiving'])
const source = readFileSync(new URL('dropship-pallet-matrix.mjs', import.meta.url), 'utf8')
const start = source.indexOf("const dropship = await prepare('MIXED-DROPSHIP'")
const branch = source.slice(start, source.indexOf('if (stopBeforeOverseasPda)', start))
for (const entry of ["'MIXED-FBA', 1, {}, 'FBA'", "'MIXED-OVERSEAS', 1, {}, 'OVERSEAS'",
'allocate(group)', 'progressShipping(group)', 'convertOverseas(group)', 'receiveOverseas(group)',
'MIXED_CONTAINER_INBOUND_COMPLETED']) assert.ok(branch.includes(entry), entry)
assert.doesNotMatch(branch, /outboundOverseas|warehouse-transfers/)
assert.match(source, /混柜完成后按入仓码查询库存/)
assert.match(source, /String\(row.customerId\) === String\(item.order.tenantId\)/)
assert.match(source, /String\(p.warehousePalletId\) === String\(row.palletId\)/)
})
test('schedule interleaves three modes and uses FBA cumulative count rather than delta', () => {
const steps = mixedReceivingSchedule(fixtures())
assert.deepEqual(steps.map(s => s.path), ['bulk', 'pallet', 'carton', 'carton', 'carton', 'carton', 'bulk', 'pallet'])
assert.deepEqual(steps.filter(s => s.path === 'bulk').map(s => [s.count, s.body.packageCount]), [[1, 1], [3, 4]])
assert.equal(steps.reduce((sum, s) => sum + s.count, 0), 12)
assert.throws(() => mixedReceivingSchedule(fixtures().slice(0, 2)))
})
function driver({ earlyComplete = false, wrongModeAccepted = false, drift = false } = {}) {
const group = fixtures()
const expected = new Map(group.map(r => [r.source.inboundCode, 0]))
const schedule = mixedReceivingSchedule(group)
const seen = new Map()
let index = 0
let complete = false
const snapshot = () => ({ status: complete ? 'COMPLETED' : 'SIGNED', totalPackages: 12,
receivedPackages: [...expected.values()].reduce((a, b) => a + b, 0),
pendingPackages: 12 - [...expected.values()].reduce((a, b) => a + b, 0),
inbounds: [...expected].map(([inboundCode, receivedPackages]) => ({ inboundCode, totalPackages: 4,
receivedPackages, pendingPackages: 4 - receivedPackages })) })
return { group, marker: 'MIXED-TEST', check: (name, _response, passed) => assert.ok(passed, name),
request: async (_method, path) => ({ body: { code: (path.endsWith('/complete') ? earlyComplete : wrongModeAccepted) ? '200' : 'A0409' } }),
call: async (_name, method, path, body) => {
if (method === 'GET') return snapshot()
if (seen.has(body.idempotencyKey)) return structuredClone(seen.get(body.idempotencyKey))
if (path.endsWith('/complete')) {
complete = true
const result = snapshot(); seen.set(body.idempotencyKey, result); return result
}
const step = schedule[index++]
assert.ok(path.endsWith(`/receipts/${step.path}`))
const inbound = drift ? 'FBA' : step.record.source.inboundCode
expected.set(inbound, expected.get(inbound) + step.count)
const result = { acceptedPackages: step.count }
seen.set(body.idempotencyKey, result)
return result
} }
}
test('mixed receiving executes the whole schedule and completes only after all three rows balance', async () => {
const result = await receiveMixedContainer(driver())
assert.equal(result.status, 'COMPLETED')
assert.equal(result.receivedPackages, 12)
})
test('mixed receiving fails on early-completion acceptance', async () => {
await assert.rejects(receiveMixedContainer(driver({ earlyComplete: true })))
})
test('mixed receiving fails if overseas or dropship can bypass pallet/carton receipt rules', async () => {
await assert.rejects(receiveMixedContainer(driver({ wrongModeAccepted: true })))
})
test('mixed receiving detects correct aggregate count allocated to the wrong inbound', async () => {
await assert.rejects(receiveMixedContainer(driver({ drift: true })))
})
@@ -0,0 +1,69 @@
import { receivingProgressMatches } from './dropship-outbound-edge-cases.mjs'
export async function run(context) {
const { runMixedContainer } = await import('./dropship-pallet-matrix.mjs')
return runMixedContainer(context)
}
export function mixedReceivingSchedule(group) {
const fba = group.filter(r => r.order.fulfillmentType === 'STANDARD' && r.order.destinationType === 'FBA')
const overseas = group.filter(r => r.order.fulfillmentType === 'STANDARD' && r.order.destinationType === 'OVERSEAS')
const dropship = group.filter(r => r.order.fulfillmentType === 'ONE_CLICK_DROPSHIP')
if (group.length !== 3 || fba.length !== 1 || overseas.length !== 1 || dropship.length !== 1
|| dropship[0].task?.pallets?.length !== 2) throw new Error('混柜必须包含普通FBA、普通海外仓及一件代发双托三票')
const [f, o, d] = [fba[0], overseas[0], dropship[0]]
const fbaPallet = f.overseas.plan.pallets?.[0]?.palletNo
const overseasPallet = o.overseas.plan.pallets?.[0]?.palletNo
if (!fbaPallet || !overseasPallet || f.expectedCartonMarks.length < 2) throw new Error('混柜普通订单缺少真实托盘或分批箱数')
const palletStep = pallet => ({ record: d, path: 'pallet', count: pallet.cartons.filter(c =>
String(c.inboundId) === String(d.source.inboundId)).length, body: { sourcePalletNo: pallet.palletNo } })
return [
{ record: f, path: 'bulk', count: 1, body: { inboundCode: f.source.inboundCode, palletCode: fbaPallet, packageCount: 1 } },
palletStep(d.task.pallets[0]),
...o.expectedCartonMarks.map(cartonMark => ({ record: o, path: 'carton', count: 1, body: { cartonMark, palletCode: overseasPallet } })),
{ record: f, path: 'bulk', count: f.expectedCartonMarks.length - 1,
body: { inboundCode: f.source.inboundCode, palletCode: fbaPallet, packageCount: f.expectedCartonMarks.length } },
palletStep(d.task.pallets[1]),
]
}
export async function receiveMixedContainer({ group, call, check, request, marker }) {
const base = '/api/pda/v1/last-leg/receiving-containers/current'
const steps = mixedReceivingSchedule(group)
const expected = group.map(r => ({ inboundCode: r.source.inboundCode, total: r.expectedCartonMarks.length, received: 0 }))
for (const record of group.filter(r => r.order.fulfillmentType === 'ONE_CLICK_DROPSHIP' || r.order.destinationType === 'OVERSEAS')) {
const before = await call('混柜错误收货模式前回查', 'GET', base)
const invalid = await request('POST', `${base}/receipts/bulk`, {
inboundCode: record.source.inboundCode, palletCode: record.overseas.plan.pallets[0].palletNo,
packageCount: 1, idempotencyKey: `${marker}-forbid-bulk-${record.label}`,
})
check('普通海外仓和一件代发均不能绕过各自规则使用FBA批量收货', invalid, invalid.body?.code === 'A0409')
const after = await call('混柜错误收货模式拒绝后回查', 'GET', base)
check('混柜错误收货模式拒绝后零漂移', null, JSON.stringify(before) === JSON.stringify(after))
}
for (const [index, step] of steps.entries()) {
const before = await call('混柜交替收货前进度', 'GET', base)
check('混柜每步前逐票和柜级进度准确', null, receivingProgressMatches(before, expected))
const premature = await request('POST', `${base}/complete`,
{ allowDifference: false, idempotencyKey: `${marker}-early-${index}` })
check('混柜任一票未齐不得正常完成', premature, premature.body?.code === 'A0409')
const afterRejected = await call('混柜提前完成拒绝后回查', 'GET', base)
check('混柜提前完成拒绝后零漂移', null, JSON.stringify(afterRejected) === JSON.stringify(before))
const path = `${base}/receipts/${step.path}`
const body = { ...step.body, idempotencyKey: `${marker}-mixed-receive-${index}` }
const received = await call(`${step.record.label}交替收货`, 'POST', path, body)
check('混柜本次只收当前操作对应箱数', null, Number(received.acceptedPackages) === step.count)
const replay = await call('混柜收货同键重放', 'POST', path, body)
check('混柜同键重放结果稳定', null, JSON.stringify(replay) === JSON.stringify(received))
expected.find(row => row.inboundCode === step.record.source.inboundCode).received += step.count
const after = await call('混柜每步后逐票回查', 'GET', base)
check('混柜交替收货不串票不重计', null, receivingProgressMatches(after, expected))
}
const body = { allowDifference: false, idempotencyKey: `${marker}-mixed-complete` }
const completed = await call('混柜全部核平后正常完成入库', 'POST', `${base}/complete`, body)
check('混柜三票全收且普通完成', null,
completed.status === 'COMPLETED' && receivingProgressMatches(completed, expected))
const replay = await call('混柜入库完成重放', 'POST', `${base}/complete`, body)
check('混柜完成重放不重复入库', null, JSON.stringify(replay) === JSON.stringify(completed))
return completed
}
@@ -7,12 +7,12 @@ import { prepareTerminalDrayage, run } from './phase5-terminal-drayage.mjs'
const source = await readFile(fileURLToPath(new URL('./phase5-terminal-drayage.mjs', import.meta.url)), 'utf8')
test('four real command payloads mutate contract fields and preserve remarks and state on conflict', async () => {
test('three real command payloads mutate contract fields and preserve remarks and state on conflict', async () => {
const marker = 'offline-terminal'
const containerBookingId = '9223372036854775806'
const containerNo = 'TEST0000001'
const actions = ['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN']
const paths = ['fleet', 'pickup-appointment', 'pickup', 'empty-return']
const actions = ['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN']
const paths = ['fleet', 'pickup-appointment', 'empty-return']
let state = { containerBookingId, containerNo, version: 0, allowedActions: ['SELECT_FLEET'] }
const history = []
const commands = new Map()
@@ -59,20 +59,19 @@ test('four real command payloads mutate contract fields and preserve remarks and
state = { ...state, version: state.version + 1 }
if (index === 0) Object.assign(state, { fleetSupplierId: body.fleetSupplierId, fleetSupplierName: body.fleetSupplierName, allowedActions: ['SCHEDULE_PICKUP'] })
if (index === 1) state.pickupAppointmentAt = body.pickupAppointmentAt
if (index === 2) Object.assign(state, { status: 'PICKED_UP', actualPickupAt: body.actualAt, allowedActions: [] })
if (index === 3) Object.assign(state, { status: 'EMPTY_RETURNED', actualEmptyReturnAt: body.actualAt, allowedActions: [], emptyReturnWarning: { status: 'RESOLVED' } })
if (index === 2) Object.assign(state, { status: 'EMPTY_RETURNED', actualEmptyReturnAt: body.actualAt, allowedActions: [], emptyReturnWarning: { status: 'RESOLVED' } })
history.push({ action: actions[index], fromVersion: index, toVersion: index + 1, reason: body[field] })
commands.set(key, { body: structuredClone(body), state: structuredClone(state) })
return response(state)
},
}
await prepareTerminalDrayage(context, producer)
state = { ...state, status: 'UNLOADED', unloadingCompletedAt: '2026-09-09T04:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] }
state = { ...state, status: 'UNLOADED', unloadingCompletedAt: '2026-09-09T04:00:00', actualPickupAt: '2026-09-09T04:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] }
await run(context)
assert.equal(commands.size, 4)
assert.equal(replays, 4)
assert.equal(conflicts, 4)
assert.equal(state.version, 4)
assert.equal(commands.size, 3)
assert.equal(replays, 3)
assert.equal(conflicts, 3)
assert.equal(state.version, 3)
assert.equal(context.createdResources.phase5TerminalDrayage.finalStatus, 'EMPTY_RETURNED')
})
@@ -90,7 +89,7 @@ test('terminal drayage prepares pickup before unloading and finalizes return bef
test('module uses only public business endpoints and reuses public trucking master data', () => {
for (const path of [
'/api/platform/v1/terminal-drayages', "'fleet'", "'pickup-appointment'", "'pickup'", "'empty-return'", '/history',
'/api/platform/v1/terminal-drayages', "'fleet'", "'pickup-appointment'", "'empty-return'", '/history',
'category=TRUCKING',
]) assert.ok(source.includes(path), `missing ${path}`)
assert.doesNotMatch(source, /\/internal\//)
@@ -98,11 +97,11 @@ test('module uses only public business endpoints and reuses public trucking mast
assert.match(source, /脚本不临时创建主数据/)
})
test('all four commands cover idempotent replay hash conflict and lifecycle semantics', () => {
test('all three commands cover idempotent replay hash conflict and lifecycle semantics', () => {
for (const token of [
'选择车队', '预约提柜', '确认实际提柜', '确认实际还柜',
'同键重放', '同键异载荷返回A0409', 'PICKED_UP', 'UNLOADED', 'EMPTY_RETURNED',
'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN', 'RESOLVED',
'选择车队', '预约提柜', '确认实际还柜',
'同键重放', '同键异载荷返回A0409', 'UNLOADED', 'EMPTY_RETURNED',
'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN', 'RESOLVED',
]) assert.ok(source.includes(token), `missing ${token}`)
assert.match(source, /JSON\.stringify\(replay\.data\) === JSON\.stringify\(first\.data\)/)
assert.match(source, /terminal-\$\{path\}.*replace\(\/\[\^A-Za-z0-9:_-\]\//)
@@ -39,7 +39,7 @@ async function command(context, target, action, path, body) {
`${BASE}/${target.containerBookingId}/${path}`, body, { 'Idempotency-Key': key })
context.assert(MODULE, `${action}重放状态和版本零漂移`, replay.response,
JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS)
const changed = ['pickup', 'empty-return'].includes(path) ? { ...body, remark: `${body.remark}-DIFFERENT` }
const changed = path === 'empty-return' ? { ...body, remark: `${body.remark}-DIFFERENT` }
: { ...body, reason: `${body.reason}-DIFFERENT` }
const hashConflict = await context.request('POST', `${BASE}/${target.containerBookingId}/${path}`,
changed, context.token, { 'Idempotency-Key': key })
@@ -103,15 +103,7 @@ export async function prepareTerminalDrayage(context, producer) {
trace(scheduled.response), OPTIONS)
if (!success(scheduled.response)) return null
const pickupAt = localIso(new Date(Date.parse(`${appointmentAt}Z`) + 60 * 60_000))
const picked = await command(context, target, '确认实际提柜', 'pickup', {
expectedVersion: Number(scheduled.data.version), actualAt: pickupAt, remark: context.marker,
})
context.assert(MODULE, '实际提柜同步PICKED_UP并等待海外仓卸柜', picked.response,
picked.data?.status === 'PICKED_UP' && picked.data?.actualPickupAt === pickupAt,
trace(picked.response), OPTIONS)
if (!success(picked.response) || picked.data?.status !== 'PICKED_UP') return null
resource.prepared = { ...target, appointmentAt, pickupAt, pickedVersion: Number(picked.data.version) }
resource.prepared = { ...target, appointmentAt, scheduledVersion: Number(scheduled.data.version) }
resource.containerBookingId = target.containerBookingId
resource.containerNo = target.containerNo
return resource.prepared
@@ -149,15 +141,15 @@ export async function run(context) {
context.assert(MODULE, '卸柜聚合保留提柜事实并开放还柜', initial.response,
id(initial.data?.containerBookingId) === target.containerBookingId
&& id(initial.data?.containerNo) === id(target.completed.containerNo)
&& Number(initial.data?.version) === Number(target.pickedVersion)
&& Number(initial.data?.version) === Number(target.scheduledVersion)
&& initial.data?.status === 'UNLOADED'
&& initial.data?.actualPickupAt === target.pickupAt
&& initial.data?.actualPickupAt === initial.data?.unloadingCompletedAt
&& initial.data?.allowedActions?.includes('RECORD_EMPTY_RETURN'),
`pollRequests=${pollRequests}, status=${initial.data?.status || 'missing'}, actions=${initial.data?.allowedActions?.join(',') || 'missing'}; ${trace(initial.response)}`, OPTIONS)
if (!success(initial.response)) return
const unloadingAt = Date.parse(`${initial.data?.unloadingCompletedAt}Z`)
const returnBase = Math.max(Date.parse(`${target.pickupAt}Z`), Number.isNaN(unloadingAt) ? 0 : unloadingAt)
const returnBase = Number.isNaN(unloadingAt) ? Date.now() : unloadingAt
const returnedAt = localIso(new Date(returnBase + 60 * 60_000))
const returned = await command(context, target, '确认实际还柜', 'empty-return', {
expectedVersion: Number(initial.data.version), actualAt: returnedAt, remark: context.marker,
@@ -176,13 +168,12 @@ export async function run(context) {
&& list.data[0]?.status === 'EMPTY_RETURNED', trace(list.response), OPTIONS)
const history = await api(context, 'GET', '读取目的港码头拖车不可变历史', `${path}/history`)
const actions = new Set((Array.isArray(history.data) ? history.data : []).map((item) => item?.action))
context.assert(MODULE, '历史完整记录四个业务动作和连续版本', history.response,
['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN'].every((action) => actions.has(action))
context.assert(MODULE, '历史完整记录三个业务动作和连续版本', history.response,
['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'].every((action) => actions.has(action))
&& history.data.every((item) => Number(item?.toVersion) === Number(item?.fromVersion) + 1),
`actions=${[...actions].join(',')}; ${trace(history.response)}`, OPTIONS)
context.assert(MODULE, '提柜和还柜备注写入不可变历史', history.response,
['RECORD_PICKUP', 'RECORD_EMPTY_RETURN'].every((action) =>
history.data?.some((item) => item.action === action && item.reason === context.marker)),
context.assert(MODULE, '还柜备注写入不可变历史', history.response,
history.data?.some((item) => item.action === 'RECORD_EMPTY_RETURN' && item.reason === context.marker),
trace(history.response), OPTIONS)
context.createdResources.phase5TerminalDrayage = {
...context.createdResources.phase5TerminalDrayage,