feat(dispatch): unify whole vehicle scheduling, source ownership and staged execution
This commit is contained in:
@@ -1,167 +1,214 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { DISPATCH_BASE as BASE, id as stringId, version, commandKey, dispatchIo, replay, readDispatch } from './grouped-dispatch-acceptance.mjs'
|
||||
import { pickAll } from './phase5-overseas-fulfillment.mjs'
|
||||
import { confirmFleetLoading } from './fleet-trip-fulfillment.mjs'
|
||||
|
||||
const OPTIONS = { scenario: 'fleet-original-fee-coverage', deploymentBlocker: true }
|
||||
const id = value => value == null ? '' : String(value)
|
||||
const ok = response => response?.http === 200 && id(response?.body?.code) === '200'
|
||||
const businessConflict = response => response?.http === 409 || (response?.http === 200 && id(response?.body?.code) === 'A0409')
|
||||
const canonical = rows => JSON.stringify(rows.map(row => JSON.stringify(row)).sort())
|
||||
const cargoIdentity = row => ({ dispatchItemId: id(row.dispatchItemId), warehousePalletId: id(row.warehousePalletId),
|
||||
orderId: id(row.orderId), sourceInboundId: id(row.sourceInboundId), packages: Number(row.packages) })
|
||||
const feeIdentity = row => ({ financeFeeId: id(row.financeFeeId), sourceVersion: Number(row.sourceVersion), sourceHash: row.sourceHash,
|
||||
sourceInboundId: id(row.sourceInboundId), customerId: id(row.customerId), currency: row.currency, amount: Number(row.amount), status: row.status })
|
||||
export const stableRows = rows => JSON.stringify(rows.map(row => JSON.stringify(row)).sort())
|
||||
const clone = value => structuredClone(value)
|
||||
const id = value => { if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('服务端ID精度无效,必须保留Long字符串'); return stringId(value) }
|
||||
const positive = value => /^[1-9]\d*$/.test(id(value))
|
||||
const sha = value => /^[a-f0-9]{64}$/i.test(value || '')
|
||||
const cents = value => { const text = String(value); if (!/^\d+(?:\.\d{1,2})?$/.test(text)) throw Error('费用必须为明确的非负两位金额'); const [whole, fraction = ''] = text.split('.'); return BigInt(whole) * 100n + BigInt(fraction.padEnd(2, '0')) }
|
||||
const cargoIdentity = row => ({ dispatchItemId: id(row.dispatchItemId), warehousePalletId: id(row.warehousePalletId), orderId: id(row.orderId), sourceInboundId: id(row.sourceInboundId), packages: Number(row.packages) })
|
||||
const feeIdentity = fee => ({ financeFeeId: id(fee.financeFeeId), customerId: id(fee.customerId), currency: fee.currency, amount: String(fee.amount), sourceVersion: fee.sourceVersion, sourceHash: fee.sourceHash, status: fee.status, sourceKeys: [...fee.sourceKeys].sort() })
|
||||
|
||||
/** Dedicated current-run dispatches only: one FULL case and a separate PARTIAL case with >=2 actual inbound sources. */
|
||||
export async function verifyFleetFeeCoverage(context, module, source, { reconcilePersisted = false } = {}) {
|
||||
if (!context.createdResources?.fleetFeeCoverageCases?.includes(source) || source.marker !== context.marker || (source.proof && !reconcilePersisted)
|
||||
|| !['FULL', 'PARTIAL'].includes(source.mode) || !source.dispatchNo || !source.customerId)
|
||||
throw new Error('原费用验收需要本轮专用、未执行的排车用例;失败后保留原请求,不重复创建')
|
||||
if (reconcilePersisted && (source.mode !== 'FULL' || source.proof?.status !== 'CHECKING'
|
||||
|| !source.proof.outboundNo || !source.proof.deliveryNos?.length || !source.proof.confirmationRequest?.idempotencyKey))
|
||||
throw new Error('已落单核对须有原成功请求、出库单及派送集合,不允许重造')
|
||||
const proof = reconcilePersisted ? source.proof : source.proof = { status: 'CHECKING', mode: source.mode, dispatchNo: source.dispatchNo, requests: [] }
|
||||
const dispatchPath = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(source.dispatchNo)}`
|
||||
const outboundPath = `/api/platform/v1/last-leg/outbound-orders?size=200&customerId=${encodeURIComponent(source.customerId)}`
|
||||
async function check(name, passed, response) {
|
||||
context.assert(module, name, response, Boolean(passed), '', OPTIONS)
|
||||
if (!passed) throw new Error(`${module}: ${name}`)
|
||||
}
|
||||
async function request(method, path, body, label, expectedMessage) {
|
||||
const response = await context.request(method, path, body)
|
||||
proof.requests.push({ label, method, path, http: response?.http, code: response?.body?.code,
|
||||
requestId: response?.requestId, traceId: response?.traceId })
|
||||
await check(label, Boolean(response?.requestId) && Boolean(response?.traceId)
|
||||
&& (expectedMessage ? businessConflict(response) && expectedMessage.test(id(response?.body?.message)) : ok(response)), response)
|
||||
export function fleetConsumerIo(context, module, proof, namespace) {
|
||||
const io = dispatchIo(context, module, proof, namespace)
|
||||
const request = async (method, path, body, label, expected) => {
|
||||
const response = await io.send(method, path, body, label)
|
||||
io.check(`${label}诊断回执完整`, response, Boolean(response?.requestId) && Boolean(response?.traceId))
|
||||
io.check(label, response, expected ? [200, 409].includes(response.http) && String(response.body?.code) === 'A0409' && expected.test(String(response.body?.message))
|
||||
: response.http === 200 && String(response.body?.code) === '200')
|
||||
return response.body?.data
|
||||
}
|
||||
async function noOutbound(label) {
|
||||
const rows = []
|
||||
let total
|
||||
for (let index = 1; ; index++) {
|
||||
const page = await request('GET', `${outboundPath}&page=${index}`, undefined, label)
|
||||
await check('客户出库列表分页元数据完整', Array.isArray(page?.records) && Number.isSafeInteger(Number(page.total))
|
||||
&& Number(page.total) >= 0 && Number(page.page) === index && Number(page.size) === 200)
|
||||
total ??= Number(page.total)
|
||||
await check('客户出库列表跨页范围稳定', total === Number(page.total) && (page.records.length > 0 || rows.length === total))
|
||||
rows.push(...page.records)
|
||||
if (rows.length >= total) break
|
||||
return { ...io, request, get: (path, label) => request('GET', path, undefined, label) }
|
||||
}
|
||||
|
||||
export async function readyFleetDispatch(context, module, io, trip, { pickerToken, actualMeasurements } = {}) {
|
||||
io.check('真实履约写入须有正式持久化检查点', null, typeof context.checkpoint === 'function')
|
||||
let current = await readDispatch(io, trip.dispatchNo)
|
||||
io.check('本轮实车仓库及未发车范围', null, context.createdResources.fleetTrips?.includes(trip) && trip.tripNo
|
||||
&& current.warehouseId === id(trip.warehouseId) && current.data.status === 'ACTIVE' && Number(current.data.counts.shippedPackageCount) === 0)
|
||||
if (current.data.stage === 'LOADING') {
|
||||
io.check('真实扫码需本轮PDA身份', null, typeof pickerToken === 'string' && pickerToken.length > 0)
|
||||
io.check('PDA逐来源真实扫码完成', null, await pickAll(context, pickerToken, trip.dispatchNo))
|
||||
await confirmFleetLoading(context, module, trip, pickerToken, actualMeasurements)
|
||||
current = await readDispatch(io, trip.dispatchNo)
|
||||
}
|
||||
io.check('安排前已真实扫码并显式装车完成', null, current.data.stage === 'AFTER_LOADING' && current.data.pickingReconciled === true
|
||||
&& Number(current.data.counts.packageCount) > 0 && Number(current.data.counts.pickedPackageCount) === Number(current.data.counts.packageCount)
|
||||
&& current.items.items.every(i => Number(i.pickedPackages) === Number(i.scheduledPackages)))
|
||||
return current
|
||||
}
|
||||
|
||||
// FeeCandidate未暴露sourceInboundId,仅在sourceKeys可唯一映射到实际原入仓时选择,混托歧义直接阻断。
|
||||
export function coveragePlan(preview, source, externalReceipt) {
|
||||
if (!preview.groups?.length) throw Error('缺少当前全车派送分组')
|
||||
const firstleg = preview.groups.flatMap(g => g.cargo.filter(c => c.sourceRegion === 'FIRSTLEG'))
|
||||
const inboundIds = [...new Set(firstleg.map(c => id(c.sourceInboundId)))].sort()
|
||||
if (!inboundIds.length || inboundIds.some(v => !positive(v)) || source.mode === 'PARTIAL' && inboundIds.length < 2) throw Error('FULL/PARTIAL需要本轮真实原入仓,PARTIAL至少两票')
|
||||
const selectedInbounds = new Set(source.mode === 'FULL' ? inboundIds : [inboundIds[0]])
|
||||
const originalFees = [], selections = new Map(), uncovered = []
|
||||
for (const group of preview.groups) {
|
||||
if (!group.groupKey || !positive(group.customerId) || !group.destination?.key || !sha(group.groupCargoHash) || !sha(group.feeCoverageCargoHash)
|
||||
|| !sha(group.externalFreightHash) || !Array.isArray(group.blockReasons) || group.blockReasons.length || !Array.isArray(group.feeSources) || !Array.isArray(group.externalQuotes)
|
||||
|| !group.cargo.length || group.cargo.some(c => id(c.customerId) !== id(group.customerId) || c.destination?.key !== group.destination.key)) throw Error('客户目的地或费用预览范围不完整')
|
||||
const own = group.cargo.filter(c => c.sourceRegion === 'FIRSTLEG')
|
||||
if (own.some(c => id(c.customerId) !== id(source.customerId))) throw Error('原费用跨客户,不能沿其他客户确认覆盖')
|
||||
if (group.externalQuotes.some(q => !externalReceipt || q.bookingNo !== externalReceipt.bookingNo || q.inboundCode !== externalReceipt.incomingNo
|
||||
|| id(q.customerId) !== id(group.customerId) || q.groupKey !== group.groupKey || q.sourceHash !== group.externalFreightHash
|
||||
|| q.currency !== externalReceipt.finalBooking.cargo.currency || cents(q.amount) !== cents(externalReceipt.finalBooking.agreedAmount))) throw Error('当前外接报价与本轮运营确认金额或来源摘要不一致')
|
||||
for (const cargo of group.cargo.filter(c => c.sourceRegion !== 'FIRSTLEG')) {
|
||||
if (!externalReceipt || cargo.inboundCode !== externalReceipt.incomingNo || !externalReceipt.createRequest
|
||||
|| id(cargo.customerId) !== id(externalReceipt.createRequest.customerId)
|
||||
|| !group.externalQuotes.some(q => q.bookingNo === externalReceipt.bookingNo && q.inboundCode === cargo.inboundCode && id(q.customerId) === id(group.customerId))) throw Error('非头程货物没有本轮已确认外接报价')
|
||||
}
|
||||
const byInbound = new Map()
|
||||
for (const fee of group.feeSources) {
|
||||
if (id(fee.customerId) !== id(group.customerId) || !positive(fee.financeFeeId) || fee.currency !== 'CNY' || !['AVAILABLE', 'SETTLED'].includes(fee.status)
|
||||
|| !Number.isSafeInteger(fee.sourceVersion) || fee.sourceVersion < 1 || !fee.sourceHash || !Array.isArray(fee.sourceKeys) || !fee.sourceKeys.length
|
||||
|| !fee.services?.includes('DELIVERY_FEE')) throw Error('原费用客户、版本或来源不可用')
|
||||
cents(fee.amount)
|
||||
const actual = own.filter(c => fee.sourceKeys.includes(c.sourceKey)), ids = new Set(actual.map(c => id(c.sourceInboundId)))
|
||||
if (ids.size !== 1 || fee.sourceKeys.some(k => !actual.some(c => c.sourceKey === k))) throw Error('原费用sourceKeys不能唯一映射原入仓;需生产预览提供原费用sourceInboundId或无混托真实来源')
|
||||
const inbound = [...ids][0]
|
||||
if (!byInbound.has(inbound)) byInbound.set(inbound, fee)
|
||||
originalFees.push({ groupKey: group.groupKey, ...feeIdentity(fee) })
|
||||
}
|
||||
await check('失败命令没有生成本排车出库事实', rows.length === total
|
||||
&& new Set(rows.map(row => row.outboundNo)).size === rows.length && rows.every(row => row.dispatchNo !== source.dispatchNo))
|
||||
if (own.some(c => !byInbound.has(id(c.sourceInboundId)))) throw Error('本轮每票必须有实际原费用,不能借其他票补齐')
|
||||
selections.set(group.groupKey, [...byInbound].filter(([inbound]) => selectedInbounds.has(inbound)).map(([, fee]) => ({ financeFeeId: id(fee.financeFeeId), sourceVersion: fee.sourceVersion, sourceHash: fee.sourceHash, services: ['DELIVERY_FEE'] })))
|
||||
if (own.some(c => !selectedInbounds.has(id(c.sourceInboundId)))) uncovered.push(group.groupKey)
|
||||
}
|
||||
const dispatch = await request('GET', dispatchPath, undefined, '核对本轮费用验收排车来源')
|
||||
await check('只处理本轮尚未拣货及发车的排车', dispatch?.dispatchNo === source.dispatchNo
|
||||
&& Number(dispatch.pickedPackageCount) === 0
|
||||
&& Number(dispatch.shippedPackageCount) === 0 && dispatch.items?.length > 0)
|
||||
if (!reconcilePersisted) await noOutbound('确认专用排车尚无出库单')
|
||||
const preview = await request('GET', `${dispatchPath}/fee-coverage`, undefined, '读取本次实际货物及原 Finance 费用来源')
|
||||
await check('原费用范围完整且可确认', preview?.sourceComplete === true && !preview.blockReason
|
||||
&& preview.dispatchNo === source.dispatchNo && /^[0-9a-f]{64}$/.test(preview.cargoHash || '')
|
||||
&& preview.cargo?.length > 0 && preview.cargo.every(row => row.sourceRegion === 'FIRSTLEG' && id(row.sourceInboundId)))
|
||||
const itemTotals = new Map()
|
||||
for (const row of preview.cargo) itemTotals.set(id(row.dispatchItemId), (itemTotals.get(id(row.dispatchItemId)) || 0) + Number(row.packages))
|
||||
await check('费用预览逐条排车箱数核平', itemTotals.size === dispatch.items.length
|
||||
&& dispatch.items.every(row => itemTotals.get(id(row.id)) === Number(row.packageCount)))
|
||||
const inboundIds = [...new Set(preview.cargo.map(row => id(row.sourceInboundId)))].sort()
|
||||
await check('原费用均属于当前客户和实际原入仓', Array.isArray(preview.feeSources) && preview.feeSources.every(fee =>
|
||||
id(fee.customerId) === id(source.customerId) && inboundIds.includes(id(fee.sourceInboundId))
|
||||
&& ['AVAILABLE', 'SETTLED'].includes(fee.status) && ['INBOUND_FEE', 'CONTAINER_FEE'].includes(fee.sourceType)
|
||||
&& fee.currency === 'CNY' && Number(fee.sourceVersion) >= 1 && fee.sourceHash && id(fee.financeFeeId)))
|
||||
const sources = inboundIds.map(inboundId => preview.feeSources.find(fee => id(fee.sourceInboundId) === inboundId))
|
||||
await check('本轮每票已产生可引用原费用,不借其他票费用补齐', sources.every(Boolean)
|
||||
&& (source.mode === 'FULL' || inboundIds.length >= 2))
|
||||
const selectedFees = source.mode === 'FULL' ? sources : [sources[0]]
|
||||
const selections = selectedFees.map(fee => ({ financeFeeId: id(fee.financeFeeId), sourceVersion: Number(fee.sourceVersion),
|
||||
sourceHash: fee.sourceHash, services: ['DELIVERY_FEE'] }))
|
||||
const freight = await request('GET', `${dispatchPath}/external-freight`, undefined, '核对本专用排车没有外接报价混入覆盖用例')
|
||||
await check('本用例为原费用货物且使用托盘派送自有预约', Array.isArray(freight?.quotes) && freight.quotes.length === 0
|
||||
&& freight.requiresDeliveryFee === true && source.transferBody?.loadingType === 'PALLET'
|
||||
&& source.transferBody.appointmentAccount === 'SELF' && source.transferBody.fees?.length === 1
|
||||
&& source.transferBody.fees[0].direction === 'RECEIVABLE' && source.transferBody.fees[0].feeType === 'DELIVERY_FEE'
|
||||
&& Number(source.transferBody.fees[0].amount) > 0)
|
||||
const body = reconcilePersisted ? proof.confirmationRequest : { ...source.transferBody, idempotencyKey: `fleet-fee-${randomUUID()}`,
|
||||
externalFreightHash: freight.snapshotHash, feeCoverageCargoHash: preview.cargoHash, feeCoverage: selections,
|
||||
fees: source.mode === 'FULL' ? [] : source.transferBody.fees }
|
||||
proof.confirmationRequest = body
|
||||
if (!reconcilePersisted) proof.originalFees = preview.feeSources.map(feeIdentity)
|
||||
const transferPath = `${dispatchPath}/transfer-delivery`
|
||||
let transferred
|
||||
if (reconcilePersisted) {
|
||||
await check('原成功请求的货物与引用仍对应本次核对范围', body.feeCoverageCargoHash === preview.cargoHash
|
||||
&& canonical(body.feeCoverage) === canonical(selections))
|
||||
const stored = await request('GET', `/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(proof.outboundNo)}`, undefined, '先核对已持久化的原出库与派送集合')
|
||||
await check('原出库和派送存在且尚未执行', stored?.outbound?.dispatchNo === source.dispatchNo
|
||||
&& stored.outbound.outboundNo === proof.outboundNo && stored.outbound.status === 'WAIT_OUTBOUND'
|
||||
&& canonical((stored.deliveries || []).map(row => row.deliveryNo)) === canonical(proof.deliveryNos)
|
||||
&& stored.deliveries.every(row => row.status === 'WAIT_DELIVERY'))
|
||||
transferred = await request('POST', transferPath, body, '确认原持久化单据后仅重放同键同载荷成功命令')
|
||||
await check('核对重放未新建任何业务单据', transferred?.replayed === true && transferred.outboundNo === proof.outboundNo
|
||||
&& canonical((transferred.deliveries || []).map(row => row.deliveryNo)) === canonical(proof.deliveryNos))
|
||||
} else {
|
||||
const staleHash = `${preview.cargoHash[0] === '0' ? '1' : '0'}${preview.cargoHash.slice(1)}`
|
||||
await request('POST', transferPath, { ...body, idempotencyKey: `stale-cargo-${randomUUID()}`, feeCoverageCargoHash: staleHash },
|
||||
'货物摘要陈旧时拒绝转派送', /货物已变化/)
|
||||
await noOutbound('陈旧货物拒绝后回查无业务写入')
|
||||
await request('POST', transferPath, { ...body, idempotencyKey: `stale-fee-${randomUUID()}`,
|
||||
feeCoverage: selections.map((row, index) => index === 0 ? { ...row, sourceVersion: row.sourceVersion + 1 } : row) },
|
||||
'原费用版本陈旧时拒绝确认', /原费用版本已变化/)
|
||||
await noOutbound('陈旧原费用拒绝后回查无业务写入')
|
||||
await request('POST', transferPath, { ...body, idempotencyKey: `fee-scope-${randomUUID()}`,
|
||||
fees: source.mode === 'FULL' ? source.transferBody.fees : [] },
|
||||
source.mode === 'FULL' ? '全覆盖禁止再登记派送应收' : '部分覆盖仍须登记未覆盖派送费',
|
||||
source.mode === 'FULL' ? /已由原费用覆盖/ : /须填写派送费/)
|
||||
await noOutbound('覆盖金额校验拒绝后回查无业务写入')
|
||||
transferred = await request('POST', transferPath, body, '正式保存本次原费用服务覆盖并转派送')
|
||||
const template = source.transferBody
|
||||
if (template?.loadingType !== 'PALLET' || template.appointmentAccount !== 'SELF' || template.fees?.length !== 1
|
||||
|| template.fees[0].direction !== 'RECEIVABLE' || template.fees[0].feeType !== 'DELIVERY_FEE' || cents(template.fees[0].amount) <= 0n) throw Error('覆盖用例须有明确的未覆盖派送应收金额')
|
||||
if (uncovered.length > 1 && uncovered.some(key => !Array.isArray(template.groupFees?.[key]) || template.groupFees[key].length !== 1)) throw Error('多个未覆盖目的地须由本轮用例明确逐组费用,不能自动分摊')
|
||||
const groups = preview.groups.map(group => {
|
||||
const fees = uncovered.includes(group.groupKey) ? clone(template.groupFees?.[group.groupKey] || template.fees) : []
|
||||
if (uncovered.includes(group.groupKey) && fees.length !== 1 || fees.some(f => f.direction !== 'RECEIVABLE' || f.feeType !== 'DELIVERY_FEE' || cents(f.amount) <= 0n || !['CNY', 'USD'].includes(f.currency) || f.currency !== template.fees[0].currency)) throw Error('逐组未覆盖应收不完整')
|
||||
return { groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, plannedDeliveryAt: template.plannedDeliveryAt || new Date(Date.now() + 21600000).toISOString(),
|
||||
deliveryMethod: template.deliveryMethod || 'TRUCK_PALLET', waybillNo: template.waybillNo || null, carrierName: template.carrierName || null,
|
||||
pickupMethod: template.pickupMethod || 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: template.vehicleType || 'TRUCK', vehicleSize: template.vehicleSize || null,
|
||||
appointmentAccount: 'SELF', sellerName: null, remark: template.remark || null, externalFreightHash: group.externalFreightHash,
|
||||
feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: selections.get(group.groupKey), fees }
|
||||
})
|
||||
return { groups, originalFees, selectedInbounds: [...selectedInbounds], inboundIds }
|
||||
}
|
||||
|
||||
async function noDocuments(io, source, label) {
|
||||
let total, rows = []
|
||||
for (let page = 1; ; page++) {
|
||||
const data = await io.get(`/api/platform/v1/last-leg/outbound-orders?${new URLSearchParams({ customerId: id(source.customerId), size: '200', page: String(page) })}`, label)
|
||||
io.check('客户出库分页完整稳定', null, Array.isArray(data?.records) && Number.isSafeInteger(Number(data.total)) && Number(data.page) === page && Number(data.size) === 200
|
||||
&& Number(data.total) >= 0 && (total === undefined || total === Number(data.total)) && (data.records.length > 0 || rows.length === Number(data.total)))
|
||||
total ??= Number(data.total); rows.push(...data.records); if (rows.length >= total) break
|
||||
}
|
||||
await check('转派送生成本次真实出库和各目的地派送', transferred?.dispatchNo === source.dispatchNo && transferred.outboundNo
|
||||
&& transferred.outboundStatus === 'WAIT_OUTBOUND' && transferred.deliveries?.length > 0
|
||||
&& transferred.deliveries.every(row => row.deliveryNo && row.deliveryStatus === 'WAIT_DELIVERY'))
|
||||
proof.outboundNo = transferred.outboundNo
|
||||
proof.deliveryNos = transferred.deliveries.map(row => row.deliveryNo).sort()
|
||||
const evidenceByDelivery = {}
|
||||
const feesByDelivery = {}
|
||||
let newReceivable = 0
|
||||
const allCargo = []
|
||||
for (const number of proof.deliveryNos) {
|
||||
const detailPath = `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(source.customerId)}`
|
||||
const detail = await request('GET', detailPath, undefined, '回查派送详情中的原费用确认快照')
|
||||
const evidence = detail?.feeCoverage
|
||||
const destination = id(detail?.order?.destinationWarehouseCode || detail?.order?.destinationWarehouseId)
|
||||
const destinationItems = new Set(dispatch.items.filter(row => id(row.destinationWarehouseCode || row.fbaWarehouseCode) === destination).map(row => id(row.id)))
|
||||
await check('费用确认货物只属于当前派送目的地', destination && destinationItems.size > 0
|
||||
&& evidence?.cargo?.every(row => destinationItems.has(id(row.dispatchItemId))))
|
||||
await check('确认快照属于本次派送及实际原费用版本', detail?.order?.dispatchNo === source.dispatchNo
|
||||
&& detail.order.deliveryNo === number && evidence?.cargoHash === preview.cargoHash
|
||||
&& evidence.operatorId && Number.isFinite(Date.parse(evidence.confirmedAt)) && evidence.cargo?.length > 0
|
||||
&& Array.isArray(evidence.references) && evidence.references.every(ref => evidence.cargo.some(row => id(row.sourceInboundId) === id(ref.source?.sourceInboundId)) && selectedFees.some(fee =>
|
||||
id(fee.financeFeeId) === id(ref.source?.financeFeeId) && Number(fee.sourceVersion) === Number(ref.source?.sourceVersion)
|
||||
&& fee.sourceHash === ref.source?.sourceHash && canonical(ref.services || []) === canonical(['DELIVERY_FEE']))))
|
||||
const selectedInbounds = new Set(selectedFees.map(fee => id(fee.sourceInboundId)))
|
||||
await check('快照保留本派送已选择的每票服务引用', evidence.cargo.every(row => !selectedInbounds.has(id(row.sourceInboundId))
|
||||
|| evidence.references.some(ref => id(ref.source?.sourceInboundId) === id(row.sourceInboundId) && ref.services.includes('DELIVERY_FEE'))))
|
||||
await check('费用明细为明确应收列表', Array.isArray(detail.fees) && detail.fees.every(row => row.feeDirection && row.feeType))
|
||||
const added = detail.fees.filter(row => row.feeDirection === 'RECEIVABLE' && row.feeType === 'DELIVERY_FEE' && row.sourceStatus !== 'VOIDED')
|
||||
await check('已全覆盖目的地不承担其他目的地的新增派送费', !evidence.cargo.every(row => selectedInbounds.has(id(row.sourceInboundId))) || added.length === 0)
|
||||
await check('新增派送费没有伪零值或混合币种', added.every(row => Number(row.amount) > 0 && row.currency === source.transferBody.fees[0].currency))
|
||||
newReceivable += added.reduce((sum, row) => sum + Number(row.amount), 0)
|
||||
allCargo.push(...evidence.cargo.map(cargoIdentity))
|
||||
evidenceByDelivery[number] = evidence
|
||||
feesByDelivery[number] = canonical(detail.fees)
|
||||
}
|
||||
await check('多目的地快照汇总与实际原货物一致且不重复', canonical(allCargo) === canonical(preview.cargo.map(cargoIdentity)))
|
||||
await check('仅未覆盖部分产生本次新增应收', Math.round(newReceivable * 100)
|
||||
=== (source.mode === 'FULL' ? 0 : Math.round(Number(source.transferBody.fees[0].amount) * 100)))
|
||||
const replay = await request('POST', transferPath, body, '原费用确认成功后同键重放')
|
||||
await check('重放保持同一出库和派送集合', replay?.replayed === true && replay.outboundNo === proof.outboundNo
|
||||
&& canonical((replay.deliveries || []).map(row => row.deliveryNo)) === canonical(proof.deliveryNos))
|
||||
for (const number of proof.deliveryNos) {
|
||||
const detail = await request('GET', `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(source.customerId)}`, undefined, '重放后回查覆盖证据')
|
||||
await check('重放未改写确认人时间和原费用快照', JSON.stringify(detail.feeCoverage) === JSON.stringify(evidenceByDelivery[number]))
|
||||
await check('成功命令重放未追加费用', Array.isArray(detail.fees) && canonical(detail.fees) === feesByDelivery[number])
|
||||
}
|
||||
const after = await request('GET', `${dispatchPath}/fee-coverage`, undefined, '回查引用原费用未改变其来源事实')
|
||||
await check('原费用版本金额与状态保持不变', canonical(after.feeSources.map(feeIdentity)) === canonical(proof.originalFees))
|
||||
proof.snapshots = evidenceByDelivery
|
||||
proof.newReceivable = newReceivable
|
||||
proof.status = 'VERIFIED'
|
||||
return proof
|
||||
io.check('拒绝后无本排车出库写入', null, rows.length === total && new Set(rows.map(r => r.outboundNo)).size === rows.length && rows.every(r => r.dispatchNo !== source.dispatchNo))
|
||||
}
|
||||
|
||||
/** 两个本轮不重叠来源用例必须先准备;从不取消已扫码FULL再复用同批实物。 */
|
||||
export async function verifyFleetFeeCoverage(context, module, source, { reconcilePersisted = false, pickerToken, actualMeasurements, externalTrip } = {}) {
|
||||
const cases = context.createdResources?.fleetFeeCoverageCases
|
||||
if (!cases?.includes(source) || source.marker !== context.marker || !['FULL', 'PARTIAL'].includes(source.mode) || source.proof && !reconcilePersisted) throw Error('原费用验收需要本轮专用、未执行的排车用例;保留原请求')
|
||||
const pair = ['FULL', 'PARTIAL'].map(mode => cases.filter(c => c.marker === context.marker && c.mode === mode))
|
||||
if (pair.some(rows => rows.length !== 1) || pair[0][0].dispatchNo === pair[1][0].dispatchNo) throw Error('需本轮生产者预先准备FULL/PARTIAL两组不重叠真实排车,不能取消已扫码FULL复用同批实物')
|
||||
if (reconcilePersisted && (!source.proof?.confirmationRequest || !source.proof?.outboundNo || !source.proof?.deliveryNos?.length)) throw Error('续验须有原成功命令和已持久化单据,不可重造')
|
||||
const proof = reconcilePersisted ? source.proof : source.proof = { marker: context.marker, status: 'CHECKING', mode: source.mode, dispatchNo: source.dispatchNo, requests: [] }
|
||||
const io = fleetConsumerIo(context, module, proof, `FEE-${source.mode}`), path = `${BASE}/${encodeURIComponent(source.dispatchNo)}`
|
||||
try {
|
||||
const scopes = []
|
||||
for (const [candidate] of pair) scopes.push(await readDispatch(io, candidate.dispatchNo))
|
||||
io.check('两组真实来源和实托均不重叠', null, scopes.every(s => s.items.items.length > 0) && scopes[0].items.items.every(i => !scopes[1].items.items.some(j => id(i.warehousePalletId) === id(j.warehousePalletId) || i.sourceKey === j.sourceKey)))
|
||||
const trip = context.createdResources.fleetTrips?.find(t => t.dispatchNo === source.dispatchNo)
|
||||
io.check('本排车有本轮正式车辆审核生产回执', null, Boolean(trip))
|
||||
const loaded = await readyFleetDispatch(context, module, io, trip, { pickerToken, actualMeasurements })
|
||||
const preview = await io.get(`${path}/delivery-arrangement`, '读取原生全车费用预览')
|
||||
io.check('原生派送门禁、版本及实载证据完整', null, preview?.dispatchNo === source.dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'AFTER_LOADING'
|
||||
&& id(version(preview.recordVersion)) === id(version(loaded.data.recordVersion)) && sha(preview.cargoHash) && sha(preview.loadingCompletionHash)
|
||||
&& (reconcilePersisted ? Boolean(preview.arrangement) : preview.arrange?.allowed === true && !preview.arrangement))
|
||||
const receipt = externalTrip?.externalReceipt
|
||||
if (externalTrip && (!context.createdResources.fleetTrips.includes(externalTrip) || externalTrip.dispatchNo !== source.dispatchNo || receipt?.status !== 'VERIFIED')) throw Error('外接费用只接受本轮同主单真实运营确认')
|
||||
const plan = coveragePlan(preview, source, receipt)
|
||||
const totals = new Map()
|
||||
for (const c of preview.groups.flatMap(g => g.cargo)) totals.set(id(c.dispatchItemId), (totals.get(id(c.dispatchItemId)) || 0) + Number(c.packages))
|
||||
io.check('费用分组逐实物客户与箱数核平', null, totals.size === loaded.items.items.length && loaded.items.items.every(i => totals.get(id(i.dispatchItemId)) === Number(i.scheduledPackages)
|
||||
&& preview.groups.filter(g => g.cargo.some(c => id(c.dispatchItemId) === id(i.dispatchItemId))).every(g => id(g.customerId) === id(i.customerId) && g.destination.key === i.destination.key)))
|
||||
const body = reconcilePersisted ? proof.confirmationRequest : { recordVersion: version(preview.recordVersion), idempotencyKey: commandKey(context.marker, `FEE-${source.mode}-${source.dispatchNo}`, 'arrange'),
|
||||
cargoHash: preview.cargoHash, loadingCompletionHash: preview.loadingCompletionHash, groups: plan.groups }
|
||||
if (!reconcilePersisted) { proof.confirmationRequest = clone(body); proof.originalFees = clone(plan.originalFees); await context.checkpoint?.() }
|
||||
else io.check('原成功请求仍为同货物和实载', null, body.cargoHash === preview.cargoHash && body.loadingCompletionHash === preview.loadingCompletionHash)
|
||||
const reject = async (label, changed, pattern) => {
|
||||
const before = await readDispatch(io, source.dispatchNo)
|
||||
await io.request('POST', `${path}/transfer-delivery`, { ...clone(body), ...changed, idempotencyKey: commandKey(context.marker, `${source.dispatchNo}-${source.mode}`, label) }, label, pattern)
|
||||
for (const customerId of new Set(preview.groups.map(g => id(g.customerId)))) await noDocuments(io, { ...source, customerId }, `${label}后无单据`)
|
||||
io.check(`${label}后整车及实物零漂移`, null, JSON.stringify(await readDispatch(io, source.dispatchNo)) === JSON.stringify(before))
|
||||
}
|
||||
if (!reconcilePersisted) {
|
||||
for (const customerId of new Set(preview.groups.map(g => id(g.customerId)))) await noDocuments(io, { ...source, customerId }, '执行前确认无出库单')
|
||||
await reject('陈旧全车货物摘要拒绝', { cargoHash: `${body.cargoHash[0] === '0' ? '1' : '0'}${body.cargoHash.slice(1)}` }, /货物.*变化/)
|
||||
const stale = clone(body.groups), selected = stale.find(g => g.feeCoverage.length).feeCoverage[0]; selected.sourceVersion = selected.sourceVersion === 2147483647 ? selected.sourceVersion - 1 : selected.sourceVersion + 1
|
||||
await reject('陈旧原费用版本拒绝', { groups: stale }, /原费用版本.*变化/)
|
||||
const wrong = clone(body.groups)
|
||||
if (source.mode === 'FULL') wrong[0].fees = clone(source.transferBody.fees)
|
||||
else wrong.find(g => g.fees.length).fees = []
|
||||
await reject(source.mode === 'FULL' ? '全覆盖重复应收拒绝' : '部分覆盖漏填应收拒绝', { groups: wrong }, source.mode === 'FULL' ? /覆盖.*重复|覆盖.*登记/ : /未覆盖.*费用/)
|
||||
if (externalTrip) {
|
||||
const extra = clone(body.groups), quoted = preview.groups.find(g => g.externalQuotes.length), input = extra.find(g => g.groupKey === quoted.groupKey), quote = quoted.externalQuotes[0]
|
||||
input.fees.push({ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '本轮重复外接派送应收反例', currency: quote.currency, amount: quote.amount })
|
||||
await reject('外接已有约定运费禁止追加重复应收', { groups: extra }, /重复|覆盖.*登记/)
|
||||
}
|
||||
} else {
|
||||
const stored = await io.get(`/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(proof.outboundNo)}`, '先核对原成功出库和派送集合')
|
||||
io.check('原单据完整且未执行', null, stored?.outbound?.dispatchNo === source.dispatchNo && stored.outbound.status === 'WAIT_OUTBOUND'
|
||||
&& stableRows((stored.deliveries || []).map(d => d.deliveryNo)) === stableRows(proof.deliveryNos) && stored.deliveries.every(d => d.status === 'WAIT_DELIVERY'))
|
||||
}
|
||||
const result = await io.request('POST', `${path}/transfer-delivery`, body, '按全车真实分组确认原费用并安排派送'), arranged = result?.result
|
||||
io.check('原生命令返回完整客户出库和客户目的地派送', null, result?.dispatchNo === source.dispatchNo && result.action === 'ARRANGE_DELIVERY' && result.status === 'ACTIVE' && result.stage === 'AFTER_LOADING'
|
||||
&& BigInt(version(result.recordVersion)) > BigInt(version(body.recordVersion))
|
||||
&& arranged?.dispatchNo === source.dispatchNo && arranged.cargoHash === body.cargoHash && arranged.deliveries?.length === preview.groups.length
|
||||
&& new Set(arranged.deliveries.map(d => d.deliveryNo)).size === arranged.deliveries.length
|
||||
&& arranged.outbounds?.length === new Set(preview.groups.map(g => id(g.customerId))).size
|
||||
&& arranged.outbounds.every(o => o.outboundNo && o.status === 'WAIT_OUTBOUND' && arranged.deliveries.filter(d => d.outboundNo === o.outboundNo).reduce((n, d) => n + Number(d.packageCount), 0) === Number(o.packageCount)) && arranged.deliveries.every(d => d.deliveryNo && d.status === 'WAIT_DELIVERY'
|
||||
&& preview.groups.some(g => g.groupKey === d.groupKey && id(g.customerId) === id(d.customerId) && g.destination.key === d.destination?.key
|
||||
&& g.cargo.reduce((n, c) => n + Number(c.packages), 0) === Number(d.packageCount))
|
||||
&& arranged.outbounds.some(o => o.outboundNo === d.outboundNo && id(o.customerId) === id(d.customerId))))
|
||||
if (reconcilePersisted) io.check('仅恢复原出库和派送集合', null, result.replayed === true && stableRows(arranged.deliveries.map(d => d.deliveryNo)) === stableRows(proof.deliveryNos))
|
||||
proof.outbounds = arranged.outbounds; proof.deliveries = arranged.deliveries; proof.command = result
|
||||
proof.outboundNo = arranged.outbounds.find(o => id(o.customerId) === id(source.customerId))?.outboundNo
|
||||
proof.deliveryNos = arranged.deliveries.map(d => d.deliveryNo).sort()
|
||||
const snapshots = {}, feeRows = {}
|
||||
let added = 0n
|
||||
for (const ref of arranged.deliveries) {
|
||||
const group = preview.groups.find(g => g.groupKey === ref.groupKey), input = body.groups.find(g => g.groupKey === ref.groupKey)
|
||||
const detail = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '回查当前客户目的地费用和原费快照')
|
||||
io.check('派送只归当前客户和目的地', null, detail?.order?.dispatchNo === source.dispatchNo && detail.order.deliveryNo === ref.deliveryNo
|
||||
&& id(detail.order.customerId) === id(group.customerId) && detail.order.outboundNo === ref.outboundNo
|
||||
&& id(detail.order.destinationWarehouseCode || detail.order.destinationWarehouseId) === id(group.destination.warehouseCode || group.destination.warehouseId))
|
||||
const evidence = detail.feeCoverage, expectedCargo = group.cargo.map(cargoIdentity)
|
||||
if (input.feeCoverage.length) {
|
||||
io.check('原费快照保留真实货物、确认人、时间和每票引用', null, evidence?.cargoHash === group.feeCoverageCargoHash && evidence.operatorId && Number.isFinite(Date.parse(evidence.confirmedAt))
|
||||
&& stableRows(evidence.cargo.map(cargoIdentity)) === stableRows(expectedCargo) && Array.isArray(evidence.references)
|
||||
&& stableRows(evidence.references.map(r => ({ financeFeeId: id(r.source?.financeFeeId), sourceVersion: r.source?.sourceVersion, sourceHash: r.source?.sourceHash, services: r.services }))) === stableRows(input.feeCoverage)
|
||||
&& evidence.references.every(r => id(r.source.customerId) === id(group.customerId) && ['INBOUND_FEE', 'CONTAINER_FEE'].includes(r.source.sourceType)
|
||||
&& group.cargo.some(c => id(c.sourceInboundId) === id(r.source.sourceInboundId)) && group.feeSources.some(f => id(f.financeFeeId) === id(r.source.financeFeeId)
|
||||
&& f.currency === r.source.currency && cents(f.amount) === cents(r.source.amount) && f.status === r.source.status)))
|
||||
} else io.check('未选原费不伪造覆盖证据', null, evidence == null || evidence.references?.length === 0)
|
||||
io.check('费用为本客户明确正数应收列表', null, Array.isArray(detail.fees) && detail.fees.every(f => f.feeDirection && f.feeType))
|
||||
const receivables = detail.fees.filter(f => f.feeDirection === 'RECEIVABLE' && f.feeType === 'DELIVERY_FEE' && f.sourceStatus !== 'VOIDED')
|
||||
const expectedAmounts = [...input.fees.map(f => `${f.currency}:${cents(f.amount)}`), ...group.externalQuotes.filter(q => cents(q.amount) > 0n).map(q => `${q.currency}:${cents(q.amount)}`)].sort()
|
||||
io.check('仅未覆盖货物和原外接报价产生应收,禁止重复或零值', null, stableRows(receivables.map(f => `${f.currency}:${cents(f.amount)}`).sort()) === stableRows(expectedAmounts)
|
||||
&& receivables.every(f => cents(f.amount) > 0n && f.sourceStatus === 'ACTIVE' && f.reviewStatus === 'APPROVED'))
|
||||
added += input.fees.reduce((n, f) => n + cents(f.amount), 0n)
|
||||
snapshots[ref.deliveryNo] = detail.feeCoverage; feeRows[ref.deliveryNo] = stableRows(detail.fees)
|
||||
}
|
||||
const replayed = await replay(io, `${path}/transfer-delivery`, body, result, '费用确认原键原载荷重放')
|
||||
io.check('费用安排重放明确恢复原成功命令', null, replayed.replayed === true)
|
||||
for (const ref of arranged.deliveries) {
|
||||
const detail = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '原键重放后回查每客户费用')
|
||||
io.check('重放不改确认快照也不新增应收', null, JSON.stringify(detail.feeCoverage) === JSON.stringify(snapshots[ref.deliveryNo]) && stableRows(detail.fees) === feeRows[ref.deliveryNo])
|
||||
}
|
||||
const after = await io.get(`${path}/delivery-arrangement`, '回查原费用来源未被确认改变')
|
||||
const currentFees = after.groups.flatMap(g => g.feeSources.map(f => ({ groupKey: g.groupKey, ...feeIdentity(f) })))
|
||||
io.check('原费用版本金额状态保持不变', null, stableRows(currentFees) === stableRows(proof.originalFees))
|
||||
proof.snapshots = snapshots; proof.newReceivable = Number(added) / 100; proof.status = 'VERIFIED'; await context.checkpoint?.(); return proof
|
||||
} catch (error) { proof.status = proof.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message; await context.checkpoint?.(); throw error }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user