Files
baiyue-firstleg/scripts/admin-test-env-modules/fleet-external-receipt-acceptance.mjs

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
}