diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 4cd343579..622883624 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -6144,14 +6144,14 @@ ON DUPLICATE KEY UPDATE deleted = 0, updated_at = CURRENT_TIMESTAMP(3); --- 平台托盘初始化主数据;内部状态仅供预占/占用,不作为平台维护字段。 +-- 平台托盘初始化主数据;托盘是可复用主数据,不维护动态占用状态。 INSERT INTO wh_pallet ( id, tenant_id, warehouse_id, pallet_code, pallet_type, capacity_cbm, - weight_kg, color, status, remark, created_by, updated_by + weight_kg, color, remark, created_by, updated_by ) VALUES - (193000000000200101,0,193000000000200001,'TP-001','木托盘',1.200000,8.000,'黄色','AVAILABLE',NULL,0,0), - (193000000000200102,0,193000000000200002,'TP-002','胶托盘',2.400000,12.000,'黑色','AVAILABLE',NULL,0,0) + (193000000000200101,0,193000000000200001,'TP-001','木托盘',1.200000,8.000,'黄色',NULL,0,0), + (193000000000200102,0,193000000000200002,'TP-002','胶托盘',2.400000,12.000,'黑色',NULL,0,0) ON DUPLICATE KEY UPDATE tenant_id = 0, warehouse_id = VALUES(warehouse_id), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java index 02269da22..f499f46e4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java @@ -16,6 +16,7 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import java.util.List; @Validated @RestController @@ -26,6 +27,11 @@ public class PlatformOceanBookingController { @GetMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query)); } + @GetMapping("/channels") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") + public ApiResponse> channels(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long originWarehouseId) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.channels(originWarehouseId)); + } @GetMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(oceanBookingId)); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java index de546a2ee..06277cdfd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java @@ -42,7 +42,6 @@ public final class OceanBookingDtos { public record DraftData(@NotNull @Positive Long customerId, @NotBlank @Size(max = 128) String customerName, @NotBlank @Size(max = 64) String channelCode, - @NotBlank @Size(max = 128) String channelName, @NotNull @Positive Long warehouseId, @NotBlank @Size(max = 128) String warehouseName, @Size(max = 128) String billOfLadingNo, @@ -92,4 +91,7 @@ public final class OceanBookingDtos { List containerRequirements, List containerAllocations, List histories) {} + + public record ChannelOption(Long priceBookVersionId, String channelCode, String channelName, + String priceModel, List pricingTaxModes) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index 9eb7c19fc..f2cbfe7e1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -10,6 +10,9 @@ import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*; import com.baiyue.firstleg.oceanbooking.entity.*; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; +import com.baiyue.firstleg.repository.PriceBookQueryRepository; +import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; import com.baiyue.firstleg.service.LoadingPickService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -25,12 +28,14 @@ import java.time.LocalDateTime; import java.util.HashSet; import java.util.HexFormat; import java.util.List; +import java.util.Map; import java.util.Set; @Service public class OceanBookingService { private static final Set PARTY_TYPES = Set.of("SHIPPER", "CONSIGNEE", "NOTIFY_PARTY"); @Resource private OceanBookingRepository repository; + @Resource private PriceBookQueryRepository priceBooks; @Resource private ObjectMapper objectMapper; @Resource private LoadingPickService loadingPickService; @@ -40,21 +45,36 @@ public class OceanBookingService { return repository.page(new Query(query.customerId(), status, trim(query.keyword()), query.page(), query.size())); } + public List channels(long originWarehouseId) { + if (originWarehouseId <= 0) throw badRequest("请选择有效起运仓"); + Long versionId = priceBooks.activeVersionId(); + if (versionId == null) return List.of(); + List rows = priceBooks.channels(versionId, originWarehouseId); + Map> taxModes = priceBooks.channelTaxModes(versionId, originWarehouseId).stream() + .filter(row -> row != null && StringUtils.hasText(row.getChannelCode()) + && StringUtils.hasText(row.getPricingTaxMode())) + .collect(java.util.stream.Collectors.groupingBy(row -> row.getChannelCode().trim(), + java.util.stream.Collectors.mapping(ChannelTaxModeRow::getPricingTaxMode, + java.util.stream.Collectors.toList()))); + return rows.stream().map(row -> channelOption(row, taxModes)).toList(); + } + public Detail detail(long oceanBookingId) { return detail(required(oceanBookingId)); } @Transactional public Detail create(long operatorId, String operatorName, String key, CreateRequest request) { if (request.expectedVersion() != 0) throw concurrent(); - validateDraft(request.data()); + validateDraftStructure(request.data()); String action = "CREATE"; String hash = hash(action, null, request); Detail replay = claimOrReplay(null, operatorId, action, key, hash); if (replay != null) return replay; + ChannelOption channel = requireActiveChannel(request.data().warehouseId(), request.data().channelCode()); OceanBookingEntity value = new OceanBookingEntity(); value.setId(IdWorker.getId()); value.setBookingNo("OB" + value.getId()); value.setStatus(OceanBookingStatus.DRAFT.name()); - copy(value, request.data()); + copy(value, request.data(), channel.channelName()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); repository.insert(value); @@ -67,13 +87,14 @@ public class OceanBookingService { @Transactional public Detail updateDraft(long oceanBookingId, long operatorId, String operatorName, String key, UpdateDraftRequest request) { - validateDraft(request.data()); + validateDraftStructure(request.data()); String action = "UPDATE_DRAFT"; Detail replay = claimOrReplay(oceanBookingId, operatorId, action, key, hash(action, oceanBookingId, request)); if (replay != null) return replay; OceanBookingEntity before = requiredVersion(oceanBookingId, request.expectedVersion()); requireStatus(before, OceanBookingStatus.DRAFT); - OceanBookingEntity value = new OceanBookingEntity(); value.setId(oceanBookingId); copy(value, request.data()); + ChannelOption channel = requireActiveChannel(request.data().warehouseId(), request.data().channelCode()); + OceanBookingEntity value = new OceanBookingEntity(); value.setId(oceanBookingId); copy(value, request.data(), channel.channelName()); if (repository.updateDraft(value, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent(); repository.replaceParties(oceanBookingId, parties(oceanBookingId, request.data().parties())); repository.replaceRequirements(oceanBookingId, requirements(oceanBookingId, request.data().containerRequirements())); @@ -90,6 +111,7 @@ public class OceanBookingService { if (replay != null) return replay; OceanBookingEntity before = requiredVersion(id, request.expectedVersion()); requireStatus(before, OceanBookingStatus.SUBMITTED); + requireActiveChannel(before.getWarehouseId(), before.getChannelCode()); validateConfirmation(before, repository.requirements(id)); if (repository.transition(id, OceanBookingStatus.SUBMITTED.name(), OceanBookingStatus.CONFIRMED.name(), null, null, request.expectedVersion(), operatorId, name) != 1) throw concurrent(); @@ -263,7 +285,7 @@ public class OceanBookingService { private OceanBookingEntity requiredVersion(long id, int version) { OceanBookingEntity value = required(id); if (!Integer.valueOf(version).equals(value.getVersion())) throw concurrent(); return value; } private static void requireStatus(OceanBookingEntity value, OceanBookingStatus status) { if (!status.name().equals(value.getStatus())) throw conflict("订舱状态不允许当前操作"); } - private void validateDraft(DraftData data) { + private void validateDraftStructure(DraftData data) { if (data.estimatedSailingAt() != null || data.estimatedArrivalAt() != null || StringUtils.hasText(data.billOfLadingNo()) || StringUtils.hasText(data.vesselName()) || StringUtils.hasText(data.voyageNo())) { @@ -284,9 +306,31 @@ public class OceanBookingService { } } private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); } - private static void copy(OceanBookingEntity v, DraftData d) { + private ChannelOption requireActiveChannel(Long originWarehouseId, String channelCode) { + if (originWarehouseId == null || originWarehouseId <= 0) throw badRequest("请选择有效起运仓"); + String normalized = normalizeChannel(channelCode); + Long versionId = priceBooks.activeVersionId(); + if (versionId == null) throw conflict("当前没有 ACTIVE 价格版本"); + ChannelRow row = priceBooks.channels(versionId, originWarehouseId).stream() + .filter(value -> normalized.equals(normalizeChannel(value.getChannelCode()))) + .findFirst().orElseThrow(() -> conflict("所选物流渠道已失效,请重新选择")); + if (!StringUtils.hasText(row.getChannelName())) throw conflict("所选物流渠道名称配置无效"); + return channelOption(row, java.util.Map.of()); + } + + private static ChannelOption channelOption(ChannelRow row, java.util.Map> taxModes) { + return new ChannelOption(row.getPriceBookVersionId(), row.getChannelCode(), row.getChannelName(), + row.getPriceModel(), List.copyOf(taxModes.getOrDefault(row.getChannelCode().trim(), List.of()))); + } + + private static String normalizeChannel(String value) { + if (!StringUtils.hasText(value)) throw badRequest("channelCode不能为空"); + return value.trim(); + } + + private static void copy(OceanBookingEntity v, DraftData d, String resolvedChannelName) { v.setCustomerId(d.customerId()); v.setCustomerName(d.customerName().trim()); - v.setChannelCode(d.channelCode().trim()); v.setChannelName(d.channelName().trim()); + v.setChannelCode(d.channelCode().trim()); v.setChannelName(resolvedChannelName); v.setWarehouseId(d.warehouseId()); v.setWarehouseName(d.warehouseName().trim()); v.setBillOfLadingNo(null); v.setShippingCompanyId(d.shippingCompanyId()); v.setShippingCompanyName(trim(d.shippingCompanyName())); diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 8705ab8b1..f7b375a5b 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -1,6 +1,13 @@ openapi: 3.0.3 info: {title: Phase 3 Ocean Booking API, version: 1.1.0} paths: + /api/platform/v1/ocean-bookings/channels: + get: + summary: List logistics channels from the current ACTIVE price book for an origin warehouse + description: 'Requires platform:firstleg:ocean-booking:list. Returns only channels with an ACTIVE price row; the selected channel name is frozen server-side on draft save.' + parameters: + - {name: originWarehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} + responses: {'200': {description: Available channel options, content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/ChannelOption'}}}}}} /api/platform/v1/ocean-bookings: post: summary: Create an ocean booking draft with immutable channel and warehouse snapshots @@ -33,12 +40,11 @@ components: data: {$ref: '#/components/schemas/DraftData'} DraftData: type: object - required: [customerId, customerName, channelCode, channelName, warehouseId, warehouseName, parties, containerRequirements] + required: [customerId, customerName, channelCode, warehouseId, warehouseName, parties, containerRequirements] properties: customerId: {type: integer, format: int64, minimum: 1} customerName: {type: string, maxLength: 128} - channelCode: {type: string, maxLength: 64} - channelName: {type: string, maxLength: 128} + channelCode: {type: string, maxLength: 64, description: Selected from GET /api/platform/v1/ocean-bookings/channels; server validates it against the current ACTIVE price book and freezes channelName.} warehouseId: {type: integer, format: int64, minimum: 1} warehouseName: {type: string, maxLength: 128} billOfLadingNo: {type: string, nullable: true, deprecated: true, description: Must be omitted until release} @@ -57,6 +63,15 @@ components: capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0} maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0} quantity: {type: integer, minimum: 1} + ChannelOption: + type: object + required: [priceBookVersionId, channelCode, channelName, priceModel, pricingTaxModes] + properties: + priceBookVersionId: {type: integer, format: int64} + channelCode: {type: string, maxLength: 64} + channelName: {type: string, maxLength: 128} + priceModel: {type: string} + pricingTaxModes: {type: array, items: {type: string}} ActionRequest: type: object required: [expectedVersion] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java index 030fb265f..133726ac0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java @@ -8,6 +8,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import java.lang.reflect.Method; import java.util.Arrays; import java.util.Map; +import java.nio.file.Files; +import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; @@ -18,6 +20,7 @@ class PlatformOceanBookingControllerContractTest { .containsExactly("/api/platform/v1/ocean-bookings"); Map permissions = Map.ofEntries( Map.entry("page", "platform:firstleg:ocean-booking:list"), + Map.entry("channels", "platform:firstleg:ocean-booking:list"), Map.entry("detail", "platform:firstleg:ocean-booking:list"), Map.entry("create", "platform:firstleg:ocean-booking:create"), Map.entry("updateDraft", "platform:firstleg:ocean-booking:edit"), @@ -34,7 +37,7 @@ class PlatformOceanBookingControllerContractTest { Method method = method(name); assertThat(method.getAnnotation(PreAuthorize.class).value()) .isEqualTo("hasAuthority('" + permission + "')"); - if (!name.equals("page") && !name.equals("detail")) { + if (!name.equals("page") && !name.equals("detail") && !name.equals("channels")) { assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull(); } }); @@ -49,6 +52,15 @@ class PlatformOceanBookingControllerContractTest { .allMatch(parameter -> parameter.getName().equals("oceanBookingId"))).isTrue(); } + @Test + void openApiFreezesServerResolvedChannelContract() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")); + assertThat(yaml).contains("/api/platform/v1/ocean-bookings/channels:", + "originWarehouseId", "platform:firstleg:ocean-booking:list", "ChannelOption") + .contains("required: [customerId, customerName, channelCode, warehouseId, warehouseName") + .doesNotContain("required: [customerId, customerName, channelCode, channelName"); + } + private static Method method(String name) { return Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods()) .filter(method -> method.getName().equals(name)).findFirst().orElseThrow(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index 121390e9d..6aa70a1dd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -3,9 +3,14 @@ package com.baiyue.firstleg.oceanbooking.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ActionRequest; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ChangeRequest; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ContainerRequirementRequest; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.CreateRequest; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DraftData; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.AllocationRequest; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.PartyRequest; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ReleaseRequest; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.UpdateDraftRequest; import com.baiyue.firstleg.service.LoadingPickService; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; @@ -14,6 +19,8 @@ import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; +import com.baiyue.firstleg.repository.PriceBookQueryRepository; +import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -23,7 +30,9 @@ import org.mockito.ArgumentCaptor; import org.mockito.MockitoAnnotations; import java.time.LocalDateTime; +import java.math.BigDecimal; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -32,6 +41,7 @@ import static org.mockito.Mockito.*; class OceanBookingServiceTest { @Mock private OceanBookingRepository repository; + @Mock private PriceBookQueryRepository priceBooks; @Mock private LoadingPickService loadingPickService; @InjectMocks private OceanBookingService service; @@ -172,12 +182,133 @@ class OceanBookingServiceTest { verify(repository, never()).insert(any(OceanBookingContainerAllocationEntity.class)); } + @Test + void listsOnlyChannelsFromCurrentActivePriceBook() { + ChannelRow channel = channel("SEA-US", "美西海运"); + when(priceBooks.activeVersionId()).thenReturn(51L); + when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel)); + when(priceBooks.channelTaxModes(51L, 701L)).thenReturn(List.of()); + + var result = service.channels(701L); + + assertThat(result).singleElement().satisfies(value -> { + assertThat(value.channelCode()).isEqualTo("SEA-US"); + assertThat(value.channelName()).isEqualTo("美西海运"); + assertThat(value.priceBookVersionId()).isEqualTo(51L); + }); + } + + @Test + void freezesAuthoritativeChannelNameWhenCreatingDraft() { + ChannelRow channel = channel("SEA-US", "价格簿美西海运"); + when(priceBooks.activeVersionId()).thenReturn(51L); + when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel)); + AtomicReference inserted = new AtomicReference<>(); + doAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return null; }) + .when(repository).insert(any(OceanBookingEntity.class)); + when(repository.find(anyLong())).thenAnswer(invocation -> inserted.get()); + + Detail result = service.create(9L, "admin", "create-channel", new CreateRequest(0, draft())); + + assertThat(result.oceanBooking().getChannelCode()).isEqualTo("SEA-US"); + assertThat(result.oceanBooking().getChannelName()).isEqualTo("价格簿美西海运"); + } + + @Test + void replaysCreateAfterChannelIsNoLongerActive() throws Exception { + ChannelRow channel = channel("SEA-US", "价格簿美西海运"); + CreateRequest request = new CreateRequest(0, draft()); + when(priceBooks.activeVersionId()).thenReturn(51L, (Long) null); + when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel)); + AtomicReference inserted = new AtomicReference<>(); + doAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return null; }) + .when(repository).insert(any(OceanBookingEntity.class)); + when(repository.find(anyLong())).thenAnswer(invocation -> inserted.get()); + + Detail created = service.create(9L, "admin", "create-replay-channel", request); + + OceanBookingOperationEntity operation = new OceanBookingOperationEntity(); + operation.setRequestHash(hashForCreate(request)); operation.setStatus("SUCCESS"); + operation.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(created)); + when(repository.claimOperation(isNull(), eq(9L), eq("CREATE"), eq("create-replay-channel"), anyString())) + .thenReturn(false); + when(repository.operation(9L, "CREATE", "create-replay-channel")).thenReturn(operation); + + Detail replay = service.create(9L, "admin", "create-replay-channel", request); + + assertThat(replay.oceanBooking().getChannelName()).isEqualTo("价格簿美西海运"); + verify(priceBooks, times(1)).activeVersionId(); + } + + @Test + void replaysDraftUpdateAfterChannelIsNoLongerActive() throws Exception { + UpdateDraftRequest request = new UpdateDraftRequest(5, draft()); + Detail expected = new Detail(booking("DRAFT", 6), List.of(), List.of(), List.of(), List.of()); + OceanBookingOperationEntity operation = new OceanBookingOperationEntity(); + operation.setRequestHash(hashForUpdate(request)); operation.setStatus("SUCCESS"); + operation.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(expected)); + when(repository.claimOperation(eq(1L), eq(9L), eq("UPDATE_DRAFT"), eq("update-replay-channel"), anyString())) + .thenReturn(false); + when(repository.operation(9L, "UPDATE_DRAFT", "update-replay-channel")).thenReturn(operation); + + Detail replay = service.updateDraft(1L, 9L, "admin", "update-replay-channel", request); + + assertThat(replay.oceanBooking().getVersion()).isEqualTo(6); + verifyNoInteractions(priceBooks); + verify(repository, never()).find(anyLong()); + } + + @Test + void rejectsConfirmationWhenFrozenChannelIsNoLongerActive() { + OceanBookingEntity submitted = booking("SUBMITTED", 3); + submitted.setWarehouseId(701L); submitted.setWarehouseName("深圳仓"); submitted.setChannelName("美西海运"); + OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); + requirement.setCapacityCbm(java.math.BigDecimal.ONE); requirement.setMaxWeightKg(java.math.BigDecimal.ONE); + when(repository.find(1L)).thenReturn(submitted); + when(repository.requirements(1L)).thenReturn(List.of(requirement)); + when(priceBooks.activeVersionId()).thenReturn(null); + + assertThatThrownBy(() -> service.confirm(1L, 9L, "admin", "confirm-channel", new ActionRequest(3, "确认"))) + .isInstanceOf(BizException.class).hasMessageContaining("ACTIVE"); + + verify(repository, never()).transition(anyLong(), anyString(), anyString(), any(), any(), anyInt(), anyLong(), anyString()); + } + + private static ChannelRow channel(String code, String name) { + ChannelRow value = new ChannelRow(); value.setPriceBookVersionId(51L); value.setChannelCode(code); + value.setChannelName(name); value.setPriceModel("FBA_WAREHOUSE_RATE"); return value; + } + + private static DraftData draft() { + List parties = List.of( + new PartyRequest("SHIPPER", "发货人", null, null, null), + new PartyRequest("CONSIGNEE", "收货人", null, null, null), + new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null)); + List requirements = List.of( + new ContainerRequirementRequest("40HQ", "DRY", BigDecimal.ONE, BigDecimal.TEN, 1)); + return new DraftData(100L, "客户", "SEA-US", 701L, "深圳仓", null, + 81L, "船公司", null, null, null, null, "CNSZX", "深圳", "USLAX", "洛杉矶", + null, null, null, null, null, null, parties, requirements); + } + private String hashForSubmit() throws Exception { var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class); method.setAccessible(true); return (String) method.invoke(service, "SUBMIT", 1L, new ActionRequest(2, "提交")); } + private String hashForCreate(CreateRequest request) throws Exception { + var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class); + method.setAccessible(true); + return (String) method.invoke(service, "CREATE", (Long) null, request); + } + + private String hashForUpdate(UpdateDraftRequest request) throws Exception { + var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class); + method.setAccessible(true); + return (String) method.invoke(service, "UPDATE_DRAFT", 1L, request); + } + private static OceanBookingEntity booking(String status, int version) { OceanBookingEntity value = new OceanBookingEntity(); value.setId(1L); value.setBookingNo("OB1"); value.setStatus(status); value.setCustomerId(100L); value.setCustomerName("客户"); diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 4923994c7..255c546c5 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -26,4 +26,8 @@ test('统一初始化 SQL 提供人工验收所需公共仓储主数据', () => "'CUS-001'", "'CUSTOMER'", "'SHIPPER'", "'EXP-TEST'", "'IT 测试快递'", ]) assert.ok(sql.includes(marker), `missing initialization marker: ${marker}`) assert.match(sql, /'EXP-TEST', 'IT 测试快递'[\s\S]*?'ACTIVE'/) + + const palletSeed = sql.match(/INSERT INTO wh_pallet \([\s\S]*?remark = VALUES\(remark\);/)?.[0] + assert.ok(palletSeed, 'missing pallet initialization seed') + assert.doesNotMatch(palletSeed, /\bstatus\b|'AVAILABLE'/) }) 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 d9f3ec0ba..b8ebc31a5 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -100,7 +100,7 @@ async function createConfirmedContainer(context, source, label) { const summary = source.detail.summary const draft = { customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户', - channelCode: summary.channelCode, channelName: summary.channelName, warehouseId: source.warehouseId, + channelCode: summary.channelCode, warehouseId: source.warehouseId, warehouseName: summary.warehouseName, shippingCompanyId: null, shippingCompanyName: `人工船司-${value}`, bookingAgentId: null, bookingAgentName: `人工代理-${value}`, departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs index 099820204..13b063761 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs @@ -20,7 +20,7 @@ function oceanPayload(context, template, index) { const source = template.oceanBooking return { expectedVersion: 0, data: { customerId: source.customerId, customerName: source.customerName, - channelCode: source.channelCode, channelName: source.channelName, + channelCode: source.channelCode, warehouseId: source.warehouseId, warehouseName: source.warehouseName, shippingCompanyId: source.shippingCompanyId, shippingCompanyName: source.shippingCompanyName, bookingAgentId: source.bookingAgentId, diff --git a/scripts/admin-test-env-modules/ocean-booking-channel-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-channel-contract.test.mjs new file mode 100644 index 000000000..17db7be4d --- /dev/null +++ b/scripts/admin-test-env-modules/ocean-booking-channel-contract.test.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict' +import { readFileSync, readdirSync } from 'node:fs' +import test from 'node:test' + +const adminPageUrl = new URL('../../../apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue', import.meta.url) +const adminTestUrl = new URL('../../../apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts', import.meta.url) +const adminApiUrl = new URL('../../../apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts', import.meta.url) +const openapiUrl = new URL('../../firstleg/src/main/resources/openapi/', import.meta.url) + +function source(url) { return readFileSync(url, 'utf8') } + +test('Admin ocean booking selects a warehouse-scoped logistics channel instead of accepting free text', () => { + const page = source(adminPageUrl) + const api = source(adminApiUrl) + const pageTest = source(adminTestUrl) + + assert.doesNotMatch(page, /]*v-model="form\.data\.channelCode"/) + assert.doesNotMatch(page, /]*v-model="form\.data\.channelName"/) + assert.match(page, /]*label="物流渠道"[^>]*>[\s\S]*?]*:model-value="form\.data\.channelCode"[^>]*@update:model-value="selectChannel\(/) + assert.match(`${page}\n${api}`, /originWarehouseId/) + assert.match(api, /basePath\s*=\s*['"]\/platform\/v1\/ocean-bookings['"][\s\S]*\$\{basePath\}\/channels/) + assert.doesNotMatch(api, /price-books[\s\S]*for \(const version of versions\)/) + assert.match(page, /shippingCompanyId/) + assert.match(page, /channelCode/) + assert.doesNotMatch(pageTest, /expect\(source\)\.toContain\(['"]v-model="form\.data\.channel(?:Code|Name)"['"]\)/) + assert.match(pageTest, /expect\(source\)\.toContain\(['"]:model-value="form\.data\.channelCode" filterable['"]\)/) + assert.match(pageTest, /expect\(source\)\.not\.toContain\(['"] { + const documents = readdirSync(openapiUrl) + .filter((name) => name.endsWith('.yaml')) + .map((name) => source(new URL(name, openapiUrl))) + const platformChannelContract = documents.find((value) => + /\/api\/platform\/v1\/ocean-bookings\/channels/.test(value) && /originWarehouseId/.test(value) + && /server validates it against the current ACTIVE price book/.test(value)) + assert.ok(platformChannelContract, + 'BLOCKED_CONTRACT: platform logistics-channel query by originWarehouseId is not frozen in OpenAPI') +}) + +test('container confirmation acceptance locks the selected channel onto the generated container', () => { + const fixture = source(new URL('./phase3-container-loading.mjs', import.meta.url)) + assert.match(fixture, /containerAllocations\?\.\[0\]/) + assert.match(fixture, /containerDetail\?\.channelCode === channelCode/) + assert.match(fixture, /selectPlatformChannelIsolationPair/) + assert.doesNotMatch(fixture, /\$\{channelCode\}-OTHER|foreignChannelCode/) +}) diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs index a4eff3ad3..1ff5aa980 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { oceanBookingDraft, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs' +import { oceanBookingDraft, publishPlatformLogisticsChannels, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs' test('ocean booking fixture resolves the current inbound customer channel and warehouse', async () => { const steps = [] @@ -31,14 +31,15 @@ test('ocean booking fixture blocks creation instead of inventing missing master assert.match(steps[0][4], /missing=customerId,customerName,channelCode,channelName,warehouseId,warehouseName/) }) -test('shared booking draft carries all required DTO fields without release transport data', () => { +test('shared booking draft omits server-owned channelName and release transport data', () => { const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-3' }, '3', { customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运', warehouseId: '41', warehouseName: '深圳仓', }) - for (const key of ['customerId', 'customerName', 'channelCode', 'channelName', 'warehouseId', 'warehouseName']) { + for (const key of ['customerId', 'customerName', 'channelCode', 'warehouseId', 'warehouseName']) { assert.ok(draft[key], `${key} is required`) } + assert.equal(draft.channelName, undefined) assert.equal(draft.containerRequirements[0].capacityCbm, 68) assert.equal(draft.containerRequirements[0].maxWeightKg, 28000) for (const key of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) { @@ -46,6 +47,45 @@ test('shared booking draft carries all required DTO fields without release trans } }) +test('platform channel producer queries the selected origin warehouse and publishes current-marker resources', async () => { + const calls = [] + const context = { + marker: 'ADMIN-RUN-CHANNELS', createdResources: {}, + request: async (method, path) => { + calls.push({ method, path }) + return { body: { code: '200', data: [ + { channelCode: 'SEA-A', channelName: '海运 A' }, + { channelCode: 'SEA-B', channelName: '海运 B' }, + ] } } + }, + apiSuccess: (_module, _name, response) => response.body.data, + assert: (_module, _name, _response, condition) => assert.equal(condition, true), + } + const catalog = await publishPlatformLogisticsChannels(context, '41', 'test') + assert.deepEqual(calls, [{ method: 'GET', path: '/api/platform/v1/ocean-bookings/channels?originWarehouseId=41' }]) + assert.equal(catalog.marker, context.marker) + assert.equal(catalog.originWarehouseId, '41') + assert.deepEqual(catalog.channels.map((value) => value.channelCode), ['SEA-A', 'SEA-B']) + assert.deepEqual(context.createdResources.platformLogisticsChannels, catalog) +}) + +test('all direct ocean booking payload builders omit channelName under the frozen DraftData contract', () => { + const regions = [ + ['manual-state-loading-pda.mjs', 'const draft = {', 'const createResponse'], + ['manual-state-ocean-trucking.mjs', 'function oceanPayload', 'async function createOcean'], + ['phase3-loading.mjs', 'const bookingDraft = {', 'const bookingCreate'], + ['phase3-order-splitting.mjs', 'function bookingPayload', 'async function loadOneFragment'], + ['phase3-container-loading.mjs', 'const bookingDraft = {', "context.assert(MODULE, '订舱渠道与仓库快照完整'"], + ] + for (const [name, start, end] of regions) { + const value = readFileSync(new URL(name, import.meta.url), 'utf8') + const region = value.slice(value.indexOf(start), value.indexOf(end)) + assert.doesNotMatch(region, /channelName\s*:/, name) + } + const container = readFileSync(new URL('phase3-container-loading.mjs', import.meta.url), 'utf8') + assert.doesNotMatch(container, /data:\s*\{[^}]*channelName\s*:/s) +}) + test('all independent ocean booking producers use the shared master-data contract', () => { for (const name of ['ocean-bookings.mjs', 'phase3-port-schedules.mjs', 'phase3-carrier-schedule-queries.mjs']) { const source = readFileSync(new URL(name, import.meta.url), 'utf8') @@ -53,6 +93,6 @@ test('all independent ocean booking producers use the shared master-data contrac assert.match(source, /oceanBookingDraft\(context, suffix, master/, name) } const container = readFileSync(new URL('phase3-container-loading.mjs', import.meta.url), 'utf8') - for (const snippet of ['channelCode: channelCode', 'channelName', 'warehouseId', 'warehouseName', + for (const snippet of ['channelCode', 'publishPlatformLogisticsChannels', 'warehouseId', 'warehouseName', 'capacityCbm: 100', 'maxWeightKg: 100000']) assert.match(container, new RegExp(snippet), snippet) }) diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs index 38439d421..8582ca4f7 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs @@ -1,4 +1,25 @@ function text(value) { return value == null ? '' : String(value).trim() } +const PLATFORM_CHANNELS = '/api/platform/v1/ocean-bookings/channels' + +export async function publishPlatformLogisticsChannels(context, originWarehouseId, module) { + const warehouseId = text(originWarehouseId) + const sourcePath = `${PLATFORM_CHANNELS}?originWarehouseId=${encodeURIComponent(warehouseId)}` + const response = await context.request('GET', sourcePath) + const data = context.apiSuccess(module, '按起运仓查询平台正式物流渠道', response, + `originWarehouseId=${warehouseId}`, { scenario: 'ocean-booking-channel-catalog', deploymentBlocker: true }) + const channels = (Array.isArray(data) ? data : []).flatMap((value) => { + const channelCode = text(value?.channelCode) + const channelName = text(value?.channelName) + return channelCode && channelName ? [{ channelCode, channelName }] : [] + }) + const resource = { marker: context.marker, originWarehouseId: warehouseId, sourcePath, channels } + context.createdResources.platformLogisticsChannels = resource + context.assert(module, '平台渠道目录归属本轮起运仓且不含空渠道', response, + Boolean(warehouseId) && channels.length > 0, + `originWarehouseId=${warehouseId}, channels=${channels.map((value) => value.channelCode).join('|') || 'none'}`, + { scenario: 'ocean-booking-channel-catalog', deploymentBlocker: true }) + return resource +} export async function resolveOceanBookingMaster(context, module) { const resources = context.createdResources || {} @@ -32,8 +53,9 @@ export async function resolveOceanBookingMaster(context, module) { } export function oceanBookingDraft(context, suffix, master, labels = {}) { + const { channelName: _readOnlyChannelName, ...writableMaster } = master return { - ...master, + ...writableMaster, shippingCompanyId: null, shippingCompanyName: labels.shippingCompanyName || `验收船公司-${suffix}`, bookingAgentId: null, bookingAgentName: labels.bookingAgentName || `验收代理-${suffix}`, vesselName: labels.vesselName, voyageNo: labels.voyageNo, diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index ae601f063..35088630f 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -1,3 +1,5 @@ +import { publishPlatformLogisticsChannels } from './ocean-booking-fixture.mjs' + const MODULE = 'phase3-container-loading' const OPTIONS = { scenario: 'phase3-container-smart-loading-pda', deploymentBlocker: true } const CONTAINERS = '/api/platform/v1/containers' @@ -46,6 +48,32 @@ export function selectContainerLoadingFixture(context) { return candidates.find((candidate) => candidate.marker === context.marker) || candidates[0] } +export function selectPlatformChannelIsolationPair(catalog, marker, originWarehouseId, primaryChannelCode) { + const catalogMarker = id(catalog?.marker) + const catalogWarehouseId = id(catalog?.originWarehouseId) + const sourcePath = id(catalog?.sourcePath) + if (catalogMarker !== id(marker) || catalogWarehouseId !== id(originWarehouseId) + || !sourcePath.startsWith('/api/platform/')) { + return { pair: null, reason: 'BLOCKED_FIXTURE: missing current-marker platform channel catalog for origin warehouse' } + } + const channels = [] + const seen = new Set() + for (const value of Array.isArray(catalog?.channels) ? catalog.channels : []) { + const channelCode = id(value?.channelCode).trim() + const channelName = id(value?.channelName).trim() + const status = id(value?.status).trim().toUpperCase() + if (!channelCode || !channelName || (status && status !== 'ACTIVE') || seen.has(channelCode)) continue + seen.add(channelCode) + channels.push({ channelCode, channelName }) + } + const primary = channels.find((value) => value.channelCode === id(primaryChannelCode)) + const foreign = channels.find((value) => value.channelCode !== id(primaryChannelCode)) + if (!primary || !foreign) { + return { pair: null, reason: `BLOCKED_FIXTURE: ACTIVE platform channels=${channels.length}, distinct pair unavailable` } + } + return { pair: { primary, foreign }, reason: null } +} + export async function run(context) { const source = selectContainerLoadingFixture(context) const tenantId = id(source?.tenantId) @@ -70,11 +98,19 @@ export async function run(context) { const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16) const channelCode = id(inbound?.summary?.channelCode || source?.channelCode) - const channelName = inbound?.summary?.channelName || source?.channelName || channelCode const warehouseName = inbound?.summary?.warehouseName || source?.warehouseName + if (!channelCode || !warehouseName) return + const channelCatalog = await publishPlatformLogisticsChannels(context, warehouseId, MODULE) + const selectedChannel = channelCatalog.channels.find((value) => value.channelCode === channelCode) + context.assert(MODULE, '入库渠道存在于当前起运仓 ACTIVE 平台渠道目录', inboundResponse, + Boolean(selectedChannel), + `originWarehouseId=${warehouseId}, channelCode=${channelCode}, catalog=${channelCatalog.channels.map((value) => value.channelCode).join('|') || 'none'}`, + OPTIONS) + if (!selectedChannel) return + const channelName = selectedChannel.channelName const bookingDraft = { customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`, - channelCode: channelCode || 'FBA', channelName, warehouseId, warehouseName, + channelCode, warehouseId, warehouseName, shippingCompanyId: null, shippingCompanyName: `验收船公司-${suffix}`, bookingAgentId: null, bookingAgentName: `验收代理-${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳', @@ -87,7 +123,6 @@ export async function run(context) { } context.assert(MODULE, '订舱渠道与仓库快照完整', inboundResponse, Boolean(channelCode && channelName && warehouseName), `channelCode=${channelCode || 'missing'}, warehouseId=${warehouseId}, warehouseName=${warehouseName || 'missing'}`, OPTIONS) - if (!channelCode || !warehouseName) return const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', { expectedVersion: 0, data: bookingDraft }, { 'Idempotency-Key': `${context.marker}-loading-booking-create` }) const bookingCreated = context.apiSuccess(MODULE, '创建含渠道仓库快照的隔离订舱', bookingCreate, '', OPTIONS) @@ -118,35 +153,42 @@ export async function run(context) { `candidateFound=${Boolean(candidate)}, available=${candidate?.availablePackageCount ?? 'missing'}`, OPTIONS) if (!candidate?.inboundDetailId) return - const foreignChannelCode = `${channelCode}-OTHER`.slice(0, 64) - const foreignBookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', { - expectedVersion: 0, - data: { ...bookingDraft, channelCode: foreignChannelCode, channelName: `异渠道-${suffix}`, remark: `${context.marker}-other-channel` }, - }, { 'Idempotency-Key': `${context.marker}-other-channel-booking-create`.slice(0, 128) }) - const foreignBookingCreated = context.apiSuccess(MODULE, '创建异渠道隔离订舱', foreignBookingCreate, '', OPTIONS) - const foreignBookingId = id(foreignBookingCreated?.oceanBooking?.id) - if (!foreignBookingId) return - const foreignSubmit = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${foreignBookingId}/submit`, { - expectedVersion: Number(foreignBookingCreated?.oceanBooking?.version), reason: context.marker, - }, { 'Idempotency-Key': `${context.marker}-other-channel-booking-submit`.slice(0, 128) }) - const foreignSubmitted = context.apiSuccess(MODULE, '提交异渠道隔离订舱', foreignSubmit, '', OPTIONS) - const foreignConfirm = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${foreignBookingId}/confirm`, { - expectedVersion: Number(foreignSubmitted?.oceanBooking?.version), reason: context.marker, - }, { 'Idempotency-Key': `${context.marker}-other-channel-booking-confirm`.slice(0, 128) }) - const foreignConfirmed = context.apiSuccess(MODULE, '确认异渠道订舱并自动生成柜次', foreignConfirm, '', OPTIONS) - const foreignContainerId = id(foreignConfirmed?.containerAllocations?.[0]?.containerBookingId) - if (!foreignContainerId) return - const foreignPlanResponse = await request(context, 'POST', `${SMART}/plans`, { - bookingId: foreignContainerId, remark: `${context.marker}-other-channel`, - }) - const foreignPlan = context.apiSuccess(MODULE, '创建异渠道智能装柜计划', foreignPlanResponse, '', OPTIONS) - const foreignAssignResponse = await request(context, 'POST', `${SMART}/plans/${foreignPlan?.id}/assignments`, { - inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1, - expectedVersion: Number(foreignPlan?.version), operationId: `${context.marker}-other-channel-assign`.slice(0, 128), - }) - context.addStep(MODULE, '异渠道入库单拒绝装入当前柜次', foreignAssignResponse, - foreignAssignResponse.http === 200 && ['A0422', '422'].includes(codeOf(foreignAssignResponse)), - `HTTP=${foreignAssignResponse.http}, code=${codeOf(foreignAssignResponse)}`, OPTIONS) + const channelIsolation = selectPlatformChannelIsolationPair( + context.createdResources?.platformLogisticsChannels, context.marker, warehouseId, channelCode) + if (!channelIsolation.pair) { + context.addStep(MODULE, '两个真实有效渠道的柜次隔离 fixture', null, false, + `${channelIsolation.reason}; originWarehouseId=${warehouseId}, primaryChannelCode=${channelCode}`, OPTIONS) + } else { + const foreignChannel = channelIsolation.pair.foreign + const foreignBookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', { + expectedVersion: 0, + data: { ...bookingDraft, channelCode: foreignChannel.channelCode, remark: `${context.marker}-other-channel` }, + }, { 'Idempotency-Key': `${context.marker}-other-channel-booking-create`.slice(0, 128) }) + const foreignBookingCreated = context.apiSuccess(MODULE, '创建真实异渠道隔离订舱', foreignBookingCreate, '', OPTIONS) + const foreignBookingId = id(foreignBookingCreated?.oceanBooking?.id) + if (!foreignBookingId) return + const foreignSubmit = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${foreignBookingId}/submit`, { + expectedVersion: Number(foreignBookingCreated?.oceanBooking?.version), reason: context.marker, + }, { 'Idempotency-Key': `${context.marker}-other-channel-booking-submit`.slice(0, 128) }) + const foreignSubmitted = context.apiSuccess(MODULE, '提交真实异渠道隔离订舱', foreignSubmit, '', OPTIONS) + const foreignConfirm = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${foreignBookingId}/confirm`, { + expectedVersion: Number(foreignSubmitted?.oceanBooking?.version), reason: context.marker, + }, { 'Idempotency-Key': `${context.marker}-other-channel-booking-confirm`.slice(0, 128) }) + const foreignConfirmed = context.apiSuccess(MODULE, '确认真实异渠道订舱并自动生成柜次', foreignConfirm, '', OPTIONS) + const foreignContainerId = id(foreignConfirmed?.containerAllocations?.[0]?.containerBookingId) + if (!foreignContainerId) return + const foreignPlanResponse = await request(context, 'POST', `${SMART}/plans`, { + bookingId: foreignContainerId, remark: `${context.marker}-other-channel`, + }) + const foreignPlan = context.apiSuccess(MODULE, '创建真实异渠道智能装柜计划', foreignPlanResponse, '', OPTIONS) + const foreignAssignResponse = await request(context, 'POST', `${SMART}/plans/${foreignPlan?.id}/assignments`, { + inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1, + expectedVersion: Number(foreignPlan?.version), operationId: `${context.marker}-other-channel-assign`.slice(0, 128), + }) + context.addStep(MODULE, '两个真实有效渠道之间拒绝跨渠道装柜', foreignAssignResponse, + foreignAssignResponse.http === 200 && ['A0422', '422'].includes(codeOf(foreignAssignResponse)), + `primary=${channelCode}, foreign=${foreignChannel.channelCode}, HTTP=${foreignAssignResponse.http}, code=${codeOf(foreignAssignResponse)}`, OPTIONS) + } const containerDetailResponse = await context.request('GET', `${CONTAINERS}/${containerId}`) const containerDetail = context.apiSuccess(MODULE, '回查货柜详情', containerDetailResponse, '', OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 1f55b18f8..1406170fa 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -120,7 +120,7 @@ export async function run(context) { const bookingDraft = { customerId: tenantId, customerName: initial?.summary?.customerName, warehouseId, warehouseName: initial?.summary?.warehouseName, - channelCode: initial?.summary?.channelCode, channelName: initial?.summary?.channelName, + channelCode: initial?.summary?.channelCode, shippingCompanyName: `P3-SHIPPING-${suffix}`, bookingAgentName: `P3-AGENT-${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID', diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index f25ab4945..c0487690b 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -120,7 +120,7 @@ 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, channelName: source.channelName, + channelCode: source.channelCode, warehouseId: source.warehouseId, warehouseName: source.warehouseName, shippingCompanyId: null, shippingCompanyName: `SPLIT-SHIPPING-${suffix}`.slice(0, 128), bookingAgentId: null, bookingAgentName: `SPLIT-AGENT-${suffix}`.slice(0, 128), diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 356cd1c9b..c9587f339 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -4,7 +4,7 @@ import test from 'node:test' import { selectCustomsFlow } from './phase3-customs.mjs' import { buildFinalizeResource } from './phase3-loading-finalize.mjs' -import { selectContainerLoadingFixture } from './phase3-container-loading.mjs' +import { selectContainerLoadingFixture, selectPlatformChannelIsolationPair } from './phase3-container-loading.mjs' import { AcceptanceRun, normalizeModuleOrder, parseArguments } from '../admin-test-env-acceptance.mjs' function source(name) { @@ -141,7 +141,8 @@ test('container loading creates and confirms booking before scoped candidates', "const planResponse = await request(context, 'POST', `${SMART}/plans`", "const assignResponse = await request", ]) - assert.match(value, /channelCode: channelCode/) + assert.match(value, /channelCode, warehouseId, warehouseName/) + assert.match(value, /publishPlatformLogisticsChannels\(context, warehouseId, MODULE\)/) assert.match(value, /warehouseId, warehouseName/) const draft = value.slice(value.indexOf('const bookingDraft = {'), value.indexOf('context.assert(MODULE, \'订舱渠道与仓库快照完整\'')) assert.doesNotMatch(draft, /billOfLadingNo|vesselName|voyageNo|estimatedSailingAt|estimatedArrivalAt/) @@ -182,12 +183,46 @@ test('container loading keeps an unmatched candidate so marker failure stays obs assert.equal(selected.marker, 'ADMIN-RUN-CAB') }) +test('channel isolation accepts only two active channels from the current platform warehouse catalog', () => { + const selected = selectPlatformChannelIsolationPair({ + marker: 'ADMIN-RUN-AUTO1', originWarehouseId: '41', + sourcePath: '/api/platform/v1/frozen-channel-contract?originWarehouseId=41', + channels: [ + { channelCode: 'SEA-A', channelName: '海运 A', status: 'ACTIVE' }, + { channelCode: 'SEA-B', channelName: '海运 B', status: 'ACTIVE' }, + { channelCode: 'SEA-C', channelName: '海运 C', status: 'DISABLED' }, + ], + }, 'ADMIN-RUN-AUTO1', '41', 'SEA-A') + assert.deepEqual(selected.pair, { + primary: { channelCode: 'SEA-A', channelName: '海运 A' }, + foreign: { channelCode: 'SEA-B', channelName: '海运 B' }, + }) + assert.equal(selected.reason, null) +}) + +test('channel isolation reports BLOCKED_FIXTURE instead of inventing a second channel', () => { + for (const catalog of [ + { marker: 'OLD-RUN', originWarehouseId: '41', sourcePath: '/api/platform/v1/channels', channels: [] }, + { marker: 'ADMIN-RUN-AUTO1', originWarehouseId: '42', sourcePath: '/api/platform/v1/channels', channels: [] }, + { marker: 'ADMIN-RUN-AUTO1', originWarehouseId: '41', sourcePath: '/api/merchant/v1/first-leg/pricing/channels', channels: [] }, + { marker: 'ADMIN-RUN-AUTO1', originWarehouseId: '41', sourcePath: '/api/platform/v1/channels', + channels: [{ channelCode: 'SEA-A', channelName: '海运 A', status: 'ACTIVE' }] }, + ]) { + const selected = selectPlatformChannelIsolationPair(catalog, 'ADMIN-RUN-AUTO1', '41', 'SEA-A') + assert.equal(selected.pair, null) + assert.match(selected.reason, /^BLOCKED_FIXTURE:/) + } + const value = source('./phase3-container-loading.mjs') + assert.doesNotMatch(value, /foreignChannelCode|\$\{channelCode\}-OTHER|异渠道-\$\{suffix\}/) + assert.match(value, /context\.createdResources\?\.platformLogisticsChannels/) +}) + test('container loading enforces channel isolation and stable PDA identity across container number changes', () => { const value = source('./phase3-container-loading.mjs') assertOrder(value, [ '确认订舱并自动生成真实柜次', - '创建异渠道隔离订舱', - '异渠道入库单拒绝装入当前柜次', + '创建真实异渠道隔离订舱', + '两个真实有效渠道之间拒绝跨渠道装柜', '无真实柜号时计划可排柜但 PDA 任务不开放', '在放舱前绑定本轮真实箱号', '订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务',