From 08187363a15c99ad0fdb9b49581f12d66d642b89 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 15 Aug 2026 11:51:15 +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 --- .../mapper/LoadingModelBridgeMapper.java | 6 +++ .../LoadingModelBridgeRepository.java | 2 + .../service/LoadingModelBridgeService.java | 35 +++++++++++++ .../firstleg/service/SmartLoadingService.java | 4 ++ .../main/resources/openapi/firstleg-v1.yaml | 2 + .../phase3-loading-model-bridge-v1.yaml | 3 +- .../LoadingModelBridgeServiceTest.java | 49 +++++++++++++++++++ .../service/SmartLoadingServiceTest.java | 27 ++++++++++ .../finance-full-fixtures-contract.test.mjs | 6 +++ .../finance-full-fixtures.mjs | 2 +- ...-inbound-status-fixtures-contract.test.mjs | 9 ++++ .../order-inbound-status-fixtures.mjs | 29 ++++++----- .../phase3-order-splitting-contract.test.mjs | 13 +++-- .../phase3-order-splitting.mjs | 22 +++------ .../truck-appointments-contract.test.mjs | 16 ++++++ .../truck-appointments.mjs | 17 ++++--- 16 files changed, 203 insertions(+), 39 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java index a24748e68..5d2b40a23 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java @@ -27,6 +27,11 @@ public interface LoadingModelBridgeMapper extends BaseMapper0") + List allocatedScopes(@Param("loadingPlanId") long loadingPlanId); + @Update("UPDATE fl_preload_plan SET updated_by=#{operatorId},updated_by_name=#{operatorName}," + "version=version+1 WHERE id=#{preloadPlanId} AND tenant_id=#{tenantId} " + "AND inbound_id=#{inboundId} AND status='PENDING' AND version=#{expectedVersion}") @@ -70,4 +75,5 @@ public interface LoadingModelBridgeMapper extends BaseMapper allocatedScopes(long loadingPlanId) { return mapper.allocatedScopes(loadingPlanId); } public PreloadLoadingPlanLinkEntity byPair(long tenantId, long inboundId, long loadingPlanId) { return mapper.selectOne(new LambdaQueryWrapper() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java index f86fd38f0..8f1bb0ae2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java @@ -7,6 +7,7 @@ import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.BindRequest import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; import com.baiyue.firstleg.loading.bridge.entity.PreloadLoadingPlanLinkEntity; import com.baiyue.firstleg.loading.bridge.repository.LoadingModelBridgeRepository; +import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.AllocationScope; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; @@ -68,6 +69,40 @@ public class LoadingModelBridgeService { return requireLink(tenantId, inboundId, loadingPlanId); } + /** + * Creates every preload link required by a pushed loading plan before PDA can start. + * The caller owns the surrounding push transaction, so a missing preload or stale + * version rolls back the push and cannot leave a LOADING task without a link. + */ + @Transactional + public List bindAllocatedBeforeLoading(long loadingPlanId, long operatorId, + String operatorName, String orchestrationKey) { + var loadingPlan = repository.loadingPlan(loadingPlanId); + if (loadingPlan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在"); + List scopes = repository.allocatedScopes(loadingPlanId); + if (scopes.isEmpty()) throw conflict("装柜计划尚未分配入库单货物,不能开始PDA作业"); + if (!"EDITING".equals(loadingPlan.status())) { + if (Set.of("LOADING", "COMPLETED").contains(loadingPlan.status()) + && scopes.stream().allMatch(scope -> repository.byPair( + scope.tenantId(), scope.inboundId(), loadingPlanId) != null)) { + return repository.byLoadingPlan(loadingPlanId); + } + throw conflict("PDA开始前的预装关联不完整,禁止在LOADING后补关联"); + } + for (AllocationScope scope : scopes) { + var preload = repository.preload(scope.tenantId(), scope.inboundId()); + var currentPlan = repository.loadingPlan(loadingPlanId); + if (preload == null || currentPlan == null) { + throw new BizException(ResultCode.NOT_FOUND, "入库单预装计划不存在"); + } + String key = "AUTO:" + hash(orchestrationKey + "|" + scope.tenantId() + "|" + + scope.inboundId() + "|" + loadingPlanId).substring(0, 60); + bind(scope.tenantId(), scope.inboundId(), loadingPlanId, operatorId, operatorName, key, + new BindRequest(preload.version(), currentPlan.version())); + } + return repository.byLoadingPlan(loadingPlanId); + } + public List byInbound(long tenantId, long inboundId) { return repository.byInbound(tenantId, inboundId); } public List byLoadingPlan(long loadingPlanId) { return repository.byLoadingPlan(loadingPlanId); } 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 854c17c7b..9bff26386 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -8,6 +8,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.*; 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 org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -33,6 +34,7 @@ public class SmartLoadingService { @Resource private SmartLoadingRepository repository; @Resource private OrderCargoClient orderCargoClient; @Resource private LoadingPickService loadingPickService; + @Resource private LoadingModelBridgeService loadingModelBridge; @Resource private ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token:}") private String internalToken; public SmartLoadingService() { } @@ -233,6 +235,7 @@ public class SmartLoadingService { throw validation("录入真实柜号后才能推送预装清单并开启PDA作业"); } if (key.equals(plan.getPushIdempotencyKey())) { + loadingModelBridge.bindAllocatedBeforeLoading(plan.getId(), operatorId, operatorName, key); loadingPickService.beginIfReady(plan.getContainerBookingId(), operatorId, operatorName); return view(plan, booking); } @@ -246,6 +249,7 @@ public class SmartLoadingService { } catch (DuplicateKeyException e) { throw conflict("幂等键已用于其他推送"); } history(planId, null, "PUSH_PRELOAD", "EDITING", "EDITING", key, operatorId, operatorName); LoadingPlanEntity pushed = repository.plan(planId); + loadingModelBridge.bindAllocatedBeforeLoading(planId, operatorId, operatorName, key); loadingPickService.beginIfReady(pushed.getContainerBookingId(), operatorId, operatorName); return view(pushed, requireBooking(pushed.getContainerBookingId())); } diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 966b3ae12..c89b3d5e5 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 预装计划与 EDITING 柜次计划的正式关联, + 任一关联缺失、版本冲突或分配数量为零都会整体失败并禁止 PDA/LOADING;不得在 LOADING 后补关联。 parameters: - {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {$ref: '#/components/parameters/IdempotencyKey'} diff --git a/firstleg/src/main/resources/openapi/phase3-loading-model-bridge-v1.yaml b/firstleg/src/main/resources/openapi/phase3-loading-model-bridge-v1.yaml index 65a66d892..e5c5ccacd 100644 --- a/firstleg/src/main/resources/openapi/phase3-loading-model-bridge-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-loading-model-bridge-v1.yaml @@ -2,6 +2,7 @@ openapi: 3.0.3 info: title: Phase 3 Preload and Container Loading Bridge API version: 1.0.0 + description: Links must be created while preload=PENDING and loadingPlan=EDITING. Smart-loading push creates all allocated links atomically before PDA task creation; LOADING plans cannot be repaired through post-hoc binding. paths: /api/platform/v1/inbounds/{inboundId}/preload/loading-plans: get: @@ -12,7 +13,7 @@ paths: /api/platform/v1/inbounds/{inboundId}/preload/loading-plans/{loadingPlanId}: post: summary: Atomically link an inbound preload and a container loading plan - description: Both optimistic-lock versions must match; either conflict rolls back the whole link operation. + description: Both optimistic-lock versions must match; either conflict rolls back the whole link operation. This endpoint never permits post-hoc binding after the loading plan enters LOADING. parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/LoadingPlanId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java index f4fbaf46d..9a556e3b0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java @@ -4,6 +4,8 @@ import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.BindRequest import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.LoadingPlanSnapshot; import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.PreloadSnapshot; +import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.AllocationScope; +import com.baiyue.firstleg.loading.bridge.entity.PreloadLoadingPlanLinkEntity; import com.baiyue.firstleg.loading.bridge.repository.LoadingModelBridgeRepository; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -42,6 +44,53 @@ class LoadingModelBridgeServiceTest { verify(repository).touchLoadingPlan(40, 3, 7, "admin"); } + @Test + void bindsEveryAllocatedInboundBeforeLoadingStarts() { + when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3)); + when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8))); + when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "PENDING", 2)); + when(repository.allocatedPackages(10, 20, 40)).thenReturn(8); + when(repository.touchPreload(30, 10, 20, 2, 7, "admin")).thenReturn(1); + when(repository.touchLoadingPlan(40, 3, 7, "admin")).thenReturn(1); + Link linked = link("PENDING", 3, "EDITING", 4, 8); + when(repository.byInbound(10, 20)).thenReturn(List.of(linked)); + when(repository.byLoadingPlan(40)).thenReturn(List.of(linked)); + + List result = service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1"); + + assertThat(result).containsExactly(linked); + verify(repository).insert(any()); + verify(repository).touchPreload(30, 10, 20, 2, 7, "admin"); + verify(repository).touchLoadingPlan(40, 3, 7, "admin"); + } + + @Test + void refusesPostHocBindingAfterLoadingStarts() { + when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 4)); + when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8))); + + assertThatThrownBy(() -> service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1")) + .hasMessageContaining("禁止在LOADING后补关联"); + + verify(repository, never()).insert(any()); + } + + @Test + void loadingReplayOnlyReadsAlreadyCompleteLinks() { + when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 4)); + when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8))); + PreloadLoadingPlanLinkEntity existing = new PreloadLoadingPlanLinkEntity(); + when(repository.byPair(10, 20, 40)).thenReturn(existing); + Link linked = link("PENDING", 3, "LOADING", 4, 8); + when(repository.byLoadingPlan(40)).thenReturn(List.of(linked)); + + assertThat(service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1")) + .containsExactly(linked); + + verify(repository, never()).insert(any()); + verify(repository, never()).touchLoadingPlan(anyLong(), anyInt(), anyLong(), anyString()); + } + @Test void secondVersionConflictDoesNotProceedAsSuccess() { when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "PENDING", 2)); 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 b60034b89..414a57a37 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -15,6 +15,7 @@ import com.baiyue.firstleg.entity.LoadingPlanEntity; 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.fasterxml.jackson.databind.ObjectMapper; import feign.FeignException; import jakarta.annotation.Resource; @@ -41,6 +42,8 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -51,6 +54,7 @@ class SmartLoadingServiceTest { @MockBean private SmartLoadingRepository repository; @MockBean private OrderCargoClient cargoClient; @MockBean private LoadingPickService loadingPickService; + @MockBean private LoadingModelBridgeService loadingModelBridge; @Resource private SmartLoadingService service; @TestConfiguration @@ -384,6 +388,29 @@ class SmartLoadingServiceTest { assertThat(result.status()).isEqualTo("EDITING"); verify(repository).push(1, "key", 3, 7, "u"); + var ordered = inOrder(loadingModelBridge, loadingPickService); + ordered.verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key"); + ordered.verify(loadingPickService).beginIfReady(99, 7, "u"); + } + + @Test + void rollsBackPushOrchestrationBeforePdaWhenBindingFails() { + LoadingPlanEntity editing = plan("EDITING", 3); + LoadingPlanEntity pushed = plan("EDITING", 4); + pushed.setPushIdempotencyKey("key"); + when(repository.plan(1)).thenReturn(editing, pushed); + when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1))); + when(repository.booking(99)).thenReturn(booking()); + when(repository.push(1, "key", 3, 7, "u")).thenReturn(1); + doThrow(new BizException(ResultCode.CONFLICT, "预装计划不存在")) + .when(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key"); + + BizException error = catchThrowableOfType( + () -> service.pushPreload(1, "key", new PushRequest(3), 7, "u"), BizException.class); + + assertThat(error.getMessage()).contains("预装计划不存在"); + verify(repository).push(1, "key", 3, 7, "u"); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); } @Test diff --git a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs index 17f8eb4b9..d71555732 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs @@ -30,6 +30,12 @@ test('fee writes use verified Firstleg paths and do not fabricate historical IDs assert.match(source, /chargeUnit === 'CBM'/) }) +test('PAYABLE fee selection uses the formal warehousePayable flag without name heuristics', () => { + const selector = source.slice(source.indexOf('async function loadFeeItem'), source.indexOf('async function createManualFee')) + assert.match(selector, /item\.warehousePayable === true/) + assert.doesNotMatch(selector, /BOOKING\|FREIGHT|订舱|海运|运费/) +}) + test('automatic fee fixture completes the reviewed pricing workflow without recalculating', () => { assert.match(source, /latest\?\.feeId/) assert.doesNotMatch(source, /latest\?\.id/) diff --git a/scripts/admin-test-env-modules/finance-full-fixtures.mjs b/scripts/admin-test-env-modules/finance-full-fixtures.mjs index 6a54f00bb..d64161066 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures.mjs @@ -106,7 +106,7 @@ async function loadFeeItem(context, direction) { const list = context.apiSuccess(MODULE, `查询${direction}人工费用项目`, response, '', OPTIONS) return rows(list).find((item) => item?.status === 'ACTIVE' && (direction === 'RECEIVABLE' ? item.warehouseReceivable === true - : item.warehousePayable === true && /BOOKING|FREIGHT|OCEAN|SHIPPING|订舱|海运|运费/i.test(`${item.feeCode || item.code || ''} ${item.feeName || item.name || ''}`))) || null + : item.warehousePayable === true)) || null } async function createManualFee(context, resource, feeItem, direction, amount, basis, businessDate, partner = null) { diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs index 8a1324fdb..45a686dd1 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs @@ -24,6 +24,15 @@ test('所有写入均走订单生命周期 HTTP API 且不清理保留数据', ( assert.doesNotMatch(source, /cleanup|remove|unlink/i) }) +test('状态矩阵复用订单共享的逐仓真实报价选择,不假设首仓或首税制', () => { + assert.match(source, /findPricedOrderRouteAcrossOrigins/) + assert.match(source, /loadChannels: \(receiving\)/) + assert.match(source, /pricing\/estimate/) + assert.doesNotMatch(source, /rows\(.*domestic.*\)\[0\]/) + assert.doesNotMatch(source, /pricingTaxModes\[0\]/) + assert.match(source, /pricingTaxMode: priced\.priced\.pricingTaxMode/) +}) + test('入库通过真实订单测量链保留测量态并最终进入 WAIT_SPLIT', () => { assert.match(source, /result\.inbounds\.WAIT_MEASURE/) assert.match(source, /processStatus: 'WAIT_MEASURE'/) diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs index d2e626fe4..cfd20c090 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs @@ -1,3 +1,5 @@ +import { findPricedOrderRouteAcrossOrigins } from './orders.mjs' + const MODULE = 'order-inbound-status-fixtures' const OPTIONS = { scenario: 'manual-order-inbound-status-matrix', deploymentBlocker: true } const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' @@ -64,19 +66,22 @@ async function loadFixtureInputs(context, token) { const profile = context.apiSuccess(MODULE, '读取 IT 测试客户资料', profileResponse) const domesticResponse = await merchantGet(context, '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', token) - const domestic = rows(context.apiSuccess(MODULE, '读取国内收货仓', domesticResponse))[0] + const domesticPage = context.apiSuccess(MODULE, '读取国内收货仓', domesticResponse) + const receivings = rows(domesticPage).filter((item) => item?.status === 'ACTIVE') const destinationResponse = await merchantGet(context, '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA', token) - const destination = rows(context.apiSuccess(MODULE, '读取 FBA 目的仓', destinationResponse))[0] - if (!profile?.id || !domestic?.id || !destination?.id) throw new Error('状态样本缺少客户或仓库基础数据') - const channelResponse = await merchantGet(context, - `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${query(domestic.id)}`, token) - const channels = context.apiSuccess(MODULE, '读取可下单渠道', channelResponse) - const channel = Array.isArray(channels) - ? channels.find((item) => Array.isArray(item.pricingTaxModes) && item.pricingTaxModes.length > 0) - : null - if (!channel) throw new Error('状态样本缺少带计价税制的可用渠道') - return { tenantId: id(profile.id), customerName: profile.tenantName || 'IT 测试客户', domestic, destination, channel } + const destinations = rows(context.apiSuccess(MODULE, '读取 FBA 目的仓', destinationResponse)) + if (!profile?.id || receivings.length === 0 || destinations.length === 0) throw new Error('状态样本缺少客户或仓库基础数据') + const priced = await findPricedOrderRouteAcrossOrigins({ + receivings, destinations, + loadChannels: (receiving) => merchantGet(context, + `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${query(receiving.id)}`, token), + estimate: (body) => merchantRequest(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, token), + }) + if (!priced.receiving || !priced.priced) throw new Error('状态样本缺少真实可计价的仓库/渠道/目的仓组合') + return { tenantId: id(profile.id), customerName: profile.tenantName || 'IT 测试客户', + domestic: priced.receiving, destination: priced.priced.destination, channel: priced.priced.channel, + pricingTaxMode: priced.priced.pricingTaxMode } } function orderPayload(context, fixture, target) { @@ -94,7 +99,7 @@ function orderPayload(context, fixture, target) { receivingWarehouseId: fixture.domestic.id, receivingWarehouseName: fixture.domestic.warehouseName, channelCode: fixture.channel.channelCode, channelName: fixture.channel.channelName, carrierId: null, carrierName: null, taxMode: 'BUY_DECLARATION', - pricingTaxMode: fixture.channel.pricingTaxModes[0], containerMode: 'LCL', customsInspection: false, + pricingTaxMode: fixture.pricingTaxMode, containerMode: 'LCL', customsInspection: false, destinationType: 'FBA', amazonWarehouseCode: fixture.destination.warehouseCode, shipmentId, referenceId, sender: { companyName: fixture.customerName, contactName: 'IT 测试联系人', phone: '13800138000', 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 235140da6..a478892a3 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 @@ -10,8 +10,9 @@ test('each remainder uses a simulation linked to a confirmed then released booki assert.match(source, /一次登记直接返回 CONFIRMED/) assert.doesNotMatch(source, /ocean-bookings\/\$\{oceanBookingId\}\/(?:submit|confirm)/) const createPayload = source.slice(source.indexOf('function bookingPayload'), source.indexOf('async function loadOneFragment')) - for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', - 'estimatedArrivalAt']) assert.match(createPayload, new RegExp(`${field}:`)) + assert.match(createPayload, /oceanBookingDraft\(\{ marker \}/) + assert.match(createPayload, /customerId: source\.tenantId/) + assert.match(createPayload, /channelCode: source\.channelCode/) assert.match(source, /ocean-booking-link/) assert.match(source, /bookingDetail\.oceanBooking\.status === 'CONFIRMED'/) assert.match(source, /`\$\{bookingPath\}\/release`/) @@ -69,5 +70,11 @@ test('booking freezes the source customer channel and warehouse scope', () => { assert.match(source, /customerId: source\.tenantId/) assert.match(source, /channelCode: source\.channelCode/) assert.match(source, /warehouseId: source\.warehouseId/) - assert.match(source, /containerRequirements: \[\{ containerType: '40HQ'/) + assert.match(source, /containerType === '40HQ'/) +}) + +test('booking fixture never invents supplier names without formal IDs', () => { + assert.match(source, /oceanBookingDraft/) + assert.doesNotMatch(source, /SPLIT-SHIPPING|SPLIT-AGENT/) + assert.doesNotMatch(source, /shippingCompanyName: `|bookingAgentName: `/) }) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index c78a3b08d..a10b7c95f 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -7,6 +7,8 @@ const CONTAINERS = '/api/platform/v1/containers' const SMART = '/api/platform/v1/first-leg/smart-loading' const PDA = '/api/platform/v1/first-leg/pda-loading' +import { oceanBookingDraft } from './ocean-booking-fixture.mjs' + function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } function isPositiveId(value) { return /^[1-9][0-9]*$/.test(id(value)) } @@ -118,23 +120,11 @@ function snapshotMatches(tree, original, loaded, remaining, activeSuffix) { function bookingPayload(marker, sequence, source) { const suffix = `${marker}-${sequence}`.replace(/[^A-Za-z0-9]/g, '').slice(-18) - return { - customerId: source.tenantId, customerName: source.customerName, - channelCode: source.channelCode, + return oceanBookingDraft({ marker }, suffix, { + customerId: source.tenantId, customerName: source.customerName, channelCode: source.channelCode, warehouseId: source.warehouseId, warehouseName: source.warehouseName, - billOfLadingNo: `BL-${suffix}`.slice(0, 128), - shippingCompanyId: null, shippingCompanyName: `SPLIT-SHIPPING-${suffix}`.slice(0, 128), - bookingAgentId: null, bookingAgentName: `SPLIT-AGENT-${suffix}`.slice(0, 128), - vesselName: `SPLIT-VESSEL-${suffix}`.slice(0, 128), voyageNo: `SV-${sequence}`.slice(0, 64), - departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', - placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', - estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00', remark: marker, - parties: [{ partyType: 'SHIPPER', partyName: `SPLIT-SHIPPER-${suffix}` }, - { partyType: 'CONSIGNEE', partyName: `SPLIT-CONSIGNEE-${suffix}` }, - { partyType: 'NOTIFY_PARTY', partyName: `SPLIT-NOTIFY-${suffix}` }], - containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 100, - maxWeightKg: 100000, quantity: 1 }], - } + }, { vesselName: `SPLIT-VESSEL-${suffix}`.slice(0, 128), voyageNo: `SV-${sequence}`.slice(0, 64), + estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00' }) } async function loadOneFragment(context, state) { diff --git a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs index 28f9aa573..13e94de57 100644 --- a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs +++ b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs @@ -62,6 +62,22 @@ test('create edit and stale replay payloads keep the resolved booking identity s assert.deepEqual(payloads.map((payload) => payload.expectedVersion), [0, 3, 0]) }) +test('truck payload sends carrier fields only as a verified ID/name pair', () => { + const identity = { oceanBookingId: '91', customerId: '21', warehouseId: '41', customerName: '本轮客户' } + const missing = truckAppointmentPayload({ marker: 'RUN-2', createdResources: {} }, '2', identity) + assert.equal(Object.hasOwn(missing, 'carrierId'), false) + assert.equal(Object.hasOwn(missing, 'carrierName'), false) + const valid = truckAppointmentPayload({ marker: 'RUN-2', createdResources: { + truckSupplier: { supplierId: '501', supplierName: '正式拖车商' }, + } }, '2', identity) + assert.deepEqual({ carrierId: valid.carrierId, carrierName: valid.carrierName }, { carrierId: '501', carrierName: '正式拖车商' }) + const incomplete = truckAppointmentPayload({ marker: 'RUN-2', createdResources: { + truckSupplier: { supplierId: '501', supplierName: null }, + } }, '2', identity) + assert.equal(Object.hasOwn(incomplete, 'carrierId'), false) + assert.equal(Object.hasOwn(incomplete, 'carrierName'), false) +}) + test('truck appointment source has no historical list or hardcoded identity fallback', () => { const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') assert.doesNotMatch(source, /customerId:\s*['"]10001['"]|warehouseId:\s*['"]193000000000000001['"]/) diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index 7b18a4433..5ca4c3854 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -69,10 +69,15 @@ export function inspectOceanBookingIdentity(marker, resource, detail = null) { export function truckAppointmentPayload(context, suffix, identity, version = 0) { const supplier = context.createdResources?.truckSupplier || {} + const supplierId = id(supplier.supplierId) + const supplierName = id(supplier.supplierName) + const carrierSnapshot = supplierId && supplierName + ? { carrierId: supplierId, carrierName: supplierName } + : {} return { outboundOrderId: null, oceanBookingId: identity.oceanBookingId, containerBookingId: null, loadingPlanId: null, warehouseId: identity.warehouseId, customerId: identity.customerId, customerName: identity.customerName, - carrierId: supplier.supplierId || null, carrierName: supplier.supplierName || `承运商-${suffix}`, fleetContact: '车队联系人', fleetPhone: '13800000004', + ...carrierSnapshot, fleetContact: '车队联系人', fleetPhone: '13800000004', driverName: '司机验收', driverPhone: '13800000005', driverIdCard: null, vehicleNo: `粤B-${suffix.slice(-6)}`, storeName: `门店-${suffix}`, storeContact: '门店联系人', storePhone: '13800000006', storeAddress: '深圳市南山区验收地址', storeCity: '深圳市', storeZipcode: '518000', packageCount: 10, grossWeightKg: 100, volumeCbm: 2.5, @@ -96,11 +101,12 @@ async function ensureTruckSupplier(context, suffix) { const detailResponse = await request(context, 'GET', `/api/platform/v1/suppliers/${encodeURIComponent(supplierId)}?tenantId=0`) supplier = data(detailResponse) } - context.addStep(MODULE, '创建本轮有效拖车供应商', response, - Boolean(supplierId) && supplier?.category === 'TRUCKING' && supplier?.status === 'ACTIVE', + context.addStep(MODULE, '创建本轮有效拖车供应商(可选)', response, + !supplierId || (supplier?.category === 'TRUCKING' && supplier?.status === 'ACTIVE' && Boolean(supplier?.name)), `supplierId=${supplierId || 'missing'}, category=${supplier?.category || 'missing'}, status=${supplier?.status || 'missing'}`, OPTIONS) if (supplierId) context.createdResources.truckSupplier = { - marker: context.marker, supplierId, supplierName: supplier.name, finalStatus: 'ACTIVE', retained: true, + marker: context.marker, supplierId, supplierName: supplier.name || supplier.supplierName || null, + category: supplier.category, status: supplier.status, finalStatus: 'ACTIVE', retained: true, } return context.createdResources.truckSupplier || null } @@ -148,8 +154,7 @@ export async function run(context) { } const bookingIdentity = Object.freeze({ ...identityResult.identity }) - const truckSupplier = await ensureTruckSupplier(context, suffix) - if (!truckSupplier) return + await ensureTruckSupplier(context, suffix) const createResponse = await request(context, 'POST', BASE, truckAppointmentPayload(context, suffix, bookingIdentity)); const appointment = context.apiSuccess(MODULE, '创建隔离拖车预约', createResponse, '', OPTIONS) const appointmentId = id(appointment?.id)