From 66816d5341e3ef1cce766b4d645932a61c6e5b79 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 29 Aug 2026 11:42:52 +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-modules/README.md | 1 + .../coload-groups-contract.test.mjs | 10 + .../admin-test-env-modules/coload-groups.mjs | 582 +++++++++++++++++- 3 files changed, 576 insertions(+), 17 deletions(-) diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index e0df46d9f..fc8a35e50 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -64,6 +64,7 @@ export async function run(context) { | 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、四类货柜资料、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-container-documents.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | | 头程整柜报关 | `phase3-container-customs-fixture.mjs`、`phase3-container-customs.mjs`;定向入口通过正式订单、测量、同柜装载和最终完成 API 生成独立 `BUY_DECLARATION + TAX_REFUND` 混合柜,客户资料只覆盖退税票,买单行由下载中心 XLSX 验证;不复用历史柜、不执行 SQL | `container-customs-acceptance-contract.test.mjs`、`container-customs-acceptance-matrix.mjs` | | 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` | +| 同柜且不可拆组 | `coload-groups.mjs`(可选;前置正式生成至少7票同线路 `WAIT_SPLIT`,另造不同线路反向票;覆盖创建/取消幂等、原子分配/移除、目标组隔离推荐、推送防绕过、PDA短拣完成拒绝且无子单、足量整组消费、普通单拆分不回归及已有父子单建组拒绝) | `coload-groups-contract.test.mjs` | | 人工计划分柜准备与只读核验 | `manual-order-splitting-fixture.mjs`、`manual-order-splitting-verify.mjs`(可选,不进入默认全量) | `manual-order-splitting-contract.test.mjs` | | 人工推送预装前置与推送后只读核验 | `manual-preload-push-fixture.mjs`、`manual-preload-push-verify.mjs`(可选,不进入默认全量) | `manual-preload-push-contract.test.mjs` | | 人工订舱关联、预装确认与 PDA 待领取核验 | `manual-pda-ready-fixture.mjs`、`manual-pda-ready-verify.mjs`(可选,不进入默认全量) | `manual-pda-ready-contract.test.mjs` | diff --git a/scripts/admin-test-env-modules/coload-groups-contract.test.mjs b/scripts/admin-test-env-modules/coload-groups-contract.test.mjs index df85d161d..f70fbbedc 100644 --- a/scripts/admin-test-env-modules/coload-groups-contract.test.mjs +++ b/scripts/admin-test-env-modules/coload-groups-contract.test.mjs @@ -17,5 +17,15 @@ test('同柜不可拆组HTTP验收锁定公开接口与防绕过矩阵', async ( '组成员不能单独删除', '整组原子移除', '智能推荐对整组全取或全不取', + '推荐候选仅含当前目标同柜组且成员身份精确', + 'ACTIVE同柜组整组取消成功', + '同柜组PDA原子批次记录部分拣货异常', + '同柜组任一成员短拣时完成被拒绝', + '短拣完成拒绝后任务、父子树和组锁零漂移', + '同柜组足量完成命令可靠受理', + '足量完成整组已消费、全部入库完成且不生成子单', + '未成组普通单推送预装不回归', + '未成组普通单仍按既有规则生成唯一余量子单', + '已有父子拆分订单创建同柜组被拒绝', ]) assert.match(source, new RegExp(fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) }) diff --git a/scripts/admin-test-env-modules/coload-groups.mjs b/scripts/admin-test-env-modules/coload-groups.mjs index 029458e08..109780a88 100644 --- a/scripts/admin-test-env-modules/coload-groups.mjs +++ b/scripts/admin-test-env-modules/coload-groups.mjs @@ -1,5 +1,12 @@ const MODULE = 'coload-groups' const OPTIONS = { scenario: 'indivisible-coload-group', deploymentBlocker: true } +const CONTAINERS = '/api/platform/v1/containers' +const SMART = '/api/platform/v1/first-leg/smart-loading' +const FIRST_LEG = '/api/platform/v1/first-leg' +const PDA = '/api/platform/v1/first-leg/pda-loading' + +import { oceanBookingDraft } from './ocean-booking-fixture.mjs' +import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' function id(value) { return value == null ? '' : String(value) } function code(response) { return id(response?.body?.code) || 'missing' } @@ -26,11 +33,286 @@ function memberFacts(group) { fullPackageCount: Number(item?.fullPackageCount), })).sort((left, right) => left.orderId.localeCompare(right.orderId)) } +function taskSignature(task) { + return JSON.stringify({ + id: id(task?.id), version: Number(task?.version), taskStatus: task?.taskStatus || null, + workflowStatus: task?.workflowStatus || null, + items: [...(task?.items || [])].map((item) => ({ + id: id(item?.id), inboundId: id(item?.inboundId), version: Number(item?.version), + planned: Number(item?.plannedPackageCount), picked: Number(item?.pickedPackageCount), + status: item?.itemStatus || null, + })).sort((left, right) => left.id.localeCompare(right.id)), + }) +} +function treeSignature(tree) { + return JSON.stringify({ + parentOrderId: id(tree?.parentOrderId), originalPackageCount: Number(tree?.originalPackageCount), + cumulativeLoadedPackageCount: Number(tree?.cumulativeLoadedPackageCount), + currentRemainderPackageCount: Number(tree?.currentRemainderPackageCount), + children: [...(tree?.children || [])].map((item) => ({ + orderId: id(item?.orderId), sourceOrderId: id(item?.sourceOrderId), + orderNo: item?.orderNo || null, splitRole: item?.splitRole || null, + packageCount: Number(item?.packageCount), loadingPlanId: id(item?.loadingPlanId), + containerBookingId: id(item?.containerBookingId), + })).sort((left, right) => `${left.orderId}|${left.splitRole}`.localeCompare(`${right.orderId}|${right.splitRole}`)), + }) +} async function readPlan(context, planId, name) { const response = await context.request('GET', `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}`) return { response, data: context.apiSuccess(MODULE, name, response, `planId=${planId}`, OPTIONS) } } +async function createSecondGroup(context, third, fourth) { + const response = await context.request('POST', '/api/platform/v1/first-leg/coload-groups', + { orderIds: [third.orderId, fourth.orderId] }, undefined, + { 'Idempotency-Key': `${context.marker}-coload-second-create`.slice(0, 128) }) + const group = context.apiSuccess(MODULE, '创建第二个同柜不可拆组用于足量完成链', response, + evidence(response), OPTIONS) + const frozen = memberFacts(group) + context.assert(MODULE, '第二组冻结两票权威入库单与满箱数', response, + Boolean(group?.groupId) && group?.status === 'ACTIVE' && frozen.length === 2 + && frozen.every((member) => [third, fourth].some((fixture) => fixture.orderId === member.orderId + && fixture.inboundId === member.inboundId && fixture.packages === member.fullPackageCount)), + `groupId=${id(group?.groupId) || 'missing'}, members=${JSON.stringify(frozen)}`, OPTIONS) + return group +} + +async function createAssignedPushedPlan(context, fixture, groupId, suffix) { + const simulationResponse = await context.request('POST', CONTAINERS, { + channelCode: fixture.channelCode, warehouseId: fixture.warehouseId, containerType: '20GP', + capacityCbm: 33, maxWeightKg: 28000, + }) + const simulation = context.apiSuccess(MODULE, `${suffix} 创建组装柜模拟柜`, simulationResponse, + evidence(simulationResponse), OPTIONS) + const bookingId = id(simulation?.id) + if (!bookingId) return null + const planResponse = await context.request('POST', `${SMART}/plans`, { + bookingId, remark: `${context.marker}-${suffix}`, + }) + let plan = context.apiSuccess(MODULE, `${suffix} 创建组装柜计划`, planResponse, + evidence(planResponse), OPTIONS) + const planId = id(plan?.id) + if (!planId) return null + const assignResponse = await context.request('POST', `${SMART}/plans/${encodeURIComponent(planId)}/group-assignments`, { + inboundId: fixture.inboundId, expectedVersion: Number(plan?.version), + operationId: `${context.marker}-${suffix}-assign`.slice(0, 128), + }) + plan = context.apiSuccess(MODULE, `${suffix} 整组原子分配`, assignResponse, + evidence(assignResponse, `groupId=${groupId}`), OPTIONS) + const pushResponse = await context.request('POST', `${SMART}/plans/${encodeURIComponent(planId)}/push-preload`, { + expectedVersion: Number(plan?.version), + }, undefined, { 'Idempotency-Key': `${context.marker}-${suffix}-push`.slice(0, 128) }) + const pushed = context.apiSuccess(MODULE, `${suffix} 整组完整推送预装`, pushResponse, + evidence(pushResponse), OPTIONS) + return pushed ? { containerBookingId: bookingId, planId, plan: pushed, simulation } : null +} + +async function createNormalPushedPlan(context, fixture, suffix) { + const simulationResponse = await context.request('POST', CONTAINERS, { + channelCode: fixture.channelCode, warehouseId: fixture.warehouseId, containerType: '20GP', + capacityCbm: 33, maxWeightKg: 28000, + }) + const simulation = context.apiSuccess(MODULE, `${suffix} 创建普通单模拟柜`, simulationResponse, + evidence(simulationResponse), OPTIONS) + const containerBookingId = id(simulation?.id) + if (!containerBookingId) return null + const planResponse = await context.request('POST', `${SMART}/plans`, { + bookingId: containerBookingId, remark: `${context.marker}-${suffix}`, + }) + let plan = context.apiSuccess(MODULE, `${suffix} 创建普通单装柜计划`, planResponse, + evidence(planResponse), OPTIONS) + const planId = id(plan?.id) + if (!planId) return null + const assignedCount = Math.max(1, Number(fixture.packages) - 1) + const assignResponse = await context.request('POST', + `${SMART}/plans/${encodeURIComponent(planId)}/inbound-assignments`, { + inboundId: fixture.inboundId, packageCount: assignedCount, + expectedVersion: Number(plan?.version), operationId: `${context.marker}-${suffix}-assign`.slice(0, 128), + }) + plan = context.apiSuccess(MODULE, `${suffix} 未成组普通单按数量分配`, assignResponse, + evidence(assignResponse), OPTIONS) + const assignedTotal = (plan?.items || []).filter((item) => id(item?.inboundId) === fixture.inboundId) + .reduce((sum, item) => sum + Number(item?.packageCount || 0), 0) + context.assert(MODULE, `${suffix} 普通单维持既有部分分配语义`, assignResponse, + assignedTotal === assignedCount && assignedCount < Number(fixture.packages), + `assigned=${assignedTotal}, source=${fixture.packages}`, OPTIONS) + const pushResponse = await context.request('POST', `${SMART}/plans/${encodeURIComponent(planId)}/push-preload`, { + expectedVersion: Number(plan?.version), + }, undefined, { 'Idempotency-Key': `${context.marker}-${suffix}-push`.slice(0, 128) }) + const pushed = context.apiSuccess(MODULE, `${suffix} 未成组普通单推送预装不回归`, pushResponse, + evidence(pushResponse), OPTIONS) + return pushed ? { containerBookingId, planId, plan: pushed, simulation, assignedCount } : null +} + +async function createReleasedBookingAndLink(context, fixture, chains) { + const inboundResponse = await context.request('GET', + `/api/platform/inbounds/${encodeURIComponent(fixture.inboundId)}?tenantId=${encodeURIComponent(fixture.tenantId)}`) + const inbound = context.apiSuccess(MODULE, 'PDA链读取订舱主数据', inboundResponse, + evidence(inboundResponse), OPTIONS) + const agentResponse = await context.request('GET', + '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&carrierCode=MATSON&page=1&size=20') + const agentPage = context.apiSuccess(MODULE, '读取已关联MATSON的订舱代理', agentResponse, + evidence(agentResponse), OPTIONS) + const agent = (agentPage?.records || []).find((item) => item?.category === 'BOOKING' && item?.status === 'ACTIVE') + const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS) + context.assert(MODULE, 'PDA链订舱代理与船公司主数据齐全', agentResponse, + Boolean(agent?.id && agent?.name && carrier?.id), + `agentId=${id(agent?.id) || 'missing'}, carrierId=${id(carrier?.id) || 'missing'}`, OPTIONS) + if (!agent?.id || !carrier?.id) return null + const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-18) + const master = { + customerId: fixture.tenantId, customerName: inbound?.summary?.customerName || `验收客户-${fixture.tenantId}`, + channelCode: fixture.channelCode, channelName: fixture.channelName || fixture.channelCode, + warehouseId: fixture.warehouseId, warehouseName: fixture.receivingWarehouseName || inbound?.summary?.warehouseName, + } + const draft = oceanBookingDraft(context, suffix, master, { + bookingAgentId: id(agent.id), bookingAgentName: agent.name, + shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON', + containerRequirements: [{ containerType: '20GP', containerKind: 'FCL', quantity: chains.length, + capacityCbm: 33, maxWeightKg: 28000 }], + }) + const createResponse = await context.request('POST', '/api/platform/v1/ocean-bookings', + { expectedVersion: 0, data: draft }, undefined, + { 'Idempotency-Key': `${context.marker}-coload-pda-booking-create`.slice(0, 128) }) + const created = context.apiSuccess(MODULE, 'PDA链创建订舱草稿', createResponse, + evidence(createResponse), OPTIONS) + const oceanBookingId = id(created?.oceanBooking?.id) + if (!oceanBookingId) return null + const releaseResponse = await context.request('POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/release`, { + expectedVersion: Number(created?.oceanBooking?.version), reason: context.marker, + }, undefined, { 'Idempotency-Key': `${context.marker}-coload-pda-booking-release`.slice(0, 128) }) + const released = context.apiSuccess(MODULE, 'PDA链放舱并冻结两柜需求', releaseResponse, + evidence(releaseResponse), OPTIONS) + const requirement = (released?.containerRequirements || []).find((item) => item?.containerType === '20GP') + context.assert(MODULE, '放舱需求数量覆盖短拣与足量两柜', releaseResponse, + released?.oceanBooking?.status === 'RELEASED' && Number(requirement?.quantity) === chains.length, + `status=${released?.oceanBooking?.status || 'missing'}, quantity=${requirement?.quantity ?? 'missing'}`, OPTIONS) + if (!requirement?.id) return null + for (let index = 0; index < chains.length; index += 1) { + const chain = chains[index] + const detailResponse = await context.request('GET', `${CONTAINERS}/${chain.containerBookingId}`) + const detail = context.apiSuccess(MODULE, `${chain.label} 关联前读取柜版本`, detailResponse, + evidence(detailResponse), OPTIONS) + const containerNo = `CL${suffix}${index + 1}`.slice(0, 64) + const linkResponse = await context.request('POST', `${CONTAINERS}/${chain.containerBookingId}/ocean-booking-link`, { + oceanBookingId, requirementId: id(requirement.id), containerNo, + expectedVersion: Number(detail?.version), + }) + const linked = context.apiSuccess(MODULE, `${chain.label} 关联RELEASED订舱并补真实柜号`, linkResponse, + evidence(linkResponse), OPTIONS) + context.assert(MODULE, `${chain.label} 保持原计划柜次身份`, linkResponse, + id(linked?.id) === chain.containerBookingId && id(linked?.oceanBookingId) === oceanBookingId + && linked?.containerNo === containerNo, + `containerBookingId=${id(linked?.id) || 'missing'}, containerNo=${linked?.containerNo || 'missing'}`, OPTIONS) + chain.containerNo = containerNo + } + return oceanBookingId +} + +async function confirmAndClaimTask(context, chain, fixture) { + const key = chain.key || chain.label.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 32) || 'chain' + const detailResponse = await context.request('GET', + `${FIRST_LEG}/loading-plans/${encodeURIComponent(chain.planId)}?warehouseId=${encodeURIComponent(fixture.warehouseId)}`) + const detail = context.apiSuccess(MODULE, `${chain.label} 读取预装确认门禁`, detailResponse, + evidence(detailResponse), OPTIONS) + context.assert(MODULE, `${chain.label} 订舱关联后整份计划可确认`, detailResponse, + detail?.summary?.status === 'EDITING' && detail?.summary?.readiness?.canConfirm === true, + `status=${detail?.summary?.status || 'missing'}, canConfirm=${detail?.summary?.readiness?.canConfirm}`, OPTIONS) + if (detail?.summary?.readiness?.canConfirm !== true) return null + const confirmResponse = await context.request('POST', + `${FIRST_LEG}/loading-plans/${encodeURIComponent(chain.planId)}/confirm`, { + expectedVersion: Number(detail?.summary?.version), + }, undefined, { 'Idempotency-Key': `${context.marker}-${key}-confirm`.slice(0, 128) }) + const confirmed = context.apiSuccess(MODULE, `${chain.label} 原子确认并生成PDA任务`, confirmResponse, + evidence(confirmResponse), OPTIONS) + const taskId = id(confirmed?.readiness?.pdaTaskId) + if (!taskId) return null + const taskResponse = await context.request('GET', + `${PDA}/tasks?loadingPlanId=${encodeURIComponent(chain.planId)}&warehouseId=${encodeURIComponent(fixture.warehouseId)}`) + const task = context.apiSuccess(MODULE, `${chain.label} 读取唯一待领取PDA任务`, taskResponse, + evidence(taskResponse), OPTIONS) + const claimResponse = await context.request('POST', `${PDA}/tasks/${taskId}/claim`, { + expectedAssignmentVersion: Number(task?.assignment?.version || 0), + operationId: `${context.marker}-${key}-claim`.slice(0, 128), + }) + const claimed = context.apiSuccess(MODULE, `${chain.label} 领取PDA任务`, claimResponse, + evidence(claimResponse), OPTIONS) + const inventoryResponse = await context.request('GET', + `${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(fixture.warehouseId)}`) + const inventory = context.apiSuccess(MODULE, `${chain.label} 读取实时库存快照`, inventoryResponse, + evidence(inventoryResponse), OPTIONS) + context.assert(MODULE, `${chain.label} PDA任务包含同柜组全部入库码`, taskResponse, + chain.inboundIds.every((inboundId) => (claimed?.items || []).some((item) => id(item?.inboundId) === inboundId)) + && typeof inventory?.snapshotToken === 'string' && inventory.snapshotToken.length === 64, + `taskId=${taskId}, inboundIds=${[...new Set((claimed?.items || []).map((item) => id(item?.inboundId)))].join(',')}`, OPTIONS) + return { task: claimed, inventory, taskId } +} + +async function fulfillmentTrees(context, fixtures, name) { + const snapshots = [] + for (const fixture of fixtures) { + const response = await context.request('GET', + `/api/platform/v1/orders/${encodeURIComponent(fixture.orderId)}/fulfillment-tree?tenantId=${encodeURIComponent(fixture.tenantId)}`) + const tree = context.apiSuccess(MODULE, `${name} ${fixture.orderId} 履约树`, response, + evidence(response), OPTIONS) + snapshots.push({ fixture, response, tree, signature: treeSignature(tree) }) + } + return snapshots +} + +async function uploadCompletionEvidence(context, fixture, label) { + const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130]) + for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) { + const path = `/api/platform/v1/files/loading-evidence/${fixture.tenantId}/${fixture.inboundId}?evidenceType=${encodeURIComponent(evidenceType)}` + const form = new FormData() + form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-${label}-${evidenceType}.png`) + let uploadResponse + try { + const raw = await fetch(`${context.options.gateway}${path}`, { + method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, + body: form, signal: AbortSignal.timeout(60_000), + }) + const body = await raw.json() + uploadResponse = { method: 'POST', path, http: raw.status, body, + requestId: body?.requestId || raw.headers.get('x-request-id'), + traceId: body?.traceId || raw.headers.get('x-trace-id') } + } catch (error) { + uploadResponse = { method: 'POST', path, http: 0, body: null, raw: String(error) } + } + const uploaded = context.apiSuccess(MODULE, `${label} 上传 ${evidenceType} 柜级证据`, uploadResponse, + evidence(uploadResponse), OPTIONS) + const fileId = id(uploaded?.fileId || uploaded?.id) + if (!fileId) return false + const bindResponse = await context.request('POST', + `/api/platform/v1/inbounds/${fixture.inboundId}/loading/evidence?tenantId=${encodeURIComponent(fixture.tenantId)}`, { + expectedVersion: 0, evidenceType, fileId, + sealNo: evidenceType === 'SEAL' ? `SEAL-${context.marker}`.slice(0, 64) : null, + remark: context.marker, + }, undefined, { 'Idempotency-Key': `${context.marker}-${label}-${evidenceType}`.slice(0, 128) }) + const bound = context.apiSuccess(MODULE, `${label} 绑定 ${evidenceType} 柜级证据`, bindResponse, + evidence(bindResponse), OPTIONS) + if (!bound) return false + } + return true +} + +async function pollCompletedTask(context, taskId, warehouseId, label) { + let response = null + let task = null + for (let attempt = 1; attempt <= 90; attempt += 1) { + response = await context.request('GET', + `${PDA}/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(warehouseId)}`) + if (code(response) === '200') task = response.body?.data + if (task?.taskStatus === 'COMPLETED' && task?.workflowStatus === 'COMPLETED') break + if (task?.taskStatus === 'COMPLETION_FAILED') break + await new Promise((resolve) => setTimeout(resolve, 1_000)) + } + context.assert(MODULE, `${label} 异步完成最终收敛`, response, + task?.taskStatus === 'COMPLETED' && task?.workflowStatus === 'COMPLETED', + evidence(response, `taskStatus=${task?.taskStatus || 'missing'}, workflow=${task?.workflowStatus || 'missing'}`), OPTIONS) + return { response, task } +} + async function createDifferentRouteFixture(context, base) { const orders = await import(new URL('./orders.mjs', import.meta.url)) const measurement = await import(new URL('./inbound-measurement.mjs', import.meta.url)) @@ -42,6 +324,8 @@ async function createDifferentRouteFixture(context, base) { const originalFulfillment = context.context.orderFulfillmentType const originalRoute = context.context.waitSplitRoute const originalVariant = context.context.waitSplitVariant + const originalDetailCount = context.context.phase3LoadingIncrementalDetailCount + const originalPackageCount = context.context.phase3LoadingIncrementalPackageCount try { for (let attempt = 1; attempt <= 3; attempt += 1) { context.marker = `${originalMarker}-ROUTE-${attempt}` @@ -52,11 +336,13 @@ async function createDifferentRouteFixture(context, base) { context.context.orderDestinationType = context.context.orderProfile.destinationType context.context.orderFulfillmentType = 'STANDARD' context.context.waitSplitVariant = { - sequence: 90 + attempt, skuCount: 1, packageCountPerSku: 2, packageCounts: [2], - totalPackageCount: 2, palletCount: 1, grossWeightPerPalletKg: 45, + sequence: 90 + attempt, skuCount: 4, packageCountPerSku: 1, packageCounts: [1, 1, 1, 1], + totalPackageCount: 4, palletCount: 1, grossWeightPerPalletKg: 45, measurementProfile: { code: 'SMALL', first: { length: 29.1, width: 19.1, height: 18.1 }, replacement: { length: 29.5, width: 19.5, height: 18.5 }, outer: { length: 34, width: 24, height: 24 } }, } + context.context.phase3LoadingIncrementalDetailCount = 4 + context.context.phase3LoadingIncrementalPackageCount = 1 context.context.waitSplitRoute = { originWarehouseName: base.receivingWarehouseName || '', randomize: true, randomIndex: 50 + attempt * 31, destinationIndex: 80 + attempt * 37, taxModeIndex: attempt, @@ -94,6 +380,8 @@ async function createDifferentRouteFixture(context, base) { context.context.orderFulfillmentType = originalFulfillment context.context.waitSplitRoute = originalRoute context.context.waitSplitVariant = originalVariant + context.context.phase3LoadingIncrementalDetailCount = originalDetailCount + context.context.phase3LoadingIncrementalPackageCount = originalPackageCount } } @@ -104,12 +392,12 @@ export async function run(context) { })) const usable = fixtures.filter((item) => item.orderId && item.inboundId && item.tenantId && item.warehouseId && item.channelCode && item.packages > 0) - context.assert(MODULE, '取得至少四票本轮 WAIT_SPLIT 新数据', null, usable.length >= 4, + context.assert(MODULE, '取得至少七票本轮 WAIT_SPLIT 新数据', null, usable.length >= 7, `count=${usable.length}, marker=${context.marker}`, OPTIONS) - if (usable.length < 4) return + if (usable.length < 7) return - const [first, second, third, fourth] = usable - const sameRoute = [second, third, fourth].every((item) => item.warehouseId === first.warehouseId + const [first, second, third, fourth, fifth, sixth, seventh] = usable + const sameRoute = [second, third, fourth, fifth, sixth, seventh].every((item) => item.warehouseId === first.warehouseId && item.channelCode === first.channelCode && item.destinationType === first.destinationType && id(item.destinationWarehouseId) === id(first.destinationWarehouseId) && id(item.destinationWarehouseCode) === id(first.destinationWarehouseCode)) @@ -175,8 +463,8 @@ export async function run(context) { `count=${queried.length}, orderIds=${queried.map((item) => id(item?.orderId)).join(',')}`, OPTIONS) const simulationResponse = await context.request('POST', '/api/platform/v1/containers', { - channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '40HC', - capacityCbm: 76, maxWeightKg: 28000, + channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '20GP', + capacityCbm: 33, maxWeightKg: 28000, }) const simulation = context.apiSuccess(MODULE, '创建本轮组装柜模拟柜', simulationResponse, evidence(simulationResponse), OPTIONS) @@ -221,10 +509,12 @@ export async function run(context) { plan = context.apiSuccess(MODULE, '整组原子分配全部成员', assignResponse, evidence(assignResponse, `groupId=${groupId}`), OPTIONS) const assigned = (plan?.items || []).filter((item) => [first.inboundId, second.inboundId].includes(id(item?.inboundId))) + const assignedTotals = new Map() + for (const item of assigned) assignedTotals.set(id(item?.inboundId), + (assignedTotals.get(id(item?.inboundId)) || 0) + Number(item?.packageCount || 0)) context.assert(MODULE, '整组分配同计划且每票均为满箱数', assignResponse, - assigned.length === 2 && assigned.every((item) => [first, second].some((fixture) => - fixture.inboundId === id(item?.inboundId) && fixture.packages === Number(item?.packageCount))), - `items=${assigned.map((item) => `${id(item?.inboundId)}:${Number(item?.packageCount)}`).join(',')}`, OPTIONS) + [first, second].every((fixture) => assignedTotals.get(fixture.inboundId) === fixture.packages), + `inboundTotals=${JSON.stringify(Object.fromEntries(assignedTotals))}`, OPTIONS) const lockedQueryResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query', { orderIds: groupOrders }) @@ -280,17 +570,74 @@ export async function run(context) { && item?.group?.lockedPlanId == null), `status=${activeQuery[0]?.group?.status || 'missing'}, lockedPlanId=${id(activeQuery[0]?.group?.lockedPlanId) || 'null'}`, OPTIONS) + let isolatedCandidateResponse = await context.request('GET', + `${SMART}/inbound-candidates?bookingId=${encodeURIComponent(bookingId)}&warehouseId=${encodeURIComponent(first.warehouseId)}`) + let isolatedCandidates = context.apiSuccess(MODULE, '推荐前隔离读取目标组候选', isolatedCandidateResponse, + evidence(isolatedCandidateResponse), OPTIONS) || [] + const competing = [...new Set(isolatedCandidates.filter((item) => item?.locked !== true) + .map((item) => id(item?.coLoadGroupId)).filter((value) => value && value !== groupId))] + for (const competingGroupId of competing) { + const competingOrders = isolatedCandidates.filter((item) => id(item?.coLoadGroupId) === competingGroupId) + .map((item) => id(item?.orderId)).filter(Boolean) + const competingQueryResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query', + { orderIds: competingOrders }) + const competingQuery = context.apiSuccess(MODULE, '清理前回读旧验收活动组', competingQueryResponse, + evidence(competingQueryResponse, `groupId=${competingGroupId}`), OPTIONS) || [] + const competingGroup = competingQuery.find((item) => id(item?.group?.groupId) === competingGroupId)?.group + if (competingGroup?.status === 'ACTIVE') { + const cleanupResponse = await context.request('POST', + `/api/platform/v1/first-leg/coload-groups/${encodeURIComponent(competingGroupId)}/cancel`, { + expectedVersion: Number(competingGroup.version), reason: '清理旧HTTP验收活动组以隔离容量候选', + }, undefined, { 'Idempotency-Key': `${context.marker}-cleanup-${competingGroupId}`.slice(0, 128) }) + context.apiSuccess(MODULE, '通过公开接口取消旧验收活动组', cleanupResponse, + evidence(cleanupResponse, `groupId=${competingGroupId}`), OPTIONS) + } + } + if (competing.length > 0) { + isolatedCandidateResponse = await context.request('GET', + `${SMART}/inbound-candidates?bookingId=${encodeURIComponent(bookingId)}&warehouseId=${encodeURIComponent(first.warehouseId)}`) + isolatedCandidates = context.apiSuccess(MODULE, '清理后重新隔离读取目标组候选', isolatedCandidateResponse, + evidence(isolatedCandidateResponse), OPTIONS) || [] + } + const candidateGroupIds = [...new Set(isolatedCandidates.filter((item) => item?.locked !== true) + .map((item) => id(item?.coLoadGroupId)).filter(Boolean))] + const targetCandidateInbounds = isolatedCandidates.filter((item) => id(item?.coLoadGroupId) === groupId) + .map((item) => id(item?.inboundId)).sort() + context.assert(MODULE, '推荐候选仅含当前目标同柜组且成员身份精确', isolatedCandidateResponse, + candidateGroupIds.length === 1 && candidateGroupIds[0] === groupId + && JSON.stringify(targetCandidateInbounds) === JSON.stringify([first.inboundId, second.inboundId].sort()), + `groupId=${groupId}, candidateGroupIds=${candidateGroupIds.join('|') || 'none'}, members=${targetCandidateInbounds.join('|')}, capacity=33cbm/28000kg`, OPTIONS) + + const targetCandidateVolume = isolatedCandidates.filter((item) => id(item?.coLoadGroupId) === groupId) + .reduce((sum, item) => sum + Number(item?.availableVolumeCbm || 0), 0) + context.assert(MODULE, '目标组自身总体积明确超过20GP容量容差', isolatedCandidateResponse, + targetCandidateVolume > 33 * 1.1, + `targetGroupId=${groupId}, volume=${targetCandidateVolume}, limit=${33 * 1.1}`, OPTIONS) + + const beforeRecommend = plan const recommendResponse = await context.request('POST', `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/recommend`, { expectedVersion: Number(plan?.version), operationId: `${context.marker}-coload-recommend`.slice(0, 128), }) - plan = context.apiSuccess(MODULE, '智能推荐按超级项处理同柜组', recommendResponse, - evidence(recommendResponse), OPTIONS) + const recommendSucceeded = recommendResponse?.http === 200 && code(recommendResponse) === '200' + const capacityRejected = recommendResponse?.http === 200 && code(recommendResponse) === 'A0422' + && String(recommendResponse?.body?.message || '').includes('同柜不可拆组整体超过货柜') + context.assert(MODULE, '目标同柜组整体超容时智能推荐明确拒绝', recommendResponse, + capacityRejected, + evidence(recommendResponse, `targetGroupId=${groupId}, members=${[first.inboundId, second.inboundId].join('|')}, message=${recommendResponse?.body?.message || 'none'}`), OPTIONS) + plan = recommendSucceeded ? recommendResponse.body.data : beforeRecommend const recommendedIds = new Set((plan?.items || []).map((item) => id(item?.inboundId))) const recommendedMembers = [first.inboundId, second.inboundId].filter((value) => recommendedIds.has(value)) context.assert(MODULE, '智能推荐对整组全取或全不取', recommendResponse, recommendedMembers.length === 0 || recommendedMembers.length === 2, `recommendedMembers=${recommendedMembers.join(',') || 'none'}`, OPTIONS) + if (capacityRejected) { + const afterRecommend = await readPlan(context, planId, '回读整组容量拒绝后计划') + context.assert(MODULE, '整组容量不足时推荐事务零漂移', afterRecommend.response, + planSignature(beforeRecommend) === planSignature(afterRecommend.data), + `before=${planSignature(beforeRecommend)}, after=${planSignature(afterRecommend.data)}`, OPTIONS) + plan = afterRecommend.data + } if (recommendedMembers.length === 0) { const manualGroupResponse = await context.request('POST', `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/group-assignments`, { @@ -309,14 +656,215 @@ export async function run(context) { }, undefined, { 'Idempotency-Key': pushKey }) const pushed = context.apiSuccess(MODULE, '整组完整时推送预装成功', pushResponse, evidence(pushResponse), OPTIONS) + const pushedTotals = new Map() + for (const item of pushed?.items || []) pushedTotals.set(id(item?.inboundId), + (pushedTotals.get(id(item?.inboundId)) || 0) + Number(item?.packageCount || 0)) context.assert(MODULE, '推送后整组仍保持同计划满量事实', pushResponse, - [first, second].every((fixture) => (pushed?.items || []).some((item) => - id(item?.inboundId) === fixture.inboundId && Number(item?.packageCount) === fixture.packages)), - `planId=${planId}, itemCount=${pushed?.items?.length ?? 'missing'}`, OPTIONS) + [first, second].every((fixture) => pushedTotals.get(fixture.inboundId) === fixture.packages), + `planId=${planId}, inboundTotals=${JSON.stringify(Object.fromEntries(pushedTotals))}`, OPTIONS) + + const secondGroup = await createSecondGroup(context, third, fourth) + const secondGroupId = id(secondGroup?.groupId) + if (!secondGroupId) return + const fullChainBase = await createAssignedPushedPlan(context, third, secondGroupId, 'coload-full') + const normalChainBase = await createNormalPushedPlan(context, seventh, 'normal-split') + if (!fullChainBase || !normalChainBase) return + + const cancelCreateResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups', + { orderIds: [fifth.orderId, sixth.orderId] }, undefined, + { 'Idempotency-Key': `${context.marker}-coload-cancel-create`.slice(0, 128) }) + const cancelGroup = context.apiSuccess(MODULE, '创建仅用于ACTIVE取消的同柜组', cancelCreateResponse, + evidence(cancelCreateResponse), OPTIONS) + const cancelGroupId = id(cancelGroup?.groupId) + if (!cancelGroupId) return + const cancelResponse = await context.request('POST', + `/api/platform/v1/first-leg/coload-groups/${encodeURIComponent(cancelGroupId)}/cancel`, { + expectedVersion: Number(cancelGroup?.version), reason: '验收ACTIVE整组取消', + }, undefined, { 'Idempotency-Key': `${context.marker}-coload-active-cancel`.slice(0, 128) }) + const cancelled = context.apiSuccess(MODULE, 'ACTIVE同柜组整组取消成功', cancelResponse, + evidence(cancelResponse), OPTIONS) + const cancelledQueryResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query', + { orderIds: [fifth.orderId, sixth.orderId] }) + const cancelledQuery = context.apiSuccess(MODULE, '取消后回读成员活动组事实', cancelledQueryResponse, + evidence(cancelledQueryResponse), OPTIONS) || [] + context.assert(MODULE, '取消组进入CANCELLED且成员不再受活动组约束', cancelResponse, + cancelled?.status === 'CANCELLED' && cancelledQuery.length === 0, + `status=${cancelled?.status || 'missing'}, activeRows=${cancelledQuery.length}`, OPTIONS) + + const partialChain = { + label: '同柜短拣链', key: 'coload-partial', groupId, containerBookingId: bookingId, planId, + fixtures: [first, second], inboundIds: [first.inboundId, second.inboundId], + } + const fullChain = { + label: '同柜足量链', key: 'coload-full', groupId: secondGroupId, ...fullChainBase, + fixtures: [third, fourth], inboundIds: [third.inboundId, fourth.inboundId], + } + const normalChain = { + label: '普通单拆分链', key: 'normal-split', ...normalChainBase, + fixtures: [seventh], inboundIds: [seventh.inboundId], + } + const oceanBookingId = await createReleasedBookingAndLink(context, first, + [partialChain, fullChain, normalChain]) + if (!oceanBookingId) return + + const partialPda = await confirmAndClaimTask(context, partialChain, first) + const fullPda = await confirmAndClaimTask(context, fullChain, third) + const normalPda = await confirmAndClaimTask(context, normalChain, seventh) + if (!partialPda || !fullPda || !normalPda) return + + const partialTreesBefore = await fulfillmentTrees(context, partialChain.fixtures, '短拣前') + const partialTarget = (partialPda.task?.items || []).find((item) => Number(item?.plannedPackageCount) > 1) + context.assert(MODULE, '短拣链存在可记录PARTIAL的多箱明细', null, Boolean(partialTarget?.id), + `planned=${partialTarget?.plannedPackageCount ?? 'missing'}, taskItems=${partialPda.task?.items?.length ?? 0}`, OPTIONS) + if (!partialTarget?.id) return + const partialBatchItems = (partialPda.task.items || []).map((item) => id(item.id) === id(partialTarget.id) + ? { itemId: id(item.id), action: 'PARTIAL', pickedPackageCount: null, + finalPickedPackageCount: Number(item.plannedPackageCount) - 1, releaseMode: 'MANUAL', + locationIds: [], version: Number(item.version), reason: '同柜不可拆组短拣异常验收' } + : { itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount), + finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [], + version: Number(item.version), reason: null }) + const partialBatchResponse = await context.request('POST', `${PDA}/tasks/${partialPda.taskId}/batch`, { + warehouseId: first.warehouseId, loadingPlanId: partialChain.planId, + containerBookingId: partialChain.containerBookingId, expectedVersion: Number(partialPda.task.version), + inventorySnapshotToken: partialPda.inventory.snapshotToken, items: partialBatchItems, + }, undefined, { 'Idempotency-Key': `${context.marker}-coload-partial-batch`.slice(0, 128) }) + const partialBatch = context.apiSuccess(MODULE, '同柜组PDA原子批次记录部分拣货异常', partialBatchResponse, + evidence(partialBatchResponse), OPTIONS) + context.assert(MODULE, '短拣批次只进入待确认且保留PARTIAL事实', partialBatchResponse, + partialBatch?.taskStatus === 'LOADING' && partialBatch?.workflowStatus === 'AWAITING_REVIEW' + && partialBatch?.items?.some((item) => id(item?.id) === id(partialTarget.id) + && item?.itemStatus === 'PARTIAL' + && Number(item?.pickedPackageCount) === Number(partialTarget.plannedPackageCount) - 1), + `taskStatus=${partialBatch?.taskStatus || 'missing'}, workflow=${partialBatch?.workflowStatus || 'missing'}`, OPTIONS) + const partialTaskBeforeComplete = taskSignature(partialBatch) + const partialCompleteResponse = await context.request('POST', `${PDA}/tasks/${partialPda.taskId}/complete`, { + expectedTaskVersion: Number(partialBatch?.version), + operationId: `${context.marker}-coload-partial-complete`.slice(0, 128), + remark: '同柜不可拆组短拣不得完成', + }) + conflict(context, '同柜组任一成员短拣时完成被拒绝', partialCompleteResponse, + `message=${partialCompleteResponse?.body?.message || 'missing'}`) + context.assert(MODULE, '短拣完成拒绝明确命中全组箱数守恒', partialCompleteResponse, + String(partialCompleteResponse?.body?.message || '').includes('全组箱数守恒'), + evidence(partialCompleteResponse, `message=${partialCompleteResponse?.body?.message || 'missing'}`), OPTIONS) + const partialTaskAfterResponse = await context.request('GET', + `${PDA}/tasks/${partialPda.taskId}?warehouseId=${encodeURIComponent(first.warehouseId)}`) + const partialTaskAfter = context.apiSuccess(MODULE, '短拣完成拒绝后回读PDA任务', partialTaskAfterResponse, + evidence(partialTaskAfterResponse), OPTIONS) + const partialTreesAfter = await fulfillmentTrees(context, partialChain.fixtures, '短拣拒绝后') + const partialGroupResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query', + { orderIds: partialChain.fixtures.map((fixture) => fixture.orderId) }) + const partialGroup = context.apiSuccess(MODULE, '短拣拒绝后回读组状态', partialGroupResponse, + evidence(partialGroupResponse), OPTIONS) || [] + context.assert(MODULE, '短拣完成拒绝后任务、父子树和组锁零漂移', partialTaskAfterResponse, + partialTaskBeforeComplete === taskSignature(partialTaskAfter) + && partialTreesBefore.every((before, index) => before.signature === partialTreesAfter[index]?.signature + && (partialTreesAfter[index]?.tree?.children || []).length === 0) + && partialGroup.length === 2 && partialGroup.every((item) => item?.group?.status === 'LOCKED' + && id(item?.group?.lockedPlanId) === partialChain.planId), + `taskStable=${partialTaskBeforeComplete === taskSignature(partialTaskAfter)}, children=${partialTreesAfter.map((item) => item.tree?.children?.length ?? -1).join('|')}, groupStatus=${partialGroup[0]?.group?.status || 'missing'}`, OPTIONS) + + const fullTreesBefore = await fulfillmentTrees(context, fullChain.fixtures, '足量完成前') + const fullBatchItems = (fullPda.task?.items || []).map((item) => ({ + itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount), + finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [], + version: Number(item.version), reason: null, + })) + const fullBatchResponse = await context.request('POST', `${PDA}/tasks/${fullPda.taskId}/batch`, { + warehouseId: third.warehouseId, loadingPlanId: fullChain.planId, + containerBookingId: fullChain.containerBookingId, expectedVersion: Number(fullPda.task.version), + inventorySnapshotToken: fullPda.inventory.snapshotToken, items: fullBatchItems, + }, undefined, { 'Idempotency-Key': `${context.marker}-coload-full-batch`.slice(0, 128) }) + const fullBatch = context.apiSuccess(MODULE, '同柜组PDA全成员足量批量拣货', fullBatchResponse, + evidence(fullBatchResponse), OPTIONS) + context.assert(MODULE, '足量批次全明细进入待确认', fullBatchResponse, + fullBatch?.workflowStatus === 'AWAITING_REVIEW' + && fullBatch?.items?.every((item) => Number(item?.pickedPackageCount) === Number(item?.plannedPackageCount)), + `workflow=${fullBatch?.workflowStatus || 'missing'}, items=${fullBatch?.items?.length ?? 0}`, OPTIONS) + if (!await uploadCompletionEvidence(context, third, 'coload-full')) return + const fullCompleteResponse = await context.request('POST', `${PDA}/tasks/${fullPda.taskId}/complete`, { + expectedTaskVersion: Number(fullBatch?.version), + operationId: `${context.marker}-coload-full-complete`.slice(0, 128), remark: context.marker, + }) + const acceptedFull = context.apiSuccess(MODULE, '同柜组足量完成命令可靠受理', fullCompleteResponse, + evidence(fullCompleteResponse), OPTIONS) + context.assert(MODULE, '足量完成先返回PROCESSING或已完成事实', fullCompleteResponse, + ['PROCESSING', 'COMPLETED'].includes(acceptedFull?.workflowStatus), + `taskStatus=${acceptedFull?.taskStatus || 'missing'}, workflow=${acceptedFull?.workflowStatus || 'missing'}`, OPTIONS) + const fullFinal = await pollCompletedTask(context, fullPda.taskId, third.warehouseId, '同柜组足量') + if (fullFinal.task?.taskStatus !== 'COMPLETED') return + const fullTreesAfter = await fulfillmentTrees(context, fullChain.fixtures, '足量完成后') + const consumedGroupResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query', + { orderIds: fullChain.fixtures.map((fixture) => fixture.orderId) }) + const consumedGroup = context.apiSuccess(MODULE, '足量完成后回读组消费事实', consumedGroupResponse, + evidence(consumedGroupResponse), OPTIONS) || [] + const completedInboundStates = [] + for (const fixture of fullChain.fixtures) { + const response = await context.request('GET', + `/api/platform/inbounds/${encodeURIComponent(fixture.inboundId)}?tenantId=${encodeURIComponent(fixture.tenantId)}`) + const inbound = context.apiSuccess(MODULE, `足量完成后回读入库单 ${fixture.inboundId}`, response, + evidence(response), OPTIONS) + completedInboundStates.push(inbound?.summary?.processStatus) + } + context.assert(MODULE, '足量完成整组已消费、全部入库完成且不生成子单', consumedGroupResponse, + consumedGroup.length === 2 && consumedGroup.every((item) => item?.group?.status === 'CONSUMED') + && completedInboundStates.every((status) => status === 'LOADED') + && fullTreesBefore.every((before, index) => (fullTreesAfter[index]?.tree?.children || []).length === 0 + && before.signature === fullTreesAfter[index]?.signature), + `groupStatus=${consumedGroup[0]?.group?.status || 'missing'}, inboundStates=${completedInboundStates.join('|')}, children=${fullTreesAfter.map((item) => item.tree?.children?.length ?? -1).join('|')}`, OPTIONS) + + const normalTreeBefore = await fulfillmentTrees(context, normalChain.fixtures, '普通单完成前') + const normalBatchItems = (normalPda.task?.items || []).map((item) => ({ + itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount), + finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [], + version: Number(item.version), reason: null, + })) + const normalBatchResponse = await context.request('POST', `${PDA}/tasks/${normalPda.taskId}/batch`, { + warehouseId: seventh.warehouseId, loadingPlanId: normalChain.planId, + containerBookingId: normalChain.containerBookingId, expectedVersion: Number(normalPda.task.version), + inventorySnapshotToken: normalPda.inventory.snapshotToken, items: normalBatchItems, + }, undefined, { 'Idempotency-Key': `${context.marker}-normal-batch`.slice(0, 128) }) + const normalBatch = context.apiSuccess(MODULE, '未成组普通单PDA批量拣货不回归', normalBatchResponse, + evidence(normalBatchResponse), OPTIONS) + if (!await uploadCompletionEvidence(context, seventh, 'normal-split')) return + const normalCompleteResponse = await context.request('POST', `${PDA}/tasks/${normalPda.taskId}/complete`, { + expectedTaskVersion: Number(normalBatch?.version), + operationId: `${context.marker}-normal-complete`.slice(0, 128), remark: context.marker, + }) + context.apiSuccess(MODULE, '未成组普通单完成命令受理', normalCompleteResponse, + evidence(normalCompleteResponse), OPTIONS) + const normalFinal = await pollCompletedTask(context, normalPda.taskId, seventh.warehouseId, '未成组普通单') + if (normalFinal.task?.taskStatus !== 'COMPLETED') return + const normalTreeAfter = await fulfillmentTrees(context, normalChain.fixtures, '普通单完成后') + const normalTree = normalTreeAfter[0]?.tree + const remainder = (normalTree?.children || []).find((item) => item?.splitRole === 'REMAINDER') + context.assert(MODULE, '未成组普通单仍按既有规则生成唯一余量子单', normalTreeAfter[0]?.response, + normalTreeBefore[0]?.tree?.children?.length === 0 && Boolean(remainder?.orderId) + && Number(normalTree?.cumulativeLoadedPackageCount) === Number(normalChain.assignedCount) + && Number(normalTree?.currentRemainderPackageCount) === Number(seventh.packages) - Number(normalChain.assignedCount), + `children=${normalTree?.children?.length ?? 0}, remainderOrderId=${id(remainder?.orderId) || 'missing'}`, OPTIONS) + if (!remainder?.orderId) return + const splitTreeBeforeReject = treeSignature(normalTree) + const splitGroupResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups', + { orderIds: [seventh.orderId, id(remainder.orderId)] }, undefined, + { 'Idempotency-Key': `${context.marker}-coload-split-reject`.slice(0, 128) }) + conflict(context, '已有父子拆分订单创建同柜组被拒绝', splitGroupResponse, + `rootOrderId=${seventh.orderId}, childOrderId=${id(remainder.orderId)}`) + const splitTreeAfterResponse = await context.request('GET', + `/api/platform/v1/orders/${encodeURIComponent(seventh.orderId)}/fulfillment-tree?tenantId=${encodeURIComponent(seventh.tenantId)}`) + const splitTreeAfter = context.apiSuccess(MODULE, '父子订单建组拒绝后回读履约树', splitTreeAfterResponse, + evidence(splitTreeAfterResponse), OPTIONS) + context.assert(MODULE, '父子订单建组拒绝后原普通拆分事实零漂移', splitTreeAfterResponse, + splitTreeBeforeReject === treeSignature(splitTreeAfter), + `treeStable=${splitTreeBeforeReject === treeSignature(splitTreeAfter)}`, OPTIONS) context.createdResources.coloadGroupAcceptance = { marker: context.marker, groupId, groupNo: group?.groupNo || null, planId, bookingId, orderIds: groupOrders, inboundIds: [first.inboundId, second.inboundId], - publicBypassResult: 'individual assignment/adjust/delete rejected before push', + fullGroupId: secondGroupId, fullTaskId: fullPda.taskId, fullPlanId: fullChain.planId, + partialTaskId: partialPda.taskId, normalTaskId: normalPda.taskId, + oceanBookingId, cancelledGroupId: cancelGroupId, remainderOrderId: id(remainder.orderId), + publicBypassResult: 'individual assignment/adjust/delete and short-pick completion rejected; full group consumed', } }