优化
This commit is contained in:
@@ -1,116 +1,244 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { DISPATCH_BASE as BASE, id as stringId, version, createBody, candidatePage, readDispatch, replay } from './grouped-dispatch-acceptance.mjs'
|
||||
import { fleetConsumerIo, readyFleetDispatch, stableRows, verifyFleetFeeCoverage } from './fleet-fee-coverage-acceptance.mjs'
|
||||
|
||||
const OPTIONS = { scenario: 'fleet-external-dispatch-price', deploymentBlocker: true }
|
||||
const id = value => value == null ? '' : String(value)
|
||||
const key = () => `fleet-dispatch-${randomUUID()}`
|
||||
const stable = rows => JSON.stringify(rows)
|
||||
|
||||
/** Continues a current-run VERIFIED external receipt. Leaves both dispatches ready for the caller's real PDA picking. */
|
||||
export async function verifyFleetExternalDispatch(context, module, tripProof) {
|
||||
const receipt = tripProof.externalReceipt
|
||||
if (!context.createdResources?.fleetTrips?.includes(tripProof) || receipt?.status !== 'VERIFIED' || tripProof.externalDispatch)
|
||||
throw new Error('外接关联只接受本轮已验证实收且尚未执行关联的车次')
|
||||
const proof = tripProof.externalDispatch = { status: 'CHECKING', marker: context.marker, requests: [] }
|
||||
const customerId = id(receipt.createRequest.customerId)
|
||||
const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`
|
||||
const dispatchBase = '/api/platform/v1/last-leg/dispatches'
|
||||
async function check(name, passed, response) {
|
||||
context.assert(module, name, response, Boolean(passed), '', OPTIONS)
|
||||
if (!passed) throw new Error(`${module}: ${name}`)
|
||||
const clone = value => structuredClone(value)
|
||||
const id = value => { if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('服务端ID精度无效,必须保留Long字符串'); return stringId(value) }
|
||||
const money = 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 identity = item => ({ dispatchItemId: id(item.dispatchItemId), sourceKey: item.sourceKey, customerId: id(item.customerId), warehousePalletId: id(item.warehousePalletId), scheduledPackages: Number(item.scheduledPackages), pickedPackages: Number(item.pickedPackages) })
|
||||
const groupIdentity = (customerId, destinationKey) => JSON.stringify([id(customerId), destinationKey])
|
||||
const sha = value => /^[a-f0-9]{64}$/i.test(value || '')
|
||||
const feeIdentity = fee => `${fee.direction}:${fee.feeType}:${fee.currency}:${money(fee.amount)}`
|
||||
function validateNativeArrangement(context, tripProof, plan) {
|
||||
if (plan?.mode !== 'NATIVE' || plan.marker !== context.marker || plan.dispatchNo !== tripProof.dispatchNo
|
||||
|| typeof plan.plannedDeliveryAt !== 'string' || !Number.isFinite(Date.parse(plan.plannedDeliveryAt))
|
||||
|| !plan.deliveryMethod || !plan.pickupMethod || plan.loadingType !== 'PALLET' || plan.appointmentAccount !== 'SELF'
|
||||
|| !Array.isArray(plan.groups) || !plan.groups.length) throw Error('普通原生安排须明确本轮主单、派送时间、PALLET/SELF及逐客户目的地费用')
|
||||
const keys = new Set()
|
||||
for (const group of plan.groups) {
|
||||
const key = groupIdentity(group.customerId, group.destinationKey)
|
||||
if (!/^[1-9]\d*$/.test(id(group.customerId)) || typeof group.destinationKey !== 'string' || !group.destinationKey.trim() || keys.has(key) || !Array.isArray(group.fees) || Object.hasOwn(group, 'feeCoverage')) throw Error('普通安排的客户目的地配置缺失、重复或含原费覆盖选择')
|
||||
keys.add(key)
|
||||
const fees = new Set()
|
||||
for (const fee of group.fees) {
|
||||
const feeKey = `${fee.direction}:${fee.feeType}`
|
||||
if (!['RECEIVABLE', 'PAYABLE'].includes(fee.direction) || !['DELIVERY_FEE', 'LOADING_FEE', 'APPOINTMENT_FEE'].includes(fee.feeType)
|
||||
|| !fee.feeName || !['CNY', 'USD'].includes(fee.currency) || money(fee.amount) <= 0n || fees.has(feeKey)) throw Error('普通安排费用必须逐组明确正数、币种和服务,不得重复')
|
||||
fees.add(feeKey)
|
||||
}
|
||||
}
|
||||
async function request(method, path, body, label, expectedMessage) {
|
||||
const response = await context.request(method, path, body)
|
||||
proof.requests.push({ method, path, label, http: response?.http, code: response?.body?.code,
|
||||
requestId: response?.requestId, traceId: response?.traceId })
|
||||
const conflict = response?.http === 409 || (response?.http === 200 && id(response?.body?.code) === 'A0409')
|
||||
await check(label, response?.requestId && response?.traceId && (expectedMessage
|
||||
? conflict && expectedMessage.test(id(response?.body?.message)) : response?.http === 200 && id(response?.body?.code) === '200'), response)
|
||||
return response.body?.data
|
||||
}
|
||||
|
||||
// 仅将明确的客户/目的地配置映射到实时groupKey与摘要;不推算费用或原费覆盖。
|
||||
export function nativeArrangementGroups(preview, plan, receipt) {
|
||||
const configured = new Map(plan.groups.map(g => [groupIdentity(g.customerId, g.destinationKey), g]))
|
||||
if (!Array.isArray(preview.groups) || preview.groups.length !== configured.size || new Set(preview.groups.map(g => groupIdentity(g.customerId, g.destination?.key))).size !== configured.size) throw Error('普通安排配置必须精确覆盖全车全部真实客户目的地')
|
||||
return preview.groups.map(group => {
|
||||
const config = configured.get(groupIdentity(group.customerId, group.destination?.key))
|
||||
if (!config || !group.groupKey || !sha(group.groupCargoHash) || !sha(group.externalFreightHash) || !sha(group.feeCoverageCargoHash)
|
||||
|| !Array.isArray(group.blockReasons) || group.blockReasons.length || !Array.isArray(group.requiredServices) || !Array.isArray(group.externalQuotes)
|
||||
|| !Array.isArray(group.feeSources) || !Array.isArray(group.cargo) || !group.cargo.length || group.cargo.some(c => id(c.customerId) !== id(group.customerId) || c.destination?.key !== group.destination.key)) throw Error('普通原生安排的实际分组或来源门禁不完整')
|
||||
if (group.externalQuotes.some(q => q.bookingNo !== receipt.bookingNo || q.inboundCode !== receipt.incomingNo || id(q.customerId) !== id(group.customerId)
|
||||
|| q.groupKey !== group.groupKey || q.sourceHash !== group.externalFreightHash || q.currency !== receipt.finalBooking.cargo.currency || money(q.amount) !== money(receipt.finalBooking.agreedAmount))) throw Error('普通安排的外接报价已变化或越过客户来源')
|
||||
if (group.requiredServices.some(service => !config.fees.some(f => f.direction === 'RECEIVABLE' && f.feeType === service))) throw Error('普通安排必须明确填写本组尚未覆盖的服务费用')
|
||||
const quotedAll = group.cargo.every(c => group.externalQuotes.some(q => q.inboundCode === c.inboundCode))
|
||||
if (quotedAll && config.fees.some(f => f.direction === 'RECEIVABLE' && f.feeType === 'DELIVERY_FEE')) throw Error('本组派送已由外接约定覆盖,不得重复登记应收')
|
||||
return { groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, plannedDeliveryAt: plan.plannedDeliveryAt,
|
||||
deliveryMethod: plan.deliveryMethod, pickupMethod: plan.pickupMethod, loadingType: plan.loadingType, appointmentAccount: plan.appointmentAccount,
|
||||
waybillNo: plan.waybillNo || null, carrierName: plan.carrierName || null, vehicleType: plan.vehicleType || null, vehicleSize: plan.vehicleSize || null, sellerName: null, remark: plan.remark || null,
|
||||
externalFreightHash: group.externalFreightHash, feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: [], fees: clone(config.fees) }
|
||||
})
|
||||
}
|
||||
|
||||
async function arrangeNative(context, module, io, proof, tripProof, plan, pickerToken, actualMeasurements) {
|
||||
const current = await readyFleetDispatch(context, module, io, tripProof, { pickerToken, actualMeasurements })
|
||||
const path = `${BASE}/${encodeURIComponent(tripProof.dispatchNo)}`, preview = await io.get(`${path}/delivery-arrangement`, '普通模式读取装车后实时全车派送分组')
|
||||
io.check('普通安排使用当前公共版本和真实实载摘要', null, preview?.dispatchNo === tripProof.dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'AFTER_LOADING'
|
||||
&& id(version(preview.recordVersion)) === id(version(current.data.recordVersion)) && preview.arrange?.allowed === true && !preview.arrangement && sha(preview.cargoHash) && sha(preview.loadingCompletionHash))
|
||||
const groups = nativeArrangementGroups(preview, plan, tripProof.externalReceipt), totals = new Map()
|
||||
for (const group of preview.groups) for (const row of group.cargo) {
|
||||
io.check('普通派送货物保留实际客户目的地和原物理行', null, current.items.items.some(i => id(i.dispatchItemId) === id(row.dispatchItemId) && id(i.customerId) === id(group.customerId) && i.destination.key === group.destination.key && id(i.warehousePalletId) === id(row.warehousePalletId)))
|
||||
totals.set(id(row.dispatchItemId), (totals.get(id(row.dispatchItemId)) || 0) + Number(row.packages))
|
||||
}
|
||||
const before = await request('GET', `${base}/capacity-plan`, undefined, '关联外接前冻结有效计划与预留')
|
||||
await check('实际外接仍计入承诺容量', before?.planValid && before.capacityPlan?.pallets?.length > 0
|
||||
&& stable(before.availableSegments) === stable(receipt.finalSegments))
|
||||
const candidates = await request('GET', `${dispatchBase}/candidates?${new URLSearchParams({ sourceStage: 'RECEIVED', orderNo: receipt.incomingNo })}`,
|
||||
undefined, '查询本轮真实外接来货的可排车实托')
|
||||
await check('候选精确匹配本轮客户起运仓来货及实托', Array.isArray(candidates) && candidates.length === 1
|
||||
&& id(candidates[0].customerId) === customerId && id(candidates[0].overseasWarehouseId) === id(tripProof.warehouseId)
|
||||
&& candidates[0].inboundCode === receipt.incomingNo && id(candidates[0].warehousePalletId) === receipt.warehousePalletId
|
||||
&& candidates[0].palletNo === receipt.palletNo && Number(candidates[0].packageCount) === 1
|
||||
&& candidates[0].sourceStage === 'RECEIVED' && candidates[0].planPalletId)
|
||||
const candidate = candidates[0]
|
||||
proof.draftToken = key()
|
||||
const container = await request('POST', `${dispatchBase}/drafts/containers`, { draftToken: proof.draftToken, capacity: 30 }, '建立本轮外接排车草稿')
|
||||
await check('草稿返回明确容器引用', container?.id)
|
||||
proof.draftContainerId = id(container.id)
|
||||
await request('POST', `${dispatchBase}/draft-locks/lock`, { planPalletId: id(candidate.planPalletId),
|
||||
draftToken: proof.draftToken, draftContainerId: proof.draftContainerId }, '正式锁定本轮外接实托')
|
||||
proof.preArrangeRequest = { planPalletIds: [id(candidate.planPalletId)], draftToken: proof.draftToken }
|
||||
const arranged = await request('POST', `${dispatchBase}/pre-arrange`, proof.preArrangeRequest, '从真实已收货来源生成外接客户排车')
|
||||
await check('外接排车只包含本票实收货物', arranged?.dispatchNo && arranged.dispatchNo !== tripProof.dispatchNo
|
||||
&& arranged.items?.length === 1 && id(arranged.items[0].warehousePalletId) === receipt.warehousePalletId
|
||||
&& Number(arranged.packageCount) === 1 && Number(arranged.pickedPackageCount) === 0 && Number(arranged.shippedPackageCount) === 0)
|
||||
proof.dispatchNo = arranged.dispatchNo
|
||||
const replay = await request('POST', `${dispatchBase}/pre-arrange`, proof.preArrangeRequest, '重放外接预排不生成第二张排车')
|
||||
await check('预排重放保持排车与实托', replay?.dispatchNo === proof.dispatchNo && stable(replay.items) === stable(arranged.items))
|
||||
const bookingPath = `${base}/bookings/${encodeURIComponent(receipt.bookingNo)}`
|
||||
const bookingList = await request('GET', `${base}/bookings`, undefined, '读取关联前最新预订及车次版本')
|
||||
const booking = bookingList?.records?.find(row => row.bookingNo === receipt.bookingNo)
|
||||
await check('本票仍为实收完成且未关联', booking?.status === 'RECEIVED' && booking.incomingNo === receipt.incomingNo && !booking.dispatchNo)
|
||||
proof.bindRequest = { tripRecordVersion: bookingList.tripRecordVersion, recordVersion: booking.recordVersion,
|
||||
dispatchNo: proof.dispatchNo, idempotencyKey: key() }
|
||||
const linked = await request('POST', `${bookingPath}/dispatch`, proof.bindRequest, '将本票外接真实排车关联到承诺车次')
|
||||
await check('关联后预订绑定同一排车', linked?.booking?.status === 'BOUND' && linked.booking.dispatchNo === proof.dispatchNo)
|
||||
const linkedReplay = await request('POST', `${bookingPath}/dispatch`, proof.bindRequest, '重放外接排车关联')
|
||||
await check('关联重放不增加版本或第二次占位', linkedReplay?.replayed === true
|
||||
&& id(linkedReplay.tripRecordVersion) === id(linked.tripRecordVersion) && stable(linkedReplay.booking) === stable(linked.booking))
|
||||
const after = await request('GET', `${base}/capacity-plan`, undefined, '读取新增真实货物后的计划容量')
|
||||
await check('新增外接货物使旧容量确认待重核', after?.planValid === false && after.cargo?.some(row => row.dispatchNo === proof.dispatchNo
|
||||
&& id(row.warehousePalletId) === receipt.warehousePalletId && Number(row.packages) === 1))
|
||||
const pallets = new Map(before.capacityPlan.pallets.map(row => [id(row.warehousePalletId), row]))
|
||||
const actual = receipt.finalBooking.receipt.source.pallets[0]
|
||||
pallets.set(receipt.warehousePalletId, { warehousePalletId: receipt.warehousePalletId, palletSpaces: 1,
|
||||
weightKg: Number(actual.weightKg), volumeCbm: Number(actual.volumeCbm),
|
||||
deliveryIndex: tripProof.stops.findIndex(stop => stop.destinationKey === receipt.finalBooking.cargo.destinationKey) })
|
||||
await check('新容量只引用原计划和已核实外接实测', after.cargo.every(row => pallets.has(id(row.warehousePalletId)))
|
||||
&& new Set(after.cargo.map(row => id(row.warehousePalletId))).size === pallets.size)
|
||||
const replanned = await request('POST', `${base}/capacity-plan`, { recordVersion: after.recordVersion, manifestHash: after.manifestHash,
|
||||
pallets: [...pallets.values()], reason: '外接预订转实际发运后按本轮实测重新确认,不重复计算原预留' }, '重新核对包含外接实物的全部货物容量')
|
||||
await check('从预留转实际关联后分段总占用守恒', replanned?.planValid === true && stable(replanned.availableSegments) === stable(before.availableSegments))
|
||||
let detail = await request('GET', base, undefined, '读取本车所有客户排车待审核情况')
|
||||
await check('自有货与外接客户排车均保留', detail?.dispatches?.some(row => row.dispatchNo === tripProof.dispatchNo)
|
||||
&& detail.dispatches.some(row => row.dispatchNo === proof.dispatchNo))
|
||||
for (const binding of detail.dispatches) detail = await request('POST', `${base}/dispatches/${encodeURIComponent(binding.dispatchNo)}/review`,
|
||||
{ recordVersion: detail.trip.recordVersion, decision: 'APPROVED' }, '关联变化后逐客户通过调度审核')
|
||||
await check('每张客户执行单都有通过事实', detail.dispatches.every(row => row.review?.status === 'APPROVED'))
|
||||
const dispatchPath = `${dispatchBase}/${encodeURIComponent(proof.dispatchNo)}`
|
||||
const freight = await request('GET', `${dispatchPath}/external-freight`, undefined, '读取本票实收约定运费')
|
||||
await check('外接报价精确对应本票实收和确认单价', freight?.requiresDeliveryFee === false && freight.quotes?.length === 1
|
||||
&& freight.quotes[0].bookingNo === receipt.bookingNo && freight.quotes[0].incomingNo === receipt.incomingNo
|
||||
&& freight.quotes[0].currency === 'CNY' && Number(freight.quotes[0].amount) === 30
|
||||
&& Number(freight.quotes[0].quantity) === 1 && Number(freight.quotes[0].unitPrice) === 30)
|
||||
proof.transferRequest = { idempotencyKey: key(), plannedDeliveryAt: new Date(Date.now() + 21600000).toISOString().slice(0, 19),
|
||||
deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK',
|
||||
vehicleSize: Number(detail.trip.maxPalletSpaces) <= 15 ? '15托' : '30托', vehiclePlate: detail.trip.vehiclePlate,
|
||||
driverName: detail.trip.driverName, driverPhone: detail.trip.driverPhone, appointmentAccount: 'SELF',
|
||||
remark: `${context.marker} 外接实收报价验收`, externalFreightHash: freight.snapshotHash, fees: [] }
|
||||
await request('POST', `${dispatchPath}/transfer-delivery`, { ...proof.transferRequest,
|
||||
idempotencyKey: key(), fees: [{ feeType: 'DELIVERY_FEE', feeName: '重复派送费测试', direction: 'RECEIVABLE', currency: 'CNY', amount: 30 }] },
|
||||
'全部外接已有约定运费时拒绝重复派送应收', /重复|再次/)
|
||||
const transferred = await request('POST', `${dispatchPath}/transfer-delivery`, proof.transferRequest, '按实收报价建立本票派送及外接应收来源')
|
||||
await check('外接只生成本目的地一张派送', transferred?.dispatchNo === proof.dispatchNo && transferred.outboundNo
|
||||
&& transferred.deliveries?.length === 1 && Number(transferred.packageCount) === 1)
|
||||
proof.outboundNo = transferred.outboundNo; proof.deliveryNo = transferred.deliveries[0].deliveryNo
|
||||
const deliveryPath = `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(proof.deliveryNo)}?customerId=${encodeURIComponent(customerId)}`
|
||||
const delivery = await request('GET', deliveryPath, undefined, '回查本票派送费用来源')
|
||||
const fees = delivery?.fees?.filter(row => row.feeDirection === 'RECEIVABLE' && row.feeType === 'DELIVERY_FEE')
|
||||
await check('本票只登记约定30元应收来源且没有伪零费用', fees?.length === 1 && Number(fees[0].amount) === 30 && fees[0].currency === 'CNY'
|
||||
&& fees[0].sourceStatus === 'ACTIVE' && fees[0].reviewStatus === 'APPROVED' && delivery.order?.dispatchNo === proof.dispatchNo)
|
||||
const transferReplay = await request('POST', `${dispatchPath}/transfer-delivery`, proof.transferRequest, '外接转派送原请求重放')
|
||||
const replayedDelivery = await request('GET', deliveryPath, undefined, '重放后回查外接应收未重复')
|
||||
await check('外接重放保持派送及费用来源', transferReplay?.replayed === true && transferReplay.outboundNo === proof.outboundNo
|
||||
&& stable(replayedDelivery.fees) === stable(delivery.fees))
|
||||
proof.customerId = customerId; proof.finalTripVersion = detail.trip.recordVersion
|
||||
proof.sourceFees = fees; proof.financeAcknowledgement = 'NOT_VERIFIED'
|
||||
proof.status = 'VERIFIED'
|
||||
return proof
|
||||
io.check('普通安排逐物理行箱数完整守恒', null, totals.size === current.items.items.length && current.items.items.every(i => totals.get(id(i.dispatchItemId)) === Number(i.scheduledPackages)))
|
||||
const body = { recordVersion: version(preview.recordVersion), idempotencyKey: io.key('native-arrangement'), cargoHash: preview.cargoHash, loadingCompletionHash: preview.loadingCompletionHash, groups }
|
||||
proof.nativeArrangement = { mode: 'NATIVE', confirmationRequest: clone(body), status: 'CHECKING' }; await context.checkpoint()
|
||||
const noDocuments = async () => {
|
||||
for (const customerId of new Set(preview.groups.map(g => id(g.customerId)))) {
|
||||
const rows = []; let total
|
||||
for (let page = 1; ; page++) {
|
||||
const data = await io.get(`/api/platform/v1/last-leg/outbound-orders?${new URLSearchParams({ customerId, page: String(page), size: '200' })}`, '普通安排拒绝前后按客户回查无出库写入')
|
||||
io.check('普通安排出库分页完整稳定', null, Array.isArray(data?.records) && Number.isSafeInteger(Number(data.total)) && Number(data.total) >= 0 && Number(data.page) === page && Number(data.size) === 200
|
||||
&& (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
|
||||
}
|
||||
io.check('普通安排拒绝未生成本主单出库', null, rows.length === total && new Set(rows.map(r => r.outboundNo)).size === rows.length && rows.every(r => r.dispatchNo !== tripProof.dispatchNo))
|
||||
}
|
||||
}
|
||||
const reject = async (action, changed, pattern) => {
|
||||
const before = await readDispatch(io, tripProof.dispatchNo)
|
||||
await io.request('POST', `${path}/transfer-delivery`, { ...clone(body), ...changed, idempotencyKey: io.key(action) }, action, pattern)
|
||||
await noDocuments(); io.check(`${action}不改整车版本及实物`, null, JSON.stringify(await readDispatch(io, tripProof.dispatchNo)) === JSON.stringify(before))
|
||||
}
|
||||
await noDocuments()
|
||||
const changedHash = hash => `${hash[0] === '0' ? '1' : '0'}${hash.slice(1)}`
|
||||
await reject('普通安排陈旧货物摘要拒绝', { cargoHash: changedHash(body.cargoHash) }, /货物.*变化/)
|
||||
const quoted = preview.groups.find(g => g.externalQuotes.length)
|
||||
io.check('普通安排包含本轮已确认外接报价', null, Boolean(quoted))
|
||||
const stale = clone(groups); stale.find(g => g.groupKey === quoted.groupKey).externalFreightHash = changedHash(quoted.externalFreightHash)
|
||||
await reject('普通安排陈旧外接报价摘要拒绝', { groups: stale }, /外接.*变化/)
|
||||
const duplicate = clone(groups), quote = quoted.externalQuotes[0]
|
||||
duplicate.find(g => g.groupKey === quoted.groupKey).fees.push({ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '本轮重复外接应收反例', currency: quote.currency, amount: quote.amount })
|
||||
await reject('普通安排重复外接应收拒绝', { groups: duplicate }, /重复|覆盖/)
|
||||
const command = await io.request('POST', `${path}/transfer-delivery`, body, '普通原生模式一次安排全车全部客户目的地'), result = command?.result
|
||||
io.check('普通原生命令完整返回真实客户出库和目的地派送', null, command?.dispatchNo === tripProof.dispatchNo && command.action === 'ARRANGE_DELIVERY' && command.status === 'ACTIVE' && command.stage === 'AFTER_LOADING'
|
||||
&& BigInt(version(command.recordVersion)) > BigInt(version(body.recordVersion)) && result?.dispatchNo === tripProof.dispatchNo && result.cargoHash === body.cargoHash
|
||||
&& result.deliveries?.length === preview.groups.length && new Set(result.deliveries.map(d => d.deliveryNo)).size === result.deliveries.length
|
||||
&& result.outbounds?.length === new Set(preview.groups.map(g => id(g.customerId))).size
|
||||
&& result.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)) && result.outbounds.some(o => o.outboundNo === d.outboundNo && id(o.customerId) === id(d.customerId)))
|
||||
&& result.outbounds.every(o => o.outboundNo && o.status === 'WAIT_OUTBOUND' && Number(o.packageCount) === result.deliveries.filter(d => d.outboundNo === o.outboundNo).reduce((n, d) => n + Number(d.packageCount), 0)))
|
||||
const snapshots = {}
|
||||
for (const ref of result.deliveries) {
|
||||
const group = preview.groups.find(g => g.groupKey === ref.groupKey), input = 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))}`, '普通安排回查客户目的地及明确费用')
|
||||
const expectedFees = [...input.fees.map(feeIdentity), ...group.externalQuotes.filter(q => money(q.amount) > 0n).map(q => feeIdentity({ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: q.currency, amount: q.amount }))]
|
||||
io.check('普通派送保留本客户本目的地费用,无伪零、重复或原费覆盖回执', null, detail?.order?.dispatchNo === tripProof.dispatchNo && detail.order.deliveryNo === ref.deliveryNo && id(detail.order.customerId) === id(ref.customerId)
|
||||
&& detail.order.outboundNo === ref.outboundNo && id(detail.order.destinationWarehouseCode || detail.order.destinationWarehouseId) === id(group.destination.warehouseCode || group.destination.warehouseId)
|
||||
&& (detail.feeCoverage == null || detail.feeCoverage.references?.length === 0) && Array.isArray(detail.fees) && new Set(detail.fees.map(f => id(f.id))).size === detail.fees.length
|
||||
&& detail.fees.every(f => money(f.amount) > 0n && f.sourceStatus === 'ACTIVE' && f.reviewStatus === 'APPROVED')
|
||||
&& stableRows(detail.fees.map(f => feeIdentity({ direction: f.feeDirection, feeType: f.feeType, currency: f.currency, amount: f.amount }))) === stableRows(expectedFees))
|
||||
snapshots[ref.deliveryNo] = { fees: clone(detail.fees), feeCoverage: clone(detail.feeCoverage) }
|
||||
}
|
||||
const again = await replay(io, `${path}/transfer-delivery`, body, command, '普通安排原键原载荷重放'); io.check('普通安排明确恢复原成功命令', null, again.replayed === true)
|
||||
for (const ref of result.deliveries) {
|
||||
const after = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '普通安排重放后核对不追加应收')
|
||||
io.check('普通安排重放不增费不改覆盖快照', null, JSON.stringify({ fees: after.fees, feeCoverage: after.feeCoverage }) === JSON.stringify(snapshots[ref.deliveryNo]))
|
||||
}
|
||||
const after = await io.get(`${path}/delivery-arrangement`, '普通安排后回查报价与原费用候选未被改写')
|
||||
const originalFacts = value => stableRows(value.groups.map(g => ({ groupKey: g.groupKey, externalQuotes: g.externalQuotes, feeSources: g.feeSources })))
|
||||
io.check('普通安排保持报价及原费用来源快照', null, originalFacts(after) === originalFacts(preview))
|
||||
Object.assign(proof.nativeArrangement, { status: 'VERIFIED', command, snapshots })
|
||||
return { confirmationRequest: body, deliveries: result.deliveries, outbounds: result.outbounds }
|
||||
}
|
||||
|
||||
/** 已确认实收来源只ADD_CARGO到原整车;复审、真实扫码、显式装车后一次安排全部客户。
|
||||
* options二选一:coverageCase为本轮PARTIAL;或arrangement={mode:'NATIVE',marker,dispatchNo,
|
||||
* plannedDeliveryAt,deliveryMethod,pickupMethod,loadingType:'PALLET',appointmentAccount:'SELF',
|
||||
* groups:[{customerId,destinationKey,fees:FeeItem[]}]}; 金额和费用覆盖均不自动推断。
|
||||
*/
|
||||
export async function verifyFleetExternalDispatch(context, module, tripProof, { pickerToken, actualMeasurements, coverageCase, arrangement } = {}) {
|
||||
const receipt = tripProof?.externalReceipt, cases = context.createdResources?.fleetFeeCoverageCases
|
||||
if (!context.createdResources?.fleetTrips?.includes(tripProof) || receipt?.status !== 'VERIFIED' || tripProof.externalDispatch)
|
||||
throw Error('外接只接受本轮已运营确认且尚未装入本车的真实来货')
|
||||
if (Boolean(coverageCase) === Boolean(arrangement)) throw Error('必须且只能选择同主单未扫码PARTIAL费用覆盖或普通NATIVE安排模式')
|
||||
if (arrangement) validateNativeArrangement(context, tripProof, arrangement)
|
||||
if (coverageCase && (!cases?.includes(coverageCase) || coverageCase.marker !== context.marker || coverageCase.mode !== 'PARTIAL'
|
||||
|| coverageCase.dispatchNo !== tripProof.dispatchNo || coverageCase.proof) || !pickerToken || typeof context.checkpoint !== 'function')
|
||||
throw Error('外接加货前须有同主单未扫码PARTIAL用例及本轮PDA身份;必须先外接后扫码安排')
|
||||
const proof = tripProof.externalDispatch = { marker: context.marker, dispatchNo: tripProof.dispatchNo, arrangementMode: arrangement ? 'NATIVE' : 'FEE_COVERAGE', status: 'CHECKING', requests: [] }
|
||||
const io = fleetConsumerIo(context, module, proof, `EXTERNAL-${tripProof.dispatchNo}`)
|
||||
const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`, path = `${BASE}/${encodeURIComponent(tripProof.dispatchNo)}`
|
||||
const commandReplay = async (path, body, result, label) => { const again = await replay(io, path, body, result, label); io.check(`${label}明确恢复原结果`, null, again.replayed === true) }
|
||||
try {
|
||||
let current = await readDispatch(io, tripProof.dispatchNo)
|
||||
io.check('只调整本轮同仓零扫码原主单', null, id(current.warehouseId) === id(tripProof.warehouseId) && current.data.status === 'ACTIVE'
|
||||
&& ['BEFORE_LOADING', 'LOADING'].includes(current.data.stage) && Number(current.data.counts.pickedPackageCount) === 0 && Number(current.data.counts.shippedPackageCount) === 0)
|
||||
const ownItems = current.items.items.map(identity)
|
||||
const before = await io.get(`${base}/capacity-plan`, '冻结外接预留与真实原容量计划')
|
||||
io.check('原容量和外接承诺均完整', null, before?.planValid === true && before.capacityPlan?.pallets?.length > 0 && JSON.stringify(before.availableSegments) === JSON.stringify(receipt.finalSegments))
|
||||
let list = await io.get(`${base}/bookings`, '核对运营确认后的原booking和原报价')
|
||||
const booking = list.records?.find(row => row.bookingNo === receipt.bookingNo), accepted = booking?.receipt
|
||||
io.check('实时booking保持本轮客户仓实收和运营确认', null, booking?.status === 'RECEIVED' && !booking.dispatchNo && booking.incomingNo === receipt.incomingNo
|
||||
&& id(booking.customerId) === id(receipt.createRequest?.customerId) && accepted?.source?.warehouseId === id(tripProof.warehouseId)
|
||||
&& accepted.source.incomingNo === receipt.incomingNo && accepted.source.status === 'READY_FOR_DISPATCH' && accepted.source.receiptHash
|
||||
&& accepted.source.pallets?.length > 0 && Array.isArray(accepted.pallets) && accepted.operatorId && Number.isFinite(Date.parse(accepted.confirmedAt))
|
||||
&& JSON.stringify(booking.cargo) === JSON.stringify(receipt.finalBooking.cargo) && String(booking.agreedAmount) === String(receipt.finalBooking.agreedAmount))
|
||||
const actual = accepted.source.pallets, packages = actual.reduce((n, p) => n + Number(p.packages), 0)
|
||||
io.check('真实来货托盘和箱数守恒', null, new Set(actual.map(p => id(p.warehousePalletId))).size === actual.length && packages === Number(accepted.source.receivedPackages)
|
||||
&& actual.every(p => p.warehousePalletId && p.palletNo && Number.isSafeInteger(Number(p.packages)) && Number(p.packages) > 0)
|
||||
&& actual.some(p => id(p.warehousePalletId) === id(receipt.warehousePalletId)))
|
||||
const page = await candidatePage(io, tripProof.warehouseId, { orderNo: receipt.incomingNo })
|
||||
const candidates = page.filter(c => c.inboundCode === receipt.incomingNo && id(c.customerId) === id(booking.customerId)
|
||||
&& id(c.warehouseId) === id(tripProof.warehouseId) && actual.some(p => id(p.warehousePalletId) === id(c.selection?.warehousePalletId)))
|
||||
io.check('候选仅为本次确认的全部真实外接来源', null, candidates.length === actual.length && candidates.every(c => c.destination.key === booking.cargo.destinationKey
|
||||
&& actual.some(p => id(p.warehousePalletId) === id(c.selection.warehousePalletId) && p.palletNo === c.palletNo && Number(p.packages) === Number(c.availablePackages))))
|
||||
const addition = createBody(context.marker, `EXTERNAL-${tripProof.dispatchNo}`, candidates)
|
||||
if (arrangement) {
|
||||
const actualGroups = new Set([...current.items.items.map(i => groupIdentity(i.customerId, i.destination?.key)), ...candidates.map(c => groupIdentity(c.customerId, c.destination?.key))])
|
||||
io.check('普通安排配置在加货前精确覆盖全部实际客户目的地', null, stableRows([...actualGroups]) === stableRows(arrangement.groups.map(g => groupIdentity(g.customerId, g.destinationKey))))
|
||||
}
|
||||
if (current.data.stage === 'LOADING') {
|
||||
proof.returnRequest = { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key('explicit-return'), reason: '本轮零扫码外接实收显式退回装车前,保留同一整车' }
|
||||
const returned = await io.request('POST', `${path}/return-to-review`, proof.returnRequest, '零扫码明确退回审核')
|
||||
await commandReplay(`${path}/return-to-review`, proof.returnRequest, returned, '退回审核原键重放')
|
||||
current = await readDispatch(io, tripProof.dispatchNo)
|
||||
}
|
||||
io.check('同一主单已处于装车前可加货', null, current.data.stage === 'BEFORE_LOADING')
|
||||
proof.addRequest = { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key('add-real-incoming'), cargo: addition.cargo, batchAllocations: addition.batchAllocations }
|
||||
const added = await io.request('POST', `${path}/items`, proof.addRequest, '真实已运营确认来源ADD_CARGO加入原主单')
|
||||
io.check('加货命令保持原整车身份', null, added.dispatchNo === tripProof.dispatchNo && added.action === 'ADD_CARGO')
|
||||
await commandReplay(`${path}/items`, proof.addRequest, added, '同主单加货原键重放')
|
||||
current = await readDispatch(io, tripProof.dispatchNo)
|
||||
io.check('加货和重放保留原客户物理行,仅增加本次完整来源', null, current.data.stage === 'BEFORE_LOADING'
|
||||
&& current.items.items.length === ownItems.length + candidates.length && ownItems.every(i => current.items.items.some(j => JSON.stringify(identity(j)) === JSON.stringify(i)))
|
||||
&& candidates.every(c => current.items.items.some(i => i.sourceKey === c.sourceKey && i.sourceVersion === c.sourceVersion && id(i.customerId) === id(booking.customerId)
|
||||
&& id(i.warehousePalletId) === id(c.selection.warehousePalletId) && i.inboundCode === receipt.incomingNo && Number(i.scheduledPackages) === Number(c.availablePackages) && Number(i.pickedPackages) === 0)))
|
||||
list = await io.get(`${base}/bookings`, '加货后刷新booking与真实车次版本')
|
||||
const latest = list.records.find(b => b.bookingNo === receipt.bookingNo)
|
||||
proof.bindRequest = { tripRecordVersion: version(list.tripRecordVersion), recordVersion: version(latest.recordVersion), dispatchNo: tripProof.dispatchNo, idempotencyKey: io.key('bind-same-whole') }
|
||||
const bound = await io.request('POST', `${base}/bookings/${encodeURIComponent(receipt.bookingNo)}/dispatch`, proof.bindRequest, '原booking绑定本车唯一整车主单')
|
||||
io.check('实收转关联只指向原主单', null, bound.booking?.status === 'BOUND' && bound.booking.dispatchNo === tripProof.dispatchNo && bound.booking.bookingNo === receipt.bookingNo
|
||||
&& JSON.stringify(bound.booking.cargo) === JSON.stringify(booking.cargo) && String(bound.booking.agreedAmount) === String(booking.agreedAmount))
|
||||
await commandReplay(`${base}/bookings/${encodeURIComponent(receipt.bookingNo)}/dispatch`, proof.bindRequest, bound, '同主单booking关联原键重放')
|
||||
const after = await io.get(`${base}/capacity-plan`, '真实加货和预留转关联后重新核对容量')
|
||||
io.check('实际新增货物使旧容量计划失效且没有第二张客户排车', null, after.planValid === false
|
||||
&& after.cargo.every(c => c.dispatchNo === tripProof.dispatchNo) && candidates.every(c => after.cargo.some(i => id(i.warehousePalletId) === id(c.selection.warehousePalletId) && Number(i.packages) === Number(c.availablePackages))))
|
||||
const pallets = new Map(before.capacityPlan.pallets.map(p => [id(p.warehousePalletId), clone(p)]))
|
||||
for (const p of actual) {
|
||||
const measurement = accepted.pallets.find(m => id(m.warehousePalletId) === id(p.warehousePalletId)), index = current.route.stops.findIndex(s => s.destinationKey === booking.cargo.destinationKey)
|
||||
io.check('新托仅使用实际运营确认托位和来货实测', null, measurement && Number.isSafeInteger(measurement.palletSpaces) && measurement.palletSpaces > 0
|
||||
&& Number(measurement.weightKg ?? p.weightKg) > 0 && Number(measurement.volumeCbm ?? p.volumeCbm) > 0 && index > 0)
|
||||
pallets.set(id(p.warehousePalletId), { warehousePalletId: id(p.warehousePalletId), palletSpaces: measurement.palletSpaces,
|
||||
weightKg: measurement.weightKg ?? p.weightKg, volumeCbm: measurement.volumeCbm ?? p.volumeCbm, deliveryIndex: index })
|
||||
}
|
||||
io.check('新容量逐托覆盖全部实际货物且没有额外资源', null, new Set(after.cargo.map(c => id(c.warehousePalletId))).size === pallets.size && after.cargo.every(c => pallets.has(id(c.warehousePalletId))))
|
||||
proof.capacityRequest = { recordVersion: version(after.recordVersion), manifestHash: after.manifestHash, pallets: [...pallets.values()], reason: '本轮同主单外接实收按实际运营确认和原计划量重新核对' }
|
||||
const planned = await io.request('POST', `${base}/capacity-plan`, proof.capacityRequest, '重新确认实际货物容量')
|
||||
io.check('预留转实际后分段占用守恒', null, planned.planValid === true && JSON.stringify(planned.availableSegments) === JSON.stringify(before.availableSegments))
|
||||
const trip = await io.get(base, '复审前核对资源和唯一整车关联')
|
||||
io.check('原车辆司机起运仓和唯一排车保持不变', null, trip.dispatches?.length === 1 && trip.dispatches[0].dispatchNo === tripProof.dispatchNo
|
||||
&& id(trip.trip?.warehouseId) === id(tripProof.warehouseId) && id(trip.trip.vehicleId) === id(tripProof.vehicleId) && id(trip.trip.driverId) === id(tripProof.driverId))
|
||||
tripProof.stops = trip.trip.stops
|
||||
for (const action of ['submit-review', 'dispatch-review', 'loading-review']) {
|
||||
const detail = await io.get(path, `${action}前刷新原主单版本`)
|
||||
const body = { recordVersion: version(detail.recordVersion), idempotencyKey: io.key(action), ...(action === 'submit-review' ? { remark: '本轮加入外接实物后全车重新提交' } : { decision: 'APPROVED', reason: '本轮外接实收全车重新审核' }) }
|
||||
const result = await io.request('POST', `${path}/${action}`, body, `整车${action}`)
|
||||
await commandReplay(`${path}/${action}`, body, result, `整车${action}原键重放`)
|
||||
}
|
||||
const ready = await io.get(path, '核对两级审核恢复真实扫码资格')
|
||||
io.check('同主单两级审核通过且仍零扫码', null, ready.stage === 'LOADING' && ready.reviewState === 'APPROVED' && ready.reviews?.dispatchReview?.status === 'APPROVED'
|
||||
&& ready.reviews?.loadingReview?.status === 'APPROVED' && Number(ready.counts.pickedPackageCount) === 0)
|
||||
const execution = arrangement ? await arrangeNative(context, module, io, proof, tripProof, arrangement, pickerToken, actualMeasurements)
|
||||
: await verifyFleetFeeCoverage(context, module, coverageCase, { pickerToken, actualMeasurements, externalTrip: tripProof })
|
||||
const groups = await io.get(`${path}/delivery-arrangement`, '安排后回查原生外接报价快照')
|
||||
const quotes = groups.groups.flatMap(g => g.externalQuotes), expected = receipt.finalBooking
|
||||
const matching = quotes.filter(q => q.bookingNo === receipt.bookingNo && q.inboundCode === receipt.incomingNo && id(q.customerId) === id(booking.customerId))
|
||||
io.check('原外接报价属于本次客户和来源且等于运营确认约定金额', null, matching.length === 1 && money(matching[0].amount) === money(expected.agreedAmount)
|
||||
&& matching[0].currency === expected.cargo.currency && matching[0].sourceHash && money(expected.agreedAmount) > 0n)
|
||||
const deliveries = execution.deliveries.filter(d => groups.groups.some(g => g.groupKey === d.groupKey && g.externalQuotes.some(q => q.bookingNo === receipt.bookingNo)))
|
||||
io.check('本次外接来源只落入其客户目的地派送', null, deliveries.length === 1 && id(deliveries[0].customerId) === id(booking.customerId))
|
||||
proof.outboundNo = deliveries[0].outboundNo; proof.deliveryNo = deliveries[0].deliveryNo; proof.deliveries = execution.deliveries
|
||||
const delivery = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(proof.deliveryNo)}?customerId=${encodeURIComponent(id(booking.customerId))}`, '回查本客户外接应收来源')
|
||||
const sources = await io.get(`/api/platform/v1/last-leg/finance-fee-sources?${new URLSearchParams({ customerId: id(booking.customerId), businessType: 'DELIVERY_ORDER', businessNo: proof.deliveryNo })}`, '按本次客户派送业务号回查外接应收来源')
|
||||
const fees = Array.isArray(sources) ? sources.filter(f => f.direction === 'RECEIVABLE' && f.feeItemCode === 'DELIVERY_FEE' && f.sourceStatus === 'ACTIVE' && f.inboundCode === receipt.incomingNo) : []
|
||||
const quantity = expected.cargo.priceBasis === 'PALLET' ? expected.cargo.palletSpaces : expected.cargo.priceBasis === 'ORDER' ? 1 : null
|
||||
io.check('外接只登记本次约定金额应收且来源已审核', null, fees.length === 1 && money(fees[0].originalAmount) === money(expected.agreedAmount)
|
||||
&& fees[0].originalCurrency === expected.cargo.currency && fees[0].reviewStatus === 'APPROVED' && id(fees[0].customerId) === id(booking.customerId)
|
||||
&& quantity != null && Number(fees[0].chargeableQuantity) === Number(quantity) && money(fees[0].unitPrice) === money(expected.cargo.unitPrice)
|
||||
&& fees[0].quantityUnit === (expected.cargo.priceBasis === 'PALLET' ? '托位' : '单')
|
||||
&& fees[0].businessNo === proof.deliveryNo && delivery.fees.some(f => id(f.id) === id(fees[0].id)))
|
||||
proof.customerId = id(booking.customerId); proof.sourceFees = fees; proof.transferRequest = execution.confirmationRequest
|
||||
proof.finalTripVersion = (await io.get(base, '交接同车资源版本')).trip.recordVersion
|
||||
proof.financeAcknowledgement = 'NOT_VERIFIED'; proof.status = 'VERIFIED'; await context.checkpoint?.(); return proof
|
||||
} catch (error) { proof.status = proof.outcomeUnknown || context.createdResources.dispatchWriteBoundary?.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message; await context.checkpoint?.(); throw error }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user