From 3a9558bd1c10b57c52a5a9161cb17ec532650f10 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 15 Aug 2026 12:12:34 +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 --- .../firstleg/service/SmartLoadingService.java | 20 +++++++++- .../split/service/SplitPreloadService.java | 29 +++++++++++++++ .../main/resources/openapi/firstleg-v1.yaml | 2 + .../service/SmartLoadingServiceTest.java | 24 +++++++++++- .../service/SplitPreloadServiceTest.java | 29 +++++++++++++++ ...ance-reconciliation-full-contract.test.mjs | 6 +++ .../finance-reconciliation-full.mjs | 9 +++-- .../phase3-order-splitting-contract.test.mjs | 14 ++++++- .../phase3-order-splitting.mjs | 37 +++++++++++++++++-- 9 files changed, 159 insertions(+), 11 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 9bff26386..96d0c7fd4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -9,6 +9,7 @@ import com.baiyue.firstleg.client.OrderCargoClient; import com.baiyue.firstleg.entity.*; import com.baiyue.firstleg.repository.SmartLoadingRepository; import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; +import com.baiyue.firstleg.split.service.SplitPreloadService; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -35,6 +36,7 @@ public class SmartLoadingService { @Resource private OrderCargoClient orderCargoClient; @Resource private LoadingPickService loadingPickService; @Resource private LoadingModelBridgeService loadingModelBridge; + @Resource private SplitPreloadService splitPreloadService; @Resource private ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token:}") private String internalToken; public SmartLoadingService() { } @@ -104,6 +106,8 @@ public class SmartLoadingService { if (candidate == null) throw conflict("入库货物不存在或尚未完成入库"); requireSameChannel(booking, candidate); requireSameCustomerScope(booking, candidate); + splitPreloadService.ensurePendingForLoadingAllocation(candidate.accountId(), candidate.inboundId(), + operatorId, operatorName); OrderCargoClient.CargoBinding cargo = loadCargo(candidate); LoadingPlanItemEntity existing = repository.activeItem(planId, candidate.accountId(), request.inboundDetailId()); int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount()); @@ -235,6 +239,9 @@ public class SmartLoadingService { throw validation("录入真实柜号后才能推送预装清单并开启PDA作业"); } if (key.equals(plan.getPushIdempotencyKey())) { + if ("EDITING".equals(plan.getPlanStatus())) { + ensurePendingPreloads(repository.items(planId), operatorId, operatorName); + } loadingModelBridge.bindAllocatedBeforeLoading(plan.getId(), operatorId, operatorName, key); loadingPickService.beginIfReady(plan.getContainerBookingId(), operatorId, operatorName); return view(plan, booking); @@ -242,7 +249,7 @@ public class SmartLoadingService { if (StringUtils.hasText(plan.getPushIdempotencyKey())) throw conflict("预装清单已推送,不能使用新的幂等键重复推送"); if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配"); List items = repository.items(planId); - if (items.isEmpty()) throw validation("装柜明细不能为空"); + ensurePendingPreloads(items, operatorId, operatorName); checkPlanCapacity(booking, items); try { if (repository.push(planId, key, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("装柜计划已被并发修改"); @@ -254,6 +261,17 @@ public class SmartLoadingService { return view(pushed, requireBooking(pushed.getContainerBookingId())); } + private void ensurePendingPreloads(List items, long operatorId, String operatorName) { + if (items.isEmpty()) throw validation("装柜明细不能为空"); + if (items.stream().anyMatch(item -> item.getAccountId() == null || item.getInboundId() == null)) { + throw conflict("装柜明细缺少客户或入库单稳定归属,不能推送预装计划"); + } + items.stream().collect(java.util.stream.Collectors.toMap( + item -> item.getAccountId() + "|" + item.getInboundId(), item -> item, (left, right) -> left)) + .values().forEach(item -> splitPreloadService.ensurePendingForLoadingAllocation( + item.getAccountId(), item.getInboundId(), operatorId, operatorName)); + } + private LoadingPlanItemEntity snapshot(long planId, Candidate c, OrderCargoClient.CargoBinding cargo, int count, long op, String name, int seq) { LoadingPlanItemEntity i = new LoadingPlanItemEntity(); i.setLoadingPlanId(planId); i.setAccountId(c.accountId()); i.setCustomerName(c.customerName()); i.setInboundId(c.inboundId()); i.setInboundDetailId(c.inboundDetailId()); i.setOrderId(c.orderId()); i.setOrderNo(c.orderNo()); i.setInboundCode(c.inboundCode()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java index 62e12ce52..820e5b29e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java @@ -22,6 +22,7 @@ import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import org.springframework.dao.DuplicateKeyException; import java.time.LocalDateTime; import java.util.List; @@ -72,6 +73,34 @@ public class SplitPreloadService { new SplitActionResponse("WAIT_SPLIT", result)); } + /** Ensures the inbound-level preload lifecycle exists before a loading allocation can be pushed. */ + @Transactional + public void ensurePendingForLoadingAllocation(long tenantId, long inboundId, long operatorId, + String operatorName) { + InboundSnapshot inbound = requireInbound(tenantId, inboundId); + requireWaitingSplit(inbound, "仅待分柜订单可以建立智能装柜预装计划"); + PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId); + if (plan != null) { + if (!PreloadStatus.PENDING.equals(plan.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "预装计划已进入确认流程,不能修改智能装柜分配"); + } + return; + } + plan = new PreloadPlanEntity(); + plan.setTenantId(tenantId); plan.setInboundId(inboundId); plan.setStatus(PreloadStatus.PENDING); + plan.setVersion(0); plan.setItemsJson("[]"); plan.setRemark("智能装柜分配自动建立"); + plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName); + plan.setUpdatedBy(operatorId); plan.setUpdatedByName(operatorName); + try { + repository.createPlan(plan); + } catch (DuplicateKeyException exception) { + PreloadPlanEntity concurrent = repository.findPlan(tenantId, inboundId); + if (concurrent == null || !PreloadStatus.PENDING.equals(concurrent.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "预装计划已被并发修改,请刷新后重试"); + } + } + } + @Transactional public SplitActionResponse startConfirming(long tenantId, long inboundId, long operatorId, String operatorName, String key, diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index c89b3d5e5..566c32cc3 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -363,6 +363,8 @@ paths: description: >- 需要 platform:firstleg:smart-loading:push 权限。真实柜号必须已录入, 否则不得推送预装清单或创建 PDA作业任务;计划必须处于 EDITING 且版本匹配。 + 智能装柜首次分配会创建入库单级 PENDING 预装聚合;push 会在同一事务内对全部实际分配再次确保该聚合存在且仍为 PENDING, + 但不会覆盖 CONFIRMING/CONFIRMED 记录,也不会在 LOADING 后补建。 推送事务会先为所有已分配入库单建立 PENDING 预装计划与 EDITING 柜次计划的正式关联, 任一关联缺失、版本冲突或分配数量为零都会整体失败并禁止 PDA/LOADING;不得在 LOADING 后补关联。 parameters: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 414a57a37..e205eca36 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -16,6 +16,7 @@ import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import com.baiyue.firstleg.repository.SmartLoadingRepository; import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; +import com.baiyue.firstleg.split.service.SplitPreloadService; import com.fasterxml.jackson.databind.ObjectMapper; import feign.FeignException; import jakarta.annotation.Resource; @@ -55,6 +56,7 @@ class SmartLoadingServiceTest { @MockBean private OrderCargoClient cargoClient; @MockBean private LoadingPickService loadingPickService; @MockBean private LoadingModelBridgeService loadingModelBridge; + @MockBean private SplitPreloadService splitPreloadService; @Resource private SmartLoadingService service; @TestConfiguration @@ -158,6 +160,7 @@ class SmartLoadingServiceTest { service.assign(1, new AssignRequest(10L, 20L, 2, 0, "assign-110-ok"), 7, "u"); + verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u"); verify(repository).touch(1, 0, 7, "u"); } @@ -388,11 +391,28 @@ class SmartLoadingServiceTest { assertThat(result.status()).isEqualTo("EDITING"); verify(repository).push(1, "key", 3, 7, "u"); - var ordered = inOrder(loadingModelBridge, loadingPickService); + var ordered = inOrder(splitPreloadService, loadingModelBridge, loadingPickService); + ordered.verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u"); ordered.verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key"); ordered.verify(loadingPickService).beginIfReady(99, 7, "u"); } + @Test + void editingPushReplayEnsuresPendingPreloadBeforePda() { + LoadingPlanEntity pushed = plan("EDITING", 4); + pushed.setPushIdempotencyKey("key"); + when(repository.plan(1)).thenReturn(pushed); + when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1))); + when(repository.booking(99)).thenReturn(booking()); + + service.pushPreload(1, "key", new PushRequest(4), 7, "u"); + + verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u"); + verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key"); + verify(loadingPickService).beginIfReady(99, 7, "u"); + verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString()); + } + @Test void rollsBackPushOrchestrationBeforePdaWhenBindingFails() { LoadingPlanEntity editing = plan("EDITING", 3); @@ -509,7 +529,7 @@ class SmartLoadingServiceTest { private static LoadingPlanItemEntity item(long id, long account, long detail, int boxes) { LoadingPlanItemEntity value = new LoadingPlanItemEntity(); value.setId(id); value.setLoadingPlanId(1L); value.setAccountId(account); - value.setInboundDetailId(detail); value.setPackageCount(boxes); + value.setInboundId(10L); value.setInboundDetailId(detail); value.setPackageCount(boxes); value.setVersion(0); value.setDeleted(false); return value; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java index b5cf588e7..9baf6a9e8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java @@ -28,6 +28,7 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; @ExtendWith(MockitoExtension.class) class SplitPreloadServiceTest { @@ -56,6 +57,34 @@ class SplitPreloadServiceTest { verify(repository).createPlan(any(PreloadPlanEntity.class)); } + @Test + void loadingAllocationCreatesPendingPreloadLifecycle() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3)); + when(repository.findPlan(10, 20)).thenReturn(null); + + service.ensurePendingForLoadingAllocation(10, 20, 7, "admin"); + + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(PreloadPlanEntity.class); + verify(repository).createPlan(captor.capture()); + assertThat(captor.getValue().getStatus()).isEqualTo("PENDING"); + assertThat(captor.getValue().getItemsJson()).isEqualTo("[]"); + assertThat(captor.getValue().getRemark()).isEqualTo("智能装柜分配自动建立"); + } + + @Test + void loadingAllocationDoesNotOverwriteConfirmedPreload() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3)); + PreloadPlanEntity plan = new PreloadPlanEntity(); + plan.setStatus("CONFIRMING"); + when(repository.findPlan(10, 20)).thenReturn(plan); + + assertThatThrownBy(() -> service.ensurePendingForLoadingAllocation(10, 20, 7, "admin")) + .hasMessageContaining("已进入确认流程"); + + verify(repository, never()).createPlan(any()); + } + @Test void confirmsPlanAndMovesInboundToLoading() { when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true); diff --git a/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs b/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs index 2118c41b3..b2d2f2d5a 100644 --- a/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs @@ -7,6 +7,12 @@ import { run } from './finance-reconciliation-full.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'finance-reconciliation-full.mjs'), 'utf8') + +test('Finance pool keeps formal MANUAL_COST and maps container cost by stable container identity', () => { + assert.doesNotMatch(source, /sourceType \?\? ''\) !== 'MANUAL_COST'/) + assert.match(source, /\[raw\.containerBookingId, raw\.loadingPlanId\]\.map\(id\)\.includes\(id\(item\?\.containerId\)\)/) + assert.match(source, /!isUnboundContainerCost\(source\.raw\) \|\| hasContainerAllocations/) +}) const financePermissions = [ 'platform:finance:customer-account:list', 'platform:finance:customer-statement:list', 'platform:finance:reconciliation:create', 'platform:finance:reconciliation:review', diff --git a/scripts/admin-test-env-modules/finance-reconciliation-full.mjs b/scripts/admin-test-env-modules/finance-reconciliation-full.mjs index 6e81b0659..22fe24eaf 100644 --- a/scripts/admin-test-env-modules/finance-reconciliation-full.mjs +++ b/scripts/admin-test-env-modules/finance-reconciliation-full.mjs @@ -176,7 +176,6 @@ async function normalizeFixture(context) { `${BASE}/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=ALL`) const poolData = poolResponse?.body?.data pool = rows(poolData).concat(rows(poolData?.items)) - .filter((item) => String(item?.sourceType ?? '') !== 'MANUAL_COST') const visible = new Set(pool.map((item) => id(item?.sourceId))) if ([...expectedSourceIds].every((source) => visible.has(source))) break await new Promise((resolve) => setTimeout(resolve, 500)) @@ -185,6 +184,8 @@ async function normalizeFixture(context) { const attemptedSources = root.feeSources.map((raw) => { const order = orders.get(id(raw.orderId)) const financeSource = poolBySource.get(sourceId(raw)) + || pool.find((item) => String(item?.direction || '').toUpperCase() === 'PAYABLE' + && [raw.containerBookingId, raw.loadingPlanId].map(id).includes(id(item?.containerId))) return { raw, feeSourceId: financePoolId(financeSource), sourceFeeId: sourceId(raw), customerId: id(raw.customerId ?? customerId), direction: String(raw.direction ?? ''), currency: String(raw.currency ?? raw.currencyCode ?? '').toUpperCase(), @@ -199,9 +200,11 @@ async function normalizeFixture(context) { || (isUnboundContainerCost(source.raw) && !hasContainerAllocations)) return { root, customerId, - sources: attemptedSources.filter((source) => source.feeSourceId && !isUnboundContainerCost(source.raw)), + sources: attemptedSources.filter((source) => source.feeSourceId + && (!isUnboundContainerCost(source.raw) || hasContainerAllocations)), uncreatedSources, - sourceMappings: attemptedSources.filter((source) => source.feeSourceId && !isUnboundContainerCost(source.raw)) + sourceMappings: attemptedSources.filter((source) => source.feeSourceId + && (!isUnboundContainerCost(source.raw) || hasContainerAllocations)) .map((source) => ({ sourceFeeId: source.sourceFeeId, feeSourceId: source.feeSourceId, sourceType: source.raw?.sourceType ?? null, direction: source.direction })), } 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 a478892a3..19eaf39f2 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 @@ -19,7 +19,9 @@ test('each remainder uses a simulation linked to a confirmed then released booki const releaseStart = source.indexOf('const releaseResponse') const releaseBlock = source.slice(releaseStart, source.indexOf('bookingDetailResponse = await', releaseStart)) for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', - 'estimatedArrivalAt', 'expectedVersion']) assert.match(releaseBlock, new RegExp(`${field}:`)) + 'estimatedArrivalAt']) assert.match(releaseBlock, new RegExp(`${field}: persistedTransport\\.${field}`)) + assert.match(releaseBlock, /expectedVersion: Number\(bookingDetail\.oceanBooking\.version\)/) + assert.doesNotMatch(releaseBlock, /BL-\$\{|SPLIT-VESSEL|SV-\$\{|2026-/) assert.match(releaseBlock, /'Idempotency-Key': `\$\{operationBase\}-booking-release`\.slice\(0, 128\)/) assert.match(source, /oceanBooking\?\.status === 'RELEASED' && releasedAllocationMatches/) assert.match(source, /id\(item\?\.containerBookingId\) === containerId/) @@ -28,6 +30,15 @@ test('each remainder uses a simulation linked to a confirmed then released booki assert.doesNotMatch(source, /candidates\?warehouseId=/) }) +test('release stops unless creation response and detail expose the same complete transport snapshot', () => { + assert.match(source, /const BOOKING_TRANSPORT_FIELDS = \[/) + assert.match(source, /const createdTransport = bookingTransportSnapshot\(bookingCreated\?\.oceanBooking\)/) + assert.match(source, /const persistedTransport = bookingTransportSnapshot\(bookingDetail\?\.oceanBooking\)/) + assert.match(source, /completeBookingTransport\(createdTransport\)/) + assert.match(source, /sameBookingTransport\(createdTransport, persistedTransport\)/) + assert.match(source, /if \(!transportFrozen\) return null/) +}) + test('PDA opens only after a real container number and uses stable plan identity', () => { const pushIndex = source.indexOf("`${SMART}/plans/${planId}/push-preload`") const transportIndex = source.indexOf("`${CONTAINERS}/${encodeURIComponent(containerId)}/transport`") @@ -71,6 +82,7 @@ test('booking freezes the source customer channel and warehouse scope', () => { assert.match(source, /channelCode: source\.channelCode/) assert.match(source, /warehouseId: source\.warehouseId/) assert.match(source, /containerType === '40HQ'/) + assert.match(source, /containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000/) }) test('booking fixture never invents supplier names without formal IDs', () => { diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index a10b7c95f..abce7ab73 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -25,6 +25,23 @@ function hasTrace(response) { && typeof response?.traceId === 'string' && response.traceId.length > 0 } +const BOOKING_TRANSPORT_FIELDS = [ + 'billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt', +] + +function bookingTransportSnapshot(value) { + return Object.fromEntries(BOOKING_TRANSPORT_FIELDS.map((field) => [field, value?.[field] ?? null])) +} + +function completeBookingTransport(snapshot) { + return BOOKING_TRANSPORT_FIELDS.every((field) => typeof snapshot?.[field] === 'string' + && snapshot[field].trim().length > 0) +} + +function sameBookingTransport(left, right) { + return BOOKING_TRANSPORT_FIELDS.every((field) => left?.[field] === right?.[field]) +} + async function request(context, method, path, body, headers = {}, token = context.token) { try { const response = await fetch(`${context.options.gateway}${path}`, { @@ -131,7 +148,7 @@ async function loadOneFragment(context, state) { const { tenantId, rootOrderId, sourceOrderId, sourceOrderNo, warehouseId, sourcePackageCount, sequence } = state const operationBase = `${context.marker}-chain-${sequence}`.slice(0, 100) const simulationResponse = await request(context, 'POST', CONTAINERS, { - channelCode: state.channelCode, warehouseId, containerType: '40HQ', capacityCbm: 100, maxWeightKg: 100000, + channelCode: state.channelCode, warehouseId, containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000, }) const simulation = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 新增内部模拟柜`, simulationResponse, '', OPTIONS) const containerId = id(simulation?.id) @@ -221,12 +238,24 @@ async function loadOneFragment(context, state) { `oceanBookingId=${id(bookingDetail?.oceanBooking?.id) || 'missing'}, containerBookingId=${containerId}, allocations=${bookingDetail?.containerAllocations?.length ?? 'n/a'}`, OPTIONS) if (!allocationMatches || id(bookingDetail?.oceanBooking?.id) !== oceanBookingId) return null + const createdTransport = bookingTransportSnapshot(bookingCreated?.oceanBooking) + const persistedTransport = bookingTransportSnapshot(bookingDetail?.oceanBooking) + const transportFrozen = completeBookingTransport(createdTransport) + && completeBookingTransport(persistedTransport) + && sameBookingTransport(createdTransport, persistedTransport) + context.assert(MODULE, `${sourceOrderNo} 创建响应与详情五项运输字段冻结一致`, bookingDetailResponse, + transportFrozen, + BOOKING_TRANSPORT_FIELDS.map((field) => `${field}=${persistedTransport[field] || 'missing'}`).join(', '), OPTIONS) + if (!transportFrozen) return null + if (bookingDetail.oceanBooking.status === 'CONFIRMED') { const releaseResponse = await request(context, 'POST', `${bookingPath}/release`, { expectedVersion: Number(bookingDetail.oceanBooking.version), - billOfLadingNo: `BL-${operationBase}`.slice(0, 128), - vesselName: `SPLIT-VESSEL-${sequence}`.slice(0, 128), voyageNo: `SV-${sequence}`.slice(0, 64), - estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00', + billOfLadingNo: persistedTransport.billOfLadingNo, + vesselName: persistedTransport.vesselName, + voyageNo: persistedTransport.voyageNo, + estimatedSailingAt: persistedTransport.estimatedSailingAt, + estimatedArrivalAt: persistedTransport.estimatedArrivalAt, reason: context.marker, }, { 'Idempotency-Key': `${operationBase}-booking-release`.slice(0, 128) }) context.apiSuccess(MODULE, `放舱 ${sourceOrderNo} 的真实订舱并满足 PDA 门禁`, releaseResponse, '', OPTIONS)