This commit is contained in:
2026-08-18 11:32:55 +08:00
parent ea26eecc50
commit 3e1010fa26
57 changed files with 1133 additions and 247 deletions
@@ -55,7 +55,8 @@ public interface FirstlegUnifiedOrderClient {
BigDecimal chargeableWeightKg, LocalDateTime measureCompletedAt,
LocalDateTime inboundedAt, String expressCompanyName, String createdByName,
String containerNo, String cabinetNo, String vesselName, String voyageNo,
LocalDateTime sailingAt, LocalDateTime portArrivalAt,
LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt,
LocalDateTime estimatedPortArrivalAt, LocalDateTime actualPortArrivalAt,
String expressTrackingNo, LocalDateTime createdAt, LocalDateTime updatedAt) {}
record Preload(String status, Integer version, Integer itemCount, LocalDateTime updatedAt) {}
record Loading(String status, Integer loadingVersion, Integer pickingVersion,
@@ -0,0 +1,37 @@
package com.baiyue.order.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.Locale;
/** 从共享配置读取全系统唯一的入仓码运行模式,并拒绝任何未知配置。 */
@Component
@RefreshScope
public class InboundCodeModeProvider {
public enum Mode { AUTO, LEGACY_MANUAL }
private final Mode mode;
public InboundCodeModeProvider(@Value("${baiyue.order.inbound-code-mode}") String configuredMode) {
if (!StringUtils.hasText(configuredMode)) {
throw new IllegalStateException("baiyue.order.inbound-code-mode 必须显式配置");
}
try {
mode = Mode.valueOf(configuredMode.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
throw new IllegalStateException(
"baiyue.order.inbound-code-mode 仅支持 AUTO 或 LEGACY_MANUAL", exception);
}
}
public Mode mode() {
return mode;
}
public boolean legacyManual() {
return mode == Mode.LEGACY_MANUAL;
}
}
@@ -20,6 +20,9 @@ import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
import java.math.BigDecimal;
import java.util.Objects;
import java.util.stream.Collectors;
/** 供头程履约服务校验入仓单所关联订单货物明细。 */
@RestController
@@ -89,6 +92,32 @@ public class InternalOrderCargoController {
return ApiResponse.success(OrderApplicationService.inboundCommand(order, packageCount));
}
@GetMapping("/{tenantId}/{orderId}/measurement-declaration")
public ApiResponse<MeasurementDeclarationSnapshot> measurementDeclaration(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long tenantId, @PathVariable long orderId) {
requireToken(token);
if (tenantId <= 0 || orderId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "租户和订单ID必须大于0");
}
var order = orders.findOwned(tenantId, orderId);
if (order == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在或不属于当前客户");
// 申报重量/体积必须属于当前订单。拆分子单未物化独立货物事实时不得复制根单整单总量。
List<OrderCargoEntity> cargoes = orders.findCargo(tenantId, orderId);
return ApiResponse.success(new MeasurementDeclarationSnapshot(
order.getReferenceId(), sum(cargoes, OrderCargoEntity::getGrossWeightKg),
sum(cargoes, OrderCargoEntity::getVolumeCbm),
cargoes.stream().map(OrderCargoEntity::getCargoName).filter(Objects::nonNull)
.map(String::trim).filter(value -> !value.isEmpty()).distinct()
.collect(Collectors.joining("、"))));
}
private BigDecimal sum(List<OrderCargoEntity> cargoes,
java.util.function.Function<OrderCargoEntity, BigDecimal> getter) {
List<BigDecimal> values = cargoes.stream().map(getter).filter(Objects::nonNull).toList();
return values.isEmpty() ? null : values.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
}
private void requireToken(String token) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
@@ -113,4 +142,8 @@ public class InternalOrderCargoController {
Integer packageCount, java.math.BigDecimal lengthCm,
java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) {
}
public record MeasurementDeclarationSnapshot(
String referenceId, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm, String defaultActualGoodsName) {}
}
@@ -9,6 +9,7 @@ import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.dto.MerchantBatchDraftDtos;
import com.baiyue.order.dto.MerchantInboundOrderQuery;
import com.baiyue.order.dto.MerchantInboundOrderRow;
import com.baiyue.order.dto.InboundCodePolicyResponse;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.service.OrderApplicationService;
import com.baiyue.order.service.OrderScopeGuard;
@@ -50,6 +51,12 @@ public class MerchantFirstLegOrderController {
@Resource
private MerchantBatchDraftService batchDraftService;
@GetMapping("/inbound-code-policy")
@PreAuthorize("hasAnyAuthority('merchant:order:list', 'merchant:order:create', 'merchant:order:edit')")
public ApiResponse<InboundCodePolicyResponse> inboundCodePolicy() {
return ApiResponse.success(service.inboundCodePolicy());
}
@BusinessAudit(event = "ORDER_CREATE", resourceType = "FIRST_LEG_ORDER", description = "Create draft / 新建草稿")
@Operation(summary = "Create draft / 新建草稿", description = "Creates a first-leg draft order in the authenticated merchant tenant. / 在当前认证商户租户下创建头程订单草稿。")
@ApiResponses({
@@ -69,7 +76,7 @@ public class MerchantFirstLegOrderController {
@BusinessAudit(event = "ORDER_BATCH_CREATE", resourceType = "FIRST_LEG_ORDER", description = "Batch create drafts / 批量创建草稿")
@Operation(summary = "Batch create drafts / 批量创建草稿",
description = "每个数组元素代表一张草稿;单行失败不影响其它行,响应直接返回订单号和入仓码。")
description = "每个数组元素代表一张草稿;单行失败不影响其它行。AUTO 模式的入仓码在提交订单时生成,草稿结果允许为空。")
@PostMapping("/batch")
@PreAuthorize("hasAuthority('merchant:order:create')")
public ApiResponse<MerchantBatchDraftDtos.Response> batchCreate(
@@ -0,0 +1,5 @@
package com.baiyue.order.dto;
/** 全系统统一的入仓码录入策略。 */
public record InboundCodePolicyResponse(String mode, boolean manualEntryRequired) {
}
@@ -71,8 +71,27 @@ public final class OrderRequests {
@NotBlank @Pattern(regexp = "FBA|OVERSEAS|PRIVATE") String destinationType,
@Size(max = 32) String amazonWarehouseCode,
@Size(max = 64) String shipmentId,
@Size(max = 64) String referenceId
@Size(max = 64) String referenceId,
@Schema(description = "旧系统入仓码;仅全局 LEGACY_MANUAL 模式接受,AUTO 模式必须留空", example = "NYEXX2608140001", maxLength = 40)
@Size(max = 40) @Pattern(regexp = "[A-Za-z0-9._:-]+") String inboundCode
) {
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
String destinationCountryCode, Long destinationWarehouseId,
String destinationWarehouseName, LocalDate expectedShipDate, String customerRemark,
Party sender, Party receiver, List<Cargo> cargoes, String pickupMethod,
LocalDateTime requestedPickupAt, Long receivingWarehouseId,
String receivingWarehouseName, String channelCode, String channelName,
Long carrierId, String carrierName, String taxMode, String pricingTaxMode,
String containerMode, Boolean customsInspection, String destinationType,
String amazonWarehouseCode, String shipmentId, String referenceId) {
this(customerId, customerName, transportMode, tradeTerm, destinationCountryCode,
destinationWarehouseId, destinationWarehouseName, expectedShipDate, customerRemark,
sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId,
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
shipmentId, referenceId, null);
}
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
String destinationCountryCode, Long destinationWarehouseId,
String destinationWarehouseName, LocalDate expectedShipDate, String customerRemark,
@@ -87,7 +106,7 @@ public final class OrderRequests {
sender, receiver, cargoes, pickupMethod, null, receivingWarehouseId,
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
shipmentId, referenceId);
shipmentId, referenceId, null);
}
@AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空")
@@ -25,7 +25,7 @@ public final class UnifiedOrderResponse {
String companyName, String businessType, String pickupMethod,
String taxMode, String containerMode, Boolean customsInspection,
String destinationType, String destinationCode,
Integer totalPalletCount, BigDecimal chargeableVolumeCbm,
Integer totalPalletCount, BigDecimal chargeableWeightKg, BigDecimal chargeableVolumeCbm,
BigDecimal volumetricWeightKg, BigDecimal totalNetWeightKg,
LocalDateTime measurementCompletedAt, LocalDateTime inboundCompletedAt,
String expressCompany, String expressTrackingNo, String createdBy,
@@ -52,8 +52,11 @@ public final class UnifiedOrderResponse {
String sealNo, LocalDateTime updatedAt) {}
public record DeliverySummary(String status, LocalDateTime completedAt, String trackingNo) {}
public record TransportSummary(String containerNo, String cabinetNo,
String vesselName, String voyageNo, LocalDateTime sailingAt,
LocalDateTime portArrivalAt) {}
String vesselName, String voyageNo,
LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt,
LocalDateTime effectiveSailingAt, Boolean effectiveSailingAtEstimated,
LocalDateTime estimatedPortArrivalAt, LocalDateTime actualPortArrivalAt,
LocalDateTime effectivePortArrivalAt, Boolean effectivePortArrivalAtEstimated) {}
public record TimelineEvent(String source, String eventType, String mainStatus, String action,
String remark, Long operatorId, String operatorName,
LocalDateTime occurredAt) {}
@@ -121,6 +121,14 @@ public class OrderRepository {
.eq(OrderEntity::getId, orderId));
}
public boolean inboundCodeExists(long tenantId, String inboundCode, Long excludedOrderId) {
if (!StringUtils.hasText(inboundCode)) return false;
return orderMapper.selectCount(new LambdaQueryWrapper<OrderEntity>()
.eq(OrderEntity::getTenantId, tenantId)
.eq(OrderEntity::getInboundCode, inboundCode)
.ne(excludedOrderId != null, OrderEntity::getId, excludedOrderId)) > 0;
}
public PageResult<OrderEntity> page(Long tenantId, OrderRequests.QueryRequest query) {
LambdaQueryWrapper<OrderEntity> wrapper = new LambdaQueryWrapper<OrderEntity>()
.eq(tenantId != null, OrderEntity::getTenantId, tenantId)
@@ -6,9 +6,11 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.OrderDetailResponse;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.dto.InboundCodePolicyResponse;
import com.baiyue.order.dto.TenantOrderCountResponse;
import com.baiyue.order.dto.TenantOrderStatisticsResponse;
import com.baiyue.order.client.FirstlegInboundClient;
import com.baiyue.order.config.InboundCodeModeProvider;
import com.baiyue.order.entity.*;
import com.baiyue.order.repository.OrderRepository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
@@ -45,9 +47,15 @@ public class OrderApplicationService {
@Resource private OrderMasterDataGuard masterDataGuard;
@Resource private OrderPricingChannelGuard pricingChannelGuard;
@Resource private ProductImageGuard productImageGuard;
@Resource private InboundCodeModeProvider inboundCodeModeProvider;
@Resource private ObjectProvider<FirstlegInboundClient> firstlegProvider;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
public InboundCodePolicyResponse inboundCodePolicy() {
boolean manual = legacyManualMode();
return new InboundCodePolicyResponse(inboundCodeModeProvider.mode().name(), manual);
}
/** 创建草稿时使用数据库唯一键保证请求幂等。 */
@Transactional
public Long createDraft(long tenantId, long userId, OrderRequests.SaveRequest request, String idempotencyKey) {
@@ -64,8 +72,8 @@ public class OrderApplicationService {
private Long createDraftInternal(long tenantId, long userId, OrderRequests.SaveRequest request) {
productImageGuard.requireOwned(tenantId, request.cargoes());
var snapshots = masterDataGuard.validate(tenantId, request);
pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(),
request.channelCode(), request.destinationType());
requirePricingChannel(tenantId, request.receivingWarehouseId(), request.channelCode(),
request.destinationType());
OrderEntity order = new OrderEntity();
long orderId = IdWorker.getId();
order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(nextNo(orderId));
@@ -104,7 +112,7 @@ public class OrderApplicationService {
&& !(allowSubmitted && before == OrderStatus.SUBMITTED)) {
throw new BizException(ResultCode.CONFLICT, "当前订单状态不允许编辑订单资料");
}
pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), request.channelCode(),
requirePricingChannel(tenantId, request.receivingWarehouseId(), request.channelCode(),
request.destinationType());
apply(order, tenantId, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
if (before == OrderStatus.SUBMITTED) {
@@ -129,7 +137,7 @@ public class OrderApplicationService {
validatePersistedPrototypeFields(tenantId, order);
masterDataGuard.validatePersisted(tenantId, order.getCustomerId(), order.getReceivingWarehouseId(),
order.getDestinationWarehouseId(), order.getDestinationType());
inboundCodePrefix = pricingChannelGuard.requireAvailable(tenantId, order.getReceivingWarehouseId(),
inboundCodePrefix = requirePricingChannel(tenantId, order.getReceivingWarehouseId(),
order.getChannelCode(), order.getDestinationType());
}
if ((target == OrderStatus.REJECTED || target == OrderStatus.CANCELLED) && !StringUtils.hasText(reason)) {
@@ -137,7 +145,13 @@ public class OrderApplicationService {
}
order.setStatus(target); order.setUpdatedBy(userId);
if (target == OrderStatus.SUBMITTED) {
if (!StringUtils.hasText(order.getInboundCode())) order.setInboundCode(nextInboundCode(inboundCodePrefix));
if (!StringUtils.hasText(order.getInboundCode())) {
if (legacyManualMode()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "当前系统使用旧系统入仓码,请先填写入仓码");
}
order.setInboundCode(nextInboundCode(inboundCodePrefix));
}
requirePrintableInboundCode(order);
order.setSubmittedAt(LocalDateTime.now());
}
if (target == OrderStatus.COMPLETED) order.setCompletedAt(LocalDateTime.now());
@@ -414,6 +428,7 @@ public class OrderApplicationService {
}
private void apply(OrderEntity order, long tenantId, OrderRequests.SaveRequest request, OrderMasterDataGuard.Snapshots snapshots) {
order.setCustomerId(tenantId);
applyInboundCode(order, request.inboundCode());
order.setCustomerName(request.customerName().trim());
order.setTransportMode(request.transportMode()); order.setTradeTerm(request.tradeTerm());
order.setDestinationCountryCode(snapshots.destination() == null ? request.destinationCountryCode()
@@ -443,6 +458,48 @@ public class OrderApplicationService {
? snapshots.destination().warehouse().warehouseCode() : request.amazonWarehouseCode());
order.setShipmentId(request.shipmentId()); order.setReferenceId(request.referenceId());
}
private void applyInboundCode(OrderEntity order, String requestedCode) {
String normalized = StringUtils.hasText(requestedCode)
? requestedCode.trim().toUpperCase(Locale.ROOT) : null;
if (!legacyManualMode()) {
if (normalized != null && !normalized.equals(order.getInboundCode())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "当前系统由新系统自动生成入仓码,请勿人工填写");
}
return;
}
if (order.getStatus() != OrderStatus.DRAFT && StringUtils.hasText(order.getInboundCode())) {
if (normalized != null && !normalized.equals(order.getInboundCode())) {
throw new BizException(ResultCode.CONFLICT, "入仓码提交后不可修改");
}
return;
}
if (normalized != null && repository.inboundCodeExists(order.getTenantId(), normalized, order.getId())) {
throw new BizException(ResultCode.CONFLICT, "入仓码已存在,请核对旧系统数据");
}
order.setInboundCode(normalized);
}
private boolean legacyManualMode() {
return inboundCodeModeProvider.legacyManual();
}
private String requirePricingChannel(long tenantId, long receivingWarehouseId, String channelCode,
String destinationType) {
if (legacyManualMode()) {
pricingChannelGuard.requireActive(tenantId, receivingWarehouseId, channelCode, destinationType);
return null;
}
return pricingChannelGuard.requireAvailable(tenantId, receivingWarehouseId, channelCode, destinationType);
}
private void requirePrintableInboundCode(OrderEntity order) {
String inboundCode = order.getInboundCode();
if (!StringUtils.hasText(inboundCode) || !inboundCode.matches("[A-Za-z0-9._:-]+")) {
throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码格式不正确");
}
int total = totalPackageCount(order);
String longestCartonCode = inboundCode + "#" + total + "-" + total;
if (longestCartonCode.codePointCount(0, longestCartonCode.length()) > 34) {
throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码过长,无法生成箱唛二维码");
}
}
private String formatAddress(com.baiyue.order.client.WarehouseClient.Detail detail) {
if (detail.addresses() == null) return null;
var a = detail.addresses().stream().filter(java.util.Objects::nonNull).findFirst().orElse(null);
@@ -20,6 +20,7 @@ import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.List;
@@ -180,10 +181,10 @@ public class UnifiedOrderQueryService {
order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null,
order.getInboundCode(),
order.getExpectedShipDate(), null, null, null, null,
order.getExpectedShipDate(), totalPackageCount(cargo), null, null, null,
null, null, null, null, null, order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(),
order.getCustomsInspection(), order.getDestinationType(), order.getAmazonWarehouseCode(),
null, null, null, null, null, null, order.getExpressCompany(), order.getExpressTrackingNo(),
null, null, null, null, null, null, null, order.getExpressCompany(), order.getExpressTrackingNo(),
string(order.getCreatedBy()), skuNos(cargo), totalQuantity(cargo), order.getShipmentId(), order.getReferenceId(),
null, null, null, null, null,
order.getCustomerRemark(), latestInternalNote,
@@ -199,7 +200,7 @@ public class UnifiedOrderQueryService {
inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), inbound.expectedArrivalAt(), inbound.feeReviewStatus(),
inbound.companyName(), inbound.businessType(), order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(),
inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(),
inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.actualNetWeightKg(),
inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.actualNetWeightKg(),
inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(),
inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), order.getShipmentId(), order.getReferenceId(), measurement(inbound),
preload(summary.preload()), loading(summary.loading()),
@@ -221,7 +222,7 @@ public class UnifiedOrderQueryService {
inbound.actualVolumeCbm(), inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(),
inbound.businessType(), order == null ? null : order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(),
inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(),
inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.actualNetWeightKg(),
inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.actualNetWeightKg(),
inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(),
inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), order == null ? null : order.getShipmentId(),
order == null ? null : order.getReferenceId(), measurement(inbound), preload(summary.preload()), loading(summary.loading()),
@@ -248,8 +249,15 @@ public class UnifiedOrderQueryService {
value.status(), value.completedAt(), value.trackingNo());
}
private UnifiedOrderResponse.TransportSummary transport(FirstlegUnifiedOrderClient.Inbound inbound) {
LocalDateTime sailingAt = inbound.actualSailingAt() != null
? inbound.actualSailingAt() : inbound.estimatedSailingAt();
LocalDateTime arrivalAt = inbound.actualPortArrivalAt() != null
? inbound.actualPortArrivalAt() : inbound.estimatedPortArrivalAt();
return new UnifiedOrderResponse.TransportSummary(inbound.containerNo(), inbound.cabinetNo(),
inbound.vesselName(), inbound.voyageNo(), inbound.sailingAt(), inbound.portArrivalAt());
inbound.vesselName(), inbound.voyageNo(), inbound.estimatedSailingAt(), inbound.actualSailingAt(),
sailingAt, sailingAt == null ? null : inbound.actualSailingAt() == null,
inbound.estimatedPortArrivalAt(), inbound.actualPortArrivalAt(), arrivalAt,
arrivalAt == null ? null : inbound.actualPortArrivalAt() == null);
}
private String orderMainStatus(OrderStatus status) {
@@ -270,13 +278,21 @@ public class UnifiedOrderQueryService {
return cargo == null ? null : cargo.totalQuantity();
}
private record CargoSummary(List<String> skuNos, Integer totalQuantity) {
private Integer totalPackageCount(CargoSummary cargo) {
return cargo == null ? null : cargo.totalPackageCount();
}
private record CargoSummary(List<String> skuNos, Integer totalQuantity, Integer totalPackageCount) {
private static CargoSummary from(List<OrderCargoEntity> cargoes) {
List<String> skuNos = cargoes.stream().map(OrderCargoEntity::getSkuNo)
.filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).distinct().toList();
int totalQuantity = cargoes.stream().map(OrderCargoEntity::getTotalQuantity)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
return new CargoSummary(skuNos, totalQuantity);
List<Integer> packageCounts = cargoes.stream().map(OrderCargoEntity::getPackageCount)
.filter(Objects::nonNull).toList();
Integer totalPackageCount = packageCounts.isEmpty() ? null
: Math.toIntExact(packageCounts.stream().mapToLong(Integer::longValue).sum());
return new CargoSummary(skuNos, totalQuantity, totalPackageCount);
}
}
private String inboundMainStatus(String status) {
@@ -13,3 +13,5 @@ spring:
url: ${ORDER_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true}
username: ${ORDER_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}}
password: ${ORDER_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}}
baiyue.order.inbound-code-mode: AUTO
-1
View File
@@ -35,7 +35,6 @@ baiyue:
url: ${WAREHOUSE_SERVICE_URL:}
file:
url: ${FILE_SERVICE_URL:}
logging:
file:
name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}"
+44 -8
View File
@@ -11,7 +11,8 @@ paths:
summary: 平台统一订单列表
description: >-
统一返回七态产品主状态、下一步动作及测量/预装/装柜/派送摘要,需要 platform:order:list。
订单创建时按物流渠道生成固定 inboundCode,格式为物流前缀+YYMMDD+当天全平台票序;每日按上海日期从0001重新开始;有入库单时同时返回
入仓码来源由全系统统一模式控制:AUTO 在订单提交时按物流渠道生成,格式为物流前缀+YYMMDD+当天全平台票序;
LEGACY_MANUAL 在草稿中录入旧系统入仓码。两种模式下入仓码一经提交均固定;有入库单时同时返回
inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion
才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。
tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询,
@@ -197,6 +198,23 @@ paths:
schema: {$ref: '#/components/schemas/FirstLegBatchDraftResponse'}
'400': {description: 请求数组为空、超过 50 项或幂等键缺失}
'403': {description: 需要 merchant:order:create 权限}
/api/merchant/v1/orders/first-leg/inbound-code-policy:
get:
summary: 查询全系统入仓码模式
description: >-
返回订单服务当前统一运行模式。AUTO 表示提交时由新系统生成;LEGACY_MANUAL
表示两套系统并跑期间必须录入旧系统生成的入仓码。该策略不按客户区分。
responses:
'200':
description: 查询成功
content:
application/json:
schema:
type: object
properties:
code: {type: string, example: '200'}
data: {$ref: '#/components/schemas/InboundCodePolicy'}
'403': {description: 需要 merchant:order:list、merchant:order:create 或 merchant:order:edit 权限之一}
/api/merchant/v1/orders/first-leg/{id}/submit:
post:
summary: 提交订单
@@ -423,10 +441,10 @@ components:
inboundVersion: {type: integer, nullable: true, description: C04 loading/complete 的 expectedVersion}
inboundCode: {type: string, nullable: true}
expectedShipDate: {type: string, format: date, nullable: true}
totalPackageCount: {type: integer, nullable: true}
totalPackageCount: {type: integer, nullable: true, description: 无入仓事实时为当前订单有效货物箱数合计;存在入仓业务单元后为该单元履约总箱数,拆分后不以父单原始箱数覆盖}
recommendedPalletType: {type: string, nullable: true, description: 当前无列表级权威事实时返回 null,不得返回固定托盘类型}
locationCodes: {type: string, nullable: true}
actualGrossWeightKg: {type: number, nullable: true}
actualGrossWeightKg: {type: number, nullable: true, description: Firstleg 实测毛重;来源 actualGrossWeightKg,不以净重回退}
actualVolumeCbm: {type: number, nullable: true}
expectedArrivalAt: {type: string, format: date-time, nullable: true}
feeReviewStatus: {type: string, nullable: true, description: Firstleg 入仓费用审核快照;财务状态不在本字段表达}
@@ -440,9 +458,10 @@ components:
destinationType: {type: string, nullable: true}
destinationCode: {type: string, nullable: true}
totalPalletCount: {type: integer, nullable: true}
chargeableWeightKg: {type: number, nullable: true, description: Firstleg confirmed billing snapshot chargeable weight in kilograms}
chargeableVolumeCbm: {type: number, nullable: true}
volumetricWeightKg: {type: number, nullable: true}
totalNetWeightKg: {type: number, nullable: true, description: Firstleg 确认后的实际净重;未确认时返回 null}
totalNetWeightKg: {type: number, nullable: true, description: 统一订单货物净重展示字段;严格来源 Firstleg actualNetWeightKg,不以毛重回退}
measurementCompletedAt: {type: string, format: date-time, nullable: true}
inboundCompletedAt: {type: string, format: date-time, nullable: true}
expressCompany: {type: string, nullable: true}
@@ -475,7 +494,7 @@ components:
nullable: true
properties:
reviewStatus: {type: string, nullable: true}
actualGrossWeightKg: {type: number, nullable: true}
actualGrossWeightKg: {type: number, nullable: true, description: Firstleg 实测毛重;不得使用净重回退}
actualVolumeCbm: {type: number, nullable: true}
chargeableWeightKg: {type: number, nullable: true}
completedAt: {type: string, format: date-time, nullable: true}
@@ -514,8 +533,14 @@ components:
cabinetNo: {type: string, nullable: true}
vesselName: {type: string, nullable: true}
voyageNo: {type: string, nullable: true}
sailingAt: {type: string, format: date-time, nullable: true}
portArrivalAt: {type: string, format: date-time, nullable: true}
estimatedSailingAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次预计开船时间}
actualSailingAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次实际开船时间}
effectiveSailingAt: {type: string, format: date-time, nullable: true, description: 实际开船时间优先,否则预计开船时间}
effectiveSailingAtEstimated: {type: boolean, nullable: true}
estimatedPortArrivalAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次预计到港时间}
actualPortArrivalAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次实际到港时间}
effectivePortArrivalAt: {type: string, format: date-time, nullable: true, description: 实际到港时间优先,否则预计到港时间}
effectivePortArrivalAtEstimated: {type: boolean, nullable: true}
UnifiedOrderTimelineEvent:
type: object
properties:
@@ -606,6 +631,11 @@ components:
type: object
required: [pricingTaxMode, cargoes]
properties:
inboundCode:
type: string
maxLength: 40
pattern: '^[A-Za-z0-9._:-]+$'
description: 仅 LEGACY_MANUAL 模式填写的旧系统入仓码;AUTO 模式必须省略
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
cargoes:
type: array
@@ -615,6 +645,12 @@ components:
订单保存请求中的完整字段以服务端 FirstLegOrderSaveRequest 为准;pricingTaxMode
是新建和编辑订单的必填报价维度;customerId 不接受客户端指定,服务端始终使用认证企业 ID;
发货单位和收货单位仅通过 sender/receiver 保存业务快照。历史存量订单的响应字段仍允许为空。
InboundCodePolicy:
type: object
required: [mode, manualEntryRequired]
properties:
mode: {type: string, enum: [AUTO, LEGACY_MANUAL]}
manualEntryRequired: {type: boolean}
FirstLegBatchDraftItem:
type: object
required: [clientRowId, draft]
@@ -644,7 +680,7 @@ components:
success: {type: boolean}
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
orderNo: {type: string}
inboundCode: {type: string, pattern: '^[A-Z]{4,6}[0-9]{10,}(?:-[1-9][0-9]*)?$', example: ZBYHK2608140001, description: 根订单使用渠道前缀+业务日期+全局票序;履约拆分产生的余量子订单追加根范围 splitSequence 后缀,如 ZBYHK2608140001-1}
inboundCode: {type: string, pattern: '^[A-Za-z0-9._:-]+$', example: ZBYHK2608140001, description: AUTO 模式为渠道前缀+业务日期+全局票序;LEGACY_MANUAL 模式为录入的旧系统入仓码;履约拆分的子订单追加 splitSequence 后缀}
errorCode: {type: string}
errorMessage: {type: string}
FirstLegOrderCargo:
@@ -0,0 +1,37 @@
package com.baiyue.order.controller;
import com.baiyue.order.dto.InboundCodePolicyResponse;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class InboundCodePolicyContractTest {
@Test
void exposesOneGlobalPolicyToOrderReadersAndEditors() throws Exception {
var method = MerchantFirstLegOrderController.class.getDeclaredMethod("inboundCodePolicy");
var authorization = method.getAnnotation(PreAuthorize.class);
assertThat(authorization).isNotNull();
assertThat(authorization.value())
.isEqualTo("hasAnyAuthority('merchant:order:list', 'merchant:order:create', 'merchant:order:edit')");
assertThat(method.getGenericReturnType().getTypeName())
.contains(InboundCodePolicyResponse.class.getName());
}
@Test
void documentsAutomaticAndLegacyManualModes() throws Exception {
String openapi = new ClassPathResource("openapi/order-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(openapi)
.contains("/api/merchant/v1/orders/first-leg/inbound-code-policy:")
.contains("enum: [AUTO, LEGACY_MANUAL]")
.contains("manualEntryRequired: {type: boolean}")
.contains("该策略不按客户区分")
.contains("仅 LEGACY_MANUAL 模式填写的旧系统入仓码;AUTO 模式必须省略");
}
}
@@ -128,6 +128,42 @@ class InternalOrderCargoControllerTest {
verify(orders).findOwned(11L, 21L);
}
@Test
void measurementDeclarationUsesOrderReferenceAndSumsCargoFacts() {
OrderEntity order = new OrderEntity();
order.setId(21L); order.setTenantId(11L); order.setReferenceId("REF-50");
OrderCargoEntity first = cargo(31L, 1, 60, "60", "40", "30");
first.setCargoName("品名A"); first.setGrossWeightKg(new BigDecimal("1200"));
first.setVolumeCbm(new BigDecimal("12.5"));
OrderCargoEntity second = cargo(32L, 2, 40, "50", "30", "20");
second.setCargoName("品名B"); second.setGrossWeightKg(new BigDecimal("800"));
second.setVolumeCbm(new BigDecimal("7.5"));
when(orders.findOwned(11L, 21L)).thenReturn(order);
when(orders.findCargo(11L, 21L)).thenReturn(List.of(first, second));
var result = controller.measurementDeclaration("internal-token", 11L, 21L).data();
assertThat(result.referenceId()).isEqualTo("REF-50");
assertThat(result.originalGrossWeightKg()).isEqualByComparingTo("2000");
assertThat(result.originalVolumeCbm()).isEqualByComparingTo("20.0");
assertThat(result.defaultActualGoodsName()).isEqualTo("品名A、品名B");
}
@Test
void splitChildWithoutDirectCargoDoesNotCopyRootDeclarationTotals() {
OrderEntity child = new OrderEntity();
child.setId(201L); child.setTenantId(11L); child.setOrderRole("CHILD"); child.setRootOrderId(21L);
when(orders.findOwned(11L, 201L)).thenReturn(child);
when(orders.findCargo(11L, 201L)).thenReturn(List.of());
var result = controller.measurementDeclaration("internal-token", 11L, 201L).data();
assertThat(result.originalGrossWeightKg()).isNull();
assertThat(result.originalVolumeCbm()).isNull();
assertThat(result.defaultActualGoodsName()).isEmpty();
verify(orders).findCargo(11L, 201L);
}
private OrderCargoEntity cargo(long id, int itemNo, int packageCount,
String length, String width, String height) {
OrderCargoEntity cargo = new OrderCargoEntity();
@@ -55,7 +55,7 @@ class UnifiedOrderApiContractTest {
"loading", "delivery", "transport", "customerRemark", "latestInternalNote",
"expectedArrivalAt", "requestedPickupAt", "feeReviewStatus", "companyName", "businessType", "pickupMethod",
"taxMode", "containerMode", "customsInspection", "destinationType", "destinationCode",
"totalPalletCount", "chargeableVolumeCbm", "volumetricWeightKg", "totalNetWeightKg",
"totalPalletCount", "chargeableWeightKg", "chargeableVolumeCbm", "volumetricWeightKg", "totalNetWeightKg",
"measurementCompletedAt", "inboundCompletedAt", "expressCompany", "expressTrackingNo",
"createdBy", "skuNos", "totalQuantity", "shipmentId", "referenceId");
assertThat(responseSchema(paths, "/api/platform/v1/orders", "get"))
@@ -252,6 +252,42 @@ class OrderApplicationServiceStateTest {
verify(repository).saveOrder(20002L, order);
}
@Test
void legacyManualModeStoresTheExternalInboundCodeOnTheDraft() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderRequests.SaveRequest request = withInboundCode(request(), "nyexx2608140001");
when(masterData.validate(10001L, request)).thenReturn(snapshots());
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData);
ReflectionTestUtils.setField(service, "inboundCodeMode", "LEGACY_MANUAL");
service.createDraft(10001L, 2L, request, "legacy-code-draft");
verify(repository).insertOrder(argThat(order -> "NYEXX2608140001".equals(order.getInboundCode())));
verify(repository, never()).nextInboundCodeSequence(any());
}
@Test
void legacyManualModeRejectsSubmissionWithoutAnExternalInboundCode() {
OrderRepository repository = mock(OrderRepository.class);
OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS");
when(repository.findOwned(20002L, 1L)).thenReturn(order);
when(repository.findCargo(20002L, 1L)).thenReturn(List.of(completeCargo()));
when(repository.findParties(20002L, 1L)).thenReturn(List.of());
OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class),
mock(OrderMasterDataGuard.class));
ReflectionTestUtils.setField(service, "inboundCodeMode", "LEGACY_MANUAL");
assertThatThrownBy(() -> service.transit(
20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null))
.isInstanceOf(BizException.class)
.hasMessage("当前系统使用旧系统入仓码,请先填写入仓码");
verify(repository, never()).nextInboundCodeSequence(any());
verify(repository, never()).saveOrder(anyLong(), any());
}
@Test
void submittingLegacyDraftRequiresPricingTaxMode() {
OrderRepository repository = mock(OrderRepository.class);
@@ -531,6 +567,18 @@ class OrderApplicationServiceStateTest {
"BUY_DECLARATION", "TAX_INCLUDED", "LCL", false, "OVERSEAS", null, null, null);
}
private OrderRequests.SaveRequest withInboundCode(OrderRequests.SaveRequest source, String inboundCode) {
return new OrderRequests.SaveRequest(
source.customerId(), source.customerName(), source.transportMode(), source.tradeTerm(),
source.destinationCountryCode(), source.destinationWarehouseId(), source.destinationWarehouseName(),
source.expectedShipDate(), source.customerRemark(), source.sender(), source.receiver(), source.cargoes(),
source.pickupMethod(), source.requestedPickupAt(), source.receivingWarehouseId(),
source.receivingWarehouseName(), source.channelCode(), source.channelName(), source.carrierId(),
source.carrierName(), source.taxMode(), source.pricingTaxMode(), source.containerMode(),
source.customsInspection(), source.destinationType(), source.amazonWarehouseCode(), source.shipmentId(),
source.referenceId(), inboundCode);
}
private OrderRequests.SaveRequest request(Long customerId, String pickupMethod,
OrderRequests.Party sender, OrderRequests.Party receiver,
String destinationType) {
@@ -5,6 +5,8 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.FirstlegUnifiedOrderClient;
import com.baiyue.order.dto.UnifiedOrderQuery;
import com.baiyue.order.dto.UnifiedOrderResponse;
import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderInternalNoteEntity;
import com.baiyue.order.entity.OrderStatus;
@@ -19,6 +21,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.IntStream;
@@ -74,6 +77,71 @@ class UnifiedOrderQueryServiceTest {
verify(firstleg, never()).summaries(any(), any());
}
@Test
void newlyCreatedOrderUsesItsOwnCargoPackageCountBeforeInboundExists() {
UnifiedOrderQuery query = query(null);
OrderEntity order = order(OrderStatus.APPROVED);
OrderCargoEntity cargo = new OrderCargoEntity();
cargo.setOrderId(77L);
cargo.setPackageCount(100);
when(orders.unifiedPage(eq(10001L), eq(query), any()))
.thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
when(firstleg.summaries(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of()));
when(orders.findCargoByOrderIds(10001L, List.of(77L))).thenReturn(List.of(cargo));
assertThat(service.page(10001L, query).records()).singleElement()
.extracting(UnifiedOrderResponse.Row::totalPackageCount).isEqualTo(100);
verify(orders).findCargoByOrderIds(10001L, List.of(77L));
}
@Test
void inboundPackageCountRemainsTheFulfillmentFactAfterSplit() {
UnifiedOrderQuery query = query(null);
OrderEntity child = order(OrderStatus.APPROVED);
child.setParentOrderId(66L);
OrderCargoEntity inheritedOriginal = new OrderCargoEntity();
inheritedOriginal.setOrderId(77L);
inheritedOriginal.setPackageCount(100);
var summary = summary("LOADING", null);
when(summary.inbound().totalPackageCount()).thenReturn(35);
when(orders.unifiedPage(eq(10001L), eq(query), any()))
.thenReturn(new PageResult<>(List.of(child), 1, 20, 1, 1));
when(firstleg.summaries(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(List.of(summary)));
when(orders.findCargoByOrderIds(10001L, List.of(77L))).thenReturn(List.of(inheritedOriginal));
assertThat(service.page(10001L, query).records()).singleElement()
.extracting(UnifiedOrderResponse.Row::totalPackageCount).isEqualTo(35);
}
@Test
void inboundMasterUsesTotalPackageCountEvenWhenLegacyPackageCountIsAbsent() {
UnifiedOrderQuery query = query(null);
OrderEntity order = order(OrderStatus.APPROVED);
var summary = summary("WAIT_MEASURE", null);
when(summary.inbound().totalPackageCount()).thenReturn(100);
when(orders.unifiedPage(eq(10001L), eq(query), any()))
.thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
when(firstleg.summaries(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(List.of(summary)));
assertThat(service.page(10001L, query).records()).singleElement()
.extracting(UnifiedOrderResponse.Row::totalPackageCount).isEqualTo(100);
}
@Test
void missingCargoAndInboundReturnsNullInsteadOfFabricatedZero() {
UnifiedOrderQuery query = query(null);
OrderEntity order = order(OrderStatus.APPROVED);
when(orders.unifiedPage(eq(10001L), eq(query), any()))
.thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
when(firstleg.summaries(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of()));
when(orders.findCargoByOrderIds(10001L, List.of(77L))).thenReturn(List.of());
assertThat(service.page(10001L, query).records()).singleElement()
.extracting(UnifiedOrderResponse.Row::totalPackageCount).isNull();
}
@Test
void pendingReviewInboundCodeFilterStaysInOrderQuery() {
UnifiedOrderQuery query = new UnifiedOrderQuery(
@@ -173,10 +241,27 @@ class UnifiedOrderQueryServiceTest {
assertThat(row.companyName()).isEqualTo("测试客户有限公司");
assertThat(row.businessType()).isEqualTo("SEA_LCL");
assertThat(row.totalPalletCount()).isEqualTo(2);
assertThat(row.chargeableWeightKg()).isEqualByComparingTo("125.500");
assertThat(row.actualGrossWeightKg()).isEqualByComparingTo("1787.500");
assertThat(row.totalNetWeightKg()).isEqualByComparingTo("1747.500");
});
verify(orders, never()).unifiedPage(any(), any(), any());
}
@Test
void unifiedRowDoesNotUseGrossWeightAsNetWeightFallback() {
UnifiedOrderQuery query = query("INBOUNDED");
var summary = summary("INBOUNDED", null);
when(summary.inbound().actualNetWeightKg()).thenReturn(null);
when(firstleg.page(eq("internal-token"), any())).thenReturn(
ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
var row = service.page(null, query).records().get(0);
assertThat(row.actualGrossWeightKg()).isEqualByComparingTo("1787.500");
assertThat(row.totalNetWeightKg()).isNull();
}
@Test
void waitSplitFilterForwardsAndReturnsWaitSplitIndependently() {
UnifiedOrderQuery query = query("WAIT_SPLIT");
@@ -201,6 +286,9 @@ class UnifiedOrderQueryServiceTest {
UnifiedOrderQuery query = query(null);
OrderEntity order = order(OrderStatus.APPROVED);
var summary = summary("LOADED", null);
when(summary.inbound().estimatedSailingAt()).thenReturn(LocalDateTime.of(2026, 8, 20, 8, 0));
when(summary.inbound().actualSailingAt()).thenReturn(LocalDateTime.of(2026, 8, 21, 9, 0));
when(summary.inbound().estimatedPortArrivalAt()).thenReturn(LocalDateTime.of(2026, 9, 1, 8, 0));
when(orders.unifiedPage(eq(10001L), eq(query), any()))
.thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
when(firstleg.summaries(eq("internal-token"), any()))
@@ -212,6 +300,10 @@ class UnifiedOrderQueryServiceTest {
assertThat(row.mainStatus()).isEqualTo("LOADED");
assertThat(row.internalOrderStatus()).isEqualTo(OrderStatus.APPROVED);
assertThat(row.inboundId()).isEqualTo(88L);
assertThat(row.transport().effectiveSailingAt()).isEqualTo(LocalDateTime.of(2026, 8, 21, 9, 0));
assertThat(row.transport().effectiveSailingAtEstimated()).isFalse();
assertThat(row.transport().effectivePortArrivalAt()).isEqualTo(LocalDateTime.of(2026, 9, 1, 8, 0));
assertThat(row.transport().effectivePortArrivalAtEstimated()).isTrue();
});
ArgumentCaptor<FirstlegUnifiedOrderClient.BatchRequest> captor =
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.BatchRequest.class);
@@ -349,6 +441,9 @@ class UnifiedOrderQueryServiceTest {
when(inbound.companyName()).thenReturn("测试客户有限公司");
when(inbound.businessType()).thenReturn("SEA_LCL");
when(inbound.palletCount()).thenReturn(2);
when(inbound.actualGrossWeightKg()).thenReturn(new BigDecimal("1787.500"));
when(inbound.actualNetWeightKg()).thenReturn(new BigDecimal("1747.500"));
when(inbound.chargeableWeightKg()).thenReturn(new BigDecimal("125.500"));
when(inbound.createdAt()).thenReturn(LocalDateTime.of(2026, 8, 6, 10, 0));
when(inbound.updatedAt()).thenReturn(LocalDateTime.of(2026, 8, 6, 11, 0));
return new FirstlegUnifiedOrderClient.Summary(inbound, status, action,