补齐海外仓和私人地址正常及短少收货真实验收

This commit is contained in:
2026-11-04 14:23:41 +08:00
parent 40475388e1
commit fbdf58c23c
7 changed files with 341 additions and 27 deletions
@@ -12,7 +12,7 @@ import { run as verifyCarrierQuery } from './phase3-carrier-schedule-queries.mjs
import { runUntilPickupReady } from './phase4-carrier-nodes.mjs'
import { resolvePickerAccess, createPicker, disablePicker, uploadPhotos } from './phase4-overseas-pda.mjs'
import { receivingProgressMatches, outboundCandidateGuards, outboundCreationGuards, outboundTaskGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs'
import { receiveMixedContainer } from './mixed-container-receiving.mjs'
import { receiveMixedContainer, receiveOverseasComplete, receiveOverseasShortage } from './mixed-container-receiving.mjs'
import { exerciseOldCabinets } from './pda-loading-link-regression.mjs'
import { selectSessionPdaOperator } from './domestic-pda-operator.mjs'
@@ -20,6 +20,14 @@ const MODULE = 'dropship-pallet-matrix'
const BASE = '/api/platform/v1/first-leg/pda-dropship'
const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
export function loadingUnitTypesMatch(group, items) {
const expected = new Set(group.map(record => record.order.fulfillmentType === 'ONE_CLICK_DROPSHIP'
? 'SEALED_PALLET' : 'PACKAGE'))
const actual = new Set(items.map(item => item.unitType))
return items.length > 0 && actual.size === expected.size
&& [...actual].every(type => expected.has(type))
}
export function sameOutboundTask(left, right) {
const databaseTime = (value) => {
const match = typeof value === 'string' && value.match(/^(.*T\d{2}:\d{2}:\d{2})(?:\.(\d+))?$/)
@@ -155,6 +163,25 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
const pallets = [...new Map(group.flatMap((item) => item.task?.pallets || [])
.map((pallet) => [String(pallet.palletId), pallet])).values()]
for (const record of group) {
if (record.order.destinationType === 'PRIVATE') {
const path = `/api/platform/v1/inbounds/${record.source.inboundId}/fees/manual-quote?tenantId=${encodeURIComponent(record.order.tenantId)}`
const body = {
selectedWeightKg: Number(record.summary.actualNetWeightKg || record.summary.actualGrossWeightKg),
selectedVolumeCbm: Number(record.summary.actualVolumeCbm), unitPrice: 1,
reason: `${marker}私人地址验收测试人工报价`,
}
check('私人地址人工报价采用实际测量重量和体积', null,
body.selectedWeightKg > 0 && body.selectedVolumeCbm > 0)
const headers = { 'Idempotency-Key': `${marker}-${record.label}-manual-quote` }
const quoted = await command('私人地址人工头程报价', 'POST', path, body, headers)
check('私人地址报价形成待分柜正式费用快照', null,
quoted.rateCode === 'MANUAL_QUOTE' && quoted.feeStatus === 'CALCULATED'
&& quoted.nextProcessStatus === 'WAIT_SPLIT' && Number(quoted.unitPrice) === body.unitPrice)
const replay = await command('私人地址人工报价原键重放', 'POST', path, body, headers)
check('私人地址报价重放保持同一费用版本', null, JSON.stringify(replay) === JSON.stringify(quoted))
record.manualQuote = { feeId: quoted.feeId, feeVersion: quoted.feeVersion,
unitPrice: body.unitPrice, purpose: 'TEST_ACCEPTANCE' }
}
const fee = await ensureInitialCalculated(context, { ...record.measurement,
tenantId: String(record.order.tenantId), inboundId: record.source.inboundId,
finalStatus: 'APPROVED', processStatus: record.summary.processStatus }, OPTIONS)
@@ -296,10 +323,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
? { action: 'PICK_PALLET', palletNo: item.palletNo }
: { action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount) }) })),
}
check(`${label}PDA全部任务均为封托单元`, null,
claimed.items.length > 0 && (mixedContainer
? claimed.items.some(item => item.unitType === 'SEALED_PALLET') && claimed.items.some(item => item.unitType !== 'SEALED_PALLET')
: claimed.items.every((item) => item.unitType === 'SEALED_PALLET')))
check(`${label}PDA任务单元与实际履约类型一致`, null, loadingUnitTypesMatch(group, claimed.items))
let picked = claimed
if (linkRegression) {
for (const palletNo of [...new Set(claimed.items.map(item => item.palletNo))]) {
@@ -426,6 +450,16 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
summary.orders.some((order) => order.inboundCode === record.source.inboundCode
&& String(order.customerId) === String(record.order.tenantId))),
`containerNo=${loading.containerNo}, expectedOrders=${group.length}, actualOrders=${summary?.orders?.length ?? 'unavailable'}`)
if (mixedContainer && group.length === 3) {
const overseas = group.find(record => record.order.fulfillmentType === 'STANDARD' && record.order.destinationType === 'OVERSEAS')
const order = summary.orders.find(item => item.inboundCode === overseas.source.inboundCode)
const detail = await call('读取普通海外仓订单当前版本', 'GET', `${base}/orders/${order.orderId}?customerId=${encodeURIComponent(order.customerId)}`)
check('普通海外仓人工计划采用当前订单版本', null, Number.isInteger(detail.order?.version))
await call('普通海外仓四箱预留两托', 'POST', `${base}/inbound-plans/manual`, {
customerId: String(order.customerId), orderId: String(order.orderId), expectedOrderVersion: detail.order.version,
palletCount: 2, palletType: 'WOOD', assigned: false, mergeRequired: false, urgent: false, remark: marker,
}, { 'Idempotency-Key': `${marker}-${label}-overseas-two-pallets` })
}
const preview = await call('入库计划预览', 'GET', `${base}/inbound-plans/container-preview?sourceContainerBookingId=${bookingId}`)
check(`${label}全柜入库计划就绪`, null, preview.ready === true && Number(preview.totalOrderCount) === group.length)
const body = { sourceContainerBookingId: bookingId, snapshotVersion: Number(preview.snapshotVersion),
@@ -437,6 +471,13 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
const matching = plans.filter((plan) => plan.lines?.some((line) => line.inboundCode === record.source.inboundCode))
check(`${record.label}计划精确关联来源入仓码`, null, matching.length === 1)
const target = matching[0]
if (record.order.destinationType === 'PRIVATE') {
const source = summary.orders.find(order => order.inboundCode === record.source.inboundCode)
const detail = await call('读取私人地址尾程收货类型', 'GET',
`${base}/orders/${source.orderId}?customerId=${encodeURIComponent(source.customerId)}`)
check(`${record.label}私人地址进入普通海外收货模式`, null,
detail.order.destinationType === 'OVERSEAS' && detail.order.orderType === 'FIRST_LEG')
}
const expectedNos = record.task?.pallets.map((pallet) => pallet.palletNo).sort()
const actualNos = (target.pallets || []).map((pallet) => pallet.palletNo).sort()
check(`${record.label}来源托盘完整绑定海外真实资源`, null,
@@ -459,6 +500,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
async function receiveOverseas(group) {
const record = group[0]
const label = record.label
const sourceContainerBookingId = String(record.loading.cabinet.id)
// The PDA account helper keeps the marker tail; retain the run identity there.
context.marker = `${label}-RECEIVE-${marker}`
const base = '/api/pda/v1/last-leg/receiving-containers'
@@ -476,19 +518,32 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
let candidate
for (let attempt = 0; attempt < 60; attempt++) {
const response = await context.request('GET', `${base}/candidates`, undefined, picker.token, {})
candidate = response?.body?.data?.find?.((item) => item.containerNo === record.loading.containerNo)
candidate = response?.body?.data?.find?.((item) => item.containerNo === record.loading.containerNo
&& String(item.sourceContainerBookingId) === sourceContainerBookingId)
if (candidate?.operable && candidate?.allPlansReady) break
if (attempt < 59) await new Promise((resolve) => setTimeout(resolve, 1000))
}
check(`${label}本柜船节点和入库计划已跨区收敛`, null,
candidate?.operable === true && candidate?.allPlansReady === true)
await call('准备拆柜会话', 'POST', `${base}/${record.loading.containerNo}/prepare`,
await call('准备拆柜会话', 'POST', `${base}/${record.loading.containerNo}/prepare?sourceContainerBookingId=${encodeURIComponent(sourceContainerBookingId)}`,
{ idempotencyKey: `${context.marker}-prepare` })
const photos = await uploadPhotos(context, picker.token)
check(`${label}三类卸柜照片齐全`, null, photos?.length === 3)
const signed = await call('照片绑定并签收', 'POST', `${base}/current/sign`,
{ photos, idempotencyKey: `${context.marker}-sign` })
check(`${label}签收成功`, null, signed.status === 'SIGNED')
const adminCall = async (name, method, path, body, headers) => {
const response = await context.request(method, path, body, context.token, headers)
const data = context.apiSuccess(MODULE, `${label}${name}`, response, '', OPTIONS)
check(`${label}${name}成功`, response, response?.body?.code === '200' && data != null)
return data
}
if (mixedContainer && group.length === 1) {
const receive = record.label === 'PRIVATE-COMPLETE' ? receiveOverseasComplete : receiveOverseasShortage
record.overseas.receiving = await receive({ record, call, adminCall, check, marker: context.marker,
request: (method, path, body) => context.request(method, path, body, picker.token, {}) })
return
}
if (mixedContainer) {
const completed = await receiveMixedContainer({ group, call, check, marker: context.marker,
request: (method, path, body) => context.request(method, path, body, picker.token, {}) })
@@ -502,6 +557,18 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
&& row.locationId && item.overseas.plan.pallets.some(p => String(p.warehousePalletId) === String(row.palletId)))
&& stock.items.reduce((sum, row) => sum + Number(row.availablePackages), 0) === item.expectedCartonMarks.length)
item.overseas.receiving = { completed, stock, receivingVerified: true }
if (item.order.fulfillmentType === 'STANDARD' && item.order.destinationType === 'OVERSEAS') {
const plan = await adminCall('回查普通海外仓现场逐托实收', 'GET', `/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(item.overseas.plan.planNo)}?customerId=${encodeURIComponent(item.order.tenantId)}`)
check('普通海外仓计划二加二可按三加一实收', null,
JSON.stringify(plan.allocations.map(row => Number(row.plannedPackageCount)).sort()) === '[2,2]'
&& JSON.stringify(plan.pallets.map(row => Number(row.actualPackageCount)).sort()) === '[1,3]')
for (const pallet of plan.pallets) {
const label = await context.requestBinary('GET', `/api/platform/v1/overseas-warehouse-resources/${item.overseas.warehouseId}/pallets/${pallet.warehousePalletId}/label`)
check('普通海外仓预留托盘标签返回有效PDF', label,
label.http === 200 && label.contentType.toLowerCase().includes('application/pdf')
&& label.bytes.length > 1000 && new TextDecoder().decode(label.bytes.slice(0, 5)) === '%PDF-')
}
}
}
return
}
@@ -666,6 +733,16 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
await progressShipping(group)
await convertOverseas(group)
await receiveOverseas(group)
const shortage = await prepare('PRIVATE-SHORTAGE', 1, {}, 'PRIVATE')
await allocate([shortage])
await progressShipping([shortage])
await convertOverseas([shortage])
await receiveOverseas([shortage])
const complete = await prepare('PRIVATE-COMPLETE', 1, {}, 'PRIVATE')
await allocate([complete])
await progressShipping([complete])
await convertOverseas([complete])
await receiveOverseas([complete])
context.createdResources.dropshipPalletMatrix.stage = 'MIXED_CONTAINER_INBOUND_COMPLETED'
return
}