发布:将已验 TEST 功能更新至既有生产灰度(2026-10-23) #30
@@ -10,6 +10,8 @@
|
||||
|
||||
货物废弃的库存列表按客户、仓、目的仓和 SKU 聚合,不带入仓码;脚本按该 SKU 的代表余额 ID 回查原始详情,并要求客户、仓、入仓码及全部数量桶与聚合值一致,不能给聚合行补造来源,也不能让混合来源汇总冒充单一余额。散板余位、询价和实收的 Long 版本接受服务端规范十进制编码,仍拒绝空值、负值、前导零和不安全整数。
|
||||
|
||||
派车出库的客户归属按正式关联派送明细核对,验证客户、原出库号、原派车号、全部派送及箱数守恒,不读取 OutboundRow 未声明的 customerId。PDA 换标候选只限定原申请和真实目标托盘,不继承原接柜计划筛选;普通换标拣货按原箱号、当前位置及已核验逐 SKU 件数逐箱提交,不把箱数当件数。已发车原单的签收阶段可通过内部 continueDepartedFulfillment 续验,须保留原批次和当前发车证明;已有签收或未知结果必须核对原命令,不能重建。
|
||||
|
||||
散板来货沿当前 PDA 接口创建空托盘草稿,生成并绑定业务托盘后,读取原预报箱号与 SKU,通过逐箱清点保存显式 TEST 尺寸、重量和件数,再打托及运营实收确认。两托三箱的受控输入逐箱登记,不把托盘草稿接口当作测量接口,也不从整托总数猜测箱规;这些合成材料不代表真机测量或照片效果验收。
|
||||
|
||||
货物废弃在同一申请上传照片后,先重复提交同一照片 ID,要求 409/A0409、诊断标识齐全,且申请与库存快照不变,才继续正常 PDA 确认。实际废弃总账必须具有有效流水 ID,匹配原客户、仓库、托盘、SKU 和入仓码,扣减前后数量差为一箱;独立箱级流水仍不能用公共库存总账替代。对应离线反例位于 `cargo-discard-fulfillment-contract.test.mjs`,不额外创建客户、申请或照片。
|
||||
|
||||
@@ -5,7 +5,7 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve, basename } from 'node:path'
|
||||
import { DISPATCH_BASE as BASE, dispatchEvidenceContext, dispatchIo } from './grouped-dispatch-acceptance.mjs'
|
||||
import { run, createClientRelabelDispatch, selectDispatchRouteFixture, dispatchRouteMatches, dispatchBusinessSnapshot, verifyDispatchRouteLifecycle } from './phase4-overseas-dispatch.mjs'
|
||||
import { run, createClientRelabelDispatch, createPdaRelabelDispatch, selectDispatchRouteFixture, dispatchRouteMatches, dispatchBusinessSnapshot, verifyDispatchRouteLifecycle } from './phase4-overseas-dispatch.mjs'
|
||||
|
||||
const clone = value => structuredClone(value)
|
||||
const rows = () => [1, 2, 3].map(n => ({ selection: { sourceType: 'INBOUND_PLAN', planPalletId: `2${n}`, returnReceiptId: null, relabelNo: null, warehousePalletId: `3${n}`, sourceVersion: `${n}`.repeat(64) },
|
||||
@@ -119,6 +119,23 @@ test('client original relabel gets its own exact dispatch and refuses repeat exe
|
||||
assert.equal(h.calls.filter(c => c.method === 'POST').length, writes)
|
||||
})
|
||||
|
||||
test('PDA relabel candidates do not inherit original receiving plan filters and missing sources fail before writes', async () => {
|
||||
for (const missing of [false, true]) {
|
||||
const h = server(); addClientRelabel(h)
|
||||
const fixture = h.context.createdResources.phase4OverseasClientServices.relabelFulfillment
|
||||
h.context.createdResources.phase4OverseasRelabel = { ...fixture, overseasWarehouseId: fixture.warehouseId }
|
||||
if (missing) h.candidates.pop()
|
||||
const proof = {}, io = dispatchIo(h.context, 'test', proof, 'PDA-RELABEL'); io.planIds = ['old-plan']
|
||||
const original = io.command.bind(io)
|
||||
io.command = async (method, path, ...args) => {
|
||||
if (path.includes('/candidates?')) assert.equal(new URL(path, 'https://offline.invalid').searchParams.has('planIds'), false)
|
||||
return original(method, path, ...args)
|
||||
}
|
||||
if (missing) { await assert.rejects(createPdaRelabelDispatch(h.context, io, proof)); assert.equal(h.calls.some(c => c.method === 'POST'), false) }
|
||||
else { await createPdaRelabelDispatch(h.context, io, proof); assert.equal(h.context.createdResources.phase4OverseasRelabel.dispatchNo, 'DS-1') }
|
||||
}
|
||||
})
|
||||
|
||||
for (const fault of ['customer', 'pallet', 'missing']) test(`client relabel excludes ${fault} candidates before writes`, async () => {
|
||||
const h = server(); addClientRelabel(h)
|
||||
if (fault === 'customer') h.candidates.at(-1).customerId = '99'
|
||||
|
||||
@@ -137,13 +137,15 @@ async function prepareDraft(context, io, candidates) {
|
||||
'not-implemented', { blocking: false })
|
||||
return draft
|
||||
}
|
||||
async function createRelabel(context, io, proof) {
|
||||
export async function createPdaRelabelDispatch(context, io, proof) {
|
||||
const fixture = context.createdResources?.phase4OverseasRelabel
|
||||
if (fixture?.marker !== context.marker || fixture.status !== 'READY_FOR_OUTBOUND') return
|
||||
const page = await candidatePage(io, fixture.overseasWarehouseId, { orderNo: fixture.relabelNo, sourceStage: 'RELABEL' })
|
||||
const page = await candidatePage(io, fixture.overseasWarehouseId, { orderNo: fixture.relabelNo, sourceStage: 'RELABEL', planIds: [] })
|
||||
const candidates = page.filter(c => c.selection?.sourceType === 'RELABEL' && c.selection.relabelNo === fixture.relabelNo && id(c.customerId) === id(fixture.customerId)
|
||||
&& id(c.warehouseId) === id(fixture.overseasWarehouseId) && fixture.targetPalletIds?.map(id).includes(id(c.selection.warehousePalletId)))
|
||||
if (!candidates.length || candidates.length !== fixture.targetPalletIds.length) { proof.relabel = { status: 'NOT_RUN', reason: '没有本轮完整换标申请来源' }; return }
|
||||
io.check('PDA原换标目标托盘候选完整且唯一', null, candidates.length > 0 && candidates.length === fixture.targetPalletIds.length
|
||||
&& new Set(candidates.map(c => id(c.selection.warehousePalletId))).size === candidates.length)
|
||||
if (fixture.dispatchNo || proof.relabel) throw Error('PDA原换标排车已开始,保留原请求,不自动重建')
|
||||
const body = createBody(context.marker, 'RELABEL', candidates), created = await io.command('POST', BASE, body, '真实换标整单Create')
|
||||
await replay(io, BASE, body, created, '换标Create原键恢复')
|
||||
const current = await readDispatch(io, created.dispatchNo)
|
||||
@@ -225,7 +227,7 @@ export async function run(context) {
|
||||
finalStatus: current.data.status, stage: current.data.stage, data: current.data, items: current.items, route: current.route,
|
||||
sourceCandidates: selected, status: 'VERIFIED', loadingEvidenceStatus: 'NOT_RUN_MISSING_ACTUAL_MEASUREMENTS' })
|
||||
io.check('交接Detail真实来源键和目的地守恒', null, same(proof.sourceKeys, sorted(selected.map(c => c.sourceKey))) && proof.finalStatus === 'ACTIVE' && proof.stage === 'BEFORE_LOADING')
|
||||
await createRelabel(context, io, proof)
|
||||
await createPdaRelabelDispatch(context, io, proof)
|
||||
await createClientRelabelDispatch(context, io, proof)
|
||||
await context.checkpoint()
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test'
|
||||
import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyAutomaticDeparture, verifyPartialOrderCompletion, verifyFulfillmentRootStatus,
|
||||
createDeliveryReturn, completeReturnedException, returnedExceptionFactsMatch, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer,
|
||||
dispatchDestinationConservation, pickAll, readAutomaticDispatchDelivery, fulfillmentEvidenceContext, verifyRelabelDispatchOutbound, verifyClientRelabelSettlement } from './phase5-overseas-fulfillment.mjs'
|
||||
dispatchDestinationConservation, dispatchOutboundMatches, continueDepartedFulfillment, pickAll, readAutomaticDispatchDelivery, fulfillmentEvidenceContext, verifyRelabelDispatchOutbound, verifyClientRelabelSettlement } from './phase5-overseas-fulfillment.mjs'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -11,6 +11,33 @@ import { parseArguments } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const source = await readFile(fileURLToPath(new URL('./phase5-overseas-fulfillment.mjs', import.meta.url)), 'utf8')
|
||||
|
||||
test('原出库客户归属沿正式关联派送明细核对并拒绝串客漏项数量漂移', () => {
|
||||
const reference = { outboundNo: 'CK-1', customerId: '101', packageCount: '7' }
|
||||
const deliveries = [{ deliveryNo: 'DL-1', outboundNo: 'CK-1', customerId: '101', packageCount: '3' },
|
||||
{ deliveryNo: 'DL-2', outboundNo: 'CK-1', customerId: '101', packageCount: '4' }]
|
||||
const detail = { outbound: { outboundNo: 'CK-1', dispatchNo: 'PC-1', status: 'OUTBOUNDED', packageCount: 7 },
|
||||
deliveries: deliveries.map(row => ({ ...row, dispatchNo: 'PC-1', packageCount: Number(row.packageCount) })) }
|
||||
assert.equal(dispatchOutboundMatches(detail, reference, 'PC-1', deliveries), true)
|
||||
for (const change of [value => { value.deliveries[0].customerId = '102' }, value => { value.deliveries.pop() },
|
||||
value => { value.deliveries[0].outboundNo = 'CK-2' }, value => { value.deliveries[0].dispatchNo = 'PC-2' },
|
||||
value => { value.deliveries[1].deliveryNo = 'DL-1' }, value => { value.deliveries[1].packageCount = 3 },
|
||||
value => { value.outbound.status = 'PICKED' }, value => { value.outbound.packageCount = 8 }]) {
|
||||
const value = structuredClone(detail); change(value)
|
||||
assert.equal(dispatchOutboundMatches(value, reference, 'PC-1', deliveries), false)
|
||||
}
|
||||
})
|
||||
|
||||
test('原发车接续拒绝外批次或已开始签收的证明且不发送请求', async () => {
|
||||
let calls = 0
|
||||
const args = { customerId: '101', warehouseId: '42', dispatchNo: 'PC-1', tripProof: { dispatchNo: 'PC-1', warehouseId: '42' },
|
||||
deliveries: [{}], outbounds: [{}], merchantToken: 'test-only' }
|
||||
for (const proof of [{ marker: 'other', dispatchNo: 'PC-1' }, { marker: 'batch', dispatchNo: 'PC-1', receiptFiles: [{}] }]) {
|
||||
await assert.rejects(continueDepartedFulfillment({ marker: 'batch', createdResources: { phase5OverseasFulfillment: proof },
|
||||
request: async () => { calls++ } }, args), /原发车证明/)
|
||||
}
|
||||
assert.equal(calls, 0)
|
||||
})
|
||||
|
||||
for (const fault of ['', 'foreign customer', 'old marks', 'sign failure', 'resource held', 'settlement failed']) {
|
||||
test(`client original relabel delivery includes receipt and resource release: ${fault || 'normal'}`, async () => {
|
||||
const relabel = { marker: 'batch', relabelNo: 'RL-CLIENT', dispatchNo: 'DP-CLIENT', warehouseId: '42', customerId: '101',
|
||||
@@ -22,13 +49,14 @@ for (const fault of ['', 'foreign customer', 'old marks', 'sign failure', 'resou
|
||||
async request(method, path) {
|
||||
calls.push('outbound')
|
||||
assert.equal(method, 'GET'); assert.ok(path.endsWith('/outbound-orders/OUT-1'))
|
||||
return { http: 200, body: { code: '200', data: { outbound: { outboundNo: 'OUT-1', dispatchNo: 'DP-CLIENT', customerId: '101',
|
||||
status: 'OUTBOUNDED', packageCount: 1 }, pallets: [{ cartonMarks: fault === 'old marks' ? 'OLD' : 'NEW' }] } } }
|
||||
return { http: 200, body: { code: '200', data: { outbound: { outboundNo: 'OUT-1', dispatchNo: 'DP-CLIENT',
|
||||
status: 'OUTBOUNDED', packageCount: 1 }, deliveries: [{ deliveryNo: 'DE-1', outboundNo: 'OUT-1', dispatchNo: 'DP-CLIENT', customerId: '101', packageCount: 1 }],
|
||||
pallets: [{ cartonMarks: fault === 'old marks' ? 'OLD' : 'NEW' }] } } }
|
||||
} }
|
||||
const helpers = {
|
||||
async prepare(_c, _m, dispatch, warehouse) { calls.push('prepare'); assert.equal(dispatch, relabel.dispatchNo); assert.equal(warehouse, '42'); return { tripNo: 'TR-1' } },
|
||||
async pick() { calls.push('pick'); return true }, async load() { calls.push('load') },
|
||||
async read() { calls.push('read'); return { outbounds: [{ customerId: '101', outboundNo: 'OUT-1' }],
|
||||
async read() { calls.push('read'); return { outbounds: [{ customerId: '101', outboundNo: 'OUT-1', packageCount: 1 }],
|
||||
deliveries: [{ customerId: fault === 'foreign customer' ? '102' : '101', outboundNo: 'OUT-1', deliveryNo: 'DE-1', packageCount: 1 }] } },
|
||||
async sign() { calls.push('sign'); return { order: { status: fault === 'sign failure' ? 'DELIVERING' : 'DELIVERED',
|
||||
deliveryNo: 'DE-1', customerId: '101', outboundNo: 'OUT-1' } } },
|
||||
@@ -228,7 +256,7 @@ test('relabel marks are checked on the authoritative outbound DTO without weaken
|
||||
assert.equal(relabelOutboundMarksMatch({ ...detail, pallets: [{ cartonMarks }] }, relabel), false)
|
||||
}
|
||||
assert.equal(relabelOutboundMarksMatch({ ...detail, outbound: { packageCount: 3 } }, relabel), false)
|
||||
assert.match(source, /detail\.data\?\.outbound\?\.dispatchNo === relabel\.dispatchNo/)
|
||||
assert.match(source, /dispatchOutboundMatches\(detail.data, transferred.outbounds\[0\], relabel.dispatchNo, transferred.deliveries\)/)
|
||||
assert.match(source, /relabel\?\.marker !== context\.marker/)
|
||||
})
|
||||
|
||||
@@ -868,6 +896,40 @@ test('native PDA scans use exact current versions including zero, immutable repl
|
||||
assert.deepEqual(context.assertions.filter(row => !row.passed), [])
|
||||
})
|
||||
|
||||
test('normal relabel scans each original carton using explicit ten-unit SKU verification and actual shared position', async () => {
|
||||
const values = ['0', '9007199254740993', '9007199254740994']
|
||||
let currentVersion = values[0], picked = 0, firstBody, firstResult
|
||||
const context = contextFor((method, path, body) => {
|
||||
if (path.includes('/relabel-applications/')) return ok({ application: { relabelNo: 'TB-1', overseasWarehouseId: '42' },
|
||||
items: [1, 2].map(n => ({ cartonMark: 'BOX-' + n, sourceInventoryPositionId: '9007199254740996', notifiedPackageCount: 1,
|
||||
operationType: 'CARTON_AND_SKU', skuItems: [{ skuCode: 'OLD', newSkuCode: 'NEW', quantity: 10 }] })) })
|
||||
if (method === 'GET') return ok({ dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'LOADING', recordVersion: currentVersion,
|
||||
pickingReconciled: picked === 2, counts: { packageCount: 2, pickedPackageCount: picked },
|
||||
items: [{ dispatchItemId: '9007199254740995', customerId: '100', sourceBusinessType: 'RELABEL_ORDER', sourceBusinessNo: 'TB-1', scheduledPackages: 2, pickedPackages: picked,
|
||||
positions: [{ positionId: '9007199254740996', balanceId: '9007199254740997', availablePackages: 2 - picked }] }] })
|
||||
assert.match(path, /items\/9007199254740995\/pick$/)
|
||||
assert.equal(Object.hasOwn(body, 'expectedVersion'), false)
|
||||
assert.equal(typeof body.positionId, 'string')
|
||||
if (body.idempotencyKey.endsWith('-STALE')) {
|
||||
assert.equal(body.recordVersion, '0'); return { http: 200, body: { code: 'A0409' } }
|
||||
}
|
||||
if (firstBody?.idempotencyKey === body.idempotencyKey) {
|
||||
assert.deepEqual(body, firstBody); return ok({ ...firstResult, replayed: true })
|
||||
}
|
||||
assert.equal(body.recordVersion, currentVersion); assert.equal(body.packages, 1); assert.equal(body.productQuantity, 10); assert.equal(body.cartonMark, 'BOX-' + (picked + 1))
|
||||
picked += 1; currentVersion = values[picked]
|
||||
firstBody = structuredClone(body)
|
||||
firstResult = { dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'LOADING', recordVersion: currentVersion,
|
||||
result: { dispatchItemId: '9007199254740995', pickedPackages: picked }, counts: { pickedPackageCount: picked } }
|
||||
return ok(firstResult)
|
||||
})
|
||||
context.createdResources.phase4OverseasRelabel = { marker: context.marker, relabelNo: 'TB-1', customerId: '100', overseasWarehouseId: '42' }
|
||||
assert.equal(await pickAll(context, 'PDA-IN-MEMORY', 'DS-1'), true)
|
||||
assert.equal(picked, 2)
|
||||
assert.equal(context.calls.some(call => /loading-complete|\/complete|\/depart/.test(call.path)), false)
|
||||
assert.deepEqual(context.assertions.filter(row => !row.passed), [])
|
||||
})
|
||||
|
||||
function automaticDispatch(recordVersion = '0') {
|
||||
return { dispatchNo: 'DS-1', recordVersion, status: 'ACTIVE', stage: 'DELIVERING', pickingReconciled: true,
|
||||
counts: { packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 2 },
|
||||
|
||||
@@ -907,8 +907,7 @@ export async function verifyRelabelDispatchOutbound(context, relabel, token, war
|
||||
`${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`)
|
||||
require('排车出库快照精确保留全部新箱唛且无原箱唛或重复箱',
|
||||
success(detail.response) && detail.data?.outbound?.outboundNo === outboundNo
|
||||
&& detail.data?.outbound?.dispatchNo === relabel.dispatchNo
|
||||
&& id(detail.data?.outbound?.customerId) === customerId && detail.data?.outbound?.status === 'OUTBOUNDED'
|
||||
&& dispatchOutboundMatches(detail.data, transferred.outbounds[0], relabel.dispatchNo, transferred.deliveries)
|
||||
&& relabelOutboundMarksMatch(detail.data, relabel))
|
||||
Object.assign(relabel, { outboundNo, deliveryNos: transferred.deliveries.map(row => row.deliveryNo).sort() })
|
||||
for (const [index, delivery] of transferred.deliveries.entries()) {
|
||||
@@ -933,7 +932,7 @@ export async function verifyRelabelDispatchOutbound(context, relabel, token, war
|
||||
}
|
||||
|
||||
// 主车结案后串行复用同仓PDA及车辆池,客户原申请与旧换标样本分别核验。
|
||||
async function finishRelabelDeliveries(context, mainTrip, warehouseId) {
|
||||
export async function finishRelabelDeliveries(context, mainTrip, warehouseId) {
|
||||
if (mainTrip.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') throw Error('主车尚未释放,不能启动换标履约')
|
||||
const access = await resolvePickerAccess(context)
|
||||
const picker = access ? await createPicker(context, 95, warehouseId, access) : null
|
||||
@@ -1017,7 +1016,27 @@ export async function pickAll(context, token, dispatchNo) {
|
||||
detail = await pda(context, token, 'GET', '扫码前刷新公共版本及真实来源', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
let item = detail.data?.items?.find(row => id(row.dispatchItemId) === id(original.dispatchItemId))
|
||||
if (!success(detail.response) || !item) return false
|
||||
const sources = item.returnCartons?.length ? item.returnCartons.map(carton => ({ carton })) : (item.positions || []).map(position => ({ position }))
|
||||
if (Number(item.scheduledPackages) === Number(item.pickedPackages)) continue
|
||||
let sources = item.returnCartons?.length ? item.returnCartons.map(carton => ({ carton })) : (item.positions || []).map(position => ({ position }))
|
||||
if (item.sourceBusinessType === 'RELABEL_ORDER') {
|
||||
const originalRelabel = [context.createdResources?.phase4OverseasRelabel, context.createdResources?.phase4OverseasClientServices?.relabelFulfillment]
|
||||
.find(row => row?.marker === context.marker && row.relabelNo === item.sourceBusinessNo && id(row.customerId) === id(item.customerId))
|
||||
if (!originalRelabel) throw Error('换标拣货缺少本轮原申请身份,不能猜测SKU件数')
|
||||
const relabel = await platform(context, 'GET', '扫码前读取原换标逐箱SKU与实际位置', `${LASTLEG_BASE}/relabel-applications/${encodeURIComponent(item.sourceBusinessNo)}`)
|
||||
const value = relabel.data
|
||||
if (!success(relabel.response) || value?.application?.relabelNo !== item.sourceBusinessNo
|
||||
|| id(value.application.overseasWarehouseId) !== id(originalRelabel.warehouseId ?? originalRelabel.overseasWarehouseId)
|
||||
|| !Array.isArray(value.items)) throw Error('换标逐箱来源或仓库身份不一致')
|
||||
sources = value.items.filter(row => (item.positions || []).some(position => id(position.positionId) === id(row.sourceInventoryPositionId)
|
||||
&& Number(position.availablePackages) > 0)).map(row => {
|
||||
if (!row.cartonMark || row.notifiedPackageCount !== 1 || !['CARTON_AND_SKU', 'SKU_LABEL'].includes(row.operationType)
|
||||
|| !row.skuItems?.length || row.skuItems.some(sku => !sku.skuCode || !sku.newSkuCode || !Number.isSafeInteger(Number(sku.quantity)) || Number(sku.quantity) <= 0))
|
||||
throw Error('换标原箱缺少明确逐SKU核验件数,不按箱数推算')
|
||||
return { position: item.positions.find(position => id(position.positionId) === id(row.sourceInventoryPositionId)),
|
||||
carton: { cartonMark: row.cartonMark, productQuantity: row.skuItems.reduce((sum, sku) => sum + Number(sku.quantity), 0) } }
|
||||
})
|
||||
if (new Set(sources.map(row => row.carton.cartonMark)).size !== sources.length) throw Error('换标逐箱来源重复')
|
||||
}
|
||||
for (const [sourceIndex, source] of sources.entries()) {
|
||||
const remaining = Number(item.scheduledPackages) - Number(item.pickedPackages)
|
||||
if (remaining <= 0) break
|
||||
@@ -1123,6 +1142,21 @@ export async function createDeliveryReturn(context, { customerId, warehouseId, d
|
||||
return { returned, returnBody }
|
||||
}
|
||||
|
||||
/** 出库详情的客户归属来自关联派送明细,OutboundRow本身不声明customerId。 */
|
||||
export function dispatchOutboundMatches(detail, reference, dispatchNo, deliveries) {
|
||||
const outbound = detail?.outbound
|
||||
const expected = Array.isArray(deliveries) ? deliveries.filter(row => row.outboundNo === reference?.outboundNo) : []
|
||||
const actual = detail?.deliveries
|
||||
return Boolean(reference?.customerId && reference.outboundNo && dispatchNo && expected.length
|
||||
&& outbound?.outboundNo === reference.outboundNo && outbound.dispatchNo === dispatchNo && outbound.status === 'OUTBOUNDED'
|
||||
&& Number(outbound.packageCount) === Number(reference.packageCount)
|
||||
&& Array.isArray(actual) && actual.length === expected.length && new Set(actual.map(row => row.deliveryNo)).size === actual.length
|
||||
&& expected.every(row => id(row.customerId) === id(reference.customerId) && actual.some(value => value.deliveryNo === row.deliveryNo
|
||||
&& value.outboundNo === reference.outboundNo && value.dispatchNo === dispatchNo && id(value.customerId) === id(reference.customerId)
|
||||
&& Number.isSafeInteger(Number(value.packageCount)) && Number(value.packageCount) > 0 && Number(value.packageCount) === Number(row.packageCount)))
|
||||
&& actual.reduce((sum, row) => sum + Number(row.packageCount), 0) === Number(outbound.packageCount))
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
context.createdResources.phase5OverseasFulfillment = {
|
||||
marker: context.marker, dispatchNo: null, outboundNo: null, deliveryNo: null, deliveryNos: [],
|
||||
@@ -1231,6 +1265,18 @@ export async function run(context) {
|
||||
try { await disablePicker(context, picker, 5) }
|
||||
finally { if (picker) picker.token = null; await context.checkpoint() }
|
||||
}
|
||||
return continueDepartedFulfillment(context, { customerId, warehouseId, dispatchNo, tripProof, deliveries, outbounds, merchantToken })
|
||||
}
|
||||
|
||||
/** 已发车原单的签收、回访及结案阶段;不重新准备订单、扫码或发车。 */
|
||||
export async function continueDepartedFulfillment(context, { customerId, warehouseId, dispatchNo, tripProof, deliveries, outbounds, merchantToken }) {
|
||||
const proof = context.createdResources?.phase5OverseasFulfillment
|
||||
if (!merchantToken || proof?.marker !== context.marker || proof.dispatchNo !== dispatchNo || tripProof?.dispatchNo !== dispatchNo
|
||||
|| id(tripProof.warehouseId) !== warehouseId || !Array.isArray(deliveries) || !deliveries.length || !Array.isArray(outbounds) || !outbounds.length
|
||||
|| proof.finalStatus || proof.receiptFiles?.length || tripProof.closure) throw Error('签收阶段必须使用本轮尚未签收的原发车证明')
|
||||
const departed = await readAutomaticDispatchDelivery(context, dispatchNo)
|
||||
if (!departed || JSON.stringify(departed.deliveries) !== JSON.stringify(deliveries) || JSON.stringify(departed.outbounds) !== JSON.stringify(outbounds))
|
||||
throw Error('原发车单据与当前客户来源或数量不一致,停止签收')
|
||||
const primaryDelivery = deliveries.find(row => id(row.customerId) === customerId)
|
||||
if (!primaryDelivery) { blocker(context, '客户签收资料前置', '本轮主客户不在实际派送结果中'); return }
|
||||
const deliveryNo = primaryDelivery.deliveryNo, outboundNo = primaryDelivery.outboundNo
|
||||
@@ -1238,8 +1284,7 @@ export async function run(context) {
|
||||
Object.assign(context.createdResources.phase5OverseasFulfillment, { outboundNo, deliveryNo, deliveryNos, outbounds, deliveries })
|
||||
for (const outboundRef of outbounds) {
|
||||
const outbound = await platform(context, 'GET', '逐客户回查发车后真实出库状态', `${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundRef.outboundNo)}`)
|
||||
const passed = outbound.data?.outbound?.outboundNo === outboundRef.outboundNo
|
||||
&& id(outbound.data?.outbound?.customerId) === id(outboundRef.customerId) && outbound.data?.outbound?.status === 'OUTBOUNDED'
|
||||
const passed = dispatchOutboundMatches(outbound.data, outboundRef, dispatchNo, deliveries)
|
||||
context.assert(MODULE, '每客户出库单独立OUTBOUNDED且归属不漂移', outbound.response, passed, trace(outbound.response), OPTIONS)
|
||||
if (!passed) return
|
||||
}
|
||||
|
||||
@@ -625,7 +625,8 @@ function nativeFragmentFixture() {
|
||||
data = { order: { id: '9007199254741001', customerId: '90', outboundNo: 'OB-F40', deliveryNo: 'DL-F40',
|
||||
version: '9007199254741002', status: departed ? 'DELIVERING' : 'WAIT_DELIVERY' }, skus: [{ inboundCode: 'IN-F40', packageCount: 1 }] }
|
||||
} else if (method === 'GET' && path.endsWith('/outbound-orders/OB-F40')) {
|
||||
data = { outbound: { customerId: '90', outboundNo: 'OB-F40', status: 'OUTBOUNDED' }, pallets: [{ inboundCode: 'IN-F40' }] }
|
||||
data = { outbound: { outboundNo: 'OB-F40', dispatchNo: 'DS-F40', status: 'OUTBOUNDED', packageCount: 1 },
|
||||
deliveries: [{ ...delivery, dispatchNo: 'DS-F40' }], pallets: [{ inboundCode: 'IN-F40' }] }
|
||||
} else if (method === 'GET' && path.endsWith('/dispatches/DS-F40')) {
|
||||
assert.equal(signed, true); events.push('normal-completed')
|
||||
data = { dispatchNo: 'DS-F40', status: 'ACTIVE', stage: 'DELIVERED', recordVersion: '9007199254741004' }
|
||||
|
||||
@@ -4,7 +4,7 @@ import { verifyPalletLabels } from './phase3-loading-finalize.mjs'
|
||||
import { stableLoadedCartonMarks } from './phase3-loading.mjs'
|
||||
import { awaitProducerConsumption, convertReceivingContainers, runReceiving,
|
||||
resolvePickerAccess, createPicker, disablePicker } from './phase4-overseas-pda.mjs'
|
||||
import { pickAll, verifyAutomaticDeparture, saveReceiptAndComplete, readAutomaticDispatchDelivery, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs'
|
||||
import { pickAll, verifyAutomaticDeparture, saveReceiptAndComplete, readAutomaticDispatchDelivery, dispatchOutboundMatches, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { assertTestDeliveryFaultProof } from '../test-delivery-fault-proof.mjs'
|
||||
import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs'
|
||||
@@ -568,8 +568,7 @@ export async function sendFragment(context, producer, picker, merchantToken, ind
|
||||
check(context, 'F40 自动发车真实出库一箱', Number(ready.data.counts?.shippedPackageCount) === 1, `dispatch=${dispatchNo}`)
|
||||
dispatchProof.departure = ready.data; dispatchProof.loadingReplay = tripProof.loadingCommand?.replay; dispatchProof.stage = 'DELIVERING'
|
||||
const outbound = await api(context, 'GET', '回查实际来源出库完成', `/api/platform/v1/last-leg/outbound-orders/${outboundNo}`)
|
||||
check(context,'F40 实际出库单 OUTBOUNDED 且来源入仓码匹配', id(outbound.outbound?.customerId) === customerId
|
||||
&& outbound.outbound?.outboundNo === outboundNo && outbound.outbound?.status === 'OUTBOUNDED'
|
||||
check(context,'F40 实际出库单 OUTBOUNDED 且来源入仓码匹配', dispatchOutboundMatches(outbound, outbounds[0], dispatchNo, deliveries)
|
||||
&& outbound.pallets?.length > 0 && outbound.pallets.every(row => row.inboundCode === producer.inboundCode), `outbound=${outboundNo}`)
|
||||
const beforeDelivery = await api(context, 'GET', '回查实际发车推进本片派送及其版本', deliveryPath)
|
||||
check(context, 'F40 实际发车已推进派送DELIVERING且身份未漂移', beforeDelivery.order?.status === 'DELIVERING'
|
||||
|
||||
Reference in New Issue
Block a user