This commit is contained in:
2026-09-15 14:18:18 +08:00
parent 6a2075fe4b
commit a474ca4d93
35 changed files with 751 additions and 228 deletions
@@ -11,7 +11,8 @@ import { run as completeCustoms } from './phase3-customs.mjs'
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 { outboundCreationGuards, outboundTaskGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs'
import { receivingProgressMatches, outboundCandidateGuards, outboundCreationGuards, outboundTaskGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs'
import { receiveMixedContainer } from './mixed-container-receiving.mjs'
const MODULE = 'dropship-pallet-matrix'
const BASE = '/api/platform/v1/first-leg/pda-dropship'
@@ -37,7 +38,11 @@ export async function prepareBrowserFixture(context) {
return runWorkflow(context, true)
}
async function runWorkflow(context, stopBeforeOverseasPda) {
export async function runMixedContainer(context) {
return runWorkflow(context, false, true)
}
async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = false) {
const marker = context.marker
const savedOptions = context.options.prepareDropshipManualCount
const savedContext = { ...context.context }
@@ -48,25 +53,40 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
context.assert(MODULE, name, response, passed, details, OPTIONS)
if (!passed) throw new Error(name)
}
async function prepare(label, palletCount, mode = {}) {
async function prepare(label, palletCount, mode = {}, destinationType = null) {
context.marker = `${marker}-${label}`
for (const key of ['orders', 'inboundMeasurement', 'ocrInbound', 'testInbound', 'dropshipManualCount']) {
delete context.createdResources[key]
}
for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key]
context.context.orderDestinationType = 'OVERSEAS'
context.context.orderFulfillmentType = 'ONE_CLICK_DROPSHIP'
context.context.orderDestinationType = destinationType || 'OVERSEAS'
context.context.orderFulfillmentType = destinationType ? 'STANDARD' : 'ONE_CLICK_DROPSHIP'
context.context.orderProfile = { taxMode: 'BUY_DECLARATION', pickupMethod: 'SELF_EXPRESS', containerMode: 'LCL' }
context.context.waitSplitVariant = { skuCount: 1, packageCountPerSku: 4, packageCounts: [4], palletCount }
if (label.startsWith('MULTI-MERGE-')) {
context.context.waitSplitVariant.skuCodes = [`SKU-SHARED-${marker}`.slice(0, 64)]
}
context.options.prepareDropshipManualCount = true
context.options.prepareDropshipManualCount = !destinationType
const start = context.steps.length
await context.executeModule(`${MODULE}-${label}-order`, () => createOrder(context))
await context.executeModule(`${MODULE}-${label}-measurement`, () => measure(context))
check(`${label}本轮订单和测量前置全部通过`, null,
!context.steps.slice(start).some((step) => !step.passed))
if (destinationType) {
const measurement = { ...context.createdResources.inboundMeasurement }
const order = { ...context.createdResources.orders }
const response = await context.request('GET',
`/api/platform/inbounds/${measurement.inboundId}?tenantId=${measurement.tenantId}`)
const detail = context.apiSuccess(MODULE, `${label}普通订单待分柜快照`, response, '', OPTIONS)
check(`${label}普通订单四箱待分柜且类型未漂移`, response,
detail?.summary?.processStatus === 'WAIT_SPLIT' && measurement.actualPackageCount === 4
&& order.fulfillmentType === 'STANDARD' && order.destinationType === destinationType)
const record = { label, order, measurement, summary: detail.summary, task: null,
source: { inboundId: String(measurement.inboundId), inboundCode: detail.summary.inboundCode },
expectedCartonMarks: Array.from({ length: 4 }, (_, index) => `${detail.summary.inboundCode}#4-${index + 1}`) }
records.push(record)
return record
}
const source = context.createdResources.dropshipManualCount
check(`${label}存在本轮未清点任务`, null, Boolean(source?.taskId))
const task = await completeDropshipPalletization(context, {
@@ -112,11 +132,11 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
check(`${label}${name}成功`, response, response?.body?.code === '200' && Boolean(value))
return value
}
const pallets = [...new Map(group.flatMap((item) => item.task.pallets)
const pallets = [...new Map(group.flatMap((item) => item.task?.pallets || [])
.map((pallet) => [String(pallet.palletId), pallet])).values()]
for (const record of group) {
const fee = await ensureInitialCalculated(context, { ...record.measurement,
tenantId: String(record.task.tenantId), inboundId: record.source.inboundId,
tenantId: String(record.order.tenantId), inboundId: record.source.inboundId,
finalStatus: 'APPROVED', processStatus: record.summary.processStatus }, OPTIONS)
check(`${record.label}初始费用已计算`, null, Boolean(fee))
}
@@ -136,14 +156,14 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
check(`${label}${name}没有混入其他订单`, null,
detail.items?.every((item) => expectedIds.has(String(item.inboundId))))
check(`${label}${name}整托汇总不重复计算箱数`, null,
plan.items?.length === pallets.length
(mixedContainer || plan.items?.length === pallets.length)
&& plan.items.reduce((sum, item) => sum + Number(item.packageCount), 0)
=== group.reduce((sum, record) => sum + record.expectedCartonMarks.length, 0))
for (const record of group) {
const actual = detail.items.filter((item) => String(item.inboundId) === record.source.inboundId)
check(`${record.label}${name}分配箱数与托盘身份正确`, null,
actual.reduce((sum, item) => sum + Number(item.packageCount), 0) === record.expectedCartonMarks.length
&& actual.every((item) => record.task.pallets.some((pallet) => String(pallet.palletId) === String(item.palletId))))
&& (!record.task || actual.every((item) => record.task.pallets.some((pallet) => String(pallet.palletId) === String(item.palletId)))))
}
}
for (const pallet of pallets) {
@@ -152,7 +172,16 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
operationId: `${context.marker}-add-${pallet.palletId}`,
})
}
if (mixedContainer) {
for (const record of group.filter(item => !item.task)) {
plan = await command(`${record.label}普通订单按整票箱数加入`, 'POST', `${path}/inbound-assignments`, {
inboundId: record.source.inboundId, packageCount: record.expectedCartonMarks.length,
expectedVersion: Number(plan.version), operationId: `${context.marker}-add-${record.label}`,
})
}
}
await conserve('手工加入后')
if (!mixedContainer) {
for (const pallet of pallets) {
plan = await command('按物理托盘完整移除', 'DELETE', `${path}/pallet-assignments/${pallet.palletId}`, {
expectedPlanVersion: Number(plan.version), operationId: `${context.marker}-remove-${pallet.palletId}`,
@@ -164,13 +193,14 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
customerId: String(group[0].task.tenantId), inboundCode: group[0].source.inboundCode,
})
await conserve('智能推荐后')
}
for (const record of group) record.loading = { cabinet, plan, allocationVerified: true }
const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS)
const agent = await resolveOceanBookingAgent(context, MODULE, 'MATSON', OPTIONS)
check(`${label}真实船司和订舱代理齐全`, null, Boolean(carrier?.id && agent?.id))
const suffix = markerSuffix(context.marker)
const draft = oceanBookingDraft(context, suffix, {
customerId: String(group[0].task.tenantId), customerName: summary.customerName,
customerId: String(group[0].order.tenantId), customerName: summary.customerName,
warehouseId: String(summary.warehouseId), channelCode: summary.channelCode,
}, { carrierCode: 'MATSON', shippingCompanyId: String(carrier.id), shippingCompanyName: carrier.name,
bookingAgentId: String(agent.id), bookingAgentName: agent.name })
@@ -211,10 +241,14 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
warehouseId: String(summary.warehouseId), loadingPlanId: String(plan.id), containerBookingId: String(cabinet.id),
expectedVersion: Number(claimed.version), inventorySnapshotToken: inventory.snapshotToken,
items: claimed.items.map((item) => ({ itemId: String(item.id), version: Number(item.version),
action: 'PICK_PALLET', palletNo: item.palletNo })),
...(item.unitType === 'SEALED_PALLET'
? { action: 'PICK_PALLET', palletNo: item.palletNo }
: { action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount) }) })),
}
check(`${label}PDA全部任务均为封托单元`, null,
claimed.items.length > 0 && claimed.items.every((item) => item.unitType === 'SEALED_PALLET'))
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')))
const picked = await command('整托拣货', 'POST', `${pda}/batch`, batch,
{ 'Idempotency-Key': `${context.marker}-pick` })
check(`${label}整托拣货等待最终确认`, null, picked.workflowStatus === 'AWAITING_REVIEW')
@@ -236,7 +270,7 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
`status=${finalTask?.taskStatus}, attempts=${terminal.attempts}`)
for (const record of group) {
const inbound = await command(`${record.label}回查入仓单实装状态`, 'GET',
`/api/platform/inbounds/${record.source.inboundId}?tenantId=${record.task.tenantId}`)
`/api/platform/inbounds/${record.source.inboundId}?tenantId=${record.order.tenantId}`)
check(`${record.label}装柜完成回写`, null, inbound.summary?.processStatus === 'LOADED')
record.loading = { cabinet: linked, plan, task: finalTask, completed, booking: released,
allocationVerified: true, loadingVerified: true, containerNo }
@@ -245,10 +279,10 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
async function progressShipping(group) {
const record = group[0]
context.marker = `${marker}-${record.label}-SHIPPING`
context.createdResources.orders = { ...record.order, tenantId: String(record.task.tenantId),
context.createdResources.orders = { ...record.order, tenantId: String(record.order.tenantId),
inboundId: record.source.inboundId, warehouseId: String(record.summary.warehouseId) }
context.createdResources.phase3Loading = {
marker: context.marker, tenantId: String(record.task.tenantId), orderId: record.order.orderId,
marker: context.marker, tenantId: String(record.order.tenantId), orderId: record.order.orderId,
inboundId: record.source.inboundId, warehouseId: String(record.summary.warehouseId),
containerBookingId: String(record.loading.cabinet.id), loadingPlanId: String(record.loading.plan.id),
oceanBookingId: String(record.loading.booking.oceanBooking.id),
@@ -270,7 +304,7 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
String(inbound.inboundId) === String(item.source.inboundId) && inbound.inboundStatus === 'LOADED'))
&& detail.timeline?.some((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'CONFIRM_DISPATCH'))
context.createdResources.phase3OutboundDispatch = { marker: context.marker, containerBookingId: bookingId,
tenantId: String(record.task.tenantId), confirmedStatus: aggregate.dispatchStatus }
tenantId: String(record.order.tenantId), confirmedStatus: aggregate.dispatchStatus }
}
for (const [name, action] of [['customs', completeCustoms], ['departure', confirmDomesticDeparture],
['carrier-query', verifyCarrierQuery], ['pickup-ready', runUntilPickupReady]]) {
@@ -309,7 +343,7 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
check(`${label}跨区事件逐票建单完整`, null,
summary?.orders?.length === group.length && group.every((record) =>
summary.orders.some((order) => order.inboundCode === record.source.inboundCode
&& String(order.customerId) === String(record.task.tenantId))))
&& String(order.customerId) === String(record.order.tenantId))))
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),
@@ -321,10 +355,10 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
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]
const expectedNos = record.task.pallets.map((pallet) => pallet.palletNo).sort()
const expectedNos = record.task?.pallets.map((pallet) => pallet.palletNo).sort()
const actualNos = (target.pallets || []).map((pallet) => pallet.palletNo).sort()
check(`${record.label}来源托盘完整绑定海外真实资源`, null,
JSON.stringify(expectedNos) === JSON.stringify(actualNos)
(expectedNos ? JSON.stringify(expectedNos) === JSON.stringify(actualNos) : actualNos.length > 0)
&& target.pallets.every((pallet) => pallet.warehousePalletId && pallet.warehouseLocationId))
record.overseas = { plan: target, warehouseId: String(preview.overseasWarehouseId),
summary: summary.orders.find((order) => order.inboundCode === record.source.inboundCode),
@@ -373,9 +407,35 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
const signed = await call('照片绑定并签收', 'POST', `${base}/current/sign`,
{ photos, idempotencyKey: `${context.marker}-sign` })
check(`${label}签收成功`, null, signed.status === 'SIGNED')
if (mixedContainer) {
const completed = await receiveMixedContainer({ group, call, check, marker: context.marker,
request: (method, path, body) => context.requestOverseas(method, path, body, {}, picker.token) })
for (const item of group) {
const stock = await call(`${item.label}混柜完成后按入仓码查询库存`, 'POST',
'/api/pda/v1/last-leg/inventory/resolve', { code: item.source.inboundCode })
check(`${item.label}库存归属和真实托盘绑定正确且箱数核平`, null,
stock.queryCode === item.source.inboundCode && Array.isArray(stock.items) && stock.items.length > 0
&& stock.items.every(row => row.inboundCode === item.source.inboundCode
&& String(row.customerId) === String(item.order.tenantId)
&& 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 }
}
return
}
const sourceNos = [...new Set(group.flatMap((item) => item.task.pallets.map((pallet) => pallet.palletNo)))]
const receivedPallets = new Set()
let accepted = 0
for (const [index, sourcePalletNo] of sourceNos.entries()) {
const before = await call('未齐收货前回查柜级事实', 'GET', `${base}/current`)
check(`${label}剩余托盘待收箱数准确`, null,
Number(before.pendingPackages) === group.reduce((sum, item) => sum + item.expectedCartonMarks.length, 0) - accepted)
const premature = await context.requestOverseas('POST', `${base}/current/complete`,
{ idempotencyKey: `${context.marker}-premature-${index}`, allowDifference: false }, {}, picker.token)
check(`${label}未收齐不得按正常完成提交`, premature, premature.body?.code === 'A0409')
const unchanged = await call('未齐完成被拒后回查柜级事实', 'GET', `${base}/current`)
check(`${label}未齐完成拒绝后事实不变且可继续收货`, null,
JSON.stringify(unchanged) === JSON.stringify(before))
const body = { sourcePalletNo, idempotencyKey: `${context.marker}-pallet-${index}` }
const received = await call('按来源实物托盘整托收货', 'POST', `${base}/current/receipts/pallet`, body)
accepted += Number(received.acceptedPackages)
@@ -385,6 +445,14 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
{ sourcePalletNo, idempotencyKey: `${context.marker}-duplicate-${index}` })
check(`${label}同托重复扫描不重复收货`, null,
duplicate.result === 'ALREADY_RECEIVED' && Number(duplicate.acceptedPackages) === 0)
receivedPallets.add(sourcePalletNo)
const progress = await call('每托收货及重扫后回查逐票进度', 'GET', `${base}/current`)
const expected = group.map(record => ({ inboundCode: record.source.inboundCode,
total: record.expectedCartonMarks.length,
received: new Set(record.task.pallets.filter(p => receivedPallets.has(p.palletNo))
.flatMap(p => p.cartons || []).filter(c => String(c.inboundId) === String(record.source.inboundId))
.map(c => c.boxCode)).size }))
check(`${label}每托实收不串票且柜级与逐票剩余同步`, null, receivingProgressMatches(progress, expected))
}
check(`${label}整柜实收总箱数守恒`, null,
accepted === group.reduce((sum, item) => sum + item.expectedCartonMarks.length, 0))
@@ -416,6 +484,7 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
return value
}
const candidatePath = `${base}/one-click-dropship/candidates?customerId=${customerId}&warehouseId=${warehouseId}`
await outboundCandidateGuards({ context, group, customerId, warehouseId, label, check, call })
const all = await call('读取专用出库可用库存', 'GET', candidatePath)
const selected = all.filter((candidate) => group.some((record) => record.source.inboundCode === candidate.inboundCode))
const inputs = selected.map((candidate) => {
@@ -480,6 +549,24 @@ async function runWorkflow(context, stopBeforeOverseasPda) {
for (const record of group) record.outbound = { task: completed, selected, detail: outbound, completed: true }
}
try {
if (mixedContainer) {
const dropship = await prepare('MIXED-DROPSHIP', 2)
await verify(dropship, 2)
context.context.waitSplitRoute = { originWarehouseName: dropship.order.receivingWarehouseName,
channelCode: dropship.order.channelCode }
const fba = await prepare('MIXED-FBA', 1, {}, 'FBA')
const overseas = await prepare('MIXED-OVERSEAS', 1, {}, 'OVERSEAS')
const group = [dropship, fba, overseas]
check('混柜三票属于同一起运仓和渠道组', null, group.every(r =>
String(r.summary.warehouseId) === String(dropship.summary.warehouseId)
&& channelGroup(r.summary.channelCode) === channelGroup(dropship.summary.channelCode)))
await allocate(group)
await progressShipping(group)
await convertOverseas(group)
await receiveOverseas(group)
context.createdResources.dropshipPalletMatrix.stage = 'MIXED_CONTAINER_INBOUND_COMPLETED'
return
}
if (stopBeforeOverseasPda) {
const record = await prepare('BROWSER-MULTI', 2)
await verify(record, 2)