197 lines
19 KiB
JavaScript
197 lines
19 KiB
JavaScript
import { randomUUID } from 'node:crypto'
|
|||
|
|
|
||
|
|
const OPTIONS = { scenario: 'fleet-external-actual-receipt', deploymentBlocker: true }
|
||
|
|
const id = value => value == null ? '' : String(value)
|
||
|
|
const key = () => `fleet-receipt-${randomUUID()}`
|
||
|
|
const ok = value => value?.http === 200 && id(value?.body?.code) === '200'
|
||
|
|
const conflict = value => value?.http === 409 || (value?.http === 200 && id(value?.body?.code) === 'A0409')
|
||
|
|
const snapshot = rows => JSON.stringify(rows.map(row => JSON.stringify(row)).sort())
|
||
|
|
|
||
|
|
/** Dedicated external receipt case; retains RECEIVED goods for subsequent dispatch. Never runs SQL or bypasses PDA. */
|
||
|
|
export async function verifyFleetExternalReceipt(context, module, tripProof, pdaToken, { reconcilePalletized = false, reconcileConfirmed = false } = {}) {
|
||
|
|
const reconciling = reconcilePalletized || reconcileConfirmed
|
||
|
|
if (!context.createdResources?.fleetTrips?.includes(tripProof) || (tripProof.externalReceipt && !reconciling) || !pdaToken)
|
||
|
|
throw new Error('外接实收验证须使用本轮车次及已绑定起运仓的PDA账号,失败后保留原证据')
|
||
|
|
if (reconciling && (tripProof.externalReceipt?.status !== 'CHECKING' || !tripProof.externalReceipt.palletizeRequest?.idempotencyKey
|
||
|
|
|| !tripProof.externalReceipt.warehousePalletId || (reconcileConfirmed ? !tripProof.externalReceipt.receiptRequest?.idempotencyKey : tripProof.externalReceipt.receiptRequest))) throw new Error('仅核对已入库的原来货与原完成命令,确认阶段须与保存的请求一致')
|
||
|
|
const proof = reconciling ? tripProof.externalReceipt : tripProof.externalReceipt = { status: 'CHECKING', marker: context.marker, requests: [] }
|
||
|
|
const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`
|
||
|
|
async function check(name, passed, response) {
|
||
|
|
context.assert(module, name, response, Boolean(passed), '', OPTIONS)
|
||
|
|
if (!passed) throw new Error(`${module}: ${name}`)
|
||
|
|
}
|
||
|
|
async function record(method, path, response, label, expectedMessage) {
|
||
|
|
proof.requests.push({ method, path, label, http: response?.http, code: response?.body?.code,
|
||
|
|
requestId: response?.requestId, traceId: response?.traceId })
|
||
|
|
await check(label, response?.requestId && response?.traceId
|
||
|
|
&& (expectedMessage ? conflict(response) && expectedMessage.test(id(response?.body?.message)) : ok(response)), response)
|
||
|
|
return response.body?.data
|
||
|
|
}
|
||
|
|
const request = async (method, path, body, label, token, expectedMessage) =>
|
||
|
|
record(method, path, await context.request(method, path, body, token), label, expectedMessage)
|
||
|
|
let plan = await request('GET', `${base}/capacity-plan`, undefined, '读取本轮真实自有货及可用容量')
|
||
|
|
await check('外接实收前已有有效自有货计划', plan?.planValid === true && !plan.blockReason && plan.cargo?.length > 0)
|
||
|
|
const customerId = id(plan.cargo[0].customerId), destinationKey = plan.cargo[0].destinationKey
|
||
|
|
const destinationIndex = tripProof.stops.findIndex(stop => stop.destinationKey === destinationKey)
|
||
|
|
if (reconciling) {
|
||
|
|
const baseline = proof.initialSegments || tripProof.reservation?.finalSegments
|
||
|
|
const original = reconcileConfirmed ? { ...proof.createRequest?.cargo, palletSpaces: 1, weightKg: 100, volumeCbm: 1 } : proof.createRequest?.cargo
|
||
|
|
await check('原实收来源及原预留基线可追溯', baseline?.length > 0 && proof.createRequest.customerId === customerId && original?.destinationKey === destinationKey)
|
||
|
|
const reserved = baseline.map(segment => segment.fromIndex >= destinationIndex ? segment : { ...segment,
|
||
|
|
occupied: { palletSpaces: Number(segment.occupied.palletSpaces) + Number(original.palletSpaces), weightKg: Number(segment.occupied.weightKg) + Number(original.weightKg), volumeCbm: Number(segment.occupied.volumeCbm) + Number(original.volumeCbm) },
|
||
|
|
remaining: { palletSpaces: Number(segment.remaining.palletSpaces) - Number(original.palletSpaces), weightKg: Number(segment.remaining.weightKg) - Number(original.weightKg), volumeCbm: Number(segment.remaining.volumeCbm) - Number(original.volumeCbm) } })
|
||
|
|
await check('已收货未确认期间仍保留原承运预留', snapshot(plan.availableSegments) === snapshot(reserved))
|
||
|
|
plan = { ...plan, availableSegments: baseline }
|
||
|
|
} else proof.initialSegments = plan.availableSegments
|
||
|
|
await check('使用本轮客户和已约定FBA卸货站且容量足够', /^[1-9]\d*$/.test(customerId)
|
||
|
|
&& destinationKey.startsWith('FBA:') && destinationIndex > 0
|
||
|
|
&& plan.availableSegments?.filter(row => row.fromIndex < destinationIndex).length === destinationIndex
|
||
|
|
&& plan.availableSegments.filter(row => row.fromIndex < destinationIndex).every(row =>
|
||
|
|
Number(row.remaining.palletSpaces) >= 2 && Number(row.remaining.weightKg) >= 100 && Number(row.remaining.volumeCbm) >= 1))
|
||
|
|
let bookingPath
|
||
|
|
if (!reconciling) {
|
||
|
|
const draft = { tripRecordVersion: plan.recordVersion, customerId, idempotencyKey: key(),
|
||
|
|
cargo: { destinationKey, cargoName: `${context.marker} 外接实收测试货`, packageCount: 1, palletSpaces: 2,
|
||
|
|
weightKg: 50, volumeCbm: 0.5, priceBasis: 'PALLET', unitPrice: 25, currency: 'CNY',
|
||
|
|
remark: '受控测试:计划2托位,PDA实测1托位100kg/1m³,运营重新确认30元/托' } }
|
||
|
|
proof.createRequest = draft
|
||
|
|
let result = await request('POST', `${base}/bookings`, draft, '登记本轮专用外接实收预订')
|
||
|
|
await check('来货由本轮询价产生', result?.booking?.status === 'DRAFT' && result.booking.customerId === customerId && result.booking.bookingNo)
|
||
|
|
proof.bookingNo = result.booking.bookingNo
|
||
|
|
bookingPath = `${base}/bookings/${encodeURIComponent(proof.bookingNo)}`
|
||
|
|
proof.confirmRequest = { tripRecordVersion: result.tripRecordVersion, recordVersion: result.booking.recordVersion, idempotencyKey: key() }
|
||
|
|
result = await request('POST', `${bookingPath}/confirm`, proof.confirmRequest, '确认来货前承运预留')
|
||
|
|
await check('预留报价为2托位乘25元', result?.booking?.status === 'CONFIRMED' && Number(result.booking.agreedAmount) === 50)
|
||
|
|
proof.incomingRequest = { tripRecordVersion: result.tripRecordVersion, recordVersion: result.booking.recordVersion,
|
||
|
|
estimatedArrivalAt: new Date(Date.now() + 1800000).toISOString(), deliveryInstructions: `${context.marker} 客户自送起运仓`, idempotencyKey: key() }
|
||
|
|
result = await request('POST', `${bookingPath}/incoming`, proof.incomingRequest, '创建真实外接来货并进入PDA待收货')
|
||
|
|
proof.incomingNo = result?.booking?.incomingNo
|
||
|
|
await check('创建来货不提前标记实收', proof.incomingNo && result.booking.status === 'CONFIRMED' && !result.booking.receipt)
|
||
|
|
const incomingReplay = await request('POST', `${bookingPath}/incoming`, proof.incomingRequest, '原请求重放来货登记')
|
||
|
|
await check('来货重放复用同一单号和版本', incomingReplay?.replayed === true && incomingReplay.booking?.incomingNo === proof.incomingNo
|
||
|
|
&& id(incomingReplay.tripRecordVersion) === id(result.tripRecordVersion) && id(incomingReplay.booking.recordVersion) === id(result.booking.recordVersion))
|
||
|
|
}
|
||
|
|
bookingPath ||= `${base}/bookings/${encodeURIComponent(proof.bookingNo)}`
|
||
|
|
const incoming = await request('GET', `${bookingPath}/incoming`, undefined, '读取运营侧来货及实收证据')
|
||
|
|
await check(reconciling ? '原来货实收阶段与检查点一致' : '实际来货为待PDA收货且没有完成托盘',
|
||
|
|
incoming?.incoming?.status === (reconcileConfirmed ? 'READY_FOR_DISPATCH' : reconcilePalletized ? 'WAIT_CUSTOMER_CONFIRMATION' : 'WAIT_PDA_RECEIVING')
|
||
|
|
&& incoming.incoming.warehouseId === id(tripProof.warehouseId) && incoming.incoming.destinationKey === destinationKey
|
||
|
|
&& incoming.incoming.inboundCode && incoming.incoming.pallets?.length === (reconciling ? 1 : 0))
|
||
|
|
proof.inboundCode = incoming.incoming.inboundCode
|
||
|
|
const filter = new URLSearchParams({ customerId, overseasWarehouseId: id(tripProof.warehouseId), inboundCode: proof.inboundCode, page: '1', size: '200' })
|
||
|
|
async function inventory(label) {
|
||
|
|
const balances = await request('GET', `/api/platform/v1/last-leg/inventory/balances?${filter}`, undefined, `${label}读取本票库存`)
|
||
|
|
const ledger = await request('GET', `/api/platform/v1/last-leg/inventory/ledger?${filter}&businessOrderNo=${encodeURIComponent(proof.incomingNo)}`, undefined, `${label}读取本票库存流水`)
|
||
|
|
for (const [kind, page] of [['库存', balances], ['流水', ledger]]) await check(`${label}${kind}精确范围且无漏页`,
|
||
|
|
Array.isArray(page?.records) && Number(page.total) === page.records.length && page.records.every(row =>
|
||
|
|
id(row.customerId) === customerId && id(row.overseasWarehouseId) === id(tripProof.warehouseId)
|
||
|
|
&& (kind === '库存' || row.inboundCode === proof.inboundCode)))
|
||
|
|
const exactBalances = [], exactPositions = []
|
||
|
|
for (const group of balances.records) {
|
||
|
|
await check(`${label}分组有可回查的余额引用`, /^[1-9]\d*$/.test(id(group.id)))
|
||
|
|
const balancePath = `/api/platform/v1/last-leg/inventory/balances/${encodeURIComponent(id(group.id))}`
|
||
|
|
const balance = await request('GET', `${balancePath}?customerId=${encodeURIComponent(customerId)}`, undefined, `${label}回查实际余额来源`)
|
||
|
|
const positions = await request('GET', `${balancePath}/positions?customerId=${encodeURIComponent(customerId)}`, undefined, `${label}回查实际余额的实物位置`)
|
||
|
|
await check(`${label}余额与位置确属本票实际实托`, id(balance?.id) === id(group.id)
|
||
|
|
&& id(balance.customerId) === customerId && id(balance.overseasWarehouseId) === id(tripProof.warehouseId)
|
||
|
|
&& balance.inboundCode === proof.inboundCode && Array.isArray(positions)
|
||
|
|
&& positions.every(row => id(row.balanceId) === id(balance.id) && id(row.customerId) === customerId
|
||
|
|
&& id(row.overseasWarehouseId) === id(tripProof.warehouseId) && row.inboundCode === proof.inboundCode
|
||
|
|
&& id(row.palletId) === proof.warehousePalletId && row.palletNo === proof.palletNo)
|
||
|
|
&& ['availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages', 'inTransitPackages', 'procurementPackages']
|
||
|
|
.every(field => Number(balance[field]) === Number(group[field])))
|
||
|
|
exactBalances.push(balance); exactPositions.push(...positions)
|
||
|
|
}
|
||
|
|
return { balances: exactBalances, positions: exactPositions, ledger: ledger.records }
|
||
|
|
}
|
||
|
|
const before = reconciling ? proof.beforeInventory : await inventory('PDA完成前')
|
||
|
|
await check('登记来货未伪造库存或入库流水', before.balances.every(row => Number(row.availablePackages) === 0)
|
||
|
|
&& before.ledger.length === 0)
|
||
|
|
proof.beforeInventory = before
|
||
|
|
const pdaBase = `/api/pda/v1/last-leg/warehouse-transfers/${encodeURIComponent(proof.incomingNo)}`
|
||
|
|
const task = await request('GET', pdaBase, undefined, 'PDA按绑定仓读取本轮来货', pdaToken)
|
||
|
|
await check('PDA真实任务与来货、客户来源及仓库一致', task?.transferNo === proof.incomingNo && task.inboundCode === proof.inboundCode
|
||
|
|
&& id(task.operatingWarehouseId) === id(tripProof.warehouseId) && task.status === (reconcileConfirmed ? 'READY_FOR_DISPATCH' : reconcilePalletized ? 'WAIT_CUSTOMER_CONFIRMATION' : 'WAIT_PDA_RECEIVING')
|
||
|
|
&& task.pallets?.length === (reconciling ? 1 : 0))
|
||
|
|
let completed
|
||
|
|
if (reconciling) completed = task
|
||
|
|
else {
|
||
|
|
const early = { tripRecordVersion: incoming.tripRecordVersion, recordVersion: incoming.recordVersion,
|
||
|
|
receiptHash: incoming.incoming.receiptHash, pallets: [{ warehousePalletId: id(plan.cargo[0].warehousePalletId), palletSpaces: 1 }], unitPrice: 30, idempotencyKey: key() }
|
||
|
|
await request('POST', `${bookingPath}/receipt-confirmation`, early, '未完成PDA测量禁止运营提前确认实收', undefined, /仓库尚未完成实收及测量/)
|
||
|
|
proof.palletDraftRequest = { idempotencyKey: key(), cartonCount: 1, lengthCm: 100, widthCm: 100, heightCm: 100, volumeCbm: 1, grossWeightKg: 100 }
|
||
|
|
const measured = await request('POST', `${pdaBase}/pallets/drafts`, proof.palletDraftRequest, 'PDA登记本轮受控实物测量', pdaToken)
|
||
|
|
await check('测量仅产生待生成实托草稿', measured?.pallets?.length === 1 && measured.pallets[0].status === 'DRAFT' && measured.pallets[0].id)
|
||
|
|
const draftReplay = await request('POST', `${pdaBase}/pallets/drafts`, proof.palletDraftRequest, '测量草稿原键重放', pdaToken)
|
||
|
|
await check('测量重放不增加托盘', snapshot(draftReplay.pallets) === snapshot(measured.pallets))
|
||
|
|
const palletPath = `${pdaBase}/pallets/${encodeURIComponent(id(measured.pallets[0].id))}`
|
||
|
|
const generated = await request('POST', `${palletPath}/generate`, { idempotencyKey: key() }, '生成仓库权威实物托盘', pdaToken)
|
||
|
|
const pallet = generated?.pallets?.[0]
|
||
|
|
await check('生成真实托盘且未入库', generated.pallets.length === 1 && pallet?.status === 'GENERATED' && pallet.warehousePalletId && pallet.palletNo)
|
||
|
|
proof.warehousePalletId = id(pallet.warehousePalletId); proof.palletNo = pallet.palletNo
|
||
|
|
const bound = await request('POST', `${palletPath}/bind`, { palletCode: pallet.palletNo, idempotencyKey: key() }, '按真实托盘码完成PDA绑定', pdaToken)
|
||
|
|
await check('PDA绑定保留同一实托和实测数量', bound?.pallets?.length === 1 && bound.pallets[0].status === 'BOUND'
|
||
|
|
&& id(bound.pallets[0].warehousePalletId) === proof.warehousePalletId && Number(bound.pallets[0].cartonCount) === 1)
|
||
|
|
const photoPath = `${pdaBase}/measurement-photos/files`
|
||
|
|
const uploaded = await record('POST', photoPath, await context.requestMultipart('POST', photoPath, {
|
||
|
|
name: `${context.marker}-TEST-MEASUREMENT.png`, type: 'image/png',
|
||
|
|
content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64'),
|
||
|
|
}, pdaToken), '上传明确标记为测试的测量凭证')
|
||
|
|
await check('测量凭证有真实文件引用', uploaded?.fileId && Number(uploaded.sizeBytes) > 0)
|
||
|
|
proof.photoFileId = id(uploaded.fileId)
|
||
|
|
proof.palletizeRequest = { idempotencyKey: key(), photoFileIds: [proof.photoFileId] }
|
||
|
|
completed = await request('POST', `${pdaBase}/palletize`, proof.palletizeRequest, 'PDA正式完成外接收货及库存入账', pdaToken)
|
||
|
|
}
|
||
|
|
await check('PDA已完成且保留原实托', completed?.status === (reconcileConfirmed ? 'READY_FOR_DISPATCH' : 'WAIT_CUSTOMER_CONFIRMATION')
|
||
|
|
&& completed.pallets?.length === 1 && completed.pallets[0].status === 'COMPLETED'
|
||
|
|
&& id(completed.pallets[0].warehousePalletId) === proof.warehousePalletId)
|
||
|
|
const receivedStock = await inventory('首次完成收货后')
|
||
|
|
await check('本票真实可用库存及不可变入库流水只增加1箱', receivedStock.balances.reduce((n, row) => n + Number(row.availablePackages), 0) === 1
|
||
|
|
&& receivedStock.positions.length === 1 && Number(receivedStock.positions[0].availablePackages) === 1
|
||
|
|
&& receivedStock.ledger.length > 0 && receivedStock.ledger.every(row => row.businessOrderNo === proof.incomingNo && row.palletNo === proof.palletNo
|
||
|
|
&& Number(row.beforePackages) === 0 && Number(row.deltaPackages) === 1 && Number(row.afterPackages) === 1)
|
||
|
|
&& receivedStock.ledger.reduce((n, row) => n + Number(row.deltaPackages), 0) === 1)
|
||
|
|
proof.receivedInventory = receivedStock
|
||
|
|
if (!reconcileConfirmed) {
|
||
|
|
const completedReplay = await request('POST', `${pdaBase}/palletize`, proof.palletizeRequest, 'PDA完成原请求重放', pdaToken)
|
||
|
|
const replayedStock = await inventory('PDA完成重放后')
|
||
|
|
await check('PDA重放不重复写入库存与流水', snapshot(replayedStock.balances) === snapshot(receivedStock.balances)
|
||
|
|
&& snapshot(replayedStock.positions) === snapshot(receivedStock.positions)
|
||
|
|
&& snapshot(replayedStock.ledger) === snapshot(receivedStock.ledger) && snapshot(completedReplay.pallets) === snapshot(completed.pallets))
|
||
|
|
}
|
||
|
|
const measuredIncoming = await request('GET', `${bookingPath}/incoming`, undefined, '运营读取PDA完成后的实测摘要')
|
||
|
|
await check('运营读取真实实收数量而非原预估', measuredIncoming?.incoming?.status === (reconcileConfirmed ? 'READY_FOR_DISPATCH' : 'WAIT_CUSTOMER_CONFIRMATION')
|
||
|
|
&& measuredIncoming.incoming.pallets?.length === 1 && id(measuredIncoming.incoming.pallets[0].warehousePalletId) === proof.warehousePalletId
|
||
|
|
&& Number(measuredIncoming.incoming.receivedPackages) === 1)
|
||
|
|
if (!reconcileConfirmed) proof.receiptRequest = { tripRecordVersion: measuredIncoming.tripRecordVersion, recordVersion: measuredIncoming.recordVersion,
|
||
|
|
receiptHash: measuredIncoming.incoming.receiptHash, pallets: [{ warehousePalletId: proof.warehousePalletId, palletSpaces: 1 }], unitPrice: 30, idempotencyKey: key() }
|
||
|
|
const accepted = await request('POST', `${bookingPath}/receipt-confirmation`, proof.receiptRequest, '运营按实际1托位和新单价30确认实收')
|
||
|
|
proof.acceptedReceipt = accepted
|
||
|
|
await check('实收确认冻结新货量和价格且形成可排车来源', accepted?.booking?.status === 'RECEIVED'
|
||
|
|
&& accepted.booking.receipt?.source?.status === 'READY_FOR_DISPATCH' && Number(accepted.booking.agreedAmount) === 30
|
||
|
|
&& Number(accepted.booking.cargo?.palletSpaces) === 1 && Number(accepted.booking.cargo?.weightKg) === 100
|
||
|
|
&& Number(accepted.booking.cargo?.volumeCbm) === 1 && Number(accepted.booking.cargo?.unitPrice) === 30)
|
||
|
|
const persisted = await request('GET', `${base}/bookings`, undefined, '冻结已持久化实收记录以核对重放')
|
||
|
|
const persistedBooking = persisted?.records?.find(row => row.bookingNo === proof.bookingNo)
|
||
|
|
const { updatedAt: responseTime, ...acceptedFacts } = accepted.booking
|
||
|
|
const { updatedAt: storedTime, ...persistedFacts } = persistedBooking || {}
|
||
|
|
await check('持久化保留全部实收业务字段与版本', snapshot([acceptedFacts]) === snapshot([persistedFacts])
|
||
|
|
&& id(persisted.tripRecordVersion) === id(accepted.tripRecordVersion))
|
||
|
|
const acceptedReplay = await request('POST', `${bookingPath}/receipt-confirmation`, proof.receiptRequest, '运营实收确认原键重放')
|
||
|
|
await check('实收重放不改价格或版本', acceptedReplay?.replayed === true && snapshot([acceptedReplay.booking]) === snapshot([persistedBooking])
|
||
|
|
&& id(acceptedReplay.tripRecordVersion) === id(accepted.tripRecordVersion))
|
||
|
|
const finalStock = await inventory('运营确认及重放后')
|
||
|
|
await check('运营确认不会再次入库', snapshot(finalStock.balances) === snapshot(receivedStock.balances)
|
||
|
|
&& snapshot(finalStock.positions) === snapshot(receivedStock.positions)
|
||
|
|
&& snapshot(finalStock.ledger) === snapshot(receivedStock.ledger))
|
||
|
|
const finalPlan = await request('GET', `${base}/capacity-plan`, undefined, '实收确认后回查分段容量')
|
||
|
|
const expected = plan.availableSegments.map(segment => segment.fromIndex >= destinationIndex ? segment : { ...segment,
|
||
|
|
occupied: { palletSpaces: Number(segment.occupied.palletSpaces) + 1, weightKg: Number(segment.occupied.weightKg) + 100, volumeCbm: Number(segment.occupied.volumeCbm) + 1 },
|
||
|
|
remaining: { palletSpaces: Number(segment.remaining.palletSpaces) - 1, weightKg: Number(segment.remaining.weightKg) - 100, volumeCbm: Number(segment.remaining.volumeCbm) - 1 } })
|
||
|
|
await check('实收替换原预留而非重复占用且仅影响约定站段', finalPlan?.planValid === true && snapshot(finalPlan.availableSegments) === snapshot(expected))
|
||
|
|
proof.finalBooking = accepted.booking; proof.finalTripVersion = accepted.tripRecordVersion
|
||
|
|
proof.finalInventory = finalStock; proof.finalSegments = finalPlan.availableSegments
|
||
|
|
proof.status = 'VERIFIED'
|
||
|
|
return proof
|
||
|
|
}
|