补齐海外仓和私人地址正常及短少收货真实验收
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user