This commit is contained in:
2026-10-22 11:56:47 +08:00
parent 5d71af1ac9
commit de96e47d74
71 changed files with 1296 additions and 332 deletions
@@ -133,14 +133,6 @@ async function merchant(context, token, method, name, path, body = undefined) {
return { response, data }
}
function transferDeliveries(data) {
return Array.isArray(data?.result?.deliveries) ? data.result.deliveries.filter((value) => nonBlank(value?.deliveryNo)) : []
}
function deliveryNumbers(data) {
return transferDeliveries(data).map((value) => id(value.deliveryNo)).sort()
}
export function dispatchDestinationConservation(detail, fixture, deliveries = null) {
const codes = values => values.map(value => id(value).trim().toUpperCase())
const sameSet = (left,right) => left.length > 0 && right.length > 0 && left.every(nonBlank)
@@ -858,7 +850,7 @@ async function verifyRelabelDispatchOutbound(context, relabel, token, warehouseI
const proof = await prepareFleetTrip(context, MODULE, relabel.dispatchNo, warehouseId)
if (!await pickAll(context, token, relabel.dispatchNo)) return
await confirmFleetLoading(context, MODULE, proof, token, relabel.loadingMeasurements)
const transferred = await arrangeDispatchDelivery(context, relabel.dispatchNo)
const transferred = await readAutomaticDispatchDelivery(context, relabel.dispatchNo)
if (!transferred) return
const customerId = id(relabel.customerId)
const outboundNo = transferred.outbounds.find(row => id(row.customerId) === customerId)?.outboundNo
@@ -869,7 +861,7 @@ async function verifyRelabelDispatchOutbound(context, relabel, token, warehouseI
success(detail.response) && detail.data?.outbound?.outboundNo === outboundNo
&& detail.data?.outbound?.dispatchNo === relabel.dispatchNo
&& relabelOutboundMarksMatch(detail.data, relabel), trace(detail.response), OPTIONS)
Object.assign(relabel, { outboundNo, deliveryNos: deliveryNumbers(transferred.data) })
Object.assign(relabel, { outboundNo, deliveryNos: transferred.deliveries.map(row => row.deliveryNo).sort() })
}
async function loginMerchant(context, customerId) {
@@ -909,17 +901,18 @@ function fixture(context) {
return { dispatch, receiving }
}
export async function verifyDepartureReadiness(context, token, dispatchNo) {
const ready = await pda(context, token, 'GET', '发车前回查PDA公共动作门禁',
export async function verifyAutomaticDeparture(context, token, dispatchNo) {
const ready = await pda(context, token, 'GET', '回查PDA自动发车结果',
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
const dispatch = await platform(context, 'GET', '发车前独立回查实际排车阶段',
const dispatch = await platform(context, 'GET', '独立回查平台自动发车结果',
`${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
const passed = success(ready.response) && success(dispatch.response)
&& [ready.data, dispatch.data].every(data => data?.dispatchNo === dispatchNo
&& data.status === 'ACTIVE' && data.stage === 'AFTER_LOADING'
&& data.pickingReconciled === true && data.actions?.DEPART?.allowed === true)
&& data.status === 'ACTIVE' && data.stage === 'DELIVERING'
&& data.pickingReconciled === true && Number(data.counts?.packageCount) > 0
&& Number(data.counts?.shippedPackageCount) === Number(data.counts?.packageCount))
&& String(ready.data.recordVersion) === String(dispatch.data.recordVersion)
context.assert(MODULE, '双端同版本装车后且服务端发车门禁通过', ready.response,
context.assert(MODULE, '双端自动发车阶段与版本一致', ready.response,
passed, `stage=${ready.data?.stage}, dispatchStage=${dispatch.data?.stage}; ${trace(ready.response)}`, OPTIONS)
if (passed) version(ready.data.recordVersion)
return passed ? ready : null
@@ -983,56 +976,30 @@ export async function pickAll(context, token, dispatchNo) {
return passed
}
export async function arrangeDispatchDelivery(context, dispatchNo) {
const path = `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`
const preview = await platform(context, 'GET', '装车后读取全车真实派送分组与费用证据', `${path}/delivery-arrangement`)
const value = preview.data
if (!success(preview.response) || value?.dispatchNo !== dispatchNo || value.status !== 'ACTIVE'
|| value.stage !== 'AFTER_LOADING' || value.arrange?.allowed !== true
|| !nonBlank(value.cargoHash) || !nonBlank(value.loadingCompletionHash)
|| !value.groups?.length || value.groups.some(group => !nonBlank(group.groupKey)
|| !nonBlank(id(group.customerId)) || !nonBlank(group.destination?.key)
|| !group.cargo?.length || !Array.isArray(group.blockReasons) || group.blockReasons.length)) {
blocker(context, '全车派送安排', '装车后正式预览或完整客户目的地分组门禁未满足', preview.response)
return null
}
const body = { recordVersion: version(value.recordVersion), idempotencyKey: key(context, 'TRANSFER', dispatchNo),
cargoHash: value.cargoHash, loadingCompletionHash: value.loadingCompletionHash,
groups: value.groups.map((group, index) => ({ groupKey: group.groupKey, groupCargoHash: group.groupCargoHash,
plannedDeliveryAt: new Date(Date.now() + 172_800_000).toISOString(),
deliveryMethod: 'TRUCK_PALLET', waybillNo: `${context.marker}-${index + 1}`.slice(0, 64),
carrierName: '本轮TEST排车', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET',
vehicleType: 'TRUCK', vehicleSize: '按本轮真实实载', appointmentAccount: 'SELF', sellerName: null,
remark: `${context.marker}-phase5-arrangement`, externalFreightHash: group.externalFreightHash,
feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: [],
fees: (group.requiredServices || []).map(service => ({ feeType: service, feeName: service,
direction: 'RECEIVABLE', currency: 'USD', amount: 10, remark: `${context.marker}-controlled-test-fee` })) })) }
const transferred = await platform(context, 'POST', '平台一次安排全车全部客户目的地派送', `${path}/transfer-delivery`, body)
if (!success(transferred.response)) return null
const result = transferred.data?.result
const deliveries = transferDeliveries(transferred.data)
const outbounds = result?.outbounds
const customerGroups = value.groups.map(group => `${id(group.customerId)}:${group.destination.key}`).sort()
const actualGroups = deliveries.map(row => `${id(row.customerId)}:${row.destination?.key}`).sort()
const passed = transferred.data?.dispatchNo === dispatchNo && transferred.data?.status === 'ACTIVE'
&& transferred.data?.stage === 'AFTER_LOADING' && result?.dispatchNo === dispatchNo
&& result.cargoHash === body.cargoHash && deliveries.length === value.groups.length
export async function readAutomaticDispatchDelivery(context, dispatchNo) {
const current = await platform(context, 'GET', '回查装车后自动派送单据', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
const value = current.data, deliveries = value?.documents?.deliveries, outbounds = value?.documents?.outbounds
const positive = count => (typeof count === 'number' || typeof count === 'string')
&& /^[1-9][0-9]*$/.test(String(count)) && Number.isSafeInteger(Number(count))
const passed = success(current.response) && value?.dispatchNo === dispatchNo && value.status === 'ACTIVE'
&& value.stage === 'DELIVERING' && value.pickingReconciled === true && positive(value.counts?.packageCount)
&& positive(value.counts.pickedPackageCount) && positive(value.counts.shippedPackageCount)
&& Number(value.counts.pickedPackageCount) === Number(value.counts.packageCount)
&& Number(value.counts.shippedPackageCount) === Number(value.counts.packageCount)
&& Array.isArray(deliveries) && deliveries.length > 0 && Array.isArray(outbounds) && outbounds.length > 0
&& new Set(deliveries.map(row => row.deliveryNo)).size === deliveries.length
&& JSON.stringify(customerGroups) === JSON.stringify(actualGroups)
&& Array.isArray(outbounds) && outbounds.length === new Set(value.groups.map(group => id(group.customerId))).size
&& outbounds.every(row => nonBlank(row.outboundNo) && row.status === 'WAIT_OUTBOUND'
&& deliveries.filter(delivery => delivery.outboundNo === row.outboundNo).every(delivery => id(delivery.customerId) === id(row.customerId))
&& new Set(outbounds.map(row => row.outboundNo)).size === outbounds.length
&& new Set(outbounds.map(row => id(row.customerId))).size === outbounds.length
&& new Set(deliveries.map(row => JSON.stringify([id(row.customerId), row.destination?.key]))).size === deliveries.length
&& deliveries.every(row => nonBlank(row.deliveryNo) && nonBlank(id(row.customerId)) && nonBlank(row.destination?.key)
&& row.status === 'DELIVERING' && positive(row.packageCount)
&& outbounds.some(outbound => outbound.outboundNo === row.outboundNo && id(outbound.customerId) === id(row.customerId)))
&& outbounds.every(row => nonBlank(row.outboundNo) && row.status === 'OUTBOUNDED' && positive(row.packageCount)
&& deliveries.filter(delivery => delivery.outboundNo === row.outboundNo).reduce((sum, delivery) => sum + Number(delivery.packageCount), 0) === Number(row.packageCount))
&& deliveries.every(row => row.status === 'WAIT_DELIVERY')
context.assert(MODULE, '派送按客户出库及客户目的地完整分组并数量守恒', transferred.response, passed, trace(transferred.response), OPTIONS)
&& deliveries.reduce((sum, row) => sum + Number(row.packageCount), 0) === Number(value.counts.packageCount)
context.assert(MODULE, '自动派送客户归属、单据状态和实发数量守恒', current.response, passed, trace(current.response), OPTIONS)
if (!passed) return null
const replay = await platform(context, 'POST', '全车安排派送原命令重放', `${path}/transfer-delivery`, body)
const stable = success(replay.response) && replay.data?.replayed === true
&& String(replay.data.recordVersion) === String(transferred.data.recordVersion)
&& JSON.stringify(replay.data.result) === JSON.stringify(result)
context.assert(MODULE, '安排重放返回原命令结果且不增单据', replay.response, stable, trace(replay.response), OPTIONS)
if (!stable) return null
return { ...transferred, preview: value, outbounds, deliveries }
return { ...current, outbounds, deliveries }
}
/** 只从本次派送的真实主单及其原出库来源创建退回请求,不要求普通入仓关联。 */
@@ -1146,40 +1113,26 @@ export async function run(context) {
const primaryInventory = proofs.find(proof => proof.customerId === customerId)
if (primaryInventory) context.createdResources.phase5OverseasFulfillment.inventoryProof = { ...primaryInventory, ...primaryInventory.conservation }
await confirmFleetLoading(context, MODULE, tripProof, picker.token, source.dispatch.loadingMeasurements)
transferred = await arrangeDispatchDelivery(context, dispatchNo)
transferred = await readAutomaticDispatchDelivery(context, dispatchNo)
if (!transferred) return
deliveries = transferred.deliveries; outbounds = transferred.outbounds
routeProof.deliveries = deliveries
context.assert(MODULE, '转派送客户目的地与本轮冻结来源守恒', transferred.response,
dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries), trace(transferred.response), OPTIONS)
if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries)) return
const ready = await verifyDepartureReadiness(context, picker.token, dispatchNo)
const ready = await verifyAutomaticDeparture(context, picker.token, dispatchNo)
if (!ready) return
const departBody = { recordVersion: version(ready.data.recordVersion),
idempotencyKey: key(context, 'DISPATCH-DEPART', dispatchNo).slice(0, 128), remark: `${context.marker}-depart` }
const departed = await pda(context, picker.token, 'POST', 'PDA实际发车并完成真实出库',
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody)
const expectedPackages = proofs.reduce((sum, proof) => sum + proof.expectedPackages, 0)
const departedCorrectly = success(departed.response) && departed.data?.dispatchNo === dispatchNo
&& departed.data.status === 'ACTIVE' && departed.data.stage === 'DELIVERING'
&& Number(departed.data.result?.shippedPackages) === expectedPackages
&& JSON.stringify(deliveryNumbers(departed.data)) === JSON.stringify(deliveries.map(row => row.deliveryNo).sort())
context.assert(MODULE, '实际发车进入DELIVERING且全部客户实发数量守恒', departed.response, departedCorrectly, trace(departed.response), OPTIONS)
if (!departedCorrectly) return
const departReplay = await pda(context, picker.token, 'POST', 'PDA发车原命令重放',
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody)
const replayStable = success(departReplay.response) && departReplay.data?.replayed === true
&& String(departReplay.data.recordVersion) === String(departed.data.recordVersion)
&& JSON.stringify(departReplay.data.result) === JSON.stringify(departed.data.result)
context.assert(MODULE, 'PDA发车重放保持原结果及公共版本', departReplay.response, replayStable, trace(departReplay.response), OPTIONS)
if (!replayStable) return
if (Number(ready.data.counts?.shippedPackageCount) !== expectedPackages) {
blocker(context, '自动发车数量', '实发数量与本轮扫码来源不一致', ready.response); return
}
for (const proof of proofs) {
proof.stockAfterDepart = await inventorySnapshot(context, proof.customerId, warehouseId, proof.inboundCodes,
`客户${proof.customerId}发车后库存`, proof.sourceBalanceIds)
proof.ledgerAfterDepart = await shipmentLedger(context, proof.customerId, dispatchNo, `客户${proof.customerId}发车后SHIP`, 'SHIP', dispatchNo)
const unchanged = inventorySourcesPresent(proof.stockAfterDepart, proof.sourcePositionBalances)
&& sameSnapshot(proof.stockAfter, proof.stockAfterDepart) && sameSnapshot(proof.ledgerAfter, proof.ledgerAfterDepart)
context.assert(MODULE, '发车及重放库存和SHIP零额外扣减', departReplay.response, unchanged, trace(departReplay.response), OPTIONS)
context.assert(MODULE, '发车及重放库存和SHIP零额外扣减', ready.response, unchanged, trace(ready.response), OPTIONS)
if (!unchanged) return
}
// 换标副场景仍核对真实新箱唛快照,执行同一原生装车前置。