This commit is contained in:
2026-09-15 10:37:34 +08:00
parent 4f13ee2ec3
commit 88e2fb0eb1
49 changed files with 465 additions and 236 deletions
+2
View File
@@ -42,6 +42,8 @@ export async function run(context) {
- `dropship-pallet-matrix` 通过总入口 `--modules dropship-pallet-matrix` 执行,自动依赖客户资质准备。通过正式 HTTP 创建五票订单,覆盖单票多托、单票单托、单票移箱合托、多票共享开放托盘;会创建并保留测试数据,不自动清理。报告 `createdResources.dropshipPalletMatrix` 保存逐票箱唛、托盘及各阶段证据,失败即停止并保留阻断;重跑创建新批次,不借用历史资源。`fullFlowVerified=false`,不能代替海外收货及一件代发专用出库的完整验收。
- 一件代发任务 `Task.pallets` 只列实际参与绑箱的托盘;未使用的预生成托盘通过 `/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets?accountId=...` 读取,客户范围取本轮任务。脚本同时核对 `generatedPalletCount/palletSetupReady`,不因空的任务托盘列表新增资源。
- `dropship-outbound-edge-cases.mjs` 是 `dropship-pallet-matrix` 内部探针,不单独运行:使用本轮库存验证超量、错误 SKU、不存在/重复箱唛、箱数不符、同键异载荷、未拣齐完成、并发同键创建、未拣取消和重放。单票单托额外先拣一箱再拣剩余箱;部分拣货被拒时仍保留失败及部署阻断,只有回查冻结事实无变化才继续整条明细拣货以采集后续证据,不能将此计为分批通过。多票共托使用同一 SKU,来源区分必须依赖入仓码。成功数据保留,未拣并发探针出库单正常取消。本扩展不代表跨客户、跨仓、多 SKU 混箱、逐箱不等件数、跨区故障注入均已覆盖,结论以当轮真实报告为准。
- `phase3-container-documents` 从本轮货柜详情读取渠道:海铁验证 DO 上传被明确拒绝且聚合中没有 DO,其他渠道继续验证 DO 上传、重放及零漂移;缺渠道不猜测适用性。
- `phase4-overseas-pda-operations` 用三个独立逐箱任务分别验证正常换标、跳过和异常,异常不得重复使用前两箱。前一步失败时保留失败并停止该链,不继续使用空响应版本。
- 海外 PDA 按真实柜号去重执行签收,缺少一件代发生产者不得用 FBA 生产者替代;原生产矩阵失败仍保留。P4 解绑样本保留为 `UNBOUND`,P5 只从本轮已完成同柜、同目的仓的入仓码中选择三箱实物托盘库存,排除 `UNBOUND` 和重复仓位,不借用历史订单。
@@ -93,8 +93,8 @@ async function createSecondGroup(context, third, fourth) {
async function createAssignedPushedPlan(context, fixture, groupId, suffix) {
const simulationResponse = await context.request('POST', CONTAINERS, {
channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HQ',
capacityCbm: 68, maxWeightKg: 19500,
})
const simulation = context.apiSuccess(MODULE, `${suffix} 创建组装柜模拟柜`, simulationResponse,
evidence(simulationResponse), OPTIONS)
@@ -119,8 +119,8 @@ async function createAssignedPushedPlan(context, fixture, groupId, suffix) {
async function createNormalPushedPlan(context, fixture, suffix) {
const simulationResponse = await context.request('POST', CONTAINERS, {
channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HQ',
capacityCbm: 68, maxWeightKg: 19500,
})
const simulation = context.apiSuccess(MODULE, `${suffix} 创建普通单模拟柜`, simulationResponse,
evidence(simulationResponse), OPTIONS)
@@ -174,8 +174,8 @@ async function createReleasedBookingAndLink(context, fixture, chains) {
const draft = oceanBookingDraft(context, suffix, master, {
bookingAgentId: id(agent.id), bookingAgentName: agent.name,
shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON',
containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: chains.length,
capacityCbm: 76, maxWeightKg: 28000 }],
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: chains.length,
capacityCbm: 68, maxWeightKg: 19500 }],
})
const createResponse = await context.request('POST', '/api/platform/v1/ocean-bookings',
{ expectedVersion: 0, data: draft }, undefined,
@@ -205,7 +205,7 @@ async function createReleasedBookingAndLink(context, fixture, chains) {
}, undefined, { 'Idempotency-Key': `${context.marker}-coload-pda-booking-release`.slice(0, 128) })
const released = context.apiSuccess(MODULE, 'PDA链放舱并冻结两柜需求', releaseResponse,
evidence(releaseResponse), OPTIONS)
const requirement = (released?.containerRequirements || []).find((item) => item?.containerType === '40HC')
const requirement = (released?.containerRequirements || []).find((item) => item?.containerType === '40HQ')
context.assert(MODULE, '放舱需求数量覆盖短拣与足量两柜', releaseResponse,
released?.oceanBooking?.status === 'RELEASED' && Number(requirement?.quantity) === chains.length,
`status=${released?.oceanBooking?.status || 'missing'}, quantity=${requirement?.quantity ?? 'missing'}`, OPTIONS)
@@ -510,10 +510,10 @@ export async function run(context) {
`status=${queried.data?.status || 'missing'}, members=${summaryInboundCodes(queried.data).join('|')}`, OPTIONS)
const simulationResponse = await context.request('POST', '/api/platform/v1/containers', {
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '20GP',
capacityCbm: 33, maxWeightKg: 28000,
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40GP',
capacityCbm: 58, maxWeightKg: 19500,
})
const simulation = context.apiSuccess(MODULE, '创建容量拒绝专用20GP模拟柜', simulationResponse,
const simulation = context.apiSuccess(MODULE, '创建容量拒绝专用40GP模拟柜', simulationResponse,
evidence(simulationResponse), OPTIONS)
let bookingId = id(simulation?.id)
if (!bookingId) return
@@ -573,7 +573,7 @@ export async function run(context) {
context.assert(MODULE, '推荐候选包含当前目标同柜组且成员身份精确', isolatedCandidateResponse,
candidateGroupIds.includes(groupId)
&& JSON.stringify(targetCandidateInbounds) === JSON.stringify([first.inboundId, second.inboundId].sort()),
`groupId=${groupId}, candidateGroupIds=${candidateGroupIds.join('|') || 'none'}, members=${targetCandidateInbounds.join('|')}, capacity=33cbm/28000kg`, OPTIONS)
`groupId=${groupId}, candidateGroupIds=${candidateGroupIds.join('|') || 'none'}, members=${targetCandidateInbounds.join('|')}, capacity=58cbm/19500kg`, OPTIONS)
const allCandidateVolume = isolatedCandidates
.reduce((sum, item) => sum + Number(item?.availableVolumeCbm || 0), 0)
context.assert(MODULE, '推荐候选总量形成容量竞争场景', isolatedCandidateResponse,
@@ -601,10 +601,10 @@ export async function run(context) {
`targetGroupId=${groupId}, assignedMembers=${capacityGroupItems.map((item) => id(item?.inboundId)).join('|') || 'none'}, before=${planSignature(beforeRecommend)}, after=${planSignature(capacityRecommendation)}`, OPTIONS)
const recommendSimulationResponse = await context.request('POST', '/api/platform/v1/containers', {
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HQ',
capacityCbm: 68, maxWeightKg: 19500,
})
const recommendSimulation = context.apiSuccess(MODULE, '创建智能推荐整组成功专用40HC模拟柜',
const recommendSimulation = context.apiSuccess(MODULE, '创建智能推荐整组成功专用40HQ模拟柜',
recommendSimulationResponse, evidence(recommendSimulationResponse), OPTIONS)
const recommendBookingId = id(recommendSimulation?.id)
if (!recommendBookingId) return
@@ -647,10 +647,10 @@ export async function run(context) {
}
const workingSimulationResponse = await context.request('POST', '/api/platform/v1/containers', {
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HQ',
capacityCbm: 68, maxWeightKg: 19500,
})
const workingSimulation = context.apiSuccess(MODULE, '创建PDA主链40HC模拟柜', workingSimulationResponse,
const workingSimulation = context.apiSuccess(MODULE, '创建PDA主链40HQ模拟柜', workingSimulationResponse,
evidence(workingSimulationResponse), OPTIONS)
bookingId = id(workingSimulation?.id)
if (!bookingId) return
@@ -966,8 +966,8 @@ export async function run(context) {
const uiGroup = context.apiSuccess(MODULE, '为Admin页面保留ACTIVE未分配跨目的仓组', uiGroupResponse,
evidence(uiGroupResponse), OPTIONS)
const uiSimulationResponse = await context.request('POST', CONTAINERS, {
channelGroupCode: channelGroup(fifth.channelCode), warehouseId: fifth.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
channelGroupCode: channelGroup(fifth.channelCode), warehouseId: fifth.warehouseId, containerType: '40HQ',
capacityCbm: 68, maxWeightKg: 19500,
})
const uiSimulation = context.apiSuccess(MODULE, '为Admin页面保留可编辑智能装柜柜次', uiSimulationResponse,
evidence(uiSimulationResponse), OPTIONS)
@@ -21,15 +21,15 @@ test('HTTP fixtures use the canonical primary and mismatch container specificati
const primarySources = productionFixtures.map(source).join('\n')
const containerFlow = source('phase3-container-loading.mjs')
assert.match(primarySources, /containerType: '40HC'[\s\S]*?capacityCbm: 76[\s\S]*?maxWeightKg: 28000/)
assert.match(containerFlow, /containerType: '20GP'[\s\S]*?capacityCbm: 33[\s\S]*?maxWeightKg: 28000/)
assert.match(primarySources, /containerType: '40HQ'[\s\S]*?capacityCbm: 68[\s\S]*?maxWeightKg: 19500/)
assert.match(containerFlow, /containerType: '40GP'[\s\S]*?capacityCbm: 58[\s\S]*?maxWeightKg: 19500/)
})
test('HTTP fixtures do not reintroduce retired aliases or arbitrary specifications', () => {
for (const name of productionFixtures) {
const fixture = source(name)
assert.doesNotMatch(fixture, /40HQ|45HQ/, name)
assert.doesNotMatch(fixture, /capacityCbm:\s*(?:100|68|28)(?:\D|$)/, name)
assert.doesNotMatch(fixture, /20GP|40HC|40NR|45HC/, name)
assert.doesNotMatch(fixture, /capacityCbm:\s*(?:100|76|67|33|28)(?:\D|$)/, name)
assert.doesNotMatch(fixture, /maxWeightKg:\s*(?:100000|22000)(?:\D|$)/, name)
}
})
@@ -37,13 +37,11 @@ test('HTTP fixtures do not reintroduce retired aliases or arbitrary specificatio
test('the frozen catalog is complete and unambiguous', () => {
// Keep every future fixture producer constrained to the same product catalog as Admin and Firstleg.
const catalog = Object.freeze({
'20GP': Object.freeze({ capacityCbm: 33, maxWeightKg: 28000 }),
'40GP': Object.freeze({ capacityCbm: 67, maxWeightKg: 28000 }),
'40HC': Object.freeze({ capacityCbm: 76, maxWeightKg: 28000 }),
'40NR': Object.freeze({ capacityCbm: 67, maxWeightKg: 28000 }),
'45HC': Object.freeze({ capacityCbm: 86, maxWeightKg: 28000 }),
'40GP': Object.freeze({ capacityCbm: 58, maxWeightKg: 19500 }),
'40HQ': Object.freeze({ capacityCbm: 68, maxWeightKg: 19500 }),
'45HQ': Object.freeze({ capacityCbm: 86, maxWeightKg: 19500 }),
})
assert.deepEqual(Object.keys(catalog), ['20GP', '40GP', '40HC', '40NR', '45HC'])
assert.equal(new Set(Object.keys(catalog)).size, 5)
assert.deepEqual(Object.keys(catalog), ['40GP', '40HQ', '45HQ'])
assert.equal(new Set(Object.keys(catalog)).size, 3)
})
@@ -0,0 +1,103 @@
// Internal probes invoked only by the registered dropship matrix and its fresh fixtures.
// A failed expectation remains a deployment blocker. Continue other probes only when
// authoritative task/inventory snapshots prove the failed request changed no state.
const MODULE = 'dropship-pallet-matrix'
const OPTIONS = { scenario: 'dropship-outbound-edge-cases', deploymentBlocker: true }
const BASE = '/api/platform/v1/last-leg/outbound-orders'
function record(edge, name, response, passed, evidence = '') {
edge.context.assert(MODULE, `${edge.label}${name}`, response, passed, evidence, OPTIONS)
}
async function request(edge, method, path, body, pda = false) {
return edge.context.requestOverseas(method, path, body, {}, pda ? edge.token : edge.context.overseasToken)
}
async function candidates(edge) {
const rows = await edge.call('边界测试回查本轮库存候选', 'GET', edge.candidatePath)
return rows.filter(row => edge.group.some(x => x.source.inboundCode === row.inboundCode))
.map(row => ({ position: row.inventoryPositionId, sku: row.skuCode,
inbound: row.inboundCode, packages: row.availablePackages }))
.sort((a, b) => `${a.position}:${a.sku}`.localeCompare(`${b.position}:${b.sku}`))
}
async function taskSnapshot(edge) {
return edge.call('边界测试回查出库冻结事实', 'GET', `${BASE}/${edge.task.outboundNo}`)
}
export async function outboundCreationGuards(edge) {
const before = await candidates(edge)
const first = edge.body.items[0]
const cases = [
['超量创建', { ...first, packages: first.packages + 100, cartonMarks: [] }],
['错误SKU创建', { ...first, sku: `WRONG-${edge.context.marker}` }],
['不存在箱唛创建', { ...first, packages: 1, cartonMarks: [`MISSING-${edge.context.marker}`] }],
['重复箱唛创建', { ...first, packages: 2, cartonMarks: [first.cartonMarks[0], first.cartonMarks[0]] }],
['箱数与箱唛不符创建', { ...first, packages: first.packages + 1 }],
]
for (const [index, [name, input]] of cases.entries()) {
const response = await request(edge, 'POST', `${BASE}/one-click-dropship?customerId=${edge.customerId}`,
{ ...edge.body, items: [input], idempotencyKey: `${edge.body.idempotencyKey}-invalid-${index}` })
record(edge, `${name}返回A0409`, response, response.body?.code === 'A0409')
const after = await candidates(edge)
edge.check(`${edge.label}${name}后库存候选零漂移`, null, JSON.stringify(before) === JSON.stringify(after))
}
// Two real concurrent requests, same fixture and same key; no parallel fixture workers.
const body = { ...edge.body, idempotencyKey: `${edge.body.idempotencyKey}-race-cancel` }
const create = () => request(edge, 'POST', `${BASE}/one-click-dropship?customerId=${edge.customerId}`, body)
const responses = await Promise.all([create(), create()])
const tasks = responses.filter(r => r.body?.code === '200').map(r => r.body.data)
record(edge, '并发同键创建均成功且只生成一个任务', responses[1],
tasks.length === 2 && tasks[0].taskNo === tasks[1].taskNo && tasks[0].outboundNo === tasks[1].outboundNo)
edge.check(`${edge.label}并发创建至少一个可回查结果`, null, tasks.length > 0)
for (const outboundNo of new Set(tasks.map(t => t.outboundNo))) {
for (let replay = 0; replay < 2; replay++) {
const cancelled = await request(edge, 'POST', `${BASE}/${outboundNo}/cancel`, { reason: '本轮未拣取消及重放验收' })
edge.check(`${edge.label}未拣取消及重复取消成功`, cancelled, cancelled.body?.code === '200')
}
const detail = await edge.call('回查取消任务状态', 'GET', `${BASE}/${outboundNo}`)
edge.check(`${edge.label}取消后明细全部取消`, null, detail.outbound?.status === 'CANCELLED'
&& detail.pallets.every(p => p.status === 'CANCELLED' && Number(p.pickedPackageCount) === 0))
}
edge.check(`${edge.label}取消后逐票SKU可用箱数恢复`, null,
JSON.stringify(before) === JSON.stringify(await candidates(edge)))
}
export async function outboundTaskGuards(edge) {
const before = await taskSnapshot(edge)
const changed = await request(edge, 'POST', `${BASE}/one-click-dropship?customerId=${edge.customerId}`,
{ ...edge.body, recipient: `${edge.body.recipient}-changed` })
record(edge, '出库创建同键异载荷返回A0409', changed, changed.body?.code === 'A0409')
edge.check(`${edge.label}同键异载荷后出库事实零漂移`, null,
JSON.stringify(before) === JSON.stringify(await taskSnapshot(edge)))
const path = `/api/pda/v1/last-leg/outbound-tasks/${edge.task.taskNo}`
const incomplete = await request(edge, 'POST', `${path}/complete`,
{ idempotencyKey: `${edge.body.idempotencyKey}-incomplete` }, true)
record(edge, '未拣齐禁止完成出库', incomplete, incomplete.body?.code === 'A0409')
edge.check(`${edge.label}未拣齐完成失败后零漂移`, null,
JSON.stringify(before) === JSON.stringify(await taskSnapshot(edge)))
}
export async function probePartialPick(edge) {
const before = await taskSnapshot(edge)
const path = `/api/pda/v1/last-leg/outbound-tasks/${edge.task.taskNo}/pick`
const partial = { ...edge.pick, packages: 1, cartonMarks: edge.pick.cartonMarks.slice(0, 1),
idempotencyKey: `${edge.pick.idempotencyKey}-partial` }
const result = await request(edge, 'POST', path, partial, true)
record(edge, '四箱任务先拣一箱成功', result, result.body?.code === '200')
const after = await taskSnapshot(edge)
if (result.body?.code !== '200') {
edge.check(`${edge.label}分批拣货失败后无库存明细变化`, null, JSON.stringify(before) === JSON.stringify(after))
// Keep the failed assertion; whole-item picking below only continues later coverage.
return 0
}
edge.check(`${edge.label}分批首箱只累计一箱`, null,
after.pallets.reduce((sum, p) => sum + Number(p.pickedPackageCount), 0) === 1)
const replay = await request(edge, 'POST', path, partial, true)
record(edge, '分批首箱同键重放成功', replay, replay.body?.code === '200')
edge.check(`${edge.label}分批重放不重复扣减`, null, JSON.stringify(after) === JSON.stringify(await taskSnapshot(edge)))
const cancel = await request(edge, 'POST', `${BASE}/${edge.task.outboundNo}/cancel`, { reason: '已拣取消拦截验收' })
record(edge, '部分已拣禁止取消', cancel, cancel.body?.code === 'A0409')
edge.check(`${edge.label}已拣取消失败后零漂移`, null, JSON.stringify(after) === JSON.stringify(await taskSnapshot(edge)))
return 1
}
@@ -3,6 +3,26 @@ 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'
test('partial-pick rejection stays failed; a changed snapshot aborts further picking', async () => {
const failures = []
const snapshot = { pallets: [{ pickedPackageCount: 0 }] }
const edge = {
label: 'SINGLE-SINGLE', task: { outboundNo: 'OD', taskNo: 'PDA' },
pick: { packages: 4, cartonMarks: ['A', 'B', 'C', 'D'], idempotencyKey: 'pick' },
context: { assert: (_module, name, _response, passed) => { if (!passed) failures.push(name) },
requestOverseas: async () => ({ body: { code: 'A0409' } }) },
check: (_name, _response, passed) => assert.ok(passed),
call: async () => structuredClone(snapshot),
}
assert.equal(await probePartialPick(edge), 0)
assert.equal(failures.length, 1)
assert.match(failures[0], /四箱任务先拣一箱成功/)
let calls = 0
edge.call = async () => ({ pallets: [{ pickedPackageCount: calls++ }] })
await assert.rejects(probePartialPick(edge))
})
test('outbound replay preserves business identity and database millisecond timestamps', () => {
const task = { id: '1', taskNo: 'PDA1', outboundNo: 'OD1', status: 'PENDING', pushedAt: '2026-09-14T20:24:45.920278604' }
@@ -15,6 +35,34 @@ 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('outbound edge probes preserve failures and require zero drift before continuing', () => {
assert.match(edges, /deploymentBlocker: true/)
assert.match(edges, /四箱任务先拣一箱成功/)
assert.match(edges, /分批拣货失败后无库存明细变化/)
assert.match(edges, /JSON.stringify\(before\) === JSON.stringify\(after\)/)
assert.match(source, /await probePartialPick/)
assert.match(source, /pick.packages -= count/)
assert.match(source, /pick.cartonMarks = pick.cartonMarks.slice\(count\)/)
})
test('outbound creation probes cover concurrent idempotency, invalid cartons and cancellation', () => {
assert.match(edges, /Promise.all\(\[create\(\), create\(\)\]\)/)
for (const scenario of ['超量创建', '错误SKU创建', '不存在箱唛创建', '重复箱唛创建',
'箱数与箱唛不符创建', '出库创建同键异载荷返回A0409', '未拣齐禁止完成出库', '部分已拣禁止取消']) {
assert.ok(edges.includes(scenario))
}
assert.match(edges, /取消后逐票SKU可用箱数恢复/)
assert.match(source, /await outboundCreationGuards\(edge\)/)
assert.match(source, /await outboundTaskGuards\(\{ ...edge, task \}\)/)
})
test('two inbound orders explicitly share a SKU without losing their separate identities', () => {
assert.match(source, /label.startsWith\('MULTI-MERGE-'\)/)
assert.match(source, /skuCodes = \[`SKU-SHARED-\$\{marker\}`/)
assert.match(source, /row.inboundCode === candidate.inboundCode/)
})
test('dedicated outbound uses exact source cartons and verifies completion and stock deduction', () => {
assert.match(source, /await outboundOverseas\(group, picker.token\)/)
@@ -11,6 +11,7 @@ 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'
const MODULE = 'dropship-pallet-matrix'
const BASE = '/api/platform/v1/first-leg/pda-dropship'
@@ -49,6 +50,9 @@ export async function run(context) {
context.context.orderFulfillmentType = '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
const start = context.steps.length
await context.executeModule(`${MODULE}-${label}-order`, () => createOrder(context))
@@ -111,7 +115,7 @@ export async function run(context) {
const summary = group[0].summary
const cabinet = await command('创建本场景模拟柜', 'POST', '/api/platform/v1/containers', {
channelGroupCode: channelGroup(summary.channelCode), warehouseId: String(summary.warehouseId),
containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500,
})
let plan = await command('读取自动生成装柜计划', 'GET', `${smart}/plans/by-booking/${cabinet.id}`)
const path = `${smart}/plans/${plan.id}`
@@ -422,10 +426,13 @@ export async function run(context) {
.reduce((sum, item) => sum + Number(item.availablePackages), 0) === record.expectedCartonMarks.length))
const body = { recipient: `HTTP acceptance ${context.marker}`, items: inputs,
remark: context.marker, idempotencyKey: `${context.marker}-outbound-create` }
const edge = { context, group, token, customerId, warehouseId, candidatePath, body, label, check, call }
await outboundCreationGuards(edge)
const task = await call('创建一件代发专用出库', 'POST', `${base}/one-click-dropship?customerId=${customerId}`, body)
for (const record of group) record.outbound = { task, selected, completed: false }
const replay = await call('专用出库创建同键重放', 'POST', `${base}/one-click-dropship?customerId=${customerId}`, body)
check(`${label}专用出库重放不重复建单`, null, sameOutboundTask(replay, task))
await outboundTaskGuards({ ...edge, task })
const pdaPath = `/api/pda/v1/last-leg/outbound-tasks/${task.taskNo}`
const detail = await call('PDA读取专用出库任务', 'GET', pdaPath, undefined, true)
check(`${label}PDA出库明细完整`, null, detail.items?.length === inputs.length)
@@ -438,6 +445,11 @@ export async function run(context) {
const pick = { outboundPalletItemId: item.outboundPalletItemId,
inventoryPositionId: candidate.inventoryPositionId, packages: inputs[index].packages,
cartonMarks: inputs[index].cartonMarks, idempotencyKey: `${context.marker}-outbound-pick-${index}` }
if (label === 'SINGLE-SINGLE' && index === 0) {
const count = await probePartialPick({ ...edge, task, pick })
pick.packages -= count
pick.cartonMarks = pick.cartonMarks.slice(count)
}
const picked = await call('PDA专用出库拣货', 'POST', `${pdaPath}/pick`, pick, true)
const repeated = await call('PDA出库拣货同键重放', 'POST', `${pdaPath}/pick`, pick, true)
check(`${label}PDA出库拣货重放不重复扣减`, null, sameOutboundTask(repeated, picked))
@@ -142,7 +142,7 @@ async function provisionCrossCustomerPlan(context) {
|| first.channelCode !== second.channelCode) return null
const containerResponse = await context.request('POST', '/api/platform/v1/containers', {
channelGroupCode: channelGroup(first.channelCode), warehouseId: id(first.receivingWarehouseId),
containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500,
})
const container = context.apiSuccess(MODULE, '创建跨客户费用分摊专用模拟柜', containerResponse,
evidence(containerResponse), OPTIONS)
@@ -82,8 +82,8 @@ test('正式 DTO/OpenAPI 暴露矩阵使用的稳定字段', () => {
test('字段校验器接受正式完整投影并精确暴露缺字段', () => {
const samples = [
[containerFieldGaps, { id: ids.container, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', warehouseId: ids.id, warehouseName: '宁波仓', channelCode: 'EXX', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, planStatus: 'EDITING' }, { requireContainerNo: true }, 'warehouseName'],
[preloadFieldGaps, { planId: ids.plan, containerBookingId: ids.container, preloadNo: 'PL-1', cabinetNo: 'CAB-1', customers: [{ accountId: ids.id, customerName: '测试客户' }], warehouseName: '宁波仓', containerType: '40HC', status: 'EDITING', skuCount: 1, totalPackageCount: 1, totalVolumeCbm: 1, totalGrossWeightKg: 10, createdAt: at }, undefined, 'customers'],
[containerFieldGaps, { id: ids.container, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', warehouseId: ids.id, warehouseName: '宁波仓', channelCode: 'EXX', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, planStatus: 'EDITING' }, { requireContainerNo: true }, 'warehouseName'],
[preloadFieldGaps, { planId: ids.plan, containerBookingId: ids.container, preloadNo: 'PL-1', cabinetNo: 'CAB-1', customers: [{ accountId: ids.id, customerName: '测试客户' }], warehouseName: '宁波仓', containerType: '40HQ', status: 'EDITING', skuCount: 1, totalPackageCount: 1, totalVolumeCbm: 1, totalGrossWeightKg: 10, createdAt: at }, undefined, 'customers'],
[loadingListFieldGaps, { planId: ids.plan, containerBookingId: ids.container, loadingListNo: 'LL-1', containerNo: 'MSCU1234567', cabinetNo: 'CAB-1', warehouseName: '宁波仓', status: 'COMPLETED', totalPackageCount: 1, totalGrossWeightKg: 10, totalNetWeightKg: 9, totalVolumeCbm: 1, totalQuantity: 2, createdAt: at }, undefined, 'containerNo'],
[oceanBookingFieldGaps, { id: ids.id, bookingNo: 'BK-1', status: 'RELEASED', vesselName: 'VESSEL', voyageNo: 'V1', estimatedSailingAt: at, estimatedArrivalAt: at, customerId: ids.id, customerName: '测试客户', shippingCompanyId: null, shippingCompanyName: null, bookingAgentId: ids.id, bookingAgentName: '代理' }, undefined, 'voyageNo'],
[portScheduleFieldGaps, { portSchedule: { schedule: { id: ids.id, scheduleNo: 'PS-1', sourceName: '船公司', vesselName: 'VESSEL', voyageNo: 'V1', departurePortCode: 'CNNGB', destinationPortCode: 'USLAX', etdAt: at, etaAt: at, observedAt: at, updatedAt: at, expiresAt: at }, expired: false } }, undefined, 'sourceName'],
@@ -118,18 +118,18 @@ async function createConfirmedContainer(context, source, label) {
bookingAgentId: id(agent.id), bookingAgentName: agent.name, departurePortCode: 'CNSZX',
vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64),
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
placeOfDelivery: 'LAX',
estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00',
remark: `${context.marker}-${label}`,
parties: [{ partyType: 'SHIPPER', partyName: `发货方-${value}` },
{ partyType: 'CONSIGNEE', partyName: `收货方-${value}` },
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${value}` }],
containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76,
maxWeightKg: 28000 }],
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68,
maxWeightKg: 19500 }],
}
const simulationResponse = await context.request('POST', CONTAINERS, {
channelGroupCode: channelGroup(summary.channelCode), warehouseId: source.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
channelGroupCode: channelGroup(summary.channelCode), warehouseId: source.warehouseId, containerType: '40HQ',
capacityCbm: 68, maxWeightKg: 19500,
})
const simulation = context.apiSuccess(MODULE, `${label} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
const containerId = id(simulation?.id)
@@ -157,7 +157,7 @@ async function createConfirmedContainer(context, source, label) {
context.assert(MODULE, `${label} 订舱为 RELEASED 且不预生成真实柜`, releasedResponse,
confirmed?.oceanBooking?.status === 'RELEASED' && (!confirmed?.containers || confirmed.containers.length === 0),
`status=${confirmed?.oceanBooking?.status || 'missing'}`, OPTIONS)
const requirement = confirmed?.containerRequirements?.find?.((item) => item.containerType === '40HC')
const requirement = confirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ')
if (!requirement?.id) throw new Error(`${label} 放舱订舱未返回柜型需求`)
const linkedResponse = await context.request('POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(requirement.id), containerNo,
@@ -46,7 +46,7 @@ function oceanPayload(context, template, index) {
bookingAgentName: source.bookingAgentId ? source.bookingAgentName : null,
departurePortCode: source.departurePortCode, departurePortName: source.departurePortName,
destinationPortCode: source.destinationPortCode, destinationPortName: source.destinationPortName,
placeOfDelivery: source.placeOfDelivery, serviceTerm: source.serviceTerm, freightTerm: source.freightTerm,
placeOfDelivery: source.placeOfDelivery,
estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00',
remark: `${context.marker} 人工列表状态 ${index}`,
parties: template.parties.map(({ partyType, partyName, address, contactName, contactPhone }) =>
@@ -23,11 +23,11 @@ test('shared DRAFT uses requirements and never registers a real container number
const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-3' }, '3', master)
assert.equal(draft.channelName, undefined)
assert.equal(draft.warehouseName, undefined)
assert.deepEqual(draft.containerRequirements, [{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000 }])
assert.deepEqual(draft.containerRequirements, [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68, maxWeightKg: 19500 }])
assert.equal(Object.hasOwn(draft, 'containers'), false)
assert.equal(JSON.stringify(draft).includes('containerNo'), false)
assert.equal(draft.serviceTerm, 'CY-CY')
assert.equal(draft.freightTerm, 'NINGBO')
assert.equal('serviceTerm' in draft, false)
assert.equal('freightTerm' in draft, false)
assert.equal(draft.carrierCode, 'OTHER')
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) assert.ok(draft[field])
})
@@ -41,8 +41,8 @@ test('shared DRAFT permits an omitted customer without inventing placeholders',
test('explicit requirement rows preserve quantity and fixed catalog facts', () => {
const requirements = [
{ containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 },
{ containerType: '20GP', containerKind: 'FCL', quantity: 1, capacityCbm: 33, maxWeightKg: 28000 },
{ containerType: '40HQ', containerKind: 'FCL', quantity: 2, capacityCbm: 68, maxWeightKg: 19500 },
{ containerType: '40GP', containerKind: 'FCL', quantity: 1, capacityCbm: 58, maxWeightKg: 19500 },
]
const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-MULTI' }, 'multi', master, { containerRequirements: requirements })
assert.deepEqual(draft.containerRequirements, requirements)
@@ -69,7 +69,7 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) {
const { channelName: _readOnlyChannelName, warehouseName: _readOnlyWarehouseName, ...writableMaster } = master
const normalizedSuffix = text(suffix).replace(/[^A-Za-z0-9]/g, '').slice(-32) || 'BOOKING'
const containerRequirements = labels.containerRequirements || [{
containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000,
containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68, maxWeightKg: 19500,
}]
return {
...writableMaster,
@@ -81,7 +81,7 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) {
bookingAgentName: labels.bookingAgentId ? labels.bookingAgentName ?? null : null,
vesselName: labels.vesselName || `验收船-${suffix}`, voyageNo: labels.voyageNo || `V-${suffix}`,
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX',
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX',
estimatedSailingAt: labels.estimatedSailingAt || '2026-09-01T08:00:00',
estimatedArrivalAt: labels.estimatedArrivalAt || '2026-09-20T08:00:00',
remark: context.marker,
@@ -27,8 +27,8 @@ function fullDraft(context, suffix, master, labels) {
return oceanBookingDraft(context, suffix, master, {
...labels,
containerRequirements: labels.containerRequirements || [
{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000 },
{ containerType: '20GP', containerKind: 'FCL', quantity: 1, capacityCbm: 33, maxWeightKg: 28000 },
{ containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68, maxWeightKg: 19500 },
{ containerType: '40GP', containerKind: 'FCL', quantity: 1, capacityCbm: 58, maxWeightKg: 19500 },
],
})
}
+2 -1
View File
@@ -449,7 +449,8 @@ function buildPayload(context, profile, master, productImageFileId, customerAddr
const carton = dropship ? selectedBoxes[0] : box
const cartonVolumeCbm = carton.lengthCm * carton.widthCm * carton.heightCm / 1_000_000
return {
skuNo: `SKU-${box.boxType}-${context.marker}`.slice(0, 64),
skuNo: context.context?.waitSplitVariant?.skuCodes?.[index]
|| `SKU-${box.boxType}-${context.marker}`.slice(0, 64),
cargoName: `${box.boxType} 测试产品`, cargoNameEn: `${box.boxType} test product`,
hsCode: '8518210000', customsHsCode: '8518210000', packageType: 'CARTON',
packingGroupNo: dropship ? mixedPackingGroupNo : null,
@@ -173,7 +173,7 @@ export async function run(context) {
OPTIONS)
if (!selectedChannel) return
const channelName = selectedChannel.channelName
const primaryRequirement = { containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }
const primaryRequirement = { containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 19500 }
const realContainerNo = iso6346ContainerNumber(`${context.marker}-initial`)
const wrongContainerNo = `TST-${suffix}-02`.slice(0, 64)
const bookingAgent = await resolveOceanBookingAgent(context, MODULE, 'MATSON', OPTIONS)
@@ -195,7 +195,7 @@ export async function run(context) {
vesselName: `验收船-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64),
departurePortCode: 'CNSZX', departurePortName: '深圳',
destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX',
serviceTerm: 'CY-CY', freightTerm: 'NINGBO', estimatedSailingAt: '2026-09-01T08:00:00',
estimatedSailingAt: '2026-09-01T08:00:00',
estimatedArrivalAt: '2026-09-20T08:00:00', remark: context.marker,
parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
@@ -203,7 +203,7 @@ export async function run(context) {
containerRequirements: [
{ containerType: primaryRequirement.containerType, containerKind: primaryRequirement.containerKind, quantity: 1,
capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg },
{ containerType: '20GP', containerKind: 'FCL', quantity: 1, capacityCbm: 33, maxWeightKg: 28000 },
{ containerType: '40GP', containerKind: 'FCL', quantity: 1, capacityCbm: 58, maxWeightKg: 19500 },
],
}
context.assert(MODULE, '订舱渠道与仓库快照完整', inboundResponse, Boolean(channelCode && channelName && warehouseName),
@@ -203,18 +203,18 @@ export async function run(context) {
bookingAgentId: id(supplier.id), bookingAgentName: supplier.name,
vesselName: `P3VESSEL-${suffix}`.slice(0, 128), voyageNo: `P3V-${suffix}`.slice(0, 64),
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX',
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX',
estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00',
remark: context.marker,
parties: [{ partyType: 'SHIPPER', partyName: `P3-SHIPPER-${suffix}` },
{ partyType: 'CONSIGNEE', partyName: `P3-CONSIGNEE-${suffix}` },
{ partyType: 'NOTIFY_PARTY', partyName: `P3-NOTIFY-${suffix}` }],
containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76,
maxWeightKg: 28000 }],
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68,
maxWeightKg: 19500 }],
}
const simulationResponse = await request(context, 'POST', '/api/platform/v1/containers', {
channelGroupCode: channelGroup(initial?.summary?.channelCode), warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
channelGroupCode: channelGroup(initial?.summary?.channelCode), warehouseId, containerType: '40HQ',
capacityCbm: 68, maxWeightKg: 19500,
})
const simulation = context.apiSuccess(MODULE, '新增三期内部模拟柜', simulationResponse, '', OPTIONS)
const containerBookingId = id(simulation?.id)
@@ -618,7 +618,7 @@ export async function run(context) {
const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '')
const frozenTransportMatches = frozenTransportValid
&& transportFields.every((field) => String(frozenTransport[field]) === String(bookingDraft[field]))
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HC')
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ')
context.assert(MODULE, 'RELEASED 订舱回显完整冻结运输快照且无真实柜号', releaseResponse,
Boolean(bookingId) && bookingConfirmed?.oceanBooking?.status === 'RELEASED' && frozenTransportMatches
&& Boolean(requirement?.id) && (!bookingConfirmed?.containers || bookingConfirmed.containers.length === 0),
@@ -102,8 +102,8 @@ test('booking freezes the source customer channel and warehouse scope', () => {
assert.match(source, /customerId: source\.tenantId/)
assert.match(source, /channelCode: source\.channelCode/)
assert.match(source, /warehouseId: source\.warehouseId/)
assert.match(source, /containerType === '40HC'/)
assert.match(source, /containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000/)
assert.match(source, /containerType === '40HQ'/)
assert.match(source, /containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500/)
})
test('split chain starts from the partial inbound and derives quantities from the real tree', () => {
@@ -188,7 +188,7 @@ async function loadOneFragment(context, state) {
if (!carrier?.id) return null
const simulationResponse = await request(context, 'POST', CONTAINERS, {
channelGroupCode: channelGroup(state.channelCode), warehouseId,
containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500,
})
const simulation = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
const containerId = id(simulation?.id)
@@ -221,7 +221,7 @@ async function loadOneFragment(context, state) {
bookingConfirmed?.oceanBooking?.status === 'RELEASED'
&& (!bookingConfirmed?.containers || bookingConfirmed.containers.length === 0),
`status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, containers=${bookingConfirmed?.containers?.length ?? 0}`, OPTIONS)
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HC')
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ')
const realContainerNo = iso6346ContainerNumber(`${context.marker}-split-${sequence}`)
if (!requirement?.id) return null
const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
@@ -46,7 +46,7 @@ function oceanDraft(context, suffix, master) {
...oceanBookingDraft(context, suffix, master),
departurePortCode: 'CNSZX',
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
placeOfDelivery: 'LAX',
remark: context.marker,
parties: [
{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
@@ -316,7 +316,7 @@ function looseboardPayload(context) {
return {
transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE',
origin: `散板起运-${suffix}`, originAddress: 'Los Angeles', destination: `散板目的-${suffix}`,
destinationAddress: 'Ontario', containerType: '20GP', packageCount: 3, totalWeightKg: 80,
destinationAddress: 'Ontario', containerType: '40GP', packageCount: 3, totalWeightKg: 80,
totalVolumeCbm: 1.8, budgetAmount: 66, currency: 'USD', contactName: '散板验收联系人',
contactInfo: '13800000001', requestedDate: new Date(Date.now() + 8 * 86400000).toISOString().slice(0, 10),
remark: context.marker, idempotencyKey: `${context.marker}-phase4-looseboard`.slice(0, 255),
@@ -144,7 +144,7 @@ async function fixture(context) {
const draft = oceanBookingDraft(context, suffix, master, {
bookingAgentId: bookingAgent.supplierId, bookingAgentName: bookingAgent.supplierName,
shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON',
containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }],
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 2, capacityCbm: 68, maxWeightKg: 19500 }],
})
const createdResponse = await request(context, 'POST', BOOKINGS, { expectedVersion: 0, data: draft },
{ 'Idempotency-Key': `${context.marker}-truck-booking-create`.slice(0, 128) })