补齐海外仓和私人地址正常及短少收货真实验收

This commit is contained in:
2026-11-04 14:23:41 +08:00
parent 40475388e1
commit fbdf58c23c
7 changed files with 341 additions and 27 deletions
+1 -1
View File
@@ -264,7 +264,7 @@ export async function run(context) {
混柜收货的每一步均区分同键同载荷重放、同键异载荷冲突和同货物新键再次扫码:普通海外仓重复箱返回冲突,FBA 相同累计数和代发同托新键返回零增量;随后回查完整会话,确保三票事实均不变。
`mixed-container-receiving.mjs` 通过总入口 `--modules mixed-container-receiving` 执行,依赖客户资质准备。新建一票双托一件代发、一票普通 FBA、一票普通海外仓订单,固定同一起运仓和渠道,经正式接口混装同柜、完成国内装柜和船节点、按柜生成海外计划。使用本轮 PDA 身份交替执行 FBA 累计收货、代发整托收货、普通海外仓逐箱收货;每步核对全部票进度、同键重放和未齐正常完成拒绝。终点为 `MIXED_CONTAINER_INBOUND_COMPLETED`,不创建后续出库或派送,不代表仓储转运单混装。报告复用 `createdResources.dropshipPalletMatrix`;失败保留阻断,成功数据保留,finally 停用本轮 PDA 操作员。真实运行须环境授权,离线合同 `mixed-container-receiving-contract.test.mjs` 不能代替真实 HTTP。
`mixed-container-receiving.mjs` 通过总入口 `--modules mixed-container-receiving` 执行,依赖客户资质准备。新建一票双托一件代发、一票普通 FBA、一票普通海外仓订单,固定同一起运仓和渠道,经正式接口混装同柜、完成国内装柜和船节点、按柜生成海外计划。普通海外仓四箱通过人工计划预留两托,验证计划二加二可按现场三加一收货,并下载两张真实预留托盘标签PDF;使用本轮 PDA 身份交替执行 FBA 累计收货、代发整托收货、普通海外仓逐箱收货,每步核对全部票进度、同键重放和未齐正常完成拒绝。另建一票私人地址四箱订单,测量审核重放按实测初算结果核对,UNPRICED时核对费用快照待生成且审核重放保持事实一致。随后通过人工报价接口按1 CNY测试单价生成初始头程费用并核对原键重放,通过预装费用门禁后核对其进入普通海外收货模式,实收三箱后带差异结束,验证非预留托盘提前拒绝、后台短少确认及同键重放、原应收保留和三箱可用库存。另建一票私人地址四箱全收,验证正常完成及原键重放、完成计划和四箱可用库存。候选与prepare均按稳定柜次和集装箱号定位。终点为 `MIXED_CONTAINER_INBOUND_COMPLETED`,不创建后续出库或派送,不代表仓储转运单混装。报告复用 `createdResources.dropshipPalletMatrix`;失败保留阻断,成功数据保留,finally 退出本轮固定 PDA 操作员会话。真实运行须环境授权,离线合同 `mixed-container-receiving-contract.test.mjs` 不能代替真实 HTTP。
一件代发矩阵出库前运行 `dropship-outbound-edge-cases.mjs` 的候选查询探针:先模拟平台首次打开页面只传海外仓,再传客户筛选,要求本轮每票全部托盘、箱数、仓位及 SKU 事实一致,并核对仓库与指定客户隔离。缺托、错误客户/仓库、A0400 或筛选后仓位漂移均阻断;候选达到服务端上限导致本轮数据不可见也不能按空场景通过。该探针不证明跨客户混柜造数已覆盖。
@@ -2,9 +2,23 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
import { sameOutboundTask } from './dropship-pallet-matrix.mjs'
import { loadingUnitTypesMatch, sameOutboundTask } from './dropship-pallet-matrix.mjs'
import { receivingProgressMatches, outboundCandidateGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs'
test('domestic loading units follow dropship, ordinary and mixed fixture composition', () => {
const dropship = { order: { fulfillmentType: 'ONE_CLICK_DROPSHIP' } }
const ordinary = { order: { fulfillmentType: 'STANDARD', destinationType: 'PRIVATE' } }
const sealed = { unitType: 'SEALED_PALLET' }
const packages = { unitType: 'PACKAGE' }
assert.equal(loadingUnitTypesMatch([dropship], [sealed]), true)
assert.equal(loadingUnitTypesMatch([ordinary], [packages]), true)
assert.equal(loadingUnitTypesMatch([dropship, ordinary], [sealed, packages]), true)
assert.equal(loadingUnitTypesMatch([ordinary], [sealed]), false)
assert.equal(loadingUnitTypesMatch([dropship, ordinary], [packages]), false)
assert.equal(loadingUnitTypesMatch([ordinary], []), false)
assert.equal(loadingUnitTypesMatch([ordinary], [{ unitType: 'UNKNOWN' }]), false)
})
test('loading confirmation assigns the authenticated PDA operator from the target warehouse', () => {
const source = readFileSync(new URL('./dropship-pallet-matrix.mjs', import.meta.url), 'utf8')
assert.match(source, /domestic-pda-operators\?warehouseId=\$\{summary\.warehouseId\}/)
@@ -249,6 +263,12 @@ test('overseas plans preserve per-inbound ownership and shared physical identity
assert.match(source, /JSON.stringify\(replay\) === JSON.stringify\(plans\)/)
})
test('private receiving mode is verified from the authoritative tail-order detail', () => {
assert.match(source, /读取私人地址尾程收货类型/)
assert.match(source, /detail.order.destinationType === 'OVERSEAS' && detail.order.orderType === 'FIRST_LEG'/)
assert.doesNotMatch(source, /line => line.destinationType === 'OVERSEAS'/)
})
test('overseas receipt uses scoped PDA identity and scans each physical pallet once', () => {
assert.match(source, /await createPicker\(context/)
assert.match(source, /await disablePicker\(context/)
@@ -12,7 +12,7 @@ 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 { receivingProgressMatches, outboundCandidateGuards, outboundCreationGuards, outboundTaskGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs'
import { receiveMixedContainer } from './mixed-container-receiving.mjs'
import { receiveMixedContainer, receiveOverseasComplete, receiveOverseasShortage } from './mixed-container-receiving.mjs'
import { exerciseOldCabinets } from './pda-loading-link-regression.mjs'
import { selectSessionPdaOperator } from './domestic-pda-operator.mjs'
@@ -20,6 +20,14 @@ const MODULE = 'dropship-pallet-matrix'
const BASE = '/api/platform/v1/first-leg/pda-dropship'
const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
export function loadingUnitTypesMatch(group, items) {
const expected = new Set(group.map(record => record.order.fulfillmentType === 'ONE_CLICK_DROPSHIP'
? 'SEALED_PALLET' : 'PACKAGE'))
const actual = new Set(items.map(item => item.unitType))
return items.length > 0 && actual.size === expected.size
&& [...actual].every(type => expected.has(type))
}
export function sameOutboundTask(left, right) {
const databaseTime = (value) => {
const match = typeof value === 'string' && value.match(/^(.*T\d{2}:\d{2}:\d{2})(?:\.(\d+))?$/)
@@ -155,6 +163,25 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
const pallets = [...new Map(group.flatMap((item) => item.task?.pallets || [])
.map((pallet) => [String(pallet.palletId), pallet])).values()]
for (const record of group) {
if (record.order.destinationType === 'PRIVATE') {
const path = `/api/platform/v1/inbounds/${record.source.inboundId}/fees/manual-quote?tenantId=${encodeURIComponent(record.order.tenantId)}`
const body = {
selectedWeightKg: Number(record.summary.actualNetWeightKg || record.summary.actualGrossWeightKg),
selectedVolumeCbm: Number(record.summary.actualVolumeCbm), unitPrice: 1,
reason: `${marker}私人地址验收测试人工报价`,
}
check('私人地址人工报价采用实际测量重量和体积', null,
body.selectedWeightKg > 0 && body.selectedVolumeCbm > 0)
const headers = { 'Idempotency-Key': `${marker}-${record.label}-manual-quote` }
const quoted = await command('私人地址人工头程报价', 'POST', path, body, headers)
check('私人地址报价形成待分柜正式费用快照', null,
quoted.rateCode === 'MANUAL_QUOTE' && quoted.feeStatus === 'CALCULATED'
&& quoted.nextProcessStatus === 'WAIT_SPLIT' && Number(quoted.unitPrice) === body.unitPrice)
const replay = await command('私人地址人工报价原键重放', 'POST', path, body, headers)
check('私人地址报价重放保持同一费用版本', null, JSON.stringify(replay) === JSON.stringify(quoted))
record.manualQuote = { feeId: quoted.feeId, feeVersion: quoted.feeVersion,
unitPrice: body.unitPrice, purpose: 'TEST_ACCEPTANCE' }
}
const fee = await ensureInitialCalculated(context, { ...record.measurement,
tenantId: String(record.order.tenantId), inboundId: record.source.inboundId,
finalStatus: 'APPROVED', processStatus: record.summary.processStatus }, OPTIONS)
@@ -296,10 +323,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
? { action: 'PICK_PALLET', palletNo: item.palletNo }
: { action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount) }) })),
}
check(`${label}PDA全部任务均为封托单元`, null,
claimed.items.length > 0 && (mixedContainer
? claimed.items.some(item => item.unitType === 'SEALED_PALLET') && claimed.items.some(item => item.unitType !== 'SEALED_PALLET')
: claimed.items.every((item) => item.unitType === 'SEALED_PALLET')))
check(`${label}PDA任务单元与实际履约类型一致`, null, loadingUnitTypesMatch(group, claimed.items))
let picked = claimed
if (linkRegression) {
for (const palletNo of [...new Set(claimed.items.map(item => item.palletNo))]) {
@@ -426,6 +450,16 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
summary.orders.some((order) => order.inboundCode === record.source.inboundCode
&& String(order.customerId) === String(record.order.tenantId))),
`containerNo=${loading.containerNo}, expectedOrders=${group.length}, actualOrders=${summary?.orders?.length ?? 'unavailable'}`)
if (mixedContainer && group.length === 3) {
const overseas = group.find(record => record.order.fulfillmentType === 'STANDARD' && record.order.destinationType === 'OVERSEAS')
const order = summary.orders.find(item => item.inboundCode === overseas.source.inboundCode)
const detail = await call('读取普通海外仓订单当前版本', 'GET', `${base}/orders/${order.orderId}?customerId=${encodeURIComponent(order.customerId)}`)
check('普通海外仓人工计划采用当前订单版本', null, Number.isInteger(detail.order?.version))
await call('普通海外仓四箱预留两托', 'POST', `${base}/inbound-plans/manual`, {
customerId: String(order.customerId), orderId: String(order.orderId), expectedOrderVersion: detail.order.version,
palletCount: 2, palletType: 'WOOD', assigned: false, mergeRequired: false, urgent: false, remark: marker,
}, { 'Idempotency-Key': `${marker}-${label}-overseas-two-pallets` })
}
const preview = await call('入库计划预览', 'GET', `${base}/inbound-plans/container-preview?sourceContainerBookingId=${bookingId}`)
check(`${label}全柜入库计划就绪`, null, preview.ready === true && Number(preview.totalOrderCount) === group.length)
const body = { sourceContainerBookingId: bookingId, snapshotVersion: Number(preview.snapshotVersion),
@@ -437,6 +471,13 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
const matching = plans.filter((plan) => plan.lines?.some((line) => line.inboundCode === record.source.inboundCode))
check(`${record.label}计划精确关联来源入仓码`, null, matching.length === 1)
const target = matching[0]
if (record.order.destinationType === 'PRIVATE') {
const source = summary.orders.find(order => order.inboundCode === record.source.inboundCode)
const detail = await call('读取私人地址尾程收货类型', 'GET',
`${base}/orders/${source.orderId}?customerId=${encodeURIComponent(source.customerId)}`)
check(`${record.label}私人地址进入普通海外收货模式`, null,
detail.order.destinationType === 'OVERSEAS' && detail.order.orderType === 'FIRST_LEG')
}
const expectedNos = record.task?.pallets.map((pallet) => pallet.palletNo).sort()
const actualNos = (target.pallets || []).map((pallet) => pallet.palletNo).sort()
check(`${record.label}来源托盘完整绑定海外真实资源`, null,
@@ -459,6 +500,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
async function receiveOverseas(group) {
const record = group[0]
const label = record.label
const sourceContainerBookingId = String(record.loading.cabinet.id)
// The PDA account helper keeps the marker tail; retain the run identity there.
context.marker = `${label}-RECEIVE-${marker}`
const base = '/api/pda/v1/last-leg/receiving-containers'
@@ -476,19 +518,32 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
let candidate
for (let attempt = 0; attempt < 60; attempt++) {
const response = await context.request('GET', `${base}/candidates`, undefined, picker.token, {})
candidate = response?.body?.data?.find?.((item) => item.containerNo === record.loading.containerNo)
candidate = response?.body?.data?.find?.((item) => item.containerNo === record.loading.containerNo
&& String(item.sourceContainerBookingId) === sourceContainerBookingId)
if (candidate?.operable && candidate?.allPlansReady) break
if (attempt < 59) await new Promise((resolve) => setTimeout(resolve, 1000))
}
check(`${label}本柜船节点和入库计划已跨区收敛`, null,
candidate?.operable === true && candidate?.allPlansReady === true)
await call('准备拆柜会话', 'POST', `${base}/${record.loading.containerNo}/prepare`,
await call('准备拆柜会话', 'POST', `${base}/${record.loading.containerNo}/prepare?sourceContainerBookingId=${encodeURIComponent(sourceContainerBookingId)}`,
{ idempotencyKey: `${context.marker}-prepare` })
const photos = await uploadPhotos(context, picker.token)
check(`${label}三类卸柜照片齐全`, null, photos?.length === 3)
const signed = await call('照片绑定并签收', 'POST', `${base}/current/sign`,
{ photos, idempotencyKey: `${context.marker}-sign` })
check(`${label}签收成功`, null, signed.status === 'SIGNED')
const adminCall = async (name, method, path, body, headers) => {
const response = await context.request(method, path, body, context.token, headers)
const data = context.apiSuccess(MODULE, `${label}${name}`, response, '', OPTIONS)
check(`${label}${name}成功`, response, response?.body?.code === '200' && data != null)
return data
}
if (mixedContainer && group.length === 1) {
const receive = record.label === 'PRIVATE-COMPLETE' ? receiveOverseasComplete : receiveOverseasShortage
record.overseas.receiving = await receive({ record, call, adminCall, check, marker: context.marker,
request: (method, path, body) => context.request(method, path, body, picker.token, {}) })
return
}
if (mixedContainer) {
const completed = await receiveMixedContainer({ group, call, check, marker: context.marker,
request: (method, path, body) => context.request(method, path, body, picker.token, {}) })
@@ -502,6 +557,18 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
&& row.locationId && item.overseas.plan.pallets.some(p => String(p.warehousePalletId) === String(row.palletId)))
&& stock.items.reduce((sum, row) => sum + Number(row.availablePackages), 0) === item.expectedCartonMarks.length)
item.overseas.receiving = { completed, stock, receivingVerified: true }
if (item.order.fulfillmentType === 'STANDARD' && item.order.destinationType === 'OVERSEAS') {
const plan = await adminCall('回查普通海外仓现场逐托实收', 'GET', `/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(item.overseas.plan.planNo)}?customerId=${encodeURIComponent(item.order.tenantId)}`)
check('普通海外仓计划二加二可按三加一实收', null,
JSON.stringify(plan.allocations.map(row => Number(row.plannedPackageCount)).sort()) === '[2,2]'
&& JSON.stringify(plan.pallets.map(row => Number(row.actualPackageCount)).sort()) === '[1,3]')
for (const pallet of plan.pallets) {
const label = await context.requestBinary('GET', `/api/platform/v1/overseas-warehouse-resources/${item.overseas.warehouseId}/pallets/${pallet.warehousePalletId}/label`)
check('普通海外仓预留托盘标签返回有效PDF', label,
label.http === 200 && label.contentType.toLowerCase().includes('application/pdf')
&& label.bytes.length > 1000 && new TextDecoder().decode(label.bytes.slice(0, 5)) === '%PDF-')
}
}
}
return
}
@@ -666,6 +733,16 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
await progressShipping(group)
await convertOverseas(group)
await receiveOverseas(group)
const shortage = await prepare('PRIVATE-SHORTAGE', 1, {}, 'PRIVATE')
await allocate([shortage])
await progressShipping([shortage])
await convertOverseas([shortage])
await receiveOverseas([shortage])
const complete = await prepare('PRIVATE-COMPLETE', 1, {}, 'PRIVATE')
await allocate([complete])
await progressShipping([complete])
await convertOverseas([complete])
await receiveOverseas([complete])
context.createdResources.dropshipPalletMatrix.stage = 'MIXED_CONTAINER_INBOUND_COMPLETED'
return
}
@@ -236,7 +236,8 @@ function measurementReplayContext(approved = false, mutate = () => {}, submitCod
}
assert.match(requestPath, /\/measurements\/approve\?/)
return { http: 200, body: { code: '200', data: {
initialPricingSucceeded: true, initialFee: structuredClone(state.fee),
initialPricingSucceeded: state.fee != null, feeStatus: state.fee ? 'CALCULATED' : 'UNPRICED',
initialFee: structuredClone(state.fee),
} }, requestId: 'approve-repeat', traceId: 'trace-approve' }
}
let data
@@ -245,7 +246,10 @@ function measurementReplayContext(approved = false, mutate = () => {}, submitCod
else if (requestPath.includes('/measurement-review-history?')) data = {
records: state.reviews, total: state.reviews.length, page: 1, size: 200,
}
else if (requestPath.includes('/fees/latest?')) data = state.fee
else if (requestPath.includes('/fees/latest?')) {
if (state.fee == null) return { http: 200, body: { code: 'A0404' } }
data = state.fee
}
else if (requestPath.includes('/fee-details?')) {
assert.equal(new URL(requestPath, 'https://example.invalid').searchParams.get('includeHistory'), 'true')
data = state.details
@@ -308,6 +312,28 @@ test('approval replay uses the original key and old expectedVersion and compares
assert.equal(JSON.stringify(evidence).includes(fixture.context.token), false)
})
test('approval replay preserves an explicit unpriced result without creating fee facts', async () => {
const fixture = measurementReplayContext(true)
fixture.state.fee = null
fixture.state.details = []
const evidence = await verifyMeasurementApprovalReplay(fixture.context, fixture.input,
{ measurementId: fixture.input.measurementId, expectedVersion: 3, reason: null }, 'unpriced-review-key')
assert.equal(evidence.unchanged, true)
assert.equal(evidence.feeId, '')
assert.equal(evidence.feeVersion, null)
assert.deepEqual(evidence.feeDetailIds, [])
})
test('approval replay rejects newly created fee facts after an unpriced approval', async () => {
const fixture = measurementReplayContext(true, state => {
state.fee = { feeId: '9007199254740993191', feeVersion: 1 }
})
fixture.state.fee = null
fixture.state.details = []
await assert.rejects(verifyMeasurementApprovalReplay(fixture.context, fixture.input,
{ measurementId: fixture.input.measurementId, expectedVersion: 3, reason: null }, 'unpriced-review-key'), /零新增/)
})
for (const [name, mutate] of [
['different fee ID', state => { state.fee.feeId = '9007199254740993191' }],
['extra fee version', state => { state.fee.feeVersion += 1 }],
@@ -320,7 +346,7 @@ for (const [name, mutate] of [
const fixture = measurementReplayContext(true, mutate)
await assert.rejects(verifyMeasurementApprovalReplay(fixture.context, fixture.input,
{ measurementId: fixture.input.measurementId, expectedVersion: 3, reason: null }, 'original-review-key'),
/零新增|唯一审核及已生成初算费用事实有效/)
/零新增|唯一审核及初算状态事实有效/)
})
}
@@ -329,7 +355,7 @@ test('review history uses the real reviewResult field and rejects a fabricated r
fixture.state.reviews[0] = { id: '9007199254740993109', measurementId: fixture.input.measurementId, result: 'APPROVED' }
await assert.rejects(verifyMeasurementApprovalReplay(fixture.context, fixture.input,
{ measurementId: fixture.input.measurementId, expectedVersion: 3, reason: null }, 'original-review-key'),
/唯一审核及已生成初算费用事实有效/)
/唯一审核及初算状态事实有效/)
assert.equal(fixture.calls.some(call => call.method === 'POST'), false)
assert.match(source, /item\.reviewResult === 'APPROVED'/)
})
@@ -203,7 +203,10 @@ export async function verifyMeasurementApprovalReplay(context, input, payload, i
}
const feeResponse = await context.request('GET',
`/api/platform/v1/inbounds/${input.inboundId}/fees/latest?${query(input.tenantId)}`)
const fee = context.apiSuccess(MODULE, `${name}原初算费用ID及版本`, feeResponse, '', OPTIONS)
const unpriced = feeResponse?.http === 200 && feeResponse.body?.code === 'A0404'
const fee = unpriced ? null : context.apiSuccess(MODULE, `${name}原初算费用ID及版本`, feeResponse, '', OPTIONS)
if (unpriced) context.assert(MODULE, `${name}初算费用快照待生成`, feeResponse, true,
'重放结果须明确保留UNPRICED状态', OPTIONS)
const detailsResponse = await context.request('GET',
`/api/platform/v1/inbounds/${input.inboundId}/fee-details?accountId=${encodeURIComponent(input.tenantId)}&includeHistory=true`)
const feeDetails = context.apiSuccess(MODULE, `${name}全部版本费用明细`, detailsResponse, '', OPTIONS)
@@ -213,14 +216,15 @@ export async function verifyMeasurementApprovalReplay(context, input, payload, i
&& snapshot.facts.measurement.measureReviewStatus === 'APPROVED'
&& reviews.length === total && new Set(reviews.map(item => asId(item.id))).size === total
&& approvedReviewCount === 1
&& Boolean(asId(fee?.feeId)) && Number.isInteger(Number(fee?.feeVersion)) && Number(fee.feeVersion) >= 1
&& Array.isArray(feeDetails) && new Set(feeDetails.map(item => asId(item.id))).size === feeDetails.length
context.assert(MODULE, `${name}唯一审核及已生成初算费用事实有效`, detailsResponse, valid,
&& (unpriced ? feeDetails.length === 0
: Boolean(asId(fee?.feeId)) && Number.isInteger(Number(fee?.feeVersion)) && Number(fee.feeVersion) >= 1)
context.assert(MODULE, `${name}唯一审核及初算状态事实有效`, detailsResponse, valid,
`reviews=${reviews.length}, feeId=${asId(fee?.feeId)}, feeVersion=${fee?.feeVersion ?? 'missing'}`
+ `, feeDetails=${feeDetails?.length ?? 'missing'}, approvedReviewCount=${approvedReviewCount}`
+ `, measurementStatus=${snapshot.facts.measurement.measurementStatus}`
+ `, measureReviewStatus=${snapshot.facts.measurement.measureReviewStatus}`, OPTIONS)
if (!valid) throw new Error('测量审核重放缺少唯一审核或已生成初算费用事实')
if (!valid) throw new Error('测量审核重放缺少唯一审核或有效初算状态事实')
return { response: snapshot.response, facts: { ...snapshot.facts, reviews, fee, feeDetails } }
}
const before = await read('审核同键重放前')
@@ -231,14 +235,17 @@ export async function verifyMeasurementApprovalReplay(context, input, payload, i
const after = await read('审核同键重放后')
const beforeDigest = measurementReplayDigest(before.facts)
const afterDigest = measurementReplayDigest(after.facts)
const unchanged = beforeDigest === afterDigest && replay?.initialPricingSucceeded === true
&& asId(replay?.initialFee?.feeId) === asId(before.facts.fee.feeId)
&& Number(replay?.initialFee?.feeVersion) === Number(before.facts.fee.feeVersion)
const pricingUnchanged = before.facts.fee
? replay?.initialPricingSucceeded === true
&& asId(replay?.initialFee?.feeId) === asId(before.facts.fee.feeId)
&& Number(replay?.initialFee?.feeVersion) === Number(before.facts.fee.feeVersion)
: replay?.initialPricingSucceeded === false && replay?.feeStatus === 'UNPRICED' && replay?.initialFee == null
const unchanged = beforeDigest === afterDigest && pricingUnchanged
context.assert(MODULE, '测量审核重放保持批次费用ID版本全部明细审核及入库历史零新增', after.response,
unchanged, `feeId=${asId(after.facts.fee.feeId)}, version=${after.facts.fee.feeVersion}`
unchanged, `feeId=${asId(after.facts.fee?.feeId)}, version=${after.facts.fee?.feeVersion ?? 'UNPRICED'}`
+ `, reviews=${after.facts.reviews.length}, details=${after.facts.feeDetails.length}`, OPTIONS)
return { measurementId: input.measurementId, unchanged, beforeDigest, afterDigest,
feeId: asId(after.facts.fee.feeId), feeVersion: after.facts.fee.feeVersion,
feeId: asId(after.facts.fee?.feeId), feeVersion: after.facts.fee?.feeVersion ?? null,
feeDetailIds: after.facts.feeDetails.map(item => asId(item.id)),
reviewIds: after.facts.reviews.map(item => asId(item.id)),
inboundHistoryCount: after.facts.inbound.statusHistory.length }
@@ -2,13 +2,13 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
import { mixedReceivingSchedule, receiveMixedContainer } from './mixed-container-receiving.mjs'
import { mixedReceivingSchedule, receiveMixedContainer, receiveOverseasComplete, receiveOverseasShortage } from './mixed-container-receiving.mjs'
function fixtures() {
const record = (type, fulfillmentType = 'STANDARD') => ({ label: type,
order: { destinationType: type, fulfillmentType }, source: { inboundId: type, inboundCode: type },
expectedCartonMarks: [1, 2, 3, 4].map(i => `${type}#4-${i}`),
overseas: { plan: { pallets: [{ palletNo: `PAL-${type}` }] } } })
overseas: { plan: { pallets: [{ palletNo: `PAL-${type}` }, ...(type === 'OVERSEAS' ? [{ palletNo: 'PAL-OVERSEAS-2' }] : [])] } } })
const d = record('DROPSHIP', 'ONE_CLICK_DROPSHIP')
d.task = { pallets: [{ palletNo: 'DP-1', cartons: [{ inboundId: 'DROPSHIP', boxCode: 'DROPSHIP#4-1' }] },
{ palletNo: 'DP-2', cartons: [2, 3, 4].map(i => ({ inboundId: 'DROPSHIP', boxCode: `DROPSHIP#4-${i}` })) }] }
@@ -18,6 +18,7 @@ function fixtures() {
test('mixed receiving is registered with prerequisites, not transfer or downstream delivery', () => {
assert.deepEqual(normalizeModuleOrder(['mixed-container-receiving']), ['merchant-qualification-fixture', 'mixed-container-receiving'])
const source = readFileSync(new URL('dropship-pallet-matrix.mjs', import.meta.url), 'utf8')
assert.match(source, /await\s+adminCall\('回查普通海外仓现场逐托实收'/)
const start = source.indexOf("const dropship = await prepare('MIXED-DROPSHIP'")
const branch = source.slice(start, source.indexOf('if (stopBeforeOverseasPda)', start))
for (const entry of ["'MIXED-FBA', 1, {}, 'FBA'", "'MIXED-OVERSEAS', 1, {}, 'OVERSEAS'",
@@ -27,12 +28,19 @@ test('mixed receiving is registered with prerequisites, not transfer or downstre
assert.match(source, /混柜完成后按入仓码查询库存/)
assert.match(source, /String\(row.customerId\) === String\(item.order.tenantId\)/)
assert.match(source, /String\(p.warehousePalletId\) === String\(row.palletId\)/)
assert.match(source, /String\(item.sourceContainerBookingId\) === sourceContainerBookingId/)
assert.match(source, /\/prepare\?sourceContainerBookingId=\$\{encodeURIComponent\(sourceContainerBookingId\)\}/)
assert.match(source, /普通海外仓四箱预留两托/)
assert.match(source, /普通海外仓计划二加二可按三加一实收/)
assert.match(source, /普通海外仓预留托盘标签返回有效PDF/)
})
test('schedule interleaves three modes and uses FBA cumulative count rather than delta', () => {
const steps = mixedReceivingSchedule(fixtures())
assert.deepEqual(steps.map(s => s.path), ['bulk', 'pallet', 'carton', 'carton', 'carton', 'carton', 'bulk', 'pallet'])
assert.deepEqual(steps.filter(s => s.path === 'bulk').map(s => [s.count, s.body.packageCount]), [[1, 1], [3, 4]])
assert.deepEqual(steps.filter(s => s.path === 'carton').map(s => s.body.palletCode),
['PAL-OVERSEAS', 'PAL-OVERSEAS', 'PAL-OVERSEAS', 'PAL-OVERSEAS-2'])
assert.equal(steps.reduce((sum, s) => sum + s.count, 0), 12)
assert.throws(() => mixedReceivingSchedule(fixtures().slice(0, 2)))
})
@@ -96,3 +104,83 @@ test('mixed receiving rejects a new-key rescan that accepts another box', async
test('mixed receiving rejects same-key different-payload success', async () => {
await assert.rejects(receiveMixedContainer(driver({ changedPayloadAccepted: true })))
})
function shortageDriver({ stockPackages = 3, wrongPalletAccepted = false } = {}) {
const record = { order: { tenantId: 'CUSTOMER' }, source: { inboundCode: 'IN-OVERSEAS' },
expectedCartonMarks: [1, 2, 3, 4].map(index => `IN-OVERSEAS#4-${index}`),
overseas: { plan: { planNo: 'IP-SHORTAGE', pallets: [{ palletNo: 'PAL-1', warehousePalletId: '900' }] } } }
const receipts = new Map()
const confirmation = new Map()
const state = { received: 0, status: 'SIGNED', planStatus: 'PENDING_RECONCILIATION', confirmations: 0 }
const snapshot = () => ({ status: state.status, receivedPackages: state.received, pendingPackages: 4 - state.received })
return { record, marker: 'SHORTAGE-TEST', state, check: (name, _response, passed) => assert.ok(passed, name),
request: async (_method, path) => ({ body: { code: path.endsWith('/pallets/resolve') && wrongPalletAccepted ? '200' : 'A0409' } }),
call: async (_name, method, path, body) => {
if (method === 'GET') return snapshot()
if (path.endsWith('/cartons/resolve')) return { inboundCode: record.source.inboundCode, destinationType: 'OVERSEAS' }
if (path.endsWith('/pallets/resolve')) return { compatible: true, palletCode: 'PAL-1' }
if (path.endsWith('/inventory/resolve')) return { queryCode: record.source.inboundCode, items: [{
inboundCode: record.source.inboundCode, customerId: 'CUSTOMER', palletId: '900', locationId: '800', availablePackages: stockPackages }] }
if (receipts.has(body.idempotencyKey)) return structuredClone(receipts.get(body.idempotencyKey))
if (path.endsWith('/receipts/carton')) {
state.received++
const result = { acceptedPackages: 1 }; receipts.set(body.idempotencyKey, result); return result
}
assert.ok(path.endsWith('/complete'))
assert.equal(body.allowDifference, true)
state.status = 'COMPLETED_WITH_DIFFERENCE'
const result = snapshot(); receipts.set(body.idempotencyKey, result); return result
},
adminCall: async (_name, method, path, body, headers) => {
if (method === 'GET') return { status: state.planStatus, version: 4, expectedPackageCount: 4, pickedPackageCount: state.received }
assert.ok(path.endsWith('/confirm-shortage'))
assert.equal(body.expectedPackageCount, 4); assert.equal(body.receivedPackageCount, 3)
const key = headers['Idempotency-Key']
if (confirmation.has(key)) return confirmation.get(key)
state.planStatus = 'PARTIAL_INBOUND'; state.confirmations++
const result = { status: state.planStatus }; confirmation.set(key, result); return result
} }
}
test('ordinary overseas shortage preserves four expected cartons and stocks three exactly once', async () => {
const input = shortageDriver()
const result = await receiveOverseasShortage(input)
assert.equal(result.shortageVerified, true)
assert.equal(result.plan.expectedPackageCount, 4)
assert.equal(result.plan.pickedPackageCount, 3)
assert.equal(input.state.confirmations, 1)
})
test('ordinary overseas shortage fails if an unreserved pallet passes validation', async () => {
await assert.rejects(receiveOverseasShortage(shortageDriver({ wrongPalletAccepted: true })))
})
test('ordinary overseas shortage fails if stock includes an unreceived carton', async () => {
await assert.rejects(receiveOverseasShortage(shortageDriver({ stockPackages: 4 })))
})
function completeDriver(stockPackages = 4) {
const input = shortageDriver({ stockPackages })
const call = input.call
input.call = async (name, method, path, body) => {
if (path.endsWith('/complete')) {
assert.equal(body.allowDifference, false)
assert.equal(input.state.received, 4)
return { status: 'COMPLETED', receivedPackages: 4, pendingPackages: 0 }
}
return call(name, method, path, body)
}
input.adminCall = async (_name, method) => {
assert.equal(method, 'GET')
return { status: 'COMPLETED', expectedPackageCount: 4, pickedPackageCount: input.state.received }
}
return input
}
test('ordinary overseas full receipt uses normal completion and stocks all four cartons', async () => {
const result = await receiveOverseasComplete(completeDriver())
assert.equal(result.normalCompletionVerified, true)
assert.equal(result.stock.items[0].availablePackages, 4)
})
test('ordinary overseas full receipt rejects stock inconsistent with actual received cartons', async () => {
await assert.rejects(receiveOverseasComplete(completeDriver(3)))
})
@@ -13,14 +13,16 @@ export function mixedReceivingSchedule(group) {
|| dropship[0].task?.pallets?.length !== 2) throw new Error('混柜必须包含普通FBA、普通海外仓及一件代发双托三票')
const [f, o, d] = [fba[0], overseas[0], dropship[0]]
const fbaPallet = f.overseas.plan.pallets?.[0]?.palletNo
const overseasPallet = o.overseas.plan.pallets?.[0]?.palletNo
if (!fbaPallet || !overseasPallet || f.expectedCartonMarks.length < 2) throw new Error('混柜普通订单缺少真实托盘或分批箱数')
const overseasPallets = o.overseas.plan.pallets || []
if (!fbaPallet || overseasPallets.length !== 2 || overseasPallets.some(pallet => !pallet.palletNo)
|| o.expectedCartonMarks.length !== 4 || f.expectedCartonMarks.length < 2) throw new Error('混柜普通订单缺少真实托盘或分批箱数')
const palletStep = pallet => ({ record: d, path: 'pallet', count: pallet.cartons.filter(c =>
String(c.inboundId) === String(d.source.inboundId)).length, body: { sourcePalletNo: pallet.palletNo } })
return [
{ record: f, path: 'bulk', count: 1, body: { inboundCode: f.source.inboundCode, palletCode: fbaPallet, packageCount: 1 } },
palletStep(d.task.pallets[0]),
...o.expectedCartonMarks.map(cartonMark => ({ record: o, path: 'carton', count: 1, body: { cartonMark, palletCode: overseasPallet } })),
...o.expectedCartonMarks.map((cartonMark, index) => ({ record: o, path: 'carton', count: 1,
body: { cartonMark, palletCode: overseasPallets[index === 3 ? 1 : 0].palletNo } })),
{ record: f, path: 'bulk', count: f.expectedCartonMarks.length - 1,
body: { inboundCode: f.source.inboundCode, palletCode: fbaPallet, packageCount: f.expectedCartonMarks.length } },
palletStep(d.task.pallets[1]),
@@ -79,3 +81,97 @@ export async function receiveMixedContainer({ group, call, check, request, marke
check('混柜完成重放不重复入库', null, JSON.stringify(replay) === JSON.stringify(completed))
return completed
}
export async function receiveOverseasComplete({ record, call, adminCall, check, marker }) {
const base = '/api/pda/v1/last-leg/receiving-containers/current'
const inboundCode = record.source.inboundCode
const plan = record.overseas.plan
const pallet = plan.pallets[0]
const expected = record.expectedCartonMarks.length
check('普通海外仓全收场景具备四个真实箱码和预留托盘', null, expected === 4 && Boolean(pallet?.palletNo))
for (const [index, cartonMark] of record.expectedCartonMarks.entries()) {
const carton = await call('全收场景解析本柜箱码', 'POST', `${base}/cartons/resolve`, { cartonMark })
check('全收箱码属于当前普通海外仓票', null, carton.inboundCode === inboundCode && carton.destinationType === 'OVERSEAS')
const resolved = await call('全收场景校验预留托盘', 'POST', `${base}/pallets/resolve`, { inboundCode, palletCode: pallet.palletNo })
check('全收预留托盘校验通过', null, resolved.compatible === true && resolved.palletCode === pallet.palletNo)
const receipt = await call('全收场景逐箱实收', 'POST', `${base}/receipts/carton`,
{ cartonMark, palletCode: pallet.palletNo, idempotencyKey: `${marker}-complete-carton-${index}` })
check('全收本次只接收当前一箱', null, Number(receipt.acceptedPackages) === 1)
}
const body = { allowDifference: false, idempotencyKey: `${marker}-ordinary-complete` }
const completed = await call('四箱全收后正常完成', 'POST', `${base}/complete`, body)
check('四箱全收正常完成且无差异', null, completed.status === 'COMPLETED'
&& Number(completed.receivedPackages) === expected && Number(completed.pendingPackages) === 0)
const replay = await call('正常完成原键重放', 'POST', `${base}/complete`, body)
check('正常完成重放不重复入库', null, JSON.stringify(replay) === JSON.stringify(completed))
const finalPlan = await adminCall('回查全收最终计划', 'GET',
`/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(plan.planNo)}?customerId=${encodeURIComponent(record.order.tenantId)}`)
check('全收计划完成且应收实收均为四箱', null, finalPlan.status === 'COMPLETED'
&& Number(finalPlan.expectedPackageCount) === expected && Number(finalPlan.pickedPackageCount) === expected)
const stock = await call('回查全收可用库存', 'POST', '/api/pda/v1/last-leg/inventory/resolve', { code: inboundCode })
check('全收库存只有本票实际收到的四箱', null, stock.queryCode === inboundCode && stock.items?.length > 0
&& stock.items.every(row => row.inboundCode === inboundCode && String(row.customerId) === String(record.order.tenantId)
&& row.locationId && plan.pallets.some(item => String(item.warehousePalletId) === String(row.palletId)))
&& stock.items.reduce((sum, row) => sum + Number(row.availablePackages), 0) === expected)
return { completed, plan: finalPlan, stock, receivingVerified: true, normalCompletionVerified: true }
}
/** 逐箱短少只按本轮真实已收箱入库,验证后台确认、重放和库存归属。 */
export async function receiveOverseasShortage({ record, call, adminCall, check, request, marker }) {
const base = '/api/pda/v1/last-leg/receiving-containers/current'
const inboundCode = record.source.inboundCode
const plan = record.overseas.plan
const pallet = plan.pallets[0]
const expected = record.expectedCartonMarks.length
const actual = expected - 1
check('普通海外仓短少场景具备四个真实箱码和预留托盘', null, expected === 4 && Boolean(pallet?.palletNo))
const before = await call('错误托盘校验前回查', 'GET', base)
const wrong = await request('POST', `${base}/pallets/resolve`, { inboundCode, palletCode: `${marker}-UNRESERVED` })
check('普通海外仓非本票预留托盘在校验时拒绝', wrong, wrong.body?.code === 'A0409')
const unchanged = await call('错误托盘校验后回查', 'GET', base)
check('错误托盘校验后实收零漂移', null, JSON.stringify(before) === JSON.stringify(unchanged))
for (const [index, cartonMark] of record.expectedCartonMarks.slice(0, actual).entries()) {
const carton = await call('短少场景解析本柜箱码', 'POST', `${base}/cartons/resolve`, { cartonMark })
check('箱码属于当前普通海外仓票', null, carton.inboundCode === inboundCode && carton.destinationType === 'OVERSEAS')
const resolved = await call('短少场景校验预留托盘', 'POST', `${base}/pallets/resolve`, { inboundCode, palletCode: pallet.palletNo })
check('预留托盘校验通过', null, resolved.compatible === true && resolved.palletCode === pallet.palletNo)
const body = { cartonMark, palletCode: pallet.palletNo, idempotencyKey: `${marker}-shortage-carton-${index}` }
const receipt = await call('短少场景逐箱实收', 'POST', `${base}/receipts/carton`, body)
check('本次只接收当前一箱', null, Number(receipt.acceptedPackages) === 1)
const replay = await call('逐箱实收同键重放', 'POST', `${base}/receipts/carton`, body)
check('逐箱重放不重复计数', null, JSON.stringify(receipt) === JSON.stringify(replay))
const duplicate = await request('POST', `${base}/receipts/carton`, { ...body, idempotencyKey: `${body.idempotencyKey}-duplicate` })
check('同箱新键重扫拒绝', duplicate, duplicate.body?.code === 'A0409')
}
const premature = await request('POST', `${base}/complete`, { allowDifference: false, idempotencyKey: `${marker}-shortage-normal-complete` })
check('短少场景拒绝正常完成', premature, premature.body?.code === 'A0409')
const completeBody = { allowDifference: true, idempotencyKey: `${marker}-shortage-onsite-complete` }
const completed = await call('带短少结束现场收货', 'POST', `${base}/complete`, completeBody)
check('现场结束保留短少一箱', null, completed.status === 'COMPLETED_WITH_DIFFERENCE'
&& Number(completed.receivedPackages) === actual && Number(completed.pendingPackages) === 1)
const completeReplay = await call('带短少结束同键重放', 'POST', `${base}/complete`, completeBody)
check('现场结束重放稳定', null, JSON.stringify(completeReplay) === JSON.stringify(completed))
const planPath = `/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(plan.planNo)}`
const customerId = String(record.order.tenantId)
const pending = await adminCall('读取短少待核平计划', 'GET', `${planPath}?customerId=${encodeURIComponent(customerId)}`)
check('短少计划等待后台确认', null, pending.status === 'PENDING_RECONCILIATION'
&& Number(pending.expectedPackageCount) === expected && Number(pending.pickedPackageCount) === actual
&& Number.isInteger(pending.version))
const confirmBody = { customerId, version: pending.version, expectedPackageCount: expected,
receivedPackageCount: actual, reason: `${marker}逐箱核对短少一箱` }
const headers = { 'Idempotency-Key': `${marker}-shortage-confirm` }
const confirmed = await adminCall('后台确认普通海外仓短少入库', 'POST', `${planPath}/confirm-shortage`, confirmBody, headers)
check('普通海外仓短少确认成功', null, confirmed.status === 'PARTIAL_INBOUND')
const confirmReplay = await adminCall('后台短少确认同键重放', 'POST', `${planPath}/confirm-shortage`, confirmBody, headers)
check('后台短少确认重放不重复入库', null, JSON.stringify(confirmReplay) === JSON.stringify(confirmed))
const finalPlan = await adminCall('回查短少入库最终计划', 'GET', `${planPath}?customerId=${encodeURIComponent(customerId)}`)
check('短少入库保留原应收及实际箱数', null, finalPlan.status === 'PARTIAL_INBOUND'
&& Number(finalPlan.expectedPackageCount) === expected && Number(finalPlan.pickedPackageCount) === actual)
const stock = await call('回查短少入库可用库存', 'POST', '/api/pda/v1/last-leg/inventory/resolve', { code: inboundCode })
check('短少库存只有本票真实已收三箱', null, stock.queryCode === inboundCode && Array.isArray(stock.items)
&& stock.items.length > 0 && stock.items.every(row => row.inboundCode === inboundCode
&& String(row.customerId) === customerId && row.locationId
&& plan.pallets.some(item => String(item.warehousePalletId) === String(row.palletId)))
&& stock.items.reduce((sum, row) => sum + Number(row.availablePackages), 0) === actual)
return { completed, plan: finalPlan, stock, receivingVerified: true, shortageVerified: true }
}