优化
This commit is contained in:
@@ -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 },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) })
|
||||
|
||||
Reference in New Issue
Block a user