From 15b563610c61ff0004ca96241a3bd6e2eac9b016 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 10:30:38 +0800 Subject: [PATCH] fix(firstleg): validate booking carrier associations --- .../client/WarehousePartnerClient.java | 20 +++++ .../client/WarehouseSupplierClient.java | 9 +- .../PlatformOceanBookingController.java | 5 ++ .../oceanbooking/dto/OceanBookingDtos.java | 1 + .../service/OceanBookingService.java | 83 +++++++++++++++++-- .../WarehouseSupplierSnapshotService.java | 13 +++ .../resources/openapi/ocean-bookings-v1.yaml | 28 ++++++- .../customs/service/CustomsServiceTest.java | 2 +- ...ormOceanBookingControllerContractTest.java | 7 +- .../service/OceanBookingServiceTest.java | 52 +++++++++++- .../ContainerFeeDetailServiceTest.java | 2 +- .../service/ContainerServiceTest.java | 2 +- .../service/TruckAppointmentServiceTest.java | 2 +- .../WarehouseSupplierSnapshotServiceTest.java | 8 +- 14 files changed, 213 insertions(+), 21 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePartnerClient.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePartnerClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePartnerClient.java new file mode 100644 index 000000000..129272709 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePartnerClient.java @@ -0,0 +1,20 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; + +import java.util.List; + +@FeignClient(name = "warehouse-service", contextId = "firstlegWarehousePartnerClient", + url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse-partners") +public interface WarehousePartnerClient { + @GetMapping("/{tenantId}/{partnerId}/active") + ApiResponse active(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, @PathVariable long partnerId); + + record Snapshot(long id, long tenantId, String partnerName, String partnerType, + String carrierCode, List types, String status) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java index da7ca114b..7559f7e14 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java @@ -6,6 +6,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; +import java.util.List; @FeignClient(name = "warehouse-service", contextId = "firstlegWarehouseSupplierClient", url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1/suppliers") @@ -16,5 +17,11 @@ public interface WarehouseSupplierClient { @RequestParam(defaultValue = "true") boolean requireActive, @RequestParam(required = false) String category); - record Snapshot(long supplierId, String name, String category, String status) {} + @GetMapping + ApiResponse> candidates(@RequestHeader("X-Internal-Service-Token") String token, + @RequestParam String category, + @RequestParam String carrierCode); + + record Snapshot(long supplierId, String name, String category, String status, + List carrierCodes) {} } 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 486fb960e..f5395533e 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 @@ -37,6 +37,11 @@ public class PlatformOceanBookingController { @RequestParam long originWarehouseId) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.channels(originWarehouseId)); } + @GetMapping("/booking-agents") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") + public ApiResponse> bookingAgents(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String carrierCode) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.bookingAgents(carrierCode)); + } @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 6d049a277..415ee8908 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 @@ -100,4 +100,5 @@ public final class OceanBookingDtos { public record ChannelOption(Long priceBookVersionId, String channelCode, String channelName, String priceModel, List pricingTaxModes) {} + public record BookingAgentOption(Long supplierId, String name, List carrierCodes) {} } 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 624a2f2b8..3ae3d7cac 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 @@ -6,6 +6,8 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.WarehouseMasterDataClient; +import com.baiyue.firstleg.client.WarehousePartnerClient; +import com.baiyue.firstleg.client.WarehouseSupplierClient; import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus; import com.baiyue.firstleg.domain.ContainerSpecificationCatalog; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*; @@ -23,6 +25,7 @@ import org.springframework.stereotype.Service; import org.springframework.beans.factory.annotation.Value; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import feign.FeignException; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -44,6 +47,7 @@ public class OceanBookingService { @Resource private ObjectMapper objectMapper; @Resource private WarehouseSupplierSnapshotService suppliers; @Resource private WarehouseMasterDataClient warehouses; + @Resource private WarehousePartnerClient partners; @Resource private FirstlegDocumentNumberGenerator documentNumbers; @Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId; @@ -56,6 +60,14 @@ public class OceanBookingService { return repository.page(new Query(query.customerId(), status, trim(query.keyword()), query.page(), query.size())); } + public List bookingAgents(String carrierCode) { + String normalized = normalizeCarrierCode(carrierCode); + return suppliers.bookingCandidates(normalized).stream() + .filter(value -> "ACTIVE".equals(value.status()) && "BOOKING".equals(value.category()) + && value.carrierCodes() != null && value.carrierCodes().contains(normalized)) + .map(value -> new BookingAgentOption(value.supplierId(), value.name(), value.carrierCodes())).toList(); + } + public PageResult linkCandidates(LinkCandidateQuery query) { return repository.linkCandidates(new LinkCandidateQuery(query.warehouseId(), query.channelCode().trim(), query.containerType().trim(), query.page(), query.size())); @@ -138,6 +150,7 @@ public class OceanBookingService { OceanBookingEntity before = requiredVersion(id, request.expectedVersion()); requireStatus(before, OceanBookingStatus.DRAFT); validateRelease(before, repository.parties(id), repository.requirements(id)); + validateMasterData(before); if (repository.transition(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.RELEASED.name(), request.expectedVersion(), operatorId, name) != 1) throw concurrent(); OceanBookingEntity after = required(id); @@ -238,27 +251,27 @@ public class OceanBookingService { return value.trim(); } - private record ResolvedDraft(String warehouseName, String channelName, String bookingAgentName) {} + private record ResolvedDraft(String warehouseName, String channelName, String shippingCompanyName, + String carrierCode, String bookingAgentName) {} private ResolvedDraft resolveDraft(DraftData d) { - String warehouseName = null; String channelName = null; String bookingAgentName = trim(d.bookingAgentName()); + String warehouseName = null; String channelName = null; if (d.warehouseId() != null && StringUtils.hasText(d.channelCode())) { WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(d.warehouseId()); warehouseName = warehouse.warehouseName(); channelName = requireActiveChannel(warehouse.id(), d.channelCode()).channelName(); } - if (d.bookingAgentId() != null) { - var snapshot = suppliers.booking(d.bookingAgentId(), d.bookingAgentName()); - bookingAgentName = snapshot == null ? null : snapshot.name(); - } else if (StringUtils.hasText(d.bookingAgentName())) throw badRequest("订舱代理名称不能脱离供应商ID提交"); - return new ResolvedDraft(warehouseName, channelName, bookingAgentName); + var carrier = resolveCarrier(d.shippingCompanyId(), d.shippingCompanyName(), d.carrierCode()); + var agent = resolveBookingAgent(d.bookingAgentId(), d.bookingAgentName(), carrier == null ? null : carrier.carrierCode()); + return new ResolvedDraft(warehouseName, channelName, carrier == null ? null : carrier.partnerName(), + carrier == null ? null : carrier.carrierCode(), agent == null ? null : agent.name()); } private static void copy(OceanBookingEntity v, DraftData d, ResolvedDraft resolved) { v.setCustomerId(d.customerId()); v.setCustomerName(trim(d.customerName())); v.setChannelCode(trim(d.channelCode())); v.setChannelName(resolved.channelName()); v.setWarehouseId(d.warehouseId()); v.setWarehouseName(resolved.warehouseName()); v.setBillOfLadingNo(trim(d.billOfLadingNo())); - v.setShippingCompanyId(d.shippingCompanyId()); v.setShippingCompanyName(trim(d.shippingCompanyName())); - v.setCarrierCode(d.carrierCode() == null ? null : d.carrierCode().trim().toUpperCase()); + v.setShippingCompanyId(d.shippingCompanyId()); v.setShippingCompanyName(resolved.shippingCompanyName()); + v.setCarrierCode(resolved.carrierCode()); v.setBookingAgentId(d.bookingAgentId()); v.setBookingAgentName(resolved.bookingAgentName()); v.setVesselName(trim(d.vesselName())); v.setVoyageNo(trim(d.voyageNo())); v.setDeparturePortCode(trim(d.departurePortCode())); v.setDeparturePortName(trim(d.departurePortName())); @@ -268,6 +281,58 @@ public class OceanBookingService { v.setRemark(trim(d.remark())); } + private void validateMasterData(OceanBookingEntity booking) { + resolveCarrier(booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getCarrierCode()); + resolveBookingAgent(booking.getBookingAgentId(), booking.getBookingAgentName(), booking.getCarrierCode()); + } + + private WarehousePartnerClient.Snapshot resolveCarrier(Long id, String suppliedName, String suppliedCode) { + if (id == null) { + if (StringUtils.hasText(suppliedName) || StringUtils.hasText(suppliedCode)) throw badRequest("船公司名称和编码不能脱离shippingCompanyId提交"); + return null; + } + try { + var response = partners.active(internalToken, 0L, id); + var value = response == null ? null : response.data(); + if (response == null || !response.successful() || value == null || value.id() != id + || value.tenantId() != 0L || !"ACTIVE".equals(value.status()) + || value.types() == null || !value.types().contains("CARRIER") + || !StringUtils.hasText(value.carrierCode())) throw badRequest("所选船公司不是启用中的CARRIER往来单位"); + String code = normalizeCarrierCode(value.carrierCode()); + if (StringUtils.hasText(suppliedCode) && !code.equals(normalizeCarrierCode(suppliedCode))) throw badRequest("船司标准编码与主数据快照不一致"); + if (StringUtils.hasText(suppliedName) && !value.partnerName().equals(suppliedName.trim())) throw badRequest("船公司名称与主数据快照不一致"); + return value; + } catch (FeignException exception) { + throw new BizException(com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "船公司主数据服务暂不可用"); + } + } + + private WarehouseSupplierClient.Snapshot resolveBookingAgent(Long id, String name, String carrierCode) { + if (id == null) { + if (StringUtils.hasText(name)) throw badRequest("订舱代理名称不能脱离供应商ID提交"); + return null; + } + WarehouseSupplierClient.Snapshot snapshot; + try { + snapshot = suppliers.booking(id, name); + } catch (BizException exception) { + if (exception.getResultCode() == ResultCode.SERVICE_UNAVAILABLE) throw exception; + throw badRequest("所选订舱代理未关联当前船公司"); + } + if (!StringUtils.hasText(carrierCode) || snapshot.carrierCodes() == null + || !snapshot.carrierCodes().contains(normalizeCarrierCode(carrierCode))) { + throw badRequest("所选订舱代理未关联当前船公司"); + } + return snapshot; + } + + private static String normalizeCarrierCode(String value) { + if (!StringUtils.hasText(value)) throw badRequest("carrierCode不能为空"); + String normalized = value.trim().toUpperCase(java.util.Locale.ROOT); + if (!CARRIER_CODES.contains(normalized)) throw badRequest("carrierCode仅支持MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER"); + return normalized; + } + private static List parties(long id, List requests) { if (requests == null) return List.of(); return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); return v; }).toList(); } private static List requirements(long id, List requests) { if (requests == null) return List.of(); int[] sort = {0}; return requests.stream().map(r -> { var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(specification.containerType()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(specification.capacityCbm()); v.setMaxWeightKg(specification.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); } private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java index 634e908d4..43fda0b6c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java @@ -8,6 +8,7 @@ import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; +import java.util.List; @Service public class WarehouseSupplierSnapshotService { @@ -18,6 +19,18 @@ public class WarehouseSupplierSnapshotService { return resolve(supplierId, suppliedName, "BOOKING"); } + public List bookingCandidates(String carrierCode) { + try { + var response = client.candidates(internalToken, "BOOKING", carrierCode); + if (response == null || !response.successful() || response.data() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订舱代理候选服务暂不可用"); + } + return response.data(); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订舱代理候选服务暂不可用"); + } + } + public WarehouseSupplierClient.Snapshot trucking(Long supplierId, String suppliedName) { return resolve(supplierId, suppliedName, "TRUCKING"); } diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 85d652016..32068c409 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -36,7 +36,13 @@ paths: /api/platform/v1/ocean-bookings/link-candidates: get: summary: Page released booking requirement candidates for one container - description: Requires platform:firstleg:ocean-booking:list. Returns only linkable requirement candidates: RELEASED, remainingQuantity greater than zero, and exact warehouseId, channelCode and containerType. Capacity and max weight are derived display snapshots and never eligibility fields. Allocated quantity counts only ACTIVE containers still pointing to the same booking requirement and is calculated by one bounded set query; booking detail calls are not used. An empty result is a normal successful page. Final target-container mutability is revalidated by the link command. + description: >- + Requires platform:firstleg:ocean-booking:list. Returns only linkable requirement candidates: + RELEASED, remainingQuantity greater than zero, and exact warehouseId, channelCode and containerType. + Capacity and max weight are derived display snapshots and never eligibility fields. Allocated quantity + counts only ACTIVE containers still pointing to the same booking requirement and is calculated by one + bounded set query; booking detail calls are not used. An empty result is a normal successful page. + Final target-container mutability is revalidated by the link command. x-permission: platform:firstleg:ocean-booking:list parameters: - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} @@ -62,6 +68,19 @@ paths: content: application/json: schema: {type: array, items: {$ref: '#/components/schemas/ChannelOption'}} + /api/platform/v1/ocean-bookings/booking-agents: + get: + summary: List ACTIVE BOOKING agents associated with one carrier code + description: Requires platform:firstleg:ocean-booking:list. Filtering is enforced by Warehouse master data; clients must not infer associations. + x-permission: platform:firstleg:ocean-booking:list + parameters: + - {name: carrierCode, in: query, required: true, schema: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER]}} + responses: + '200': + description: Server-filtered booking agent options + content: + application/json: + schema: {type: array, items: {$ref: '#/components/schemas/BookingAgentOption'}} components: parameters: OceanBookingId: {name: oceanBookingId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} @@ -121,6 +140,13 @@ components: channelName: {type: string} priceModel: {type: string} pricingTaxModes: {type: array, items: {type: string}} + BookingAgentOption: + type: object + required: [supplierId, name, carrierCodes] + properties: + supplierId: {type: integer, format: int64} + name: {type: string} + carrierCodes: {type: array, items: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER]}} LinkCandidate: type: object required: [oceanBookingId, bookingNo, requirementId, containerType, capacityCbm, maxWeightKg, requiredQuantity, allocatedQuantity, remainingQuantity, customerName, warehouseName, channelName] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsServiceTest.java index 3bd9a2a96..6f3c9cc7c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsServiceTest.java @@ -34,7 +34,7 @@ class CustomsServiceTest { ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); ReflectionTestUtils.setField(service, "internalToken", "token"); when(suppliers.customs(anyLong(), anyString())) - .thenReturn(new com.baiyue.firstleg.client.WarehouseSupplierClient.Snapshot(88L, "线下报关代理", "CUSTOMS", "ACTIVE")); + .thenReturn(new com.baiyue.firstleg.client.WarehouseSupplierClient.Snapshot(88L, "线下报关代理", "CUSTOMS", "ACTIVE", java.util.List.of())); when(repository.completeOperation(anyLong(), anyString(), anyLong())).thenReturn(1); } 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 df0b2fca1..dfd9d5606 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 @@ -21,6 +21,7 @@ class PlatformOceanBookingControllerContractTest { Map permissions = Map.ofEntries( Map.entry("page", "platform:firstleg:ocean-booking:list"), Map.entry("linkCandidates", "platform:firstleg:ocean-booking:list"), + Map.entry("bookingAgents", "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"), @@ -31,7 +32,7 @@ class PlatformOceanBookingControllerContractTest { Method method = method(name); assertThat(method.getAnnotation(PreAuthorize.class).value()) .isEqualTo("hasAuthority('" + permission + "')"); - if (!name.equals("page") && !name.equals("detail") && !name.equals("channels") + if (!name.equals("page") && !name.equals("detail") && !name.equals("channels") && !name.equals("bookingAgents") && !name.equals("linkCandidates")) { assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull(); } @@ -94,6 +95,10 @@ class PlatformOceanBookingControllerContractTest { .contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER]") .contains("Required before RELEASE") .contains("OTHER means manual carrier-node tracking"); + assertThat(yaml) + .contains("/api/platform/v1/ocean-bookings/booking-agents:") + .contains("Filtering is enforced by Warehouse master data") + .contains("BookingAgentOption:"); } private static Method method(String name) { 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 2b846c7ba..1a9f2253f 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 @@ -37,6 +37,7 @@ class OceanBookingServiceTest { @Mock private PriceBookQueryUseCase priceBooks; @Mock private WarehouseSupplierSnapshotService suppliers; @Mock private WarehouseMasterDataClient warehouses; + @Mock private com.baiyue.firstleg.client.WarehousePartnerClient partners; @Mock private FirstlegDocumentNumberGenerator documentNumbers; @InjectMocks private OceanBookingService service; private ObjectMapper objectMapper; @@ -116,6 +117,7 @@ class OceanBookingServiceTest { @Test void releasesCompleteAggregateAndReplaysFrozenResult() throws Exception { + stubMasterData(); OceanBookingEntity draft = completeBooking("DRAFT", 3); OceanBookingEntity released = completeBooking("RELEASED", 4); when(repository.find(1L)).thenReturn(draft, released); @@ -139,6 +141,51 @@ class OceanBookingServiceTest { verify(repository, times(1)).transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin"); } + @Test + void rejectsCarrierSnapshotMismatchAndUnassociatedBookingAgent() { + stubMasterData(); + when(partners.active(any(), eq(0L), eq(71L))).thenReturn(ApiResponse.success( + new com.baiyue.firstleg.client.WarehousePartnerClient.Snapshot(71L, 0L, "船公司", "CARRIER", + "SML", List.of("CARRIER"), "ACTIVE"))); + assertThatThrownBy(() -> service.create(9L, "admin", "carrier-mismatch", + new CreateRequest(0, completeDraft()))) + .isInstanceOf(BizException.class).hasMessage("船司标准编码与主数据快照不一致"); + + stubMasterData(); + when(suppliers.booking(81L, null)).thenReturn( + new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("SML"))); + assertThatThrownBy(() -> service.create(9L, "admin", "agent-mismatch", + new CreateRequest(0, completeDraft()))) + .isInstanceOf(BizException.class).hasMessage("所选订舱代理未关联当前船公司"); + verify(repository, never()).insert(any(OceanBookingEntity.class)); + } + + @Test + void bookingAgentCandidatesAreFilteredByCarrierCode() { + when(suppliers.bookingCandidates("MATSON")).thenReturn(List.of( + new WarehouseSupplierClient.Snapshot(81L, "匹配代理", "BOOKING", "ACTIVE", List.of("MATSON")), + new WarehouseSupplierClient.Snapshot(82L, "不匹配代理", "BOOKING", "ACTIVE", List.of("SML")))); + + assertThat(service.bookingAgents("matson")).extracting(BookingAgentOption::name) + .containsExactly("匹配代理"); + } + + @Test + void releaseRevalidatesCurrentCarrierAndBookingAgentAssociation() { + stubMasterData(); + OceanBookingEntity draft = completeBooking("DRAFT", 3); + when(repository.find(1L)).thenReturn(draft); + when(repository.parties(1L)).thenReturn(parties()); + when(repository.requirements(1L)).thenReturn(List.of(requirement())); + when(suppliers.booking(81L, null)).thenReturn( + new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("SML"))); + + assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-agent-mismatch", + new ReleaseRequest(3, "确认"))) + .isInstanceOf(BizException.class).hasMessage("所选订舱代理未关联当前船公司"); + verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString()); + } + @Test void releasedAggregateRejectsEditDeleteAndRepeatRelease() { when(repository.find(1L)).thenReturn(booking("RELEASED", 4)); @@ -255,7 +302,10 @@ class OceanBookingServiceTest { new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse( 701L, 0L, "SZX", "深圳中转仓", "DOMESTIC", "ACTIVE")))); when(suppliers.booking(81L, null)).thenReturn( - new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE")); + new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("MATSON"))); + when(partners.active(any(), eq(0L), eq(71L))).thenReturn(ApiResponse.success( + new com.baiyue.firstleg.client.WarehousePartnerClient.Snapshot(71L, 0L, "船公司", "CARRIER", + "MATSON", List.of("CARRIER"), "ACTIVE"))); } private static DraftData partialDraft(String remark) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java index f0d052059..ba6923554 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java @@ -217,7 +217,7 @@ class ContainerFeeDetailServiceTest { when(feeItems.findItem(11L)).thenReturn(item()); when(suppliers.fee(71L, null)).thenReturn( new com.baiyue.firstleg.client.WarehouseSupplierClient.Snapshot( - 71L, "现有供应商", "TRUCKING", "ACTIVE")); + 71L, "现有供应商", "TRUCKING", "ACTIVE", List.of())); LocalDateTime paidAt = LocalDateTime.of(2026, 8, 20, 10, 0); var request = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE", LocalDate.of(2026, 8, 20), new BigDecimal("300.00"), "CNY", 71L, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 4dba3b293..5dd81555f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -65,7 +65,7 @@ class ContainerServiceTest { when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation -> activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓")); when(suppliers.trucking(202L, "拖车公司A")) - .thenReturn(new WarehouseSupplierClient.Snapshot(202L, "拖车公司A", "TRUCKING", "ACTIVE")); + .thenReturn(new WarehouseSupplierClient.Snapshot(202L, "拖车公司A", "TRUCKING", "ACTIVE", List.of())); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java index 5a43be896..6e94f4608 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java @@ -58,7 +58,7 @@ class TruckAppointmentServiceTest { new IamCustomerSnapshotClient.Customer(invocation.getArgument(0), "C-7", "Canonical Customer", "ACTIVE", null, null)); when(suppliers.trucking(any(), any())).thenReturn( - new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE")); + new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE", java.util.List.of())); lenient().when(repository.attachments(anyLong())).thenReturn(List.of()); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotServiceTest.java index f836b05dc..f6fff6f2a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotServiceTest.java @@ -36,7 +36,7 @@ class WarehouseSupplierSnapshotServiceTest { @Test void resolvesActiveBookingSupplierByCategoryWithoutScopeAndUsesServerName() { when(client.get("internal-token", 7L, true, "BOOKING")) - .thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(7L, "海运代理", "BOOKING", "ACTIVE"))); + .thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(7L, "海运代理", "BOOKING", "ACTIVE", java.util.List.of("MATSON")))); var snapshot = service.booking(7L, "海运代理"); assertThat(snapshot.name()).isEqualTo("海运代理"); verify(client).get("internal-token", 7L, true, "BOOKING"); @@ -46,7 +46,7 @@ class WarehouseSupplierSnapshotServiceTest { void feeWithoutCategoryAcceptsAnyActiveWarehouseSupplier() { when(client.get("internal-token", 8L, true, null)) .thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot( - 8L, "任意启用供应商", "CERTIFICATE", "ACTIVE"))); + 8L, "任意启用供应商", "CERTIFICATE", "ACTIVE", java.util.List.of()))); assertThat(service.fee(8L, null).name()).isEqualTo("任意启用供应商"); verify(client).get("internal-token", 8L, true, null); @@ -55,7 +55,7 @@ class WarehouseSupplierSnapshotServiceTest { @Test void rejectsClientNameThatDiffersFromWarehouseSnapshot() { when(client.get(anyString(), eq(9L), eq(true), eq("CUSTOMS"))) - .thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(9L, "真实报关行", "CUSTOMS", "ACTIVE"))); + .thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(9L, "真实报关行", "CUSTOMS", "ACTIVE", java.util.List.of()))); assertThatThrownBy(() -> service.customs(9L, "伪造名称")) .isInstanceOf(BizException.class).hasMessageContaining("不一致"); } @@ -64,7 +64,7 @@ class WarehouseSupplierSnapshotServiceTest { void rejectsSupplierSnapshotWithWrongCategoryOrInactiveStatus() { when(client.get("internal-token", 7L, true, "BOOKING")) .thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot( - 7L, "错误代理", "TRUCKING", "ACTIVE"))); + 7L, "错误代理", "TRUCKING", "ACTIVE", java.util.List.of()))); assertThatThrownBy(() -> service.booking(7L, null)) .isInstanceOf(BizException.class).hasMessageContaining("类别不匹配");