fix: 支持多SKU共箱整托装柜

This commit is contained in:
2026-09-16 21:22:18 +08:00
parent 1cd10a9d3b
commit 9ab1c328ba
9 changed files with 125 additions and 24 deletions
+2
View File
@@ -76,6 +76,8 @@ export async function run(context) {
`dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账。随后从本轮逐票库存候选创建一件代发专用出库,携带原始箱唛由 PDA 拣货、完成,核对终态、幂等和可用库存扣减;finally 停用本轮操作员。出库重放按业务身份和数据库毫秒时间精度核对。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;脚本覆盖不等于真实 HTTP 已通过,须检查当轮报告和库存审计。
`dropship-physical-carton-chain.mjs` 复用同一条正式链路,专门覆盖多 SKU 共用物理箱:20 箱×2 SKU/2 托、5 箱×3 SKU/1 托、7 箱×2 SKU/预计 2 托后合为 1 托。每个场景从新订单、测量、箱唛冻结和 PDA 清点开始,按去重箱唛核对预装、装柜、尾程计划、海外整托收货、库存候选和一件代发出库;每箱必须保留全部 SKU 明细,但任何汇总不得按 SKU 行重复累计箱数。使用 `--modules dropship-physical-carton-chain`,会保留本轮业务数据和 JSON 报告,不自动清理。
- `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`,不因空的任务托盘列表新增资源。
@@ -176,6 +176,18 @@ test('dropship matrix has a real customer prerequisite without generic dispatch
assert.ok(!modules.includes('phase5-overseas-fulfillment'))
})
test('physical-carton chain covers mixed SKU cartons without multiplying carton totals', () => {
const modules = normalizeModuleOrder(['dropship-physical-carton-chain'])
assert.deepEqual(modules, ['merchant-qualification-fixture', 'dropship-physical-carton-chain'])
assert.match(source, /prepare\('PHYSICAL-20X2', 2[\s\S]*totalPackageCount: 20, skuCount: 2/)
assert.match(source, /prepare\('PHYSICAL-5X3', 1[\s\S]*totalPackageCount: 5, skuCount: 3/)
assert.match(source, /prepare\('PHYSICAL-7X2-MERGED', 2, \{ mergeToFirstPallet: true \}/)
assert.match(source, /carton\.skuLines\.length === \(record\.expectedSkuCount \|\| 1\)/)
assert.match(source, /packagesByPhysicalUnit/)
assert.match(source, /new Set\(actual\.map\(\(item\) => item\.skuCode\)/)
assert.match(source, /PHYSICAL_CARTON_OUTBOUND_COMPLETED/)
})
test('all four physical pallet shapes are explicitly produced', () => {
assert.match(source, /prepare\('SINGLE-MULTI', 2\)/)
assert.match(source, /prepare\('SINGLE-SINGLE', 1\)/)
@@ -190,7 +202,7 @@ test('allocation checks both physical summaries and uncollapsed per-inbound quan
assert.match(source, /first-leg\/loading-plans\/\$\{plan.id\}/)
assert.match(source, /const actual = detail.items.filter/)
assert.match(source, /plan.items\?\.length === pallets.length/)
assert.match(source, /actual.reduce\([\s\S]*?record.expectedCartonMarks.length/)
assert.match(source, /packagesByPhysicalUnit\.values\(\)[\s\S]*?record.expectedCartonMarks.length/)
assert.match(source, /await conserve\('手工加入后'\)/)
assert.match(source, /await conserve\('智能推荐后'\)/)
assert.match(source, /fullFlowVerified: false/)
@@ -47,7 +47,12 @@ export async function runDomesticLinkRegression(context) {
return runWorkflow(context, false, false, true)
}
async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = false, linkRegression = false) {
export async function runPhysicalCartonRegression(context) {
return runWorkflow(context, false, false, false, true)
}
async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = false, linkRegression = false,
physicalCartonRegression = false) {
const marker = context.marker
const savedOptions = context.options.prepareDropshipManualCount
const savedContext = { ...context.context }
@@ -58,7 +63,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
context.assert(MODULE, name, response, passed, details, OPTIONS)
if (!passed) throw new Error(name)
}
async function prepare(label, palletCount, mode = {}, destinationType = null) {
async function prepare(label, palletCount, mode = {}, destinationType = null, variant = {}) {
context.marker = `${marker}-${label}`
for (const key of ['orders', 'inboundMeasurement', 'ocrInbound', 'testInbound', 'dropshipManualCount']) {
delete context.createdResources[key]
@@ -67,7 +72,13 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
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 }
const totalPackageCount = Number(variant.totalPackageCount || 4)
const skuCount = Number(variant.skuCount || 1)
context.context.waitSplitVariant = {
skuCount, packageCountPerSku: totalPackageCount,
packageCounts: Array.from({ length: skuCount }, () => totalPackageCount),
totalPackageCount, palletCount,
}
if (label.startsWith('MULTI-MERGE-')) {
context.context.waitSplitVariant.skuCodes = [`SKU-SHARED-${marker}`.slice(0, 64)]
}
@@ -101,6 +112,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
Boolean(task) && !context.steps.slice(start).some((step) => !step.passed))
const record = { label, order: { ...context.createdResources.orders },
measurement: { ...context.createdResources.inboundMeasurement }, source: { ...source }, task,
expectedSkuCount: skuCount,
expectedCartonMarks: Array.from({ length: source.totalCartons }, (_, index) =>
`${source.inboundCode}#${source.totalCartons}-${index + 1}`) }
records.push(record)
@@ -116,6 +128,8 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
task?.taskStatus === 'COMPLETED' && task.pallets?.length === expectedPallets
&& task.pallets.every((pallet) => pallet.status === 'SEALED')
&& cartons.length === record.expectedCartonMarks.length
&& cartons.every((carton) => Array.isArray(carton.skuLines)
&& carton.skuLines.length === (record.expectedSkuCount || 1))
&& JSON.stringify(cartons.map((carton) => carton.boxCode).sort())
=== JSON.stringify([...record.expectedCartonMarks].sort()))
record.task = task
@@ -177,9 +191,18 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
=== 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)
const packagesByPhysicalUnit = new Map()
for (const item of actual) {
const key = item.palletId == null ? `ITEM:${item.id}` : `PALLET:${item.palletId}`
packagesByPhysicalUnit.set(key, Math.max(packagesByPhysicalUnit.get(key) || 0,
Number(item.packageCount)))
}
check(`${record.label}${name}分配箱数与托盘身份正确`, null,
actual.reduce((sum, item) => sum + Number(item.packageCount), 0) === record.expectedCartonMarks.length
&& (!record.task || actual.every((item) => record.task.pallets.some((pallet) => String(pallet.palletId) === String(item.palletId)))))
[...packagesByPhysicalUnit.values()].reduce((sum, count) => sum + count, 0)
=== record.expectedCartonMarks.length
&& new Set(actual.map((item) => item.skuCode).filter(Boolean)).size === (record.expectedSkuCount || 1)
&& (!record.task || actual.every((item) => record.task.pallets.some((pallet) =>
String(pallet.palletId) === String(item.palletId)))))
}
}
for (const pallet of pallets) {
@@ -603,6 +626,26 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
for (const record of group) record.outbound = { task: completed, selected, detail: outbound, completed: true }
}
try {
if (physicalCartonRegression) {
const scenarios = [
await prepare('PHYSICAL-20X2', 2, {}, null, { totalPackageCount: 20, skuCount: 2 }),
await prepare('PHYSICAL-5X3', 1, {}, null, { totalPackageCount: 5, skuCount: 3 }),
await prepare('PHYSICAL-7X2-MERGED', 2, { mergeToFirstPallet: true }, null,
{ totalPackageCount: 7, skuCount: 2 }),
]
for (const [index, record] of scenarios.entries()) await verify(record, index === 2 ? 1 : index === 0 ? 2 : 1)
const groups = scenarios.map((record) => [record])
for (const group of groups) await allocate(group)
context.createdResources.dropshipPalletMatrix.stage = 'PHYSICAL_CARTON_LOADING_COMPLETED'
for (const group of groups) await progressShipping(group)
context.createdResources.dropshipPalletMatrix.stage = 'PHYSICAL_CARTON_PICKUP_READY'
for (const group of groups) {
await convertOverseas(group)
await receiveOverseas(group)
}
context.createdResources.dropshipPalletMatrix.stage = 'PHYSICAL_CARTON_OUTBOUND_COMPLETED'
return
}
if (mixedContainer) {
const dropship = await prepare('MIXED-DROPSHIP', 2)
await verify(dropship, 2)
@@ -0,0 +1,5 @@
import { runPhysicalCartonRegression } from './dropship-pallet-matrix.mjs'
export async function run(context) {
return runPhysicalCartonRegression(context)
}