import { run as createOrder } from './orders.mjs' import { run as measure, completeDropshipPalletization } from './inbound-measurement.mjs' import { channelGroup } from './smart-loading-channel-group.mjs' import { ensureInitialCalculated } from './inbound-fees.mjs' import { oceanBookingDraft, resolveOceanBookingAgent } from './ocean-booking-fixture.mjs' import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' import { iso6346ContainerNumber } from './iso6346-container-number.mjs' import { uploadEvidence, markerSuffix } from './phase3-loading.mjs' import { waitForCompletion } from './phase3-loading-finalize.mjs' 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' const MODULE = 'dropship-pallet-matrix' const BASE = '/api/platform/v1/first-leg/pda-dropship' const OPTIONS = { scenario: MODULE, deploymentBlocker: true } // Domestic producer stage: these retained loaded fixtures must subsequently be verified // through overseas receipt and dedicated dropship outbound, not generic dispatch. export async function run(context) { const marker = context.marker const savedOptions = context.options.prepareDropshipManualCount const savedContext = { ...context.context } const records = [] context.createdResources.dropshipPalletMatrix = { marker, stage: 'DOMESTIC_PALLETIZATION', records, fullFlowVerified: false } const check = (name, response, passed, details = '') => { context.assert(MODULE, name, response, passed, details, OPTIONS) if (!passed) throw new Error(name) } async function prepare(label, palletCount, mode = {}) { 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.orderProfile = { taxMode: 'BUY_DECLARATION', pickupMethod: 'SELF_EXPRESS', containerMode: 'LCL' } context.context.waitSplitVariant = { skuCount: 1, packageCountPerSku: 4, packageCounts: [4], palletCount } context.options.prepareDropshipManualCount = true 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)) const source = context.createdResources.dropshipManualCount check(`${label}存在本轮未清点任务`, null, Boolean(source?.taskId)) const task = await completeDropshipPalletization(context, { ...source, measurement: { actualPackageCount: source.totalCartons, expectedPalletCount: palletCount }, ...mode, }) check(`${label}托盘作业断言全部通过`, null, Boolean(task) && !context.steps.slice(start).some((step) => !step.passed)) const record = { label, order: { ...context.createdResources.orders }, measurement: { ...context.createdResources.inboundMeasurement }, source: { ...source }, task, expectedCartonMarks: Array.from({ length: source.totalCartons }, (_, index) => `${source.inboundCode}#${source.totalCartons}-${index + 1}`) } records.push(record) return record } async function verify(record, expectedPallets) { const { taskId, warehouseId, inboundId } = record.source const response = await context.request('GET', `${BASE}/tasks/${taskId}?warehouseId=${warehouseId}`) const task = context.apiSuccess(MODULE, `${record.label}回查权威任务`, response, '', OPTIONS) const cartons = (task?.pallets || []).flatMap((pallet) => pallet.cartons || []) .filter((carton) => String(carton.inboundId) === String(inboundId)) check(`${record.label}逐票箱唛归属和数量守恒`, response, task?.taskStatus === 'COMPLETED' && task.pallets?.length === expectedPallets && task.pallets.every((pallet) => pallet.status === 'SEALED') && cartons.length === record.expectedCartonMarks.length && JSON.stringify(cartons.map((carton) => carton.boxCode).sort()) === JSON.stringify([...record.expectedCartonMarks].sort())) record.task = task const inboundResponse = await context.request('GET', `/api/platform/inbounds/${inboundId}?tenantId=${task.tenantId}`) const inbound = context.apiSuccess(MODULE, `${record.label}封托后回查入仓状态`, inboundResponse, '', OPTIONS) check(`${record.label}实际进入待分柜状态`, inboundResponse, inbound?.summary?.processStatus === 'WAIT_SPLIT') record.summary = inbound.summary record.domesticVerified = true } async function allocate(group) { const label = group.map((item) => item.label).join('+') context.marker = `${marker}-${group[0].label}-LOAD` const smart = '/api/platform/v1/first-leg/smart-loading' async function command(name, method, path, body, headers = {}) { const response = await context.request(method, path, body, undefined, headers) const value = context.apiSuccess(MODULE, `${label}${name}`, response, '', OPTIONS) check(`${label}${name}成功`, response, response?.body?.code === '200' && Boolean(value)) return value } 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, finalStatus: 'APPROVED', processStatus: record.summary.processStatus }, OPTIONS) check(`${record.label}初始费用已计算`, null, Boolean(fee)) } const summary = group[0].summary const cabinet = await command('创建本场景模拟柜', 'POST', '/api/platform/v1/containers', { channelGroupCode: channelGroup(summary.channelCode), warehouseId: String(summary.warehouseId), containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, }) let plan = await command('读取自动生成装柜计划', 'GET', `${smart}/plans/by-booking/${cabinet.id}`) const path = `${smart}/plans/${plan.id}` async function conserve(name) { // Smart-loading items are physical pallet summaries. The loading detail is the // authoritative per-inbound composition; shared pallets must satisfy both views. const detail = await command(`${name}逐票装柜组成`, 'GET', `/api/platform/v1/first-leg/loading-plans/${plan.id}?warehouseId=${summary.warehouseId}`) const expectedIds = new Set(group.map((record) => String(record.source.inboundId))) check(`${label}${name}没有混入其他订单`, null, detail.items?.every((item) => expectedIds.has(String(item.inboundId)))) check(`${label}${name}整托汇总不重复计算箱数`, null, 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)))) } } for (const pallet of pallets) { plan = await command('手工整托加入', 'POST', `${path}/pallet-assignments`, { palletId: String(pallet.palletId), expectedVersion: Number(plan.version), operationId: `${context.marker}-add-${pallet.palletId}`, }) } await conserve('手工加入后') for (const pallet of pallets) { plan = await command('按物理托盘完整移除', 'DELETE', `${path}/pallet-assignments/${pallet.palletId}`, { expectedPlanVersion: Number(plan.version), operationId: `${context.marker}-remove-${pallet.palletId}`, }) } check(`${label}整托移除不残留其他票明细`, null, plan.items?.length === 0) plan = await command('智能推荐整托加入', 'POST', `${path}/recommend`, { expectedVersion: Number(plan.version), operationId: `${context.marker}-recommend`, 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, warehouseId: String(summary.warehouseId), channelCode: summary.channelCode, }, { carrierCode: 'MATSON', shippingCompanyId: String(carrier.id), shippingCompanyName: carrier.name, bookingAgentId: String(agent.id), bookingAgentName: agent.name }) const booking = await command('创建独立订舱', 'POST', '/api/platform/v1/ocean-bookings', { expectedVersion: 0, data: draft }, { 'Idempotency-Key': `${context.marker}-booking` }) const bookingId = String(booking.oceanBooking.id) const form = new FormData() form.append('expectedVersion', String(booking.oceanBooking.version)) form.append('documentType', 'BOOKING_CONFIRMATION') form.append('file', new Blob([`HTTP acceptance ${context.marker}`], { type: 'text/plain' }), 'booking-confirmation.txt') const docResponse = await context.requestMultipart('POST', `/api/platform/v1/ocean-bookings/${bookingId}/documents/upload`, form, context.token, { 'Idempotency-Key': `${context.marker}-booking-document` }) const doc = context.apiSuccess(MODULE, `${label}上传订舱确认文件`, docResponse, '', OPTIONS) check(`${label}订舱文件已持久化`, docResponse, Boolean(doc?.document?.id)) const released = await command('放舱', 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, { expectedVersion: Number(doc.bookingVersion), reason: context.marker }, { 'Idempotency-Key': `${context.marker}-release` }) const latest = await command('回查模拟柜版本', 'GET', `/api/platform/v1/containers/domestic/${cabinet.id}`) const containerNo = iso6346ContainerNumber(context.marker) const linked = await command('关联订舱并补真实柜号', 'POST', `/api/platform/v1/containers/${cabinet.id}/ocean-booking-link`, { oceanBookingId: bookingId, requirementId: String(released.containerRequirements[0].id), containerNo, expectedVersion: Number(latest.container.version), }) const loadingDetail = await command('读取整份预装确认门禁', 'GET', `/api/platform/v1/first-leg/loading-plans/${plan.id}?warehouseId=${summary.warehouseId}`) check(`${label}全部成员可以确认预装`, null, loadingDetail.summary?.readiness?.canConfirm === true) const confirmed = await command('确认整份预装', 'POST', `/api/platform/v1/first-leg/loading-plans/${plan.id}/confirm`, { expectedVersion: Number(loadingDetail.summary.version) }, { 'Idempotency-Key': `${context.marker}-confirm` }) const taskId = String(confirmed.readiness.pdaTaskId) const pda = `/api/platform/v1/first-leg/pda-loading/tasks/${taskId}` const task = await command('读取装柜PDA任务', 'GET', `${pda}?warehouseId=${summary.warehouseId}`) const claimed = await command('领取装柜任务', 'POST', `${pda}/claim`, { expectedAssignmentVersion: Number(task.assignment?.version || 0), operationId: `${context.marker}-claim`, }) const inventory = await command('读取整托库存快照', 'GET', `${pda}/inventory-snapshot?warehouseId=${summary.warehouseId}`) const batch = { 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 })), } check(`${label}PDA全部任务均为封托单元`, null, claimed.items.length > 0 && 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') for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) { const response = await uploadEvidence(context, taskId, String(summary.warehouseId), evidenceType, 0, evidenceType, { sealNo: evidenceType === 'SEAL' ? `SEAL-${suffix}` : null }) const evidence = context.apiSuccess(MODULE, `${label}上传${evidenceType}装柜证据`, response, '', OPTIONS) check(`${label}${evidenceType}已绑定本柜`, response, String(evidence?.containerBookingId) === String(cabinet.id) && evidence?.evidences?.some((item) => item.evidenceType === evidenceType && item.status !== 'VOID')) } const current = await command('读取最终任务版本', 'GET', `${pda}?warehouseId=${summary.warehouseId}`) const completed = await command('最终完成装柜', 'POST', `${pda}/complete`, { expectedTaskVersion: Number(current.version), operationId: `${context.marker}-complete`, remark: context.marker, }, { 'Idempotency-Key': `${context.marker}-complete` }) const terminal = await waitForCompletion(context, `${pda}?warehouseId=${summary.warehouseId}`, `${pda}/operations`) const finalTask = terminal.task check(`${label}任务真实完成`, terminal.taskResponse, finalTask?.taskStatus === 'COMPLETED', `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}`) check(`${record.label}装柜完成回写`, null, inbound.summary?.processStatus === 'LOADED') record.loading = { cabinet: linked, plan, task: finalTask, completed, booking: released, allocationVerified: true, loadingVerified: true, containerNo } } } async function progressShipping(group) { const record = group[0] context.marker = `${marker}-${record.label}-SHIPPING` context.createdResources.orders = { ...record.order, tenantId: String(record.task.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, 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), containerNo: record.loading.containerNo, finalStatus: 'LOADED', } for (const key of ['phase3Customs', 'phase3OutboundDispatch', 'phase3CarrierScheduleQuery', 'phase4CarrierPickupReady']) { delete context.createdResources[key] } async function confirmDomesticDeparture() { const bookingId = String(record.loading.cabinet.id) const response = await context.request('GET', `/api/platform/v1/first-leg/outbound-dispatches/${bookingId}`) const detail = context.apiSuccess(MODULE, `${record.label}回查报关后的实际离仓事实`, response, '', OPTIONS) const aggregate = detail?.aggregate check(`${record.label}报关完成自动离仓且全柜逐票已装`, response, String(aggregate?.containerBookingId) === bookingId && aggregate?.dispatchStatus === 'WAITING_FOR_LOADING' && aggregate.inbounds?.length === group.length && group.every((item) => aggregate.inbounds.some((inbound) => 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 } } for (const [name, action] of [['customs', completeCustoms], ['departure', confirmDomesticDeparture], ['carrier-query', verifyCarrierQuery], ['pickup-ready', runUntilPickupReady]]) { const start = context.steps.length await context.executeModule(`${MODULE}-${record.label}-${name}`, () => action(context)) check(`${record.label}${name}阶段全部通过`, null, !context.steps.slice(start).some((step) => !step.passed)) } check(`${record.label}已完成国内离仓并达到可提柜节点`, null, context.createdResources.phase3OutboundDispatch?.confirmedStatus === 'WAITING_FOR_LOADING' && context.createdResources.phase4CarrierPickupReady?.ready === true) for (const item of group) item.shipping = { departure: context.createdResources.phase3OutboundDispatch, carrier: context.createdResources.phase3CarrierScheduleQuery, pickupReady: context.createdResources.phase4CarrierPickupReady, shippingVerified: true, } } async function convertOverseas(group) { const label = group[0].label const loading = group[0].loading const bookingId = String(loading.cabinet.id) const base = '/api/platform/v1/last-leg' async function call(name, method, path, body, headers) { const response = await context.requestOverseas(method, path, body, headers) const data = context.apiSuccess(MODULE, `${label}${name}`, response, '', OPTIONS) check(`${label}${name}成功`, response, response?.body?.code === '200' && data != null) return data } let summary for (let attempt = 0; attempt < 60; attempt++) { const response = await context.requestOverseas('GET', `${base}/orders/container-summaries/${bookingId}`) summary = response?.body?.code === '200' ? response.body.data : null if (summary?.orders?.length === group.length) break if (attempt < 59) await new Promise((resolve) => setTimeout(resolve, 1000)) } 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)))) 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), snapshotHash: preview.snapshotHash } const headers = { 'Idempotency-Key': `${marker}-${label}-convert` } const plans = await call('按柜生成真实入库计划', 'POST', `${base}/inbound-plans/convert-container`, body, headers) check(`${label}每票独立计划`, null, Array.isArray(plans) && plans.length === group.length) for (const record of group) { 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 actualNos = (target.pallets || []).map((pallet) => pallet.palletNo).sort() check(`${record.label}来源托盘完整绑定海外真实资源`, null, JSON.stringify(expectedNos) === JSON.stringify(actualNos) && 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), planVerified: true } } const byPallet = new Map() for (const plan of plans) for (const pallet of plan.pallets) { const identity = `${pallet.warehousePalletId}:${pallet.warehouseLocationId}` check(`${label}同一来源托盘只绑定一个海外实体`, null, !byPallet.has(pallet.palletNo) || byPallet.get(pallet.palletNo) === identity) byPallet.set(pallet.palletNo, identity) } const replay = await call('计划转换同键重放', 'POST', `${base}/inbound-plans/convert-container`, body, headers) check(`${label}计划重放不新增或改变实物资源`, null, JSON.stringify(replay) === JSON.stringify(plans)) } async function receiveOverseas(group) { const record = group[0] const label = record.label // 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' const access = await resolvePickerAccess(context) check(`${label}海外PDA角色可用`, null, Boolean(access)) const picker = await createPicker(context, records.indexOf(record) + 1, record.overseas.warehouseId, access) try { check(`${label}海外PDA账号和安全投影可用`, null, Boolean(picker?.token)) async function call(name, method, path, body) { const response = await context.requestOverseas(method, path, body, {}, picker.token) const data = context.apiSuccess(MODULE, `${label}${name}`, response, '', OPTIONS) check(`${label}${name}成功`, response, response?.body?.code === '200' && data != null) return data } let candidate for (let attempt = 0; attempt < 60; attempt++) { const response = await context.requestOverseas('GET', `${base}/candidates`, undefined, {}, picker.token) candidate = response?.body?.data?.find?.((item) => item.containerNo === record.loading.containerNo) 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`, { 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 sourceNos = [...new Set(group.flatMap((item) => item.task.pallets.map((pallet) => pallet.palletNo)))] let accepted = 0 for (const [index, sourcePalletNo] of sourceNos.entries()) { const body = { sourcePalletNo, idempotencyKey: `${context.marker}-pallet-${index}` } const received = await call('按来源实物托盘整托收货', 'POST', `${base}/current/receipts/pallet`, body) accepted += Number(received.acceptedPackages) const replay = await call('整托收货同键重放', 'POST', `${base}/current/receipts/pallet`, body) check(`${label}实收重放结果一致`, null, JSON.stringify(replay) === JSON.stringify(received)) const duplicate = await call('同托新键重复扫描', 'POST', `${base}/current/receipts/pallet`, { sourcePalletNo, idempotencyKey: `${context.marker}-duplicate-${index}` }) check(`${label}同托重复扫描不重复收货`, null, duplicate.result === 'ALREADY_RECEIVED' && Number(duplicate.acceptedPackages) === 0) } check(`${label}整柜实收总箱数守恒`, null, accepted === group.reduce((sum, item) => sum + item.expectedCartonMarks.length, 0)) const current = await call('逐票回查实收', 'GET', `${base}/current`) check(`${label}逐票实收核平且没有遗漏`, null, Number(current.pendingPackages) === 0 && current.inbounds?.length === group.length && group.every((item) => current.inbounds.some((inbound) => inbound.inboundCode === item.source.inboundCode && Number(inbound.receivedPackages) === item.expectedCartonMarks.length))) const body = { idempotencyKey: `${context.marker}-complete` } const completed = await call('完成海外拆柜入库', 'POST', `${base}/current/complete`, body) const replay = await call('完成入库同键重放', 'POST', `${base}/current/complete`, body) check(`${label}海外入库完成且重放稳定`, null, completed.status === 'COMPLETED' && JSON.stringify(replay) === JSON.stringify(completed)) for (const item of group) item.overseas.receiving = { completed, current, receivingVerified: true } await outboundOverseas(group, picker.token) } finally { await disablePicker(context, picker, records.indexOf(record) + 1) } } async function outboundOverseas(group, token) { const label = group[0].label const base = '/api/platform/v1/last-leg/outbound-orders' const customerId = String(group[0].task.tenantId) const warehouseId = group[0].overseas.warehouseId async function call(name, method, path, body, pda = false) { const response = await context.requestOverseas(method, path, body, {}, pda ? token : context.overseasToken) const value = context.apiSuccess(MODULE, `${label}${name}`, response, '', OPTIONS) check(`${label}${name}成功`, response, response?.body?.code === '200' && value != null) return value } const candidatePath = `${base}/one-click-dropship/candidates?customerId=${customerId}&warehouseId=${warehouseId}` const all = await call('读取专用出库可用库存', 'GET', candidatePath) const selected = all.filter((candidate) => group.some((record) => record.source.inboundCode === candidate.inboundCode)) const inputs = selected.map((candidate) => { const record = group.find((item) => item.source.inboundCode === candidate.inboundCode) const pallet = record.task.pallets.find((item) => item.palletNo === candidate.palletNo) const cartonMarks = (pallet?.cartons || []).filter((carton) => String(carton.inboundId) === String(record.source.inboundId)).map((carton) => carton.boxCode) check(`${label}专用出库候选逐票逐托箱数准确`, null, cartonMarks.length > 0 && Number(candidate.availablePackages) === cartonMarks.length && String(candidate.customerId) === customerId && String(candidate.overseasWarehouseId) === warehouseId) return { inventoryPositionId: candidate.inventoryPositionId, packages: cartonMarks.length, sku: candidate.skuCode, cartonMarks } }) check(`${label}专用出库候选覆盖全部票和箱`, null, group.every((record) => selected.filter((item) => item.inboundCode === record.source.inboundCode) .reduce((sum, item) => sum + Number(item.availablePackages), 0) === record.expectedCartonMarks.length)) const body = { recipient: `HTTP acceptance ${context.marker}`, items: inputs, remark: context.marker, idempotencyKey: `${context.marker}-outbound-create` } const task = await call('创建一件代发专用出库', 'POST', `${base}/one-click-dropship?customerId=${customerId}`, body) for (const record of group) record.outbound = { task, selected, completed: false } const replay = await call('专用出库创建同键重放', 'POST', `${base}/one-click-dropship?customerId=${customerId}`, body) check(`${label}专用出库重放不重复建单`, null, JSON.stringify(replay) === JSON.stringify(task)) const pdaPath = `/api/pda/v1/last-leg/outbound-tasks/${task.taskNo}` const detail = await call('PDA读取专用出库任务', 'GET', pdaPath, undefined, true) check(`${label}PDA出库明细完整`, null, detail.items?.length === inputs.length) for (const [index, candidate] of selected.entries()) { const item = detail.items.find((row) => row.palletNo === candidate.palletNo && row.skuCode === candidate.skuCode) check(`${label}PDA拣货来源仓位和入仓码一致`, null, Boolean(item) && item.positions?.some((position) => String(position.inventoryPositionId) === String(candidate.inventoryPositionId) && position.inboundCode === candidate.inboundCode)) const pick = { outboundPalletItemId: item.outboundPalletItemId, inventoryPositionId: candidate.inventoryPositionId, packages: inputs[index].packages, cartonMarks: inputs[index].cartonMarks, idempotencyKey: `${context.marker}-outbound-pick-${index}` } const picked = await call('PDA专用出库拣货', 'POST', `${pdaPath}/pick`, pick, true) const repeated = await call('PDA出库拣货同键重放', 'POST', `${pdaPath}/pick`, pick, true) check(`${label}PDA出库拣货重放不重复扣减`, null, JSON.stringify(repeated) === JSON.stringify(picked)) } const completion = { idempotencyKey: `${context.marker}-outbound-complete` } const completed = await call('PDA完成专用出库', 'POST', `${pdaPath}/complete`, completion, true) const repeated = await call('PDA专用出库完成重放', 'POST', `${pdaPath}/complete`, completion, true) check(`${label}专用出库任务完成且重放稳定`, null, completed.status === 'COMPLETED' && JSON.stringify(repeated) === JSON.stringify(completed)) const outbound = await call('回查专用出库单终态', 'GET', `${base}/${task.outboundNo}`) check(`${label}专用出库单真实完成且箱数核平`, null, outbound.outbound?.status === 'OUTBOUNDED' && outbound.outbound?.outboundType === 'ONE_CLICK_DROPSHIP' && Number(outbound.outbound.packageCount) === inputs.reduce((sum, item) => sum + item.packages, 0) && outbound.pallets.every((item) => Number(item.packageCount) === Number(item.pickedPackageCount))) const remaining = await call('回查专用出库可用库存扣减', 'GET', candidatePath) check(`${label}本轮每票库存全部扣减`, null, !remaining.some((candidate) => group.some((record) => record.source.inboundCode === candidate.inboundCode))) for (const record of group) record.outbound = { task: completed, selected, detail: outbound, completed: true } } try { await verify(await prepare('SINGLE-MULTI', 2), 2) await verify(await prepare('SINGLE-SINGLE', 1), 1) await verify(await prepare('SINGLE-MERGE', 2, { mergeToFirstPallet: true }), 1) const first = await prepare('MULTI-MERGE-A', 1, { deferSeal: true }) const targetPalletNo = first.task.pallets[0].palletNo const second = await prepare('MULTI-MERGE-B', 1, { targetPalletNo }) await verify(first, 1) await verify(second, 1) check('多票共托物理身份相同且两票保持独立归属', null, first.task.pallets[0].palletNo === second.task.pallets[0].palletNo && first.source.inboundCode !== second.source.inboundCode && first.task.pallets[0].cartons.length === first.expectedCartonMarks.length + second.expectedCartonMarks.length) const groups = [[records[0]], [records[1]], [records[2]], [first, second]] for (const group of groups) await allocate(group) context.createdResources.dropshipPalletMatrix.stage = 'DOMESTIC_LOADING_COMPLETED' for (const group of groups) await progressShipping(group) context.createdResources.dropshipPalletMatrix.stage = 'SHIPPING_PICKUP_READY' for (const group of groups) { await convertOverseas(group) await receiveOverseas(group) } context.createdResources.dropshipPalletMatrix.stage = 'DEDICATED_OUTBOUND_COMPLETED' } finally { context.marker = marker context.options.prepareDropshipManualCount = savedOptions context.context = savedContext } }