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");
}