Merge remote-tracking branch 'origin/test' into test

This commit is contained in:
2026-09-14 17:26:01 +08:00
22 changed files with 302 additions and 56 deletions
+14 -9
View File
@@ -1344,18 +1344,19 @@ export class AcceptanceRun {
}
}
const orders = await import(new URL('./admin-test-env-modules/orders.mjs', import.meta.url))
// One-click dropship has a physically indivisible sealed pallet. The phase-three loading
// fixture normally creates four one-carton SKUs so it can exercise partial loading, but that
// would intentionally leave three cartons outside the sealed pallet and make the later
// cross-region handover invalid. Keep this producer variant to one carton only; the separate
// STANDARD incremental fixture below continues to cover SKIP/PARTIAL/dynamic mutations.
// Keep the primary one-click producer deliberately multi-pallet. This is the regression
// fixture for the real business shape where one inbound has more than one sealed DP pallet:
// every pallet must remain independently assignable while the handover stays indivisible.
// The separate STANDARD incremental fixture below continues to cover SKIP/PARTIAL mutations.
const primaryDropship = this.context.orderFulfillmentType === 'ONE_CLICK_DROPSHIP'
const hadWaitSplitVariant = Object.hasOwn(this.context, 'waitSplitVariant')
const savedWaitSplitVariant = this.context.waitSplitVariant
const primaryDropshipVariant = {
...(savedWaitSplitVariant || {}), skuCount: 1, packageCountPerSku: 4,
packageCounts: [4], palletCount: 2,
}
if (primaryDropship) {
this.context.waitSplitVariant = {
...(savedWaitSplitVariant || {}), skuCount: 1, packageCountPerSku: 1, packageCounts: [1],
}
this.context.waitSplitVariant = primaryDropshipVariant
}
try {
await this.executeModule('orders-phase3-loading-fixture', () => orders.run(this))
@@ -1368,11 +1369,13 @@ export class AcceptanceRun {
this.assert('phase3-loading-fixture', 'A0 隔离使用本轮订单收货仓', null,
Boolean(warehouseId), `warehouseId=${warehouseId || 'missing'}`,
{ scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true })
const requiredPalletCount = Math.max(1, Number(this.context?.waitSplitVariant?.palletCount || 1))
const requiredPalletCount = primaryDropship ? primaryDropshipVariant.palletCount
: Math.max(1, Number(this.context?.waitSplitVariant?.palletCount || 1))
const isolation = requireSharedOverflow
? await this.disableAvailableLocationsForA0(warehouseId, requiredPalletCount)
: { forced: false, disabled: [] }
this.context.phase3LoadingRequireSharedOverflow = requireSharedOverflow
if (primaryDropship) this.context.waitSplitVariant = primaryDropshipVariant
try {
const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url))
await this.executeModule('inbound-measurement-phase3-loading-fixture', () => measurement.run(this))
@@ -1380,6 +1383,8 @@ export class AcceptanceRun {
if (requireSharedOverflow) {
await this.restoreAvailableLocationsAfterA0(warehouseId, isolation)
}
if (hadWaitSplitVariant) this.context.waitSplitVariant = savedWaitSplitVariant
else delete this.context.waitSplitVariant
}
const fixture = this.createdResources.inboundMeasurement || this.createdResources.orders
const fixtureOrder = this.createdResources.orders || {}
+2 -2
View File
@@ -48,7 +48,7 @@ export async function run(context) {
一件代发的 `inbound-measurement` 前置在测量草稿录入预计托数;审核通过时系统一次性预生成全部 DP 托盘码,允许空托先打印。PDA 既支持先清点箱再扫预生成托盘码批量归托,也支持先扫托盘后逐箱直接归托;预计托数不得超过实测箱数。验收逐托封托并确认全部箱数已绑定、全部预生成托盘已封托后任务才完成,默认一件代发后续链复用该前置。人工验收造数可传 `--prepare-dropship-manual-count true`,脚本会停在任务未领取、箱未清点、托盘未绑定的状态。
该前置保存成功解析且清点的原始 `boxCode`,随装柜完成生产 fixture 传入默认 `phase4-overseas-pda`。默认矩阵同时创建 FBA 和 OVERSEAS 两种一件代发,必须先将全部原箱码经海外 `current/cartons/resolve` 精确解析到同一入仓码及冻结目的类型,再进入实收及库存验证;OVERSEAS 逐箱实收,A0404、错误箱码/归属或数量缺失均阻断,不扩展推算箱号或排除失败候选。现行 V2 托盘 SKU 分组必须携带 `sourceInboundDetailId`,离线合同同时锁定两个生产分支来源明细和上述真实 HTTP 调用链。
该前置保存成功解析且清点的原始 `boxCode`,随装柜完成生产 fixture 传入默认 `phase4-overseas-pda`。默认矩阵同时创建 FBA 和 OVERSEAS 两种一件代发,其中主生产者固定为 4 箱 2 个已封 DP 托盘:先逐托人工分配并确认首托加入后次托仍可选,再反证单条删除受阻、整托移除成功,随后执行真实智能推荐并核对两托完整加入,最终恢复精确两托继续装柜。海外侧必须先将全部原箱码经 `current/cartons/resolve` 精确解析到同一入仓码及冻结目的类型,再进入逐来源托盘实收及库存验证;OVERSEAS 逐箱实收,A0404、错误箱码/归属或数量缺失均阻断,不扩展推算箱号或排除失败候选。现行 V2 托盘 SKU 分组必须携带 `sourceInboundDetailId`,离线合同同时锁定两个生产分支来源明细和上述真实 HTTP 调用链。
- `request(method, path, body?, token?, extraHeaders?)`:JSON HTTP 请求,自动使用平台 Token;幂等写操作通过 `extraHeaders` 传 `Idempotency-Key`。
- `requestBinary(method, path)`:文件下载,不把二进制写入报告。
@@ -181,7 +181,7 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env" s
`remaining = total - assigned`;随后尝试把箱数调整为权威总数加一,必须得到 `A0400`,并回查
plan/item 版本、分配箱数及总数投影全部零漂移。脚本不从当前已分配数量反推订单总箱数。
`phase3-loading` 复用本轮一件代发 fixture,核对 task items 与 inventory units 的 `unitType=SEALED_PALLET/palletId/palletNo/indivisible`,整托统一通过 `/batch` 提交 `PICK_PALLET + palletNo`。合法整托行混入非法普通行必须整批零写;普通 `PICK/PARTIAL/SKIP` 不得绕过整托限制。同参同键重放不得增加版本或审计,同键更换托盘码必须冲突。以上接入现有全量链,不建立独立 HTTP 入口。
`phase3-loading` 复用本轮双托一件代发 fixture,逐托核对 task items 与 inventory units 的 `unitType=SEALED_PALLET/palletId/palletNo/indivisible`,两托统一通过同一次 `/batch` 提交各自的 `PICK_PALLET + palletNo`。合法整托行混入非法普通行必须整批零写;普通 `PICK/PARTIAL/SKIP` 不得绕过整托限制。同参同键重放不得增加版本或审计,同键更换托盘码必须冲突。以上接入现有全量链,不建立独立 HTTP 入口。
`phase3-pda-convergence` 同时回查 `/tasks/pending` 与稳定 task detail:两者的 `workflowStatus`、`itemCount`、`pendingItemCount` 必须来自同一任务明细事实。全拣任务必须在两处均为 `AWAITING_REVIEW`,可执行证据 GET,但派生 `actions=[]`,不得重复发送 batch;操作历史必须恰有一次有效 `BATCH`。若 pending 仍按 assignment 返回 `IN_PROGRESS`,该轮以真实 HTTP 失败阻断,不由脚本或前端改写状态。
+107 -15
View File
@@ -229,9 +229,11 @@ export async function run(context) {
&& Number(item.availablePackageCount) > 0) : []
let candidate = inboundCandidates[0]
let dropshipCandidates = []
let dropshipCandidatePath = null
if (source?.fulfillmentType === 'ONE_CLICK_DROPSHIP') {
const group = channelGroup(initial?.summary?.channelCode)
const candidatePath = `${smartLoadingBase}/inbound-candidates?channelGroupCodes=${encodeURIComponent(group)}&warehouseId=${encodeURIComponent(warehouseId)}&customerId=${encodeURIComponent(tenantId)}&inboundCode=${encodeURIComponent(initial?.summary?.inboundCode || source?.inboundCode || '')}&orderStatuses=WAIT_SPLIT&page=1&size=20`
dropshipCandidatePath = candidatePath
const palletCandidatesResponse = await context.request('GET', candidatePath)
const palletCandidatePage = context.apiSuccess(MODULE, '查询一件代发整托装柜候选', palletCandidatesResponse, '', OPTIONS)
dropshipCandidates = Array.isArray(palletCandidatePage?.records)
@@ -255,24 +257,112 @@ export async function run(context) {
if (!loadingPlanId) return
const dropshipAssignment = source?.fulfillmentType === 'ONE_CLICK_DROPSHIP'
const initialAssignmentCount = dropshipAssignment
? dropshipCandidates.filter((item) => id(item.palletId) === id(candidate.palletId))
.reduce((total, item) => total + Number(item.availablePackageCount || 0), 0) : 1
const assignmentPath = dropshipAssignment
? `${smartLoadingBase}/plans/${loadingPlanId}/pallet-assignments`
: `${smartLoadingBase}/plans/${loadingPlanId}/assignments`
const assignmentBody = dropshipAssignment
? { palletId: id(candidate.palletId), expectedVersion: Number(loadingPlan.version),
operationId: `${context.marker}-${loadingPlanId}-bridge-assign-pallet`.slice(0, 128) }
: { inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: initialAssignmentCount,
expectedVersion: Number(loadingPlan.version), operationId: `${context.marker}-${loadingPlanId}-bridge-assign`.slice(0, 128) }
const assignmentResponse = await request(context, 'POST', assignmentPath, assignmentBody)
const assignedLoadingPlan = context.apiSuccess(MODULE, '将本轮入库货物分配到柜次计划', assignmentResponse, '', OPTIONS)
? dropshipCandidates.reduce((total, item) => total + Number(item.availablePackageCount || 0), 0) : 1
let assignmentResponse
let assignedLoadingPlan
if (dropshipAssignment) {
const assignmentPath = `${smartLoadingBase}/plans/${loadingPlanId}/pallet-assignments`
let workingPlan = loadingPlan
for (const [index, pallet] of dropshipCandidates.entries()) {
const body = { palletId: id(pallet.palletId), expectedVersion: Number(workingPlan.version),
operationId: `${context.marker}-${loadingPlanId}-bridge-assign-pallet-${id(pallet.palletId)}`.slice(0, 128) }
assignmentResponse = await request(context, 'POST', assignmentPath, body)
workingPlan = context.apiSuccess(MODULE, `逐托分配一件代发托盘${index + 1}/${dropshipCandidates.length}`,
assignmentResponse, '', OPTIONS)
if (!workingPlan) return
if (index === 0 && dropshipCandidates.length > 1 && dropshipCandidatePath) {
const remainingResponse = await context.request('GET', dropshipCandidatePath)
const remainingPage = context.apiSuccess(MODULE, '分配首托后刷新同票剩余整托候选',
remainingResponse, '', OPTIONS)
const remainingIds = new Set((remainingPage?.records || []).map((item) => id(item?.palletId)))
const expectedRemaining = dropshipCandidates.slice(1).map((item) => id(item.palletId))
context.assert(MODULE, '一件代发分配首托后其余托盘仍可独立分配', remainingResponse,
expectedRemaining.every((palletId) => remainingIds.has(palletId)),
`remaining=${[...remainingIds].join(',') || 'missing'}, expected=${expectedRemaining.join(',')}`, OPTIONS)
}
}
assignedLoadingPlan = workingPlan
} else {
const assignmentPath = `${smartLoadingBase}/plans/${loadingPlanId}/assignments`
const assignmentBody = { inboundId, inboundDetailId: id(candidate.inboundDetailId),
packageCount: initialAssignmentCount, expectedVersion: Number(loadingPlan.version),
operationId: `${context.marker}-${loadingPlanId}-bridge-assign`.slice(0, 128) }
assignmentResponse = await request(context, 'POST', assignmentPath, assignmentBody)
assignedLoadingPlan = context.apiSuccess(MODULE, '将本轮入库货物分配到柜次计划', assignmentResponse, '', OPTIONS)
}
context.assert(MODULE, '柜次计划明细和版本真实持久化', assignmentResponse,
assignedLoadingPlan?.items?.filter?.((item) => id(item.inboundId) === inboundId)
.reduce((total, item) => total + Number(item.packageCount || 0), 0) === initialAssignmentCount
&& Number(assignedLoadingPlan?.version) >= 1,
`loadingPlanId=${loadingPlanId}, assignedPackages=${initialAssignmentCount}, version=${assignedLoadingPlan?.version ?? 'missing'}`, OPTIONS)
if (dropshipAssignment && dropshipCandidates.length > 1) {
const firstPalletItem = assignedLoadingPlan?.items?.find?.((item) =>
id(item.inboundId) === inboundId && id(item.palletId) === id(dropshipCandidates[0].palletId))
const singleDelete = await request(context, 'DELETE',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${id(firstPalletItem?.id)}`, {
expectedPlanVersion: Number(assignedLoadingPlan?.version),
expectedItemVersion: Number(firstPalletItem?.version),
operationId: `${context.marker}-${loadingPlanId}-reject-single-pallet-line-delete`.slice(0, 128),
})
context.assert(MODULE, '已封托托盘拒绝按单条装柜明细删除', singleDelete,
singleDelete.http === 200 && codeOf(singleDelete) === 'A0409',
`HTTP=${singleDelete.http}, code=${codeOf(singleDelete)}`, OPTIONS)
let emptiedPlan = assignedLoadingPlan
for (const [index, pallet] of dropshipCandidates.entries()) {
const removedResponse = await request(context, 'DELETE',
`${smartLoadingBase}/plans/${loadingPlanId}/pallet-assignments/${id(pallet.palletId)}`, {
expectedPlanVersion: Number(emptiedPlan.version),
operationId: `${context.marker}-${loadingPlanId}-remove-whole-pallet-${id(pallet.palletId)}`.slice(0, 128),
})
emptiedPlan = context.apiSuccess(MODULE, `整托移除一件代发托盘${index + 1}/${dropshipCandidates.length}`,
removedResponse, '', OPTIONS)
if (!emptiedPlan) return
}
context.assert(MODULE, '逐托移除后本票装柜明细归零', assignmentResponse,
!emptiedPlan.items?.some?.((item) => id(item.inboundId) === inboundId),
`remaining=${emptiedPlan.items?.filter?.((item) => id(item.inboundId) === inboundId).length ?? 'missing'}`, OPTIONS)
const recommendResponse = await request(context, 'POST',
`${smartLoadingBase}/plans/${loadingPlanId}/recommend`, {
expectedVersion: Number(emptiedPlan.version),
operationId: `${context.marker}-${loadingPlanId}-recommend-multi-pallet`.slice(0, 128),
customerId: tenantId,
inboundCode: initial?.summary?.inboundCode || source?.inboundCode,
})
const recommendedPlan = context.apiSuccess(MODULE, '智能推荐按整托重新加入一件代发多托',
recommendResponse, '', OPTIONS)
const expectedPalletIds = new Set(dropshipCandidates.map((item) => id(item.palletId)))
const recommendedPalletIds = new Set((recommendedPlan?.items || [])
.filter((item) => id(item.inboundId) === inboundId).map((item) => id(item.palletId)))
context.assert(MODULE, '智能推荐保留同票全部封托托盘且不按箱拆分', recommendResponse,
expectedPalletIds.size === dropshipCandidates.length
&& [...expectedPalletIds].every((palletId) => recommendedPalletIds.has(palletId)),
`expected=${[...expectedPalletIds].join(',')}, actual=${[...recommendedPalletIds].join(',') || 'missing'}`, OPTIONS)
if (!recommendedPlan || ![...expectedPalletIds].every((palletId) => recommendedPalletIds.has(palletId))) return
const clearedResponse = await request(context, 'DELETE',
`${smartLoadingBase}/plans/${loadingPlanId}/unpicked-items`, {
expectedPlanVersion: Number(recommendedPlan.version),
operationId: `${context.marker}-${loadingPlanId}-clear-recommendation`.slice(0, 128),
})
let exactPlan = context.apiSuccess(MODULE, '移除未拣并释放智能推荐临时分配',
clearedResponse, '', OPTIONS)
if (!exactPlan) return
for (const [index, pallet] of dropshipCandidates.entries()) {
assignmentResponse = await request(context, 'POST',
`${smartLoadingBase}/plans/${loadingPlanId}/pallet-assignments`, {
palletId: id(pallet.palletId), expectedVersion: Number(exactPlan.version),
operationId: `${context.marker}-${loadingPlanId}-final-pallet-${id(pallet.palletId)}`.slice(0, 128),
})
exactPlan = context.apiSuccess(MODULE, `智能推荐回归后恢复本票整托${index + 1}/${dropshipCandidates.length}`,
assignmentResponse, '', OPTIONS)
if (!exactPlan) return
}
assignedLoadingPlan = exactPlan
}
const assignedItem = assignedLoadingPlan?.items?.find?.((item) => id(item.inboundId) === inboundId)
const orderRemarks = source || {}
context.assert(MODULE, '装柜明细客户备注与最新内部备注来源精确且不冒用货物备注', assignmentResponse,
@@ -690,16 +780,18 @@ export async function run(context) {
if (dropshipAssignment) {
const palletUnits = Array.isArray(inventory?.units) ? inventory.units : []
const expectedPallets = new Map(dropshipCandidates.map((item) =>
[id(item.palletId), item.palletNo]))
const taskUnitsValid = canonicalPickItems.every((item) => item.unitType === 'SEALED_PALLET'
&& id(item.palletId) === id(candidate.palletId) && item.palletNo === candidate.palletNo
&& expectedPallets.get(id(item.palletId)) === item.palletNo
&& /^DP-[0-9]{6}-[0-9]{4}$/.test(item.palletNo) && item.indivisible === true)
const inventoryUnitsValid = canonicalPickItems.every((item) => palletUnits.some((unit) =>
id(unit.pickItemId) === id(item.id) && id(unit.loadingPlanItemId) === id(item.loadingPlanItemId)
&& id(unit.inboundId) === id(item.inboundId) && unit.unitType === 'SEALED_PALLET'
&& id(unit.palletId) === id(item.palletId) && unit.palletNo === item.palletNo && unit.indivisible === true))
context.assert(MODULE, 'PDA task与inventory明确返回SEALED_PALLET及DP整托四字段', inventoryResponse,
taskUnitsValid && inventoryUnitsValid,
`taskUnits=${canonicalPickItems.length}, inventoryUnits=${palletUnits.length}`, OPTIONS)
taskUnitsValid && inventoryUnitsValid && canonicalPickItems.length === expectedPallets.size,
`taskUnits=${canonicalPickItems.length}, inventoryUnits=${palletUnits.length}, expectedPallets=${expectedPallets.size}`, OPTIONS)
if (!taskUnitsValid || !inventoryUnitsValid) return
}
@@ -92,6 +92,7 @@ test('measurement and loading pass destination identity into the LOADED producer
assert.match(finalize, /phase4InboundProducerFixtures = collectPhase4InboundProducerFixture/)
const entry = read('../admin-test-env-acceptance.mjs')
assert.match(entry, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/)
assert.match(entry, /packageCounts: \[4\], palletCount: 2/)
assert.match(entry, /sealedSourcePalletNos: Array\.isArray\(fixture\.sealedSourcePalletNos\)/)
assert.match(entry, /orderFulfillmentType/)
assert.match(entry, /fulfillmentType: fixtureOrder\.fulfillmentType \|\| 'STANDARD'/)
@@ -102,6 +103,12 @@ test('measurement and loading pass destination identity into the LOADED producer
assert.match(entry, /skuLines: Array\.isArray\(fixtureOrder\.skuLines\) \? fixtureOrder\.skuLines : \[\]/)
const pda = read('phase4-overseas-pda.mjs')
assert.match(loading, /pallet-assignments/)
assert.match(loading, /一件代发分配首托后其余托盘仍可独立分配/)
assert.match(loading, /已封托托盘拒绝按单条装柜明细删除/)
assert.match(loading, /智能推荐按整托重新加入一件代发多托/)
assert.match(loading, /智能推荐保留同票全部封托托盘且不按箱拆分/)
assert.match(loading, /customerId:\s*tenantId/)
assert.match(loading, /inboundCode:\s*initial\?\.summary\?\.inboundCode\s*\|\|\s*source\?\.inboundCode/)
assert.match(loading, /canonicalPickItems\.map/)
assert.match(loading, /pickedPackageCount: Number\(item\.plannedPackageCount\)/)
assert.match(loading, /const dropshipWholePallet = source\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'/)
@@ -527,7 +527,10 @@ test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING au
assert.match(loading, /inbound-candidates\?channelGroupCodes=/)
assert.match(loading, /unitType === 'SEALED_PALLET'/)
assert.match(loading, /indivisible === true/)
assert.match(loading, /palletId: id\(candidate\.palletId\)/)
assert.match(loading, /palletId: id\(pallet\.palletId\)/)
assert.match(loading, /一件代发分配首托后其余托盘仍可独立分配/)
assert.match(loading, /智能推荐保留同票全部封托托盘且不按箱拆分/)
assert.match(loading, /customerId:\s*tenantId/)
assert.doesNotMatch(loading, /bridge-assign-inbound/)
assert.match(loading, /skipBatchVerified, partialBatchVerified, dynamicIncrementVerified/)
assert.match(convergence, /expectedBatchCount = fixture\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP' \? 1 : 4/)