From 800ebb875f0c09f3b6c23e11beae463bf959f5c9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 10 Sep 2026 21:18:52 +0800 Subject: [PATCH] fix(acceptance): enforce immutable confirmed fees --- .../service/CarrierScheduleQueryService.java | 11 ++++ .../firstleg/service/FbaPricingService.java | 13 ++-- .../CarrierScheduleQueryServiceTest.java | 20 +++++++ .../service/FbaPricingServiceTest.java | 5 +- scripts/admin-test-env-acceptance.mjs | 13 ++-- .../coload-groups-contract.test.mjs | 2 + .../admin-test-env-modules/coload-groups.mjs | 26 +++----- ...container-fee-allocation-contract.test.mjs | 1 + .../firstleg-container-fee-allocation.mjs | 3 +- ...manual-state-loading-pda-contract.test.mjs | 1 + .../manual-state-loading-pda.mjs | 3 +- .../phase3-order-splitting-contract.test.mjs | 8 +++ .../phase3-order-splitting.mjs | 59 ++++++++++++++----- ...verseas-acceptance-entry-contract.test.mjs | 4 +- 14 files changed, 116 insertions(+), 53 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java index 5741edc80..7ec142ab3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java @@ -222,6 +222,7 @@ public class CarrierScheduleQueryService { } CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion()); CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); + requireManualActualAdvance(value, node, request.actualAt()); CarrierScheduleQueryEntity before = copy(value); String providerEventId = sha256("MANUAL|" + id + '|' + key); CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node, @@ -306,6 +307,16 @@ public class CarrierScheduleQueryService { return false; } + private void requireManualActualAdvance(CarrierScheduleQueryEntity value, CarrierScheduleNode node, + LocalDateTime actualAt) { + if (actualAt == null) return; + CarrierScheduleNode current = value.getLatestStandardNode() == null ? null + : CarrierScheduleNode.require(value.getLatestStandardNode()); + if ("STOPPED".equals(value.getStatus()) || current != null && node.sequence() <= current.sequence()) { + throw conflict("实际船节点必须按业务顺序向后推进"); + } + } + public boolean hasOutboundDepartureFact(long customerId, long oceanBookingId, long containerBookingId) { CarrierScheduleQueryEntity query = repository.findByBusinessScope(customerId, oceanBookingId, containerBookingId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 7d5fcda01..e27119f62 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -98,8 +98,8 @@ public class FbaPricingService { if (published == null || !"SENT".equals(published.getFeeStatus())) { throw conflict("费用状态已变化,请刷新后重试"); } - if (feeRevisionLocked(inbound, published)) { - throw conflict("客户已确认且该票已装柜完成,不允许再修改或重新发送费用"); + if (feeRevisionLocked(published)) { + throw conflict("客户已确认,不允许再修改或重新发送费用"); } } var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId); @@ -490,8 +490,8 @@ public class FbaPricingService { if (current == null || !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) { throw conflict("入库单没有可修改的初算或已发布费用"); } - if (feeRevisionLocked(inbound, current)) { - throw conflict("客户已确认且该票已装柜完成,不允许再修改费用"); + if (feeRevisionLocked(current)) { + throw conflict("客户已确认,不允许再修改费用"); } if (!request.expectedFeeVersion().equals(current.getFeeVersion())) { throw conflict("费用版本已变化,请刷新后重试"); @@ -613,9 +613,8 @@ public class FbaPricingService { .contains(processStatus); } - private static boolean feeRevisionLocked(InboundPricingEntity inbound, InboundFeeEntity fee) { - return fee != null && fee.getCustomerConfirmedAt() != null - && List.of("LOADED", "DELIVERED").contains(inbound.getProcessStatus()); + private static boolean feeRevisionLocked(InboundFeeEntity fee) { + return fee != null && fee.getCustomerConfirmedAt() != null; } private static PricingDestinationType destinationType(InboundPricingEntity inbound) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index 0614e2eec..ddbf2e5fd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -474,6 +474,26 @@ class CarrierScheduleQueryServiceTest { verify(repository).updateState(before, 3, 7L, "admin"); } + @Test + void manualActualFactRejectsCurrentOrEarlierBusinessNodeBeforeWritingFact() { + CarrierScheduleQueryEntity task = task(0, 3); + task.setStatus("READY"); task.setLatestStandardNode("AT_ORIGIN_PORT"); + task.setLatestNodeObservedAt(NOW.minusHours(1)); + when(repository.lock(1L)).thenReturn(task); + + assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-same-1", + new ManualNodeRequest(3, "AT_ORIGIN_PORT", NOW, null, NOW, + "CNSZX", "深圳", "重复当前节点"))) + .isInstanceOf(BizException.class).hasMessageContaining("按业务顺序向后推进"); + assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-earlier-1", + new ManualNodeRequest(3, "DEPARTED_WAREHOUSE", NOW, null, NOW, + "CNSZX", "深圳", "补录更早节点"))) + .isInstanceOf(BizException.class).hasMessageContaining("按业务顺序向后推进"); + + verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class)); + verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString()); + } + @Test void holdIsPersistedAsSeparateImmutableExceptionFact() { CarrierScheduleQueryEntity task = task(0, 1); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index b0b6aeebf..77b1c98a6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -461,7 +461,6 @@ class FbaPricingServiceTest { inbound.setProcessStatus("WAIT_SPLIT"); inbound.setFeeStatus("SENT"); InboundFeeEntity current = savedFee("SENT"); - current.setCustomerConfirmedAt(NOW.minusHours(1)); AtomicReference inserted = new AtomicReference<>(); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current); @@ -531,10 +530,10 @@ class FbaPricingServiceTest { } @Test - @DisplayName("客户已确认且装柜完成时拒绝平台再次修改") + @DisplayName("客户确认后即拒绝平台再次修改") void rejectsAdjustmentAfterCustomerConfirmation() { InboundPricingEntity inbound = inbound("10", "1"); - inbound.setProcessStatus("LOADED"); + inbound.setProcessStatus("WAIT_SPLIT"); InboundFeeEntity current = savedFee("SENT"); current.setCustomerConfirmedAt(NOW); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 6e83c061d..6444347f4 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -531,6 +531,7 @@ export class AcceptanceRun { this.modules = [] this.scenarios = [] this.createdResources = {} + this.phase4ProducerAttempts = new Map() this.deploymentBlockers = [] this.coverageGaps = [] this.context = {} @@ -1466,7 +1467,9 @@ export class AcceptanceRun { async runPhase4ProducerVariant(label, destinationType, fulfillmentType = 'STANDARD', pickupRequired = false) { const matrixMarker = this.marker - const variantMarker = `${matrixMarker}-P4-${label}` + const attempt = (this.phase4ProducerAttempts.get(label) || 0) + 1 + this.phase4ProducerAttempts.set(label, attempt) + const variantMarker = `${matrixMarker}-P4-${label}${attempt === 1 ? '' : `-R${attempt}`}` const contextKeys = ['orderDestinationType', 'orderFulfillmentType', 'phase4ProducerMatrixMarker'] const createdKeys = [ 'phase3Loading', 'phase3LoadingCompletedProducer', 'phase3ContainerDocuments', 'orchestrationFixtures', 'oceanBooking', @@ -1571,7 +1574,7 @@ export class AcceptanceRun { const hasStandardFbaProducer = () => (this.createdResources.phase4InboundProducerFixtures?.containers || []) .some((item) => item?.destinationType === 'FBA' && item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP' - && String(item?.resourceKey || '').includes('-P4-FBA-STANDARD:')) + && /-P4-FBA-STANDARD(?:-R\d+)?:/.test(String(item?.resourceKey || ''))) if (!hasStandardFbaProducer()) { await this.runPhase4ProducerVariant('FBA-STANDARD', 'FBA', 'STANDARD', true) } @@ -1579,7 +1582,7 @@ export class AcceptanceRun { await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true) } const hasSecondaryProducer = () => (this.createdResources.phase4InboundProducerFixtures?.containers || []) - .some((item) => String(item?.resourceKey || '').includes('-P4-SECONDARY:')) + .some((item) => /-P4-SECONDARY(?:-R\d+)?:/.test(String(item?.resourceKey || ''))) if (!hasSecondaryProducer()) { await this.runPhase4ProducerVariant('SECONDARY', 'FBA', 'STANDARD', false) } @@ -1588,10 +1591,10 @@ export class AcceptanceRun { const dropshipReady = containers.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP') const standardFbaReady = containers.find((item) => item?.destinationType === 'FBA' && item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP' - && String(item?.resourceKey || '').includes('-P4-FBA-STANDARD:')) + && /-P4-FBA-STANDARD(?:-R\d+)?:/.test(String(item?.resourceKey || ''))) const overseasReady = containers.find((item) => item?.destinationType === 'OVERSEAS') const awaitingVariants = containers.filter((item) => ['SECONDARY'] - .some((label) => String(item?.resourceKey || '').includes(`-P4-${label}:`))) + .some((label) => new RegExp(`-P4-${label}(?:-R\\d+)?:`).test(String(item?.resourceKey || '')))) const shippingMatrixReady = dropshipReady?.shippingReady === true && Boolean(dropshipReady?.carrierScheduleQueryId) && standardFbaReady?.shippingReady === true && Boolean(standardFbaReady?.carrierScheduleQueryId) 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 5a03ba02c..ee667f20e 100644 --- a/scripts/admin-test-env-modules/coload-groups-contract.test.mjs +++ b/scripts/admin-test-env-modules/coload-groups-contract.test.mjs @@ -43,4 +43,6 @@ test('同柜不可拆组HTTP验收锁定公开接口与防绕过矩阵', async ( assert.doesNotMatch(source, /跨仓库渠道或目的仓港创建整组被拒绝/) assert.doesNotMatch(source, /已有父子拆分订单创建同柜组被拒绝/) assert.doesNotMatch(source, /push-preload/) + assert.match(source, /import \{ channelGroup \} from '\.\/smart-loading-channel-group\.mjs'/) + assert.equal((source.match(/channelGroupCode:\s*channelGroup\(/g) || []).length, 6) }) diff --git a/scripts/admin-test-env-modules/coload-groups.mjs b/scripts/admin-test-env-modules/coload-groups.mjs index b183a2032..e8ef138b2 100644 --- a/scripts/admin-test-env-modules/coload-groups.mjs +++ b/scripts/admin-test-env-modules/coload-groups.mjs @@ -1,6 +1,7 @@ const MODULE = 'coload-groups' const OPTIONS = { scenario: 'indivisible-coload-group', deploymentBlocker: true } const CONTAINERS = '/api/platform/v1/containers' +import { channelGroup } from './smart-loading-channel-group.mjs' 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' @@ -10,19 +11,6 @@ import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' import { iso6346ContainerNumber } from './iso6346-container-number.mjs' function id(value) { return value == null ? '' : String(value) } -function channelGroup(channelCode) { - const value = String(channelCode || '').toUpperCase() - if (value.startsWith('EXX_')) return 'EXX' - if (value.startsWith('MATSON_CLX_')) return 'MATSON_CLX' - if (value.startsWith('MATSON_MAX_')) return 'MATSON_MAX' - if (value.startsWith('HEDE_')) return 'HEDE' - if (value.startsWith('OA_LA_') || value === 'OA_PARCEL') return 'OA_LA' - if (value.startsWith('OA_CHICAGO_')) return 'OA_CHICAGO' - if (value.startsWith('OA_HOUSTON_')) return 'OA_HOUSTON' - if (value.startsWith('OA_SAVANNAH_')) return 'OA_SAVANNAH' - if (value === 'OA_NEW_YORK_TRUCK') return value - throw new Error(`unsupported smart-loading channel group: ${channelCode}`) -} function code(response) { return id(response?.body?.code) || 'missing' } function evidence(response, extra = '') { return `HTTP=${response?.http ?? 'missing'}, code=${code(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}${extra ? `, ${extra}` : ''}` @@ -105,7 +93,7 @@ async function createSecondGroup(context, third, fourth) { async function createAssignedPushedPlan(context, fixture, groupId, suffix) { const simulationResponse = await context.request('POST', CONTAINERS, { - channelCode: fixture.channelCode, warehouseId: fixture.warehouseId, containerType: '40HC', + channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, }) const simulation = context.apiSuccess(MODULE, `${suffix} 创建组装柜模拟柜`, simulationResponse, @@ -131,7 +119,7 @@ async function createAssignedPushedPlan(context, fixture, groupId, suffix) { async function createNormalPushedPlan(context, fixture, suffix) { const simulationResponse = await context.request('POST', CONTAINERS, { - channelCode: fixture.channelCode, warehouseId: fixture.warehouseId, containerType: '40HC', + channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, }) const simulation = context.apiSuccess(MODULE, `${suffix} 创建普通单模拟柜`, simulationResponse, @@ -522,7 +510,7 @@ export async function run(context) { `status=${queried.data?.status || 'missing'}, members=${summaryInboundCodes(queried.data).join('|')}`, OPTIONS) const simulationResponse = await context.request('POST', '/api/platform/v1/containers', { - channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '20GP', + channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '20GP', capacityCbm: 33, maxWeightKg: 28000, }) const simulation = context.apiSuccess(MODULE, '创建容量拒绝专用20GP模拟柜', simulationResponse, @@ -613,7 +601,7 @@ export async function run(context) { `targetGroupId=${groupId}, assignedMembers=${capacityGroupItems.map((item) => id(item?.inboundId)).join('|') || 'none'}, before=${planSignature(beforeRecommend)}, after=${planSignature(capacityRecommendation)}`, OPTIONS) const recommendSimulationResponse = await context.request('POST', '/api/platform/v1/containers', { - channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '40HC', + channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, }) const recommendSimulation = context.apiSuccess(MODULE, '创建智能推荐整组成功专用40HC模拟柜', @@ -659,7 +647,7 @@ export async function run(context) { } const workingSimulationResponse = await context.request('POST', '/api/platform/v1/containers', { - channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '40HC', + channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, }) const workingSimulation = context.apiSuccess(MODULE, '创建PDA主链40HC模拟柜', workingSimulationResponse, @@ -978,7 +966,7 @@ export async function run(context) { const uiGroup = context.apiSuccess(MODULE, '为Admin页面保留ACTIVE未分配跨目的仓组', uiGroupResponse, evidence(uiGroupResponse), OPTIONS) const uiSimulationResponse = await context.request('POST', CONTAINERS, { - channelCode: fifth.channelCode, warehouseId: fifth.warehouseId, containerType: '40HC', + channelGroupCode: channelGroup(fifth.channelCode), warehouseId: fifth.warehouseId, containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, }) const uiSimulation = context.apiSuccess(MODULE, '为Admin页面保留可编辑智能装柜柜次', uiSimulationResponse, diff --git a/scripts/admin-test-env-modules/firstleg-container-fee-allocation-contract.test.mjs b/scripts/admin-test-env-modules/firstleg-container-fee-allocation-contract.test.mjs index 01473bc7d..9ca244e90 100644 --- a/scripts/admin-test-env-modules/firstleg-container-fee-allocation-contract.test.mjs +++ b/scripts/admin-test-env-modules/firstleg-container-fee-allocation-contract.test.mjs @@ -18,6 +18,7 @@ test('candidate plans only come from resources created by the current acceptance financeFullFixtures: { feeSources: [{ loadingPlanId: '104' }] }, }), ['101', '102', '103', '104']) assert.doesNotMatch(source, /pageSize=\d+.*smart-loading|containers\?page|历史柜/) + assert.match(source, /channelGroupCode:\s*channelGroup\(first\.channelCode\)/) }) test('allocation builder selects one real order per distinct customer and preserves exact cents', () => { diff --git a/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs b/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs index 86f22ab80..0343a9ff2 100644 --- a/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs +++ b/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs @@ -2,6 +2,7 @@ const MODULE = 'firstleg-container-fee-allocation' const OPTIONS = { scenario: 'firstleg-container-fee-allocation-http', deploymentBlocker: true } import { run as createMerchant } from './merchant-qualification-fixture.mjs' +import { channelGroup } from './smart-loading-channel-group.mjs' function id(value) { return value == null ? '' : String(value) } function rows(value) { return Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] } @@ -140,7 +141,7 @@ async function provisionCrossCustomerPlan(context) { || id(first.receivingWarehouseId) !== id(second.receivingWarehouseId) || first.channelCode !== second.channelCode) return null const containerResponse = await context.request('POST', '/api/platform/v1/containers', { - channelCode: first.channelCode, warehouseId: id(first.receivingWarehouseId), + channelGroupCode: channelGroup(first.channelCode), warehouseId: id(first.receivingWarehouseId), containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, }) const container = context.apiSuccess(MODULE, '创建跨客户费用分摊专用模拟柜', containerResponse, diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs index 788d65410..314b60a0d 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs @@ -17,6 +17,7 @@ test('retains all clickable container, loading-list and PDA workflow states thro assert.match(source, /\.\/orders\.mjs/) assert.match(source, /\.\/inbound-measurement\.mjs/) assert.match(source, /context\.request\('POST', CONTAINERS/) + assert.match(source, /channelGroupCode:\s*channelGroup\(summary\.channelCode\)/) assert.match(source, /summary\?\.processStatus === 'WAIT_SPLIT'/) const pushIndex = source.indexOf('`${SMART}/plans/${plan.id}`') const transportIndex = source.indexOf('ocean-booking-link') diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs index 199582d2f..d13797cd0 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -9,6 +9,7 @@ const PRELOAD_STATUSES = ['EDITING', 'LOADING'] const PDA_STATUSES = ['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'COMPLETED'] import { resolveOceanBookingAgent } from './ocean-booking-fixture.mjs' +import { channelGroup } from './smart-loading-channel-group.mjs' function id(value) { return value == null ? '' : String(value) } function records(value) { return Array.isArray(value?.records) ? value.records : [] } @@ -127,7 +128,7 @@ async function createConfirmedContainer(context, source, label) { maxWeightKg: 28000 }], } const simulationResponse = await context.request('POST', CONTAINERS, { - channelCode: summary.channelCode, warehouseId: source.warehouseId, containerType: '40HC', + channelGroupCode: channelGroup(summary.channelCode), warehouseId: source.warehouseId, containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, }) const simulation = context.apiSuccess(MODULE, `${label} 新增内部模拟柜`, simulationResponse, '', OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs index 14dc2b700..41ee5ace2 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs @@ -106,6 +106,14 @@ test('booking freezes the source customer channel and warehouse scope', () => { assert.match(source, /containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000/) }) +test('split chain starts from the partial inbound and derives quantities from the real tree', () => { + assert.match(source, /fixture\?\.incrementalInboundId \|\| fixture\?\.inboundId/) + assert.match(source, /const originalPackageCount = Number\(firstTree\?\.originalPackageCount\)/) + assert.match(source, /while \(activeRemainders\(tree\)\.length > 0\)/) + assert.match(source, /sourcePackageCount = Number\(source\?\.packageCount\)/) + assert.doesNotMatch(source, /snapshotMatches\(firstTree, 4, 1, 3, 1\)/) +}) + test('booking fixture never invents supplier names without formal IDs', () => { assert.match(source, /oceanBookingDraft/) assert.doesNotMatch(source, /SPLIT-SHIPPING|SPLIT-AGENT/) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index b498c44c8..0ceb8900f 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -486,7 +486,7 @@ async function loadOneFragment(context, state) { export async function run(context) { const fixture = context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading const tenantId = id(fixture?.tenantId) - const inboundId = id(fixture?.inboundId) + const inboundId = id(fixture?.incrementalInboundId || fixture?.inboundId) const warehouseId = id(fixture?.warehouseId) const containerBookingId = id(fixture?.containerBookingId) if (!tenantId || !inboundId || !warehouseId || !containerBookingId || fixture?.marker !== context.marker) { @@ -513,8 +513,20 @@ export async function run(context) { const firstTreeResponse = await context.request('GET', treePath(orderId, tenantId)) const firstTree = context.apiSuccess(MODULE, '查询首次拆分后的根履约树', firstTreeResponse, `orderId=${orderId}, tenantId=${tenantId}`, OPTIONS) + const originalPackageCount = Number(firstTree?.originalPackageCount) + const initialLoadedPackageCount = Number(firstTree?.cumulativeLoadedPackageCount) + const initialRemainingPackageCount = Number(firstTree?.currentRemainderPackageCount) + const initialActive = activeRemainders(firstTree) + const initialActiveSequence = Number(initialActive[0]?.splitSequence) context.assert(MODULE, '新批次由来源单承接已装且只创建一个余量子单', firstTreeResponse, - treeShape(firstTree, orderId) && snapshotMatches(firstTree, 4, 1, 3, 1) + treeShape(firstTree, orderId) + && Number.isInteger(originalPackageCount) && originalPackageCount > 1 + && Number.isInteger(initialLoadedPackageCount) && initialLoadedPackageCount > 0 + && Number.isInteger(initialRemainingPackageCount) && initialRemainingPackageCount > 0 + && originalPackageCount === initialLoadedPackageCount + initialRemainingPackageCount + && initialActive.length === 1 && Number.isInteger(initialActiveSequence) + && snapshotMatches(firstTree, originalPackageCount, initialLoadedPackageCount, + initialRemainingPackageCount, initialActiveSequence) && firstTree.children.filter((row) => row.splitRole === 'LOADED').length === 1 && firstTree.children.filter((row) => row.splitRole === 'REMAINDER').length === 1, `original=${firstTree?.originalPackageCount ?? 'missing'}, loaded=${firstTree?.cumulativeLoadedPackageCount ?? 'missing'}, remaining=${firstTree?.currentRemainderPackageCount ?? 'missing'}`, OPTIONS) @@ -536,7 +548,9 @@ export async function run(context) { `/api/merchant/v1/orders/${encodeURIComponent(orderId)}/fulfillment-tree`, undefined, merchantToken) const merchantTree = context.apiSuccess(MODULE, '客户查询本客户根履约树', merchantTreeResponse, '', OPTIONS) context.assert(MODULE, '客户履约树与平台数量聚合一致', merchantTreeResponse, - treeShape(merchantTree, orderId) && snapshotMatches(merchantTree, 4, 1, 3, 1), + treeShape(merchantTree, orderId) + && snapshotMatches(merchantTree, originalPackageCount, initialLoadedPackageCount, + initialRemainingPackageCount, initialActiveSequence), `children=${merchantTree?.children?.length ?? 'missing'}`, OPTIONS) const forbidden = await request(context, 'GET', treePath(orderId, tenantId), undefined, {}, merchantToken) @@ -571,41 +585,54 @@ export async function run(context) { cleanupNote: '公开 API 不允许删除订单/柜次/PDA审计记录;只有全部分片完成且无活动余量时标记业务清理完成。', } let tree = firstTree - for (const step of [ - { sourcePackageCount: 3, expectedLoaded: 2, expectedRemaining: 2, sourceSuffix: 1, activeSuffix: 2 }, - { sourcePackageCount: 2, expectedLoaded: 3, expectedRemaining: 1, sourceSuffix: 2, activeSuffix: 3 }, - { sourcePackageCount: 1, expectedLoaded: 4, expectedRemaining: 0, sourceSuffix: 3, activeSuffix: null }, - ]) { + let completedFragments = 0 + while (activeRemainders(tree).length > 0) { const active = activeRemainders(tree) const source = active[0] - context.assert(MODULE, `链式拆分来源固定为根编号 -${step.sourceSuffix}`, null, - active.length === 1 && source?.orderNo === `${tree.parentOrderNo}-${step.sourceSuffix}`, + const sourceSuffix = Number(source?.splitSequence) + const sourcePackageCount = Number(source?.packageCount) + const beforeLoaded = Number(tree.cumulativeLoadedPackageCount) + const beforeRemaining = Number(tree.currentRemainderPackageCount) + context.assert(MODULE, `链式拆分来源固定为根编号 -${sourceSuffix}`, null, + active.length === 1 && Number.isInteger(sourceSuffix) && sourceSuffix > 0 + && source?.orderNo === `${tree.parentOrderNo}-${sourceSuffix}` + && Number.isInteger(sourcePackageCount) && sourcePackageCount === beforeRemaining, `active=${active.map((row) => row.orderNo).join(',') || 'none'}`, OPTIONS) if (!source) return const result = await loadOneFragment(context, { tenantId, rootOrderId: orderId, sourceOrderId: id(source.orderId), sourceOrderNo: source.orderNo, customerName, channelCode, channelName, warehouseId, warehouseName, - sourcePackageCount: step.sourcePackageCount, sequence: step.sourceSuffix, + sourcePackageCount, sequence: sourceSuffix, }) if (!result?.tree) return tree = result.tree + completedFragments += 1 + const expectedLoaded = beforeLoaded + 1 + const expectedRemaining = beforeRemaining - 1 + const activeSuffix = expectedRemaining > 0 ? sourceSuffix + 1 : null Object.assign(context.createdResources.phase3OrderSplitting, { activeRemainderCount: activeRemainders(tree).length, finalLoadedPackageCount: Number(tree.cumulativeLoadedPackageCount), finalRemainingPackageCount: Number(tree.currentRemainderPackageCount), }) - context.assert(MODULE, `完成第 ${step.sourceSuffix + 1} 个分片后根数量守恒`, result.treeResponse, + context.assert(MODULE, `完成余量分片 -${sourceSuffix} 后根数量守恒`, result.treeResponse, treeShape(tree, orderId) - && snapshotMatches(tree, 4, step.expectedLoaded, step.expectedRemaining, step.activeSuffix), + && snapshotMatches(tree, originalPackageCount, expectedLoaded, expectedRemaining, activeSuffix), `original=${tree.originalPackageCount}, loaded=${tree.cumulativeLoadedPackageCount}, remaining=${tree.currentRemainderPackageCount}`, OPTIONS) + if (completedFragments > originalPackageCount) { + context.addStep(MODULE, '余量链在原始箱数边界内收敛', null, false, + `original=${originalPackageCount}, fragments=${completedFragments}`, OPTIONS) + return + } } const remainderCodes = tree.children.filter((row) => row.splitRole === 'REMAINDER').map((row) => row.orderNo) + const expectedRemainderCodes = Array.from({ length: completedFragments }, (_, index) => + `${tree.parentOrderNo}-${initialActiveSequence + index}`) context.assert(MODULE, '余量子单始终按根编号全局递增且禁止嵌套编号', null, - JSON.stringify(remainderCodes) === JSON.stringify([ - `${tree.parentOrderNo}-1`, `${tree.parentOrderNo}-2`, `${tree.parentOrderNo}-3`, - ]) && remainderCodes.every((code) => !/-[0-9]+-[0-9]+$/.test(code)), + JSON.stringify(remainderCodes) === JSON.stringify(expectedRemainderCodes) + && remainderCodes.every((code) => !/-[0-9]+-[0-9]+$/.test(code)), `codes=${remainderCodes.join(',')}`, OPTIONS) context.assert(MODULE, '最后一次全量装完不再生成子单且根整体完成', null, activeRemainders(tree).length === 0 && Number(tree.currentRemainderPackageCount) === 0 diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index 9e90032c5..0c82de1e1 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -160,10 +160,12 @@ test('PDA entry expands the real phase-three producer chain and builds an FBA/OV assert.match(source, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/) assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true\)/) assert.match(source, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', true\)/) - assert.match(source, /includes\('-P4-FBA-STANDARD:'\)/) + assert.match(source, /-P4-FBA-STANDARD\(\?:-R\\d\+\)\?:/) assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/) assert.match(source, /runUntilPickupReady\(this\)/) const variant = source.slice(source.indexOf('async runPhase4ProducerVariant'), source.indexOf('async ensurePhase4PdaProducerMatrix')) + assert.match(variant, /this\.phase4ProducerAttempts\.get\(label\)/) + assert.match(variant, /attempt === 1 \? '' : `-R\$\{attempt\}`/) assert.ok(variant.indexOf('ensureInitialCalculated') < variant.indexOf('phase3-loading-phase4-')) assert.match(variant, /fulfillmentType === 'ONE_CLICK_DROPSHIP'/) assert.doesNotMatch(variant, /inboundFees\.run\(this\)/)