fix(firstleg): validate booking carrier associations

This commit is contained in:
2026-08-27 10:30:38 +08:00
parent 079ff43f73
commit 15b563610c
14 changed files with 213 additions and 21 deletions
@@ -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<Snapshot> 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<String> types, String status) {}
}
@@ -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<List<Snapshot>> 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<String> carrierCodes) {}
}
@@ -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<List<BookingAgentOption>> 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> detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(oceanBookingId)); }
@@ -100,4 +100,5 @@ public final class OceanBookingDtos {
public record ChannelOption(Long priceBookVersionId, String channelCode, String channelName,
String priceModel, List<String> pricingTaxModes) {}
public record BookingAgentOption(Long supplierId, String name, List<String> carrierCodes) {}
}
@@ -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<BookingAgentOption> 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<LinkCandidate> 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<OceanBookingPartyEntity> parties(long id, List<PartyRequest> 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<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequirementRequest> 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) {
@@ -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<WarehouseSupplierClient.Snapshot> 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");
}
@@ -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]
@@ -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);
}
@@ -21,6 +21,7 @@ class PlatformOceanBookingControllerContractTest {
Map<String, String> 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) {
@@ -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) {
@@ -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,
@@ -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
@@ -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());
}
@@ -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("类别不匹配");