From d53de922db1092908de8038fde49294ab0a5ccfa Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 14 Sep 2026 18:34:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/admin-test-env-acceptance.mjs | 3 +- scripts/admin-test-env-modules/README.md | 4 + .../dropship-pallet-matrix-contract.test.mjs | 64 +++ .../dropship-pallet-matrix.mjs | 410 ++++++++++++++++++ .../inbound-measurement.mjs | 49 ++- .../phase3-loading-finalize.mjs | 2 +- .../admin-test-env-modules/phase3-loading.mjs | 2 +- .../phase4-overseas-pda.mjs | 2 +- 8 files changed, 525 insertions(+), 11 deletions(-) create mode 100644 scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/dropship-pallet-matrix.mjs diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 10747e214..7ceab527f 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -11,7 +11,7 @@ const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitt 'manual-pda-ready-fixture', 'manual-pda-ready-verify', 'manual-pda-batch-fixture', 'manual-pda-batch-verify', 'manual-loading-complete-fixture', 'manual-loading-complete-verify', - 'coload-groups'] + 'coload-groups', 'dropship-pallet-matrix'] const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES]) const A0_LOCATION_PAGE_SIZE = 200 const A0_MAX_LOCATION_MUTATIONS = 8 @@ -42,6 +42,7 @@ export function planA0LocationIsolation(locations, requiredSlots = 1, } const MODULE_DEPENDENCIES = new Map([ + ['dropship-pallet-matrix', ['merchant-qualification-fixture']], ['suppliers', ['merchant-qualification-fixture']], ['supplemental', ['merchant-qualification-fixture']], ['orders', ['merchant-qualification-fixture']], diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index af324b574..e53d2e685 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -37,6 +37,10 @@ export async function run(context) { ## 可用上下文 +`dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;尚未包含海外实收和专用出库验收。 + +- `dropship-pallet-matrix` 通过总入口 `--modules dropship-pallet-matrix` 执行,自动依赖客户资质准备。通过正式 HTTP 创建五票订单,覆盖单票多托、单票单托、单票移箱合托、多票共享开放托盘;会创建并保留测试数据,不自动清理。报告 `createdResources.dropshipPalletMatrix` 保存逐票箱唛、托盘及各阶段证据,失败即停止并保留阻断;重跑创建新批次,不借用历史资源。`fullFlowVerified=false`,不能代替海外收货及一件代发专用出库的完整验收。 + - 一件代发任务 `Task.pallets` 只列实际参与绑箱的托盘;未使用的预生成托盘通过 `/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets?accountId=...` 读取,客户范围取本轮任务。脚本同时核对 `generatedPalletCount/palletSetupReady`,不因空的任务托盘列表新增资源。 - `phase3-container-documents` 从本轮货柜详情读取渠道:海铁验证 DO 上传被明确拒绝且聚合中没有 DO,其他渠道继续验证 DO 上传、重放及零漂移;缺渠道不猜测适用性。 - `phase4-overseas-pda-operations` 用三个独立逐箱任务分别验证正常换标、跳过和异常,异常不得重复使用前两箱。前一步失败时保留失败并停止该链,不继续使用空响应版本。 diff --git a/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs b/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs new file mode 100644 index 000000000..ccdb777d2 --- /dev/null +++ b/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs @@ -0,0 +1,64 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' + +const source = readFileSync(new URL('./dropship-pallet-matrix.mjs', import.meta.url), 'utf8') + +test('dropship matrix has a real customer prerequisite without generic dispatch fixtures', () => { + const modules = normalizeModuleOrder(['dropship-pallet-matrix']) + assert.ok(modules.includes('merchant-qualification-fixture')) + assert.ok(modules.includes('dropship-pallet-matrix')) + assert.ok(!modules.includes('phase5-overseas-fulfillment')) +}) + +test('all four physical pallet shapes are explicitly produced', () => { + assert.match(source, /prepare\('SINGLE-MULTI', 2\)/) + assert.match(source, /prepare\('SINGLE-SINGLE', 1\)/) + assert.match(source, /prepare\('SINGLE-MERGE', 2, \{ mergeToFirstPallet: true \}\)/) + assert.match(source, /prepare\('MULTI-MERGE-A', 1, \{ deferSeal: true \}\)/) + assert.match(source, /prepare\('MULTI-MERGE-B', 1, \{ targetPalletNo \}\)/) + assert.match(source, /await verify\(first, 1\)/) + assert.match(source, /await verify\(second, 1\)/) +}) + +test('allocation checks both physical summaries and uncollapsed per-inbound quantities', () => { + assert.match(source, /first-leg\/loading-plans\/\$\{plan.id\}/) + assert.match(source, /const actual = detail.items.filter/) + assert.match(source, /plan.items\?\.length === pallets.length/) + assert.match(source, /actual.reduce\([\s\S]*?record.expectedCartonMarks.length/) + assert.match(source, /await conserve\('手工加入后'\)/) + assert.match(source, /await conserve\('智能推荐后'\)/) + assert.match(source, /fullFlowVerified: false/) +}) + +test('each scenario reaches physical loading through released booking and asynchronous completion', () => { + assert.match(source, /ocean-booking-link/) + assert.match(source, /readiness\?\.canConfirm === true/) + assert.match(source, /action: 'PICK_PALLET'/) + assert.match(source, /inventorySnapshotToken: inventory.snapshotToken/) + for (const type of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) { + assert.ok(source.includes(`'${type}'`)) + } + assert.match(source, /await waitForCompletion\(context/) + assert.match(source, /finalTask\?\.taskStatus === 'COMPLETED'/) + assert.match(source, /inbound.summary\?\.processStatus === 'LOADED'/) +}) + +test('shipping uses the current group and requires real departure and pickup readiness', () => { + assert.match(source, /for \(const group of groups\) await progressShipping\(group\)/) + assert.match(source, /containerBookingId: String\(record.loading.cabinet.id\)/) + assert.match(source, /\['customs', completeCustoms\]/) + assert.match(source, /\['departure', confirmDomesticDeparture\]/) + assert.match(source, /\['pickup-ready', runUntilPickupReady\]/) + assert.match(source, /confirmedStatus === 'WAITING_FOR_LOADING'/) + assert.match(source, /phase4CarrierPickupReady\?\.ready === true/) +}) + +test('overseas plans preserve per-inbound ownership and shared physical identity', () => { + assert.match(source, /inbound-plans\/convert-container/) + assert.match(source, /matching.length === 1/) + assert.match(source, /JSON.stringify\(expectedNos\) === JSON.stringify\(actualNos\)/) + assert.match(source, /byPallet.get\(pallet.palletNo\) === identity/) + assert.match(source, /JSON.stringify\(replay\) === JSON.stringify\(plans\)/) +}) diff --git a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs new file mode 100644 index 000000000..d788fe0d1 --- /dev/null +++ b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs @@ -0,0 +1,410 @@ +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, { + 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 + context.marker = `${marker}-${label}-RECEIVE` + 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 } + } finally { + await disablePicker(context, picker, records.indexOf(record) + 1) + } + } + 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 = 'OVERSEAS_RECEIVING_COMPLETED' + } finally { + context.marker = marker + context.options.prepareDropshipManualCount = savedOptions + context.context = savedContext + } +} diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 16263019b..00976a860 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -88,7 +88,8 @@ async function waitForCompletedMeasurement(context, measurementPath, expectedSta return { response, measurement, attempts } } -export async function completeDropshipPalletization(context, { inboundId, inboundCode, warehouseId, measurement }) { +export async function completeDropshipPalletization(context, { inboundId, inboundCode, warehouseId, measurement, + targetPalletNo = null, deferSeal = false, mergeToFirstPallet = false }) { const pendingResponse = await context.request('GET', `/api/platform/v1/first-leg/pda-dropship/tasks/pending?warehouseId=${warehouseId}&keyword=${encodeURIComponent(inboundCode)}`) const pending = context.apiSuccess(MODULE, '查询一件代发待合托任务', pendingResponse, @@ -154,7 +155,7 @@ export async function completeDropshipPalletization(context, { inboundId, inboun discrepancyAction: 'MATCH', discrepancyRemark: null, })), - palletNo: index === 0 ? null : generatedPallets[Math.min(index, generatedPallets.length - 1)].palletNo, + palletNo: index === 0 ? null : (targetPalletNo || generatedPallets[Math.min(index, generatedPallets.length - 1)].palletNo), operationId: `${context.marker}-dropship-count-${index + 1}`.slice(0, 128), } const countResponse = await context.request('POST', @@ -186,16 +187,19 @@ export async function completeDropshipPalletization(context, { inboundId, inboun && Number(replayed?.unboundCartons) === Number(counted?.unboundCartons), `counted=${replayed?.countedCartons ?? 'missing'}, unbound=${replayed?.unboundCartons ?? 'missing'}`, OPTIONS) - const firstPallet = generatedPallets[0] + const firstPallet = targetPalletNo ? { palletNo: targetPalletNo } : generatedPallets[0] const bindPayload = { warehouseId, palletNo: firstPallet.palletNo, operationId: `${context.marker}-dropship-bind-first`.slice(0, 128) } const bindResponse = await context.request('POST', `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/bind-pending`, bindPayload) const bound = context.apiSuccess(MODULE, '后扫预生成托盘码并绑定全部待组托箱', bindResponse, `taskId=${taskId}`, OPTIONS) - const pallet = bound?.pallets?.find((item) => asId(item.palletId) === asId(firstPallet.palletId)) || null + const pallet = bound?.pallets?.find((item) => item.palletNo === firstPallet.palletNo) || null context.assert(MODULE, '待组托箱绑定到OPEN托盘且不再游离', bindResponse, - Number(bound?.unboundCartons) === 0 && Number(pallet?.cartonCount) === 1 + Number(bound?.unboundCartons) === 0 + && (targetPalletNo + ? pallet?.cartons?.filter((item) => asId(item.inboundId) === inboundId).length === 1 + : Number(pallet?.cartonCount) === 1) && pallet?.status === 'OPEN' && pallet?.palletNo === firstPallet.palletNo && bound?.pallets?.length === 1, `unbound=${bound?.unboundCartons ?? 'missing'}, palletStatus=${pallet?.status ?? 'missing'}`, OPTIONS) @@ -207,7 +211,8 @@ export async function completeDropshipPalletization(context, { inboundId, inboun && Number(replayBind?.boundCartons) === Number(bound?.boundCartons) && Number(replayBind?.unboundCartons) === 0, '', OPTIONS) } else { - const expectedPallet = generatedPallets[Math.min(index, generatedPallets.length - 1)] + const expectedPallet = targetPalletNo ? { palletNo: targetPalletNo } + : generatedPallets[Math.min(index, generatedPallets.length - 1)] context.assert(MODULE, `第${index + 1}箱直接进入当前OPEN托盘`, countResponse, counted?.pallet?.palletNo === expectedPallet.palletNo && counted?.pallet?.status === 'OPEN', `palletNo=${counted?.pallet?.palletNo ?? 'missing'}, status=${counted?.pallet?.status ?? 'missing'}`, OPTIONS) @@ -218,10 +223,40 @@ export async function completeDropshipPalletization(context, { inboundId, inboun context.assert(MODULE, '预生成托盘覆盖测量冻结的全部箱数', countedTaskResponse, totalCartons > 0 && countedCartons === totalCartons && Number(sealed?.boundCartons) === totalCartons - && sealed?.pallets?.length === expectedPalletCount + && sealed?.pallets?.length === (targetPalletNo ? 1 : expectedPalletCount) && sealed?.pallets?.every((item) => Number(item?.cartonCount) > 0), `counted=${countedCartons}, bound=${sealed?.boundCartons ?? 'missing'}, expected=${totalCartons || 'missing'}`, OPTIONS) + if (mergeToFirstPallet) { + const target = sealed?.pallets?.[0] + const sources = (sealed?.pallets || []).slice(1) + context.assert(MODULE, '单票合托前确有多个实物托盘', countedTaskResponse, + Boolean(target) && sources.length > 0, '', OPTIONS) + if (!target || sources.length === 0) return null + for (const source of sources) { + for (const carton of source.cartons || []) { + const payload = { warehouseId, targetPalletNo: target.palletNo, + operationId: `${context.marker}-merge-${carton.cartonId}`.slice(0, 128) } + const path = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/cartons/${carton.cartonId}/move` + const movedResponse = await context.request('POST', path, payload) + sealed = context.apiSuccess(MODULE, '通过正式移箱接口合并单票托盘', movedResponse, '', OPTIONS) + const replayResponse = await context.request('POST', path, payload) + const replay = context.apiSuccess(MODULE, '单票合托移箱同键重放', replayResponse, '', OPTIONS) + context.assert(MODULE, '移箱重放保持总箱数守恒', replayResponse, + Number(replay?.boundCartons) === totalCartons && Number(replay?.unboundCartons) === 0, '', OPTIONS) + } + } + context.assert(MODULE, '单票合托后仅一个实物托盘且箱唛不丢失', null, + sealed?.pallets?.length === 1 && sealed.pallets[0].palletNo === target.palletNo + && sealed.pallets[0].cartons?.length === totalCartons + && new Set(sealed.pallets[0].cartons.map((item) => item.boxCode)).size === totalCartons, + '', OPTIONS) + } + if (deferSeal) { + context.assert(MODULE, '多票合托首票归托后保持开放等待下一票', null, + sealed?.taskStatus !== 'COMPLETED' && sealed?.pallets?.every((item) => item.status === 'OPEN'), '', OPTIONS) + return { ...sealed, sealedCartonMarks, sealedSourcePalletNos: [] } + } let lastSealResponse = countedTaskResponse for (let index = 0; index < (sealed?.pallets || []).length; index += 1) { const pallet = sealed.pallets.find((item) => item.status === 'OPEN') diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index e04443e30..4c494d314 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -150,7 +150,7 @@ function blockResourceIdentity(resource) { }) } -async function waitForCompletion(context, taskDetailPath, operationsPath, attempts = 90) { +export async function waitForCompletion(context, taskDetailPath, operationsPath, attempts = 90) { let taskResponse = null let task = null let operationsResponse = null diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 4c8d1d26e..985840bbb 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -87,7 +87,7 @@ async function request(context, method, path, body, headers = {}) { } catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } } } -async function uploadEvidence(context, taskId, warehouseId, evidenceType, expectedVersion, keySuffix, +export async function uploadEvidence(context, taskId, warehouseId, evidenceType, expectedVersion, keySuffix, { sealNo = null, remark = context.marker, fileSuffix = evidenceType } = {}) { const query = new URLSearchParams({ warehouseId: String(warehouseId), expectedVersion: String(expectedVersion), evidenceType, remark }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 3f23972d0..00e5cbf0f 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -262,7 +262,7 @@ async function verifyWarehouseQueries(context, token, inboundCode, pallet, desti : null } -async function uploadPhotos(context, token) { +export async function uploadPhotos(context, token) { const malicious = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', { name: `${context.marker}-forged.png`, type: 'image/png', content: Uint8Array.from(Buffer.from('')), }, token)