This commit is contained in:
2026-08-19 14:02:11 +08:00
590 changed files with 23946 additions and 5168 deletions
@@ -44,7 +44,8 @@ public interface FirstlegInboundClient {
Long carrierId, String carrierName, String taxMode, String pricingTaxMode, String containerMode,
Boolean customsInspection, String destinationType, Long destinationWarehouseId,
String destinationCode, String destinationName, String publicRemark,
String expressCompanyName, String expressTrackingNo, Integer totalPackageCount
String expressCompanyName, String expressTrackingNo, Integer totalPackageCount,
String pickupMethod, LocalDateTime requestedPickupAt
) {
}
}
@@ -10,8 +10,10 @@ import org.springframework.web.bind.annotation.RequestHeader;
url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/pricing")
public interface FirstlegPricingClient {
@PostMapping("/channels/validate")
ApiResponse<Void> validateChannel(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ChannelValidationRequest request);
ApiResponse<ChannelValidationResponse> validateChannel(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ChannelValidationRequest request);
record ChannelValidationRequest(long tenantId, long originWarehouseId, String channelCode) {}
record ChannelValidationRequest(long tenantId, long originWarehouseId, String channelCode,
String destinationType, boolean requireInboundCodePrefix) {}
record ChannelValidationResponse(String channelCode, String inboundCodePrefix) {}
}
@@ -47,10 +47,16 @@ public interface FirstlegUnifiedOrderClient {
Long customerId, String customerName, Long warehouseId, String warehouseName,
Long deliveryWarehouseId, String deliveryWarehouseName, String channelName,
Integer totalPackageCount, String locationCodes,
BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm,
LocalDateTime expectedArrivalAt, LocalDateTime requestedPickupAt, String feeReviewStatus,
String companyName, String businessType, String taxMode, String containerMode,
Boolean customsInspection, String destinationType, String destinationCode,
BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg, BigDecimal actualVolumeCbm,
BigDecimal volumeWeightKg, BigDecimal chargeableVolumeCbm, Integer palletCount,
BigDecimal chargeableWeightKg, LocalDateTime measureCompletedAt,
String loadingStatus, String containerNo, String cabinetNo, String vesselName, String voyageNo,
LocalDateTime sailingAt, LocalDateTime portArrivalAt,
LocalDateTime inboundedAt, String expressCompanyName, String createdByName,
String containerNo, String cabinetNo, String vesselName, String voyageNo,
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,
@@ -1,16 +0,0 @@
package com.baiyue.order.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@FeignClient(name="warehouse-service", contextId="warehousePartnerClient",
url="${baiyue.clients.warehouse.url:}", path="/internal/warehouse-partners")
public interface PartnerClient {
@GetMapping("/{tenantId}/{id}/active")
ApiResponse<Partner> active(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable("tenantId") long tenantId, @PathVariable("id") long id);
record Partner(Long id, Long tenantId, String partnerName, String status,
String partnerType, List<String> types) { }
}
@@ -0,0 +1,38 @@
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:AUTO}") 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
@@ -48,10 +51,16 @@ public class InternalOrderCargoController {
if (cargo == null) {
throw new BizException(ResultCode.NOT_FOUND, "订单货物明细不存在或不属于该订单");
}
var order = orders.findOwned(tenantId, orderId);
if (order == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在或不属于当前客户");
String latestInternalNote = orders.findNotes(tenantId, orderId).stream()
.map(com.baiyue.order.entity.OrderInternalNoteEntity::getContent)
.filter(java.util.Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty())
.findFirst().orElse(null);
return ApiResponse.success(new CargoBinding(cargo.getId(), orderId, cargo.getItemNo(),
cargo.getCargoName(), cargo.getPackageCount(), cargo.getGrossWeightKg(), cargo.getVolumeCbm(),
cargo.getSkuNo(), cargo.getCargoNameEn(), cargo.getUnitsPerPackage(), cargo.getTotalQuantity(),
cargo.getNetWeightKg(), cargo.getCargoRemark()));
cargo.getNetWeightKg(), cargo.getCargoRemark(), order.getCustomerRemark(), latestInternalNote));
}
@GetMapping("/{tenantId}/{orderId}")
@@ -89,6 +98,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, "内部服务凭证无效");
@@ -100,12 +135,22 @@ public class InternalOrderCargoController {
java.math.BigDecimal originalGrossWeightKg,
java.math.BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn,
Integer unitsPerPackage, Integer totalQuantity,
java.math.BigDecimal netWeightKg, String cargoRemark) {
java.math.BigDecimal netWeightKg, String cargoRemark,
String customerRemark, String latestInternalNote) {
public CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer originalPackageCount, java.math.BigDecimal originalGrossWeightKg,
java.math.BigDecimal originalVolumeCbm) {
this(cargoId, orderId, itemNo, cargoName, originalPackageCount,
originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null);
originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null, null, null);
}
public CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer originalPackageCount, java.math.BigDecimal originalGrossWeightKg,
java.math.BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn,
Integer unitsPerPackage, Integer totalQuantity, java.math.BigDecimal netWeightKg,
String cargoRemark) {
this(cargoId, orderId, itemNo, cargoName, originalPackageCount, originalGrossWeightKg,
originalVolumeCbm, skuNo, cargoNameEn, unitsPerPackage, totalQuantity, netWeightKg,
cargoRemark, null, null);
}
}
@@ -113,4 +158,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(
@@ -8,6 +8,7 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.order.dto.BatchOrderReviewResponse;
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.entity.OrderEntity;
@@ -47,6 +48,12 @@ public class PlatformFirstLegOrderController {
@Resource
private OrderApplicationService service;
@GetMapping("/inbound-code-policy")
@PreAuthorize("hasAnyAuthority('platform:order:list', 'platform:order:create', 'platform:order:edit', 'platform:order:submit')")
public ApiResponse<InboundCodePolicyResponse> inboundCodePolicy() {
return ApiResponse.success(service.inboundCodePolicy());
}
@BusinessAudit(event = "ORDER_CREATE", resourceType = "FIRST_LEG_ORDER", description = "平台代客创建草稿")
@Operation(summary = "平台代客创建草稿", description = "在明确指定的目标租户内创建头程订单草稿,必须携带幂等键。")
@ApiResponses({
@@ -0,0 +1,5 @@
package com.baiyue.order.dto;
/** 全系统统一的入仓码录入策略。 */
public record InboundCodePolicyResponse(String mode, boolean manualEntryRequired) {
}
@@ -28,7 +28,8 @@ public final class OrderRequests {
@Schema(name = "FirstLegOrderSaveRequest", description = "First-leg order create or update request / 头程订单新增或更新请求")
public record SaveRequest(
@Schema(description = "Customer ID / 客户 ID", example = "30001") Long customerId,
@Schema(description = "Deprecated and ignored. Customer ID is always taken from authenticated enterprise context / 已废弃并忽略,客户 ID 固定取认证企业上下文", deprecated = true)
Long customerId,
@Schema(description = "Customer name / 客户名称", example = "Shenzhen Example Trading Co., Ltd. / 深圳示例贸易有限公司", maxLength = 128, requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(max = 128) String customerName,
@Schema(description = "Transport mode / 运输方式", example = "SEA", allowableValues = {"SEA", "AIR", "RAIL", "TRUCK"}, requiredMode = Schema.RequiredMode.REQUIRED)
@@ -52,6 +53,8 @@ public final class OrderRequests {
@Schema(description = "Cargo lines / 货物明细", requiredMode = Schema.RequiredMode.REQUIRED)
@NotEmpty @Valid List<Cargo> cargoes,
@NotBlank @Pattern(regexp = "SELF_EXPRESS|PICKUP|SELF_TRUCK") String pickupMethod,
@Schema(description = "Customer-requested pickup appointment; only applies to PICKUP / 客户要求平台上门提货的预约时间,仅 PICKUP 适用")
LocalDateTime requestedPickupAt,
@NotNull Long receivingWarehouseId,
@NotBlank @Size(max = 128) String receivingWarehouseName,
@NotBlank @Size(max = 64) String channelCode,
@@ -68,8 +71,44 @@ 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,
Party sender, Party receiver, List<Cargo> cargoes, String pickupMethod,
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, null, receivingWarehouseId,
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
shipmentId, referenceId, null);
}
@AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空")
public boolean isCarrierSnapshotComplete() {
return (carrierId == null) == (carrierName == null || carrierName.isBlank());
@@ -21,10 +21,19 @@ public final class UnifiedOrderResponse {
Long inboundId, Integer inboundVersion, String inboundCode, LocalDate expectedShipDate,
Integer totalPackageCount, String recommendedPalletType, String locationCodes,
BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm,
LocalDateTime expectedArrivalAt, String feeReviewStatus,
String companyName, String businessType, String pickupMethod,
String taxMode, String containerMode, Boolean customsInspection,
String destinationType, String destinationCode,
Integer totalPalletCount, BigDecimal chargeableWeightKg, BigDecimal chargeableVolumeCbm,
BigDecimal volumetricWeightKg, BigDecimal totalNetWeightKg,
LocalDateTime measurementCompletedAt, LocalDateTime inboundCompletedAt,
String expressCompany, String expressTrackingNo, String createdBy,
List<String> skuNos, Integer totalQuantity, String shipmentId, String referenceId,
MeasurementSummary measurement, PreloadSummary preload,
LoadingSummary loading, DeliverySummary delivery, TransportSummary transport,
String customerRemark, String latestInternalNote,
LocalDateTime createdAt, LocalDateTime updatedAt
LocalDateTime createdAt, LocalDateTime updatedAt, LocalDateTime requestedPickupAt
) {}
public record OrderRelationSummary(String role, Long rootOrderId, Long parentOrderId,
@@ -42,9 +51,12 @@ public final class UnifiedOrderResponse {
Integer expectedCount, Integer pickedCount, Integer evidenceCount,
String sealNo, LocalDateTime updatedAt) {}
public record DeliverySummary(String status, LocalDateTime completedAt, String trackingNo) {}
public record TransportSummary(String loadingStatus, String containerNo, String cabinetNo,
String vesselName, String voyageNo, LocalDateTime sailingAt,
LocalDateTime portArrivalAt) {}
public record TransportSummary(String containerNo, String cabinetNo,
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) {}
@@ -33,6 +33,7 @@ public class OrderEntity extends BaseEntity {
private LocalDate expectedShipDate;
private String customerRemark;
private String pickupMethod;
private LocalDateTime requestedPickupAt;
private Long receivingWarehouseId;
private String receivingWarehouseName;
private String receivingWarehouseAddress;
@@ -0,0 +1,25 @@
package com.baiyue.order.mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDate;
@Mapper
public interface InboundCodeSequenceMapper {
@Insert("""
INSERT INTO ord_inbound_code_daily_sequence
(business_date, current_value, created_at, updated_at)
VALUES
(#{businessDate}, 1, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE
current_value = current_value + 1,
updated_at = CURRENT_TIMESTAMP(3)
""")
int increment(@Param("businessDate") LocalDate businessDate);
@Select("SELECT current_value FROM ord_inbound_code_daily_sequence WHERE business_date = #{businessDate}")
Long currentValue(@Param("businessDate") LocalDate businessDate);
}
@@ -13,6 +13,8 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.List;
@@ -33,6 +35,17 @@ public class OrderRepository {
@Resource private OutboxEventMapper outboxMapper;
@Resource private OrderChildPurgeMapper childPurgeMapper;
@Resource private OrderIdempotencyRecordMapper idempotencyMapper;
@Resource private InboundCodeSequenceMapper inboundCodeSequenceMapper;
@Transactional(propagation = Propagation.MANDATORY)
public long nextInboundCodeSequence(java.time.LocalDate businessDate) {
if (businessDate == null || inboundCodeSequenceMapper.increment(businessDate) < 1) {
throw new IllegalStateException("Failed to allocate inbound code sequence");
}
Long value = inboundCodeSequenceMapper.currentValue(businessDate);
if (value == null || value < 1) throw new IllegalStateException("Failed to read inbound code sequence");
return value;
}
public void insertOrder(OrderEntity order) { orderMapper.insert(order); }
public int saveOrder(long tenantId, OrderEntity order) {
@@ -108,6 +121,13 @@ public class OrderRepository {
.eq(OrderEntity::getId, orderId));
}
public boolean inboundCodeExists(String inboundCode, Long excludedOrderId) {
if (!StringUtils.hasText(inboundCode)) return false;
return orderMapper.selectCount(new LambdaQueryWrapper<OrderEntity>()
.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)
@@ -195,10 +215,10 @@ public class OrderRepository {
return cargoMapper.selectObjs(wrapper).stream().map(value -> ((Number) value).longValue()).toList();
}
public List<OrderCargoEntity> findCargoByOrderIds(long tenantId, List<Long> orderIds) {
public List<OrderCargoEntity> findCargoByOrderIds(Long tenantId, List<Long> orderIds) {
if (orderIds.isEmpty()) return List.of();
return cargoMapper.selectList(new LambdaQueryWrapper<OrderCargoEntity>()
.eq(OrderCargoEntity::getTenantId, tenantId)
.eq(tenantId != null, OrderCargoEntity::getTenantId, tenantId)
.in(OrderCargoEntity::getOrderId, orderIds)
.orderByAsc(OrderCargoEntity::getOrderId)
.orderByAsc(OrderCargoEntity::getItemNo));
@@ -3,7 +3,10 @@ package com.baiyue.order.service;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.repository.OrderRepository;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.Resource;
import jakarta.validation.Validator;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@@ -13,9 +16,19 @@ import org.springframework.transaction.annotation.Transactional;
public class MerchantBatchDraftItemService {
@Resource private OrderApplicationService orders;
@Resource private OrderRepository repository;
@Resource private Validator validator;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public OrderEntity create(long tenantId, long userId, OrderRequests.SaveRequest draft, String idempotencyKey) {
var violations = validator.validate(draft);
if (!violations.isEmpty()) {
String message = violations.stream()
.sorted(java.util.Comparator.comparing(violation -> violation.getPropertyPath().toString()))
.findFirst()
.map(violation -> violation.getPropertyPath() + ": " + violation.getMessage())
.orElse("草稿参数不合法");
throw new BizException(ResultCode.VALIDATION_FAILED, message);
}
Long id = orders.createDraft(tenantId, userId, draft, idempotencyKey);
return repository.findOwned(tenantId, id);
}
@@ -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;
@@ -33,18 +35,27 @@ import java.time.*;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
import java.time.format.DateTimeFormatter;
@Service
public class OrderApplicationService {
private static final ZoneId INBOUND_CODE_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter INBOUND_CODE_DATE = DateTimeFormatter.ofPattern("yyMMdd");
@Resource private OrderRepository repository;
@Resource private ObjectMapper json;
@Resource private OrderEligibilityGuard eligibilityGuard;
@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) {
@@ -61,16 +72,21 @@ 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());
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));
order.setInboundCode("RK-" + orderId); order.setStatus(OrderStatus.DRAFT);
apply(order, request, snapshots); order.setCreatedBy(userId); order.setUpdatedBy(userId);
repository.insertOrder(order);
order.setStatus(OrderStatus.DRAFT);
apply(order, tenantId, request, snapshots);
order.setCreatedBy(userId); order.setUpdatedBy(userId);
try {
repository.insertOrder(order);
} catch (DuplicateKeyException duplicate) {
throw duplicateInboundCode(order, duplicate);
}
saveChildren(tenantId, userId, order.getId(), request);
record(tenantId, order.getId(), null, OrderStatus.DRAFT, "CREATE", null, userId, null);
prepareDraftInbound(order, request);
return order.getId();
}
@@ -100,19 +116,21 @@ public class OrderApplicationService {
&& !(allowSubmitted && before == OrderStatus.SUBMITTED)) {
throw new BizException(ResultCode.CONFLICT, "当前订单状态不允许编辑订单资料");
}
pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), request.channelCode());
apply(order, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
if (before == OrderStatus.REJECTED && !StringUtils.hasText(order.getInboundCode())) {
order.setInboundCode("RK-" + order.getId());
}
requirePricingChannel(tenantId, request.receivingWarehouseId(), request.channelCode(),
request.destinationType());
apply(order, tenantId, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
if (before == OrderStatus.SUBMITTED) {
order.setStatus(OrderStatus.SUBMITTED);
}
if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update");
try {
if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update");
} catch (DuplicateKeyException duplicate) {
throw duplicateInboundCode(order, duplicate);
}
repository.replaceDraftChildren(tenantId, id);
saveChildren(tenantId, userId, id, request);
record(tenantId, id, before, order.getStatus(), "UPDATE", null, userId, null);
prepareDraftInbound(order, request);
if (before == OrderStatus.SUBMITTED) prepareDraftInbound(order, request);
}
@Transactional
@@ -122,23 +140,28 @@ public class OrderApplicationService {
OrderStatus from = order.getStatus();
if (!allowed(from, target)) throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
from == target ? "该操作已完成,请勿重复提交" : "订单状态不允许当前操作: " + from + " -> " + target);
String inboundCodePrefix = null;
if (target == OrderStatus.SUBMITTED) {
validatePersistedPrototypeFields(tenantId, order);
masterDataGuard.validatePersisted(tenantId, order.getCustomerId(), order.getReceivingWarehouseId(),
order.getDestinationWarehouseId(), order.getDestinationType());
pricingChannelGuard.requireAvailable(tenantId, order.getReceivingWarehouseId(), order.getChannelCode());
inboundCodePrefix = requirePricingChannel(tenantId, order.getReceivingWarehouseId(),
order.getChannelCode(), order.getDestinationType());
}
if ((target == OrderStatus.REJECTED || target == OrderStatus.CANCELLED) && !StringUtils.hasText(reason)) {
throw new BizException("Reason is required");
}
order.setStatus(target); order.setUpdatedBy(userId);
if (target == OrderStatus.SUBMITTED) {
if (!StringUtils.hasText(order.getInboundCode())) order.setInboundCode("RK-" + order.getId());
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.DRAFT && !StringUtils.hasText(order.getInboundCode())) {
order.setInboundCode("RK-" + order.getId());
}
if (target == OrderStatus.COMPLETED) order.setCompletedAt(LocalDateTime.now());
if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update");
record(tenantId, id, from, target, target.name(), reason, userId, username);
@@ -159,6 +182,21 @@ public class OrderApplicationService {
public PageResult<OrderEntity> page(Long tenantId, OrderRequests.QueryRequest query) { return repository.page(tenantId, query); }
String nextInboundCode(String prefix) {
LocalDate businessDate = LocalDate.now(INBOUND_CODE_ZONE);
return formatInboundCode(prefix, businessDate, repository.nextInboundCodeSequence(businessDate));
}
static String formatInboundCode(String prefix, LocalDate date, long sequence) {
if (!StringUtils.hasText(prefix) || !prefix.matches("[A-Z]{4,6}")) {
throw new BizException(ResultCode.CONFLICT, "渠道入仓码前缀配置无效");
}
if (date == null || sequence < 1) {
throw new BizException(ResultCode.CONFLICT, "入仓码票序生成失败");
}
return prefix + date.format(INBOUND_CODE_DATE) + String.format(Locale.ROOT, "%04d", sequence);
}
public List<TenantOrderCountResponse> countByTenantIds(List<Long> tenantIds) {
var counts = repository.countByTenantIds(tenantIds);
return tenantIds.stream().distinct()
@@ -270,6 +308,9 @@ public class OrderApplicationService {
if ("PICKUP".equals(request.pickupMethod()) && request.sender() == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "上门提货时必须填写发货信息");
}
if ("PICKUP".equals(request.pickupMethod()) && request.requestedPickupAt() == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "上门提货时必须填写预约提货时间");
}
if ("PRIVATE".equals(request.destinationType()) && request.receiver() == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "私人地址必须填写收货信息");
}
@@ -393,9 +434,10 @@ public class OrderApplicationService {
default -> false;
};
}
private void apply(OrderEntity order, OrderRequests.SaveRequest request, OrderMasterDataGuard.Snapshots snapshots) {
order.setCustomerId(snapshots.partner() == null ? null : snapshots.partner().id());
order.setCustomerName(snapshots.partner() == null ? request.customerName() : snapshots.partner().partnerName());
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()
: snapshots.destination().warehouse().countryCode());
@@ -403,7 +445,9 @@ public class OrderApplicationService {
order.setDestinationWarehouseName(snapshots.destination() == null ? request.destinationWarehouseName()
: snapshots.destination().warehouse().warehouseName());
order.setExpectedShipDate(request.expectedShipDate()); order.setCustomerRemark(request.customerRemark());
order.setPickupMethod(request.pickupMethod()); order.setReceivingWarehouseId(request.receivingWarehouseId());
order.setPickupMethod(request.pickupMethod());
order.setRequestedPickupAt("PICKUP".equals(request.pickupMethod()) ? request.requestedPickupAt() : null);
order.setReceivingWarehouseId(request.receivingWarehouseId());
order.setReceivingWarehouseName(snapshots.receiving().warehouse().warehouseName());
order.setReceivingWarehouseAddress(formatAddress(snapshots.receiving()));
order.setReceivingTime(formatReceivingTime(snapshots.receiving()));
@@ -422,6 +466,54 @@ 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(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 RuntimeException duplicateInboundCode(OrderEntity order, DuplicateKeyException duplicate) {
if (StringUtils.hasText(order.getInboundCode())) {
return new BizException(ResultCode.CONFLICT, "入仓码已存在,请核对旧系统数据");
}
return duplicate;
}
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);
@@ -506,7 +598,8 @@ public class OrderApplicationService {
order.getTaxMode(), order.getPricingTaxMode(),
order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(),
order.getDestinationWarehouseId(), order.getAmazonWarehouseCode(), order.getDestinationWarehouseName(),
order.getCustomerRemark(), order.getExpressCompany(), order.getExpressTrackingNo(), totalPackageCount);
order.getCustomerRemark(), order.getExpressCompany(), order.getExpressTrackingNo(), totalPackageCount,
order.getPickupMethod(), order.getRequestedPickupAt());
}
private void prepareDraftInbound(OrderEntity order, OrderRequests.SaveRequest request) {
FirstlegInboundClient client = firstlegProvider == null ? null : firstlegProvider.getIfAvailable();
@@ -141,11 +141,21 @@ public class OrderFulfillmentSplitService {
child.setDestinationWarehouseName(root.getDestinationWarehouseName()); child.setReceivingWarehouseId(root.getReceivingWarehouseId());
child.setReceivingWarehouseName(root.getReceivingWarehouseName()); child.setChannelCode(root.getChannelCode());
child.setChannelName(root.getChannelName()); child.setDestinationType(root.getDestinationType());
child.setInboundCode(remainderInboundCode(root, sequence));
child.setCreatedBy(operatorId); child.setUpdatedBy(operatorId);
if (orders.insert(child) != 1) throw new BizException(ResultCode.CONFLICT, "余量子订单创建失败");
return child;
}
private String remainderInboundCode(OrderEntity root, int sequence) {
String rootInboundCode = Optional.ofNullable(root.getInboundCode()).orElse("").trim();
String childInboundCode = rootInboundCode + "-" + sequence;
if (rootInboundCode.isEmpty() || sequence < 1 || childInboundCode.length() > 40) {
throw new BizException(ResultCode.CONFLICT, "根订单入仓码格式无效,无法创建余量子订单");
}
return childInboundCode;
}
private OrderFulfillmentSplitEntity ledger(OrderSplitDtos.CreateRequest r, OrderEntity root, OrderEntity source,
long childId, String role, String hash, long operatorId) {
OrderFulfillmentSplitEntity row = new OrderFulfillmentSplitEntity();
@@ -5,7 +5,6 @@ import jakarta.annotation.Resource;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.PartnerClient;
import com.baiyue.order.client.WarehouseClient;
import com.baiyue.order.dto.OrderRequests;
import feign.FeignException;
@@ -21,13 +20,11 @@ import org.slf4j.LoggerFactory;
public class OrderMasterDataGuard {
private static final Logger log = LoggerFactory.getLogger(OrderMasterDataGuard.class);
@Resource private WarehouseClient warehouses;
@Resource private PartnerClient partners;
@Value("${baiyue.security.internal-service-token}") private String token;
@Value("${baiyue.warehouse.public-tenant-id:0}")
private long publicTenantId = 0L;
public Snapshots validate(long tenantId, OrderRequests.SaveRequest request) {
try {
PartnerClient.Partner partner=request.customerId()==null ? null : loadPartner(request.customerId());
var receiving=loadWarehouse(request.receivingWarehouseId(),"收货仓库");
requireWarehouseType(receiving, "DOMESTIC", "收货仓库");
if (receiving.addresses()==null || receiving.addresses().stream().noneMatch(Objects::nonNull))
@@ -47,7 +44,7 @@ public class OrderMasterDataGuard {
destination=loadWarehouse(request.destinationWarehouseId(),"目的仓库");
requireWarehouseType(destination, request.destinationType(), "目的仓库");
}
return new Snapshots(partner,receiving,destination);
return new Snapshots(receiving,destination);
} catch (FeignException exception) {
throw downstreamFailure(exception);
} catch (BizException exception) {
@@ -56,10 +53,12 @@ public class OrderMasterDataGuard {
throw downstreamFailure(exception);
}
}
public void validatePersisted(long tenantId, Long partnerId, Long receivingWarehouseId,
public void validatePersisted(long tenantId, Long customerId, Long receivingWarehouseId,
Long destinationWarehouseId, String destinationType) {
try {
if (partnerId!=null) loadPartner(partnerId);
if (customerId != null && customerId != tenantId) {
throw new BizException(ResultCode.FORBIDDEN,"订单客户必须是当前登录企业");
}
if (receivingWarehouseId==null) throw new BizException(ResultCode.NOT_FOUND,"收货仓库已停用或不可用");
WarehouseClient.Detail receiving=loadWarehouse(receivingWarehouseId,"收货仓库");
requireWarehouseType(receiving,"DOMESTIC","收货仓库");
@@ -78,25 +77,6 @@ public class OrderMasterDataGuard {
throw downstreamFailure(exception);
}
}
private PartnerClient.Partner loadPartner(long id) {
ApiResponse<PartnerClient.Partner> response;
try {
response=partners.active(token,publicTenantId,id);
} catch (FeignException.NotFound exception) {
throw new BizException(ResultCode.NOT_FOUND,"平台往来单位不存在或不可用");
}
requireSuccessful(response,"往来单位");
if (response.data()==null) throw unavailable("往来单位");
var value=response.data();
if (!Long.valueOf(publicTenantId).equals(value.tenantId()))
throw new BizException(ResultCode.FORBIDDEN,"往来单位不属于平台公共主数据");
if (!"ACTIVE".equals(value.status()))
throw new BizException(ResultCode.CONFLICT,"往来单位已停用");
if (value.types() == null || !value.types().contains("SHIPPER"))
throw new BizException(ResultCode.VALIDATION_FAILED,"所选往来单位不具备发货单位类型");
if (!StringUtils.hasText(value.partnerName())) throw unavailable("往来单位");
return value;
}
private WarehouseClient.Detail loadWarehouse(long id,String name) {
ApiResponse<WarehouseClient.Detail> response;
try {
@@ -139,6 +119,5 @@ public class OrderMasterDataGuard {
throw new BizException(code, message);
}
}
public record Snapshots(PartnerClient.Partner partner, WarehouseClient.Detail receiving,
WarehouseClient.Detail destination) { }
public record Snapshots(WarehouseClient.Detail receiving, WarehouseClient.Detail destination) { }
}
@@ -14,10 +14,22 @@ public class OrderPricingChannelGuard {
@Resource private FirstlegPricingClient client;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public void requireAvailable(long tenantId, long originWarehouseId, String channelCode) {
public String requireAvailable(long tenantId, long originWarehouseId, String channelCode,
String destinationType) {
return validate(tenantId, originWarehouseId, channelCode, destinationType, true);
}
public void requireActive(long tenantId, long originWarehouseId, String channelCode,
String destinationType) {
validate(tenantId, originWarehouseId, channelCode, destinationType, false);
}
private String validate(long tenantId, long originWarehouseId, String channelCode,
String destinationType, boolean requireInboundCodePrefix) {
try {
ApiResponse<Void> response = client.validateChannel(internalToken,
new FirstlegPricingClient.ChannelValidationRequest(tenantId, originWarehouseId, channelCode));
ApiResponse<FirstlegPricingClient.ChannelValidationResponse> response = client.validateChannel(internalToken,
new FirstlegPricingClient.ChannelValidationRequest(
tenantId, originWarehouseId, channelCode, destinationType, requireInboundCodePrefix));
if (response == null) {
throw unavailable();
}
@@ -27,6 +39,11 @@ public class OrderPricingChannelGuard {
? code.message() : response.message();
throw new BizException(code, message);
}
if (response.data() == null || requireInboundCodePrefix
&& (response.data().inboundCodePrefix() == null || response.data().inboundCodePrefix().isBlank())) {
throw new BizException(ResultCode.CONFLICT, "所选渠道未配置入仓码前缀");
}
return response.data().inboundCodePrefix();
} catch (BizException exception) {
throw exception;
} catch (FeignException exception) {
@@ -9,6 +9,7 @@ import com.baiyue.order.dto.OrderDetailResponse;
import com.baiyue.order.dto.UnifiedOrderQuery;
import com.baiyue.order.dto.UnifiedOrderResponse;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.entity.OrderInternalNoteEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.repository.OrderRepository;
@@ -19,10 +20,12 @@ 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;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -56,9 +59,12 @@ public class UnifiedOrderQueryService {
? Map.of()
: summaries(tenantId, source.records().stream().map(OrderEntity::getId).toList());
Map<Long, String> latestNotes = latestNotes(tenantId, source.records().stream().map(OrderEntity::getId).toList());
Map<Long, CargoSummary> cargoSummaries = cargoSummaries(tenantId,
source.records().stream().map(OrderEntity::getId).toList());
Map<Long, Integer> childCounts = childCounts(source.records());
List<UnifiedOrderResponse.Row> rows = source.records().stream()
.map(order -> row(order, inboundByOrder.get(order.getId()), latestNotes.get(order.getId()), childCounts)).toList();
.map(order -> row(order, inboundByOrder.get(order.getId()), latestNotes.get(order.getId()),
childCounts, cargoSummaries.get(order.getId()))).toList();
return new PageResult<>(rows, source.page(), source.size(), source.total(), source.pages());
}
@@ -72,7 +78,7 @@ public class UnifiedOrderQueryService {
? null : firstlegDetail(tenantId, orderId);
String latestNote = orderDetail.internalNotes().isEmpty() ? null : orderDetail.internalNotes().get(0).getContent();
UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary(), latestNote,
childCounts(List.of(order)));
childCounts(List.of(order)), CargoSummary.from(orderDetail.cargoes()));
List<UnifiedOrderResponse.TimelineEvent> timeline = new ArrayList<>();
orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent(
"ORDER", "STATUS", orderMainStatus(item.getToStatus()), item.getAction(), item.getReason(),
@@ -106,10 +112,12 @@ public class UnifiedOrderQueryService {
Map<Long, OrderEntity> orderById = orders.findByIds(tenantId, orderIds).stream()
.collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left));
Map<Long, String> latestNotes = latestNotes(tenantId, orderIds);
Map<Long, CargoSummary> cargoSummaries = cargoSummaries(tenantId, orderIds);
Map<Long, Integer> childCounts = childCounts(List.copyOf(orderById.values()));
List<UnifiedOrderResponse.Row> records = source.records().stream()
.map(summary -> row(summary, orderById.get(summary.inbound().orderId()),
latestNotes.get(summary.inbound().orderId()), childCounts))
latestNotes.get(summary.inbound().orderId()), childCounts,
cargoSummaries.get(summary.inbound().orderId())))
.toList();
return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages());
}
@@ -134,6 +142,13 @@ public class UnifiedOrderQueryService {
OrderInternalNoteEntity::getOrderId, OrderInternalNoteEntity::getContent, (latest, ignored) -> latest));
}
private Map<Long, CargoSummary> cargoSummaries(Long tenantId, List<Long> orderIds) {
if (orderIds.isEmpty()) return Map.of();
return orders.findCargoByOrderIds(tenantId, orderIds).stream()
.collect(Collectors.groupingBy(OrderCargoEntity::getOrderId,
Collectors.collectingAndThen(Collectors.toList(), CargoSummary::from)));
}
private Map<Long, Integer> childCounts(List<OrderEntity> pageOrders) {
if (pageOrders.isEmpty()) return Map.of();
return orders.findChildrenByParentOrders(pageOrders).stream()
@@ -155,7 +170,8 @@ public class UnifiedOrderQueryService {
}
private UnifiedOrderResponse.Row row(OrderEntity order, FirstlegUnifiedOrderClient.Summary summary,
String latestInternalNote, Map<Long, Integer> childCounts) {
String latestInternalNote, Map<Long, Integer> childCounts,
CargoSummary cargo) {
if (summary == null) {
String mainStatus = orderMainStatus(order.getStatus());
return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), mainStatus,
@@ -165,9 +181,14 @@ public class UnifiedOrderQueryService {
order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null,
order.getInboundCode(),
order.getExpectedShipDate(), null, null, null, null, null, null, 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, null, order.getExpressCompany(), order.getExpressTrackingNo(),
string(order.getCreatedBy()), skuNos(cargo), totalQuantity(cargo), order.getShipmentId(), order.getReferenceId(),
null, null, null, null, null,
order.getCustomerRemark(), latestInternalNote,
order.getCreatedAt(), order.getUpdatedAt());
order.getCreatedAt(), order.getUpdatedAt(), order.getRequestedPickupAt());
}
FirstlegUnifiedOrderClient.Inbound inbound = summary.inbound();
return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(),
@@ -175,15 +196,21 @@ public class UnifiedOrderQueryService {
order.getCustomerId(), order.getCustomerName(),
order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(),
order.getExpectedShipDate(), inbound.totalPackageCount(), "STANDARD", inbound.locationCodes(),
inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), measurement(inbound),
order.getExpectedShipDate(), inbound.totalPackageCount(), null, inbound.locationCodes(),
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.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()),
delivery(summary.delivery()), transport(inbound), order.getCustomerRemark(), latestInternalNote,
order.getCreatedAt(), order.getUpdatedAt());
order.getCreatedAt(), order.getUpdatedAt(), inbound.requestedPickupAt());
}
private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary, OrderEntity order,
String latestInternalNote, Map<Long, Integer> childCounts) {
String latestInternalNote, Map<Long, Integer> childCounts,
CargoSummary cargo) {
var inbound = summary.inbound();
return new UnifiedOrderResponse.Row(inbound.orderId(), inbound.tenantId(), inbound.orderNo(),
summary.mainStatus(), summary.nextAction(), relation(order, childCounts),
@@ -191,10 +218,16 @@ public class UnifiedOrderQueryService {
inbound.customerId(), inbound.customerName(), inbound.channelName(), inbound.warehouseId(),
inbound.warehouseName(), inbound.deliveryWarehouseId(), inbound.deliveryWarehouseName(),
inbound.id(), inbound.version(), inbound.inboundCode(), null, inbound.totalPackageCount(),
"STANDARD", inbound.locationCodes(), inbound.actualGrossWeightKg(),
inbound.actualVolumeCbm(), measurement(inbound), preload(summary.preload()), loading(summary.loading()),
null, inbound.locationCodes(), inbound.actualGrossWeightKg(),
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.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()),
delivery(summary.delivery()), transport(inbound), order == null ? null : order.getCustomerRemark(), latestInternalNote,
inbound.createdAt(), inbound.updatedAt());
inbound.createdAt(), inbound.updatedAt(), inbound.requestedPickupAt());
}
private UnifiedOrderResponse.MeasurementSummary measurement(FirstlegUnifiedOrderClient.Inbound inbound) {
@@ -216,8 +249,15 @@ public class UnifiedOrderQueryService {
value.status(), value.completedAt(), value.trackingNo());
}
private UnifiedOrderResponse.TransportSummary transport(FirstlegUnifiedOrderClient.Inbound inbound) {
return new UnifiedOrderResponse.TransportSummary(inbound.loadingStatus(), inbound.containerNo(),
inbound.cabinetNo(), inbound.vesselName(), inbound.voyageNo(), inbound.sailingAt(), inbound.portArrivalAt());
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.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) {
@@ -225,6 +265,36 @@ public class UnifiedOrderQueryService {
return status == OrderStatus.SUBMITTED ? "PENDING_REVIEW"
: ACTIVE_ORDER_STATUSES.contains(status) ? "WAIT_MEASURE" : null;
}
private String string(Long value) {
return value == null ? null : value.toString();
}
private List<String> skuNos(CargoSummary cargo) {
return cargo == null || cargo.skuNos().isEmpty() ? null : cargo.skuNos();
}
private Integer totalQuantity(CargoSummary cargo) {
return cargo == null ? null : cargo.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();
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) {
if (status == null) return null;
return switch (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
+2 -1
View File
@@ -35,7 +35,8 @@ baiyue:
url: ${WAREHOUSE_SERVICE_URL:}
file:
url: ${FILE_SERVICE_URL:}
order:
inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:AUTO}
logging:
file:
name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}"
+65 -2
View File
@@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
order_no VARCHAR(32) NOT NULL COMMENT '客户范围内唯一订单号',
inbound_code VARCHAR(40) NULL COMMENT '客户提交后生成的入仓码,草稿为空',
inbound_code VARCHAR(40) NULL COMMENT '全平台唯一入仓码;来源由全局模式决定,提交后固定',
customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID',
customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照',
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态:DRAFT草稿、SUBMITTED已提交、APPROVED审核通过、ACCEPTED已接单、REJECTED审核驳回、PROCESSING履约中、COMPLETED已完成、CANCELLED已取消',
@@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
expected_ship_date DATE NULL COMMENT '预计发运日期',
customer_remark VARCHAR(1000) NULL COMMENT '客户可见备注',
pickup_method VARCHAR(24) NULL COMMENT '取货方式',
requested_pickup_at DATETIME(3) NULL COMMENT '客户要求平台上门提货的预约时间',
receiving_warehouse_id BIGINT UNSIGNED NULL COMMENT '国内收货仓ID',
receiving_warehouse_name VARCHAR(128) NULL COMMENT '国内收货仓快照',
receiving_warehouse_address VARCHAR(512) NULL COMMENT '国内收货仓完整地址快照',
@@ -53,13 +54,75 @@ CREATE TABLE IF NOT EXISTS ord_order (
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_order_tenant_no (tenant_id, order_no),
UNIQUE KEY uk_order_tenant_inbound_code (tenant_id, inbound_code),
UNIQUE KEY uk_order_inbound_code (inbound_code),
KEY idx_order_tenant_status_time (tenant_id, status, created_at),
KEY idx_order_tenant_role_submitted (tenant_id, order_role, submitted_at),
KEY idx_order_tenant_customer (tenant_id, customer_id),
KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id)
) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态';
-- 既有开发库必须先确认不存在跨客户重复码;若存在则在此处失败,不自动改写业务数据。
SET @order_global_inbound_index_sql = IF(
NOT EXISTS(
SELECT 1 FROM information_schema.statistics
WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order'
AND index_name = 'uk_order_inbound_code'
),
'ALTER TABLE baiyue_order.ord_order ADD UNIQUE KEY uk_order_inbound_code (inbound_code)',
'SELECT 1'
);
PREPARE order_global_inbound_index_stmt FROM @order_global_inbound_index_sql;
EXECUTE order_global_inbound_index_stmt;
DEALLOCATE PREPARE order_global_inbound_index_stmt;
SET @order_tenant_inbound_index_drop_sql = IF(
EXISTS(
SELECT 1 FROM information_schema.statistics
WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order'
AND index_name = 'uk_order_tenant_inbound_code'
),
'ALTER TABLE baiyue_order.ord_order DROP INDEX uk_order_tenant_inbound_code',
'SELECT 1'
);
PREPARE order_tenant_inbound_index_drop_stmt FROM @order_tenant_inbound_index_drop_sql;
EXECUTE order_tenant_inbound_index_drop_stmt;
DEALLOCATE PREPARE order_tenant_inbound_index_drop_stmt;
-- Existing development databases may still have the former NOT NULL contract.
SET @order_inbound_code_nullable_sql = IF(
EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order'
AND column_name = 'inbound_code' AND is_nullable = 'NO'
),
'ALTER TABLE baiyue_order.ord_order MODIFY COLUMN inbound_code VARCHAR(40) NULL COMMENT ''全平台唯一入仓码;来源由全局模式决定,提交后固定''',
'SELECT 1'
);
PREPARE order_inbound_code_nullable_stmt FROM @order_inbound_code_nullable_sql;
EXECUTE order_inbound_code_nullable_stmt;
DEALLOCATE PREPARE order_inbound_code_nullable_stmt;
SET @order_requested_pickup_at_sql = IF(
EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order'
AND column_name = 'requested_pickup_at'
),
'SELECT 1',
'ALTER TABLE baiyue_order.ord_order ADD COLUMN requested_pickup_at DATETIME(3) NULL COMMENT ''客户要求平台上门提货的预约时间'' AFTER pickup_method'
);
PREPARE order_requested_pickup_at_stmt FROM @order_requested_pickup_at_sql;
EXECUTE order_requested_pickup_at_stmt;
DEALLOCATE PREPARE order_requested_pickup_at_stmt;
CREATE TABLE IF NOT EXISTS ord_inbound_code_daily_sequence (
business_date DATE NOT NULL COMMENT '上海时区业务日期',
current_value BIGINT UNSIGNED NOT NULL COMMENT '当天全平台已分配票数',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '当天首次分配时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最近分配时间',
PRIMARY KEY (business_date)
) ENGINE=InnoDB COMMENT='入仓码每日票序;不同客户和物流渠道共用,按上海日期重新从1开始';
CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split (
id BIGINT UNSIGNED NOT NULL COMMENT '拆分台账ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
+87 -12
View File
@@ -6,12 +6,28 @@ info:
security:
- bearerAuth: []
paths:
/api/platform/v1/orders/first-leg/inbound-code-policy:
get:
summary: 平台端查询全系统入仓码模式
description: 与客户下单端返回同一份全局策略,不按客户区分。
responses:
'200':
description: 查询成功
content:
application/json:
schema:
type: object
properties:
code: {type: string, example: '200'}
data: {$ref: '#/components/schemas/InboundCodePolicy'}
'403': {description: 需要平台订单列表、创建、编辑或提交权限之一}
/api/platform/v1/orders:
get:
summary: 平台统一订单列表
description: >-
统一返回七态产品主状态、下一步动作及测量/预装/装柜/派送摘要,需要 platform:order:list。
草稿不生成 inboundCode,客户提交进入待审核后即返回固定 inboundCode;有入库单时同时返回
入仓码来源由全系统统一模式控制:AUTO 在订单提交时按物流渠道生成,格式为物流前缀+YYMMDD+当天全平台票序;
LEGACY_MANUAL 在草稿中录入旧系统入仓码。两种模式下入仓码一经提交均固定;有入库单时同时返回
inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion
才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。
tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询,
@@ -52,7 +68,7 @@ paths:
/api/platform/v1/orders/{orderId}:
get:
summary: 平台统一订单详情
description: 合并 Order 与 Firstleg 时间线并按发生时间升序返回;待审核订单直接返回 Order 在提交时生成的 inboundCode;平台可见内部备注。
description: 合并 Order 与 Firstleg 时间线并按发生时间升序返回;所有状态直接返回 Order 创建时生成的 inboundCode;平台可见内部备注。
parameters:
- $ref: '#/components/parameters/UnifiedOrderId'
- $ref: '#/components/parameters/UnifiedTenantId'
@@ -197,6 +213,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,11 +456,36 @@ 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}
recommendedPalletType: {type: string, 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 入仓费用审核快照;财务状态不在本字段表达}
companyName: {type: string, nullable: true}
businessType: {type: string, nullable: true}
pickupMethod: {type: string, nullable: true}
requestedPickupAt: {type: string, format: date-time, nullable: true, description: Customer-requested pickup appointment from the pickup snapshot}
taxMode: {type: string, nullable: true}
containerMode: {type: string, nullable: true}
customsInspection: {type: boolean, nullable: true}
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 actualNetWeightKg,不以毛重回退}
measurementCompletedAt: {type: string, format: date-time, nullable: true}
inboundCompletedAt: {type: string, format: date-time, nullable: true}
expressCompany: {type: string, nullable: true}
expressTrackingNo: {type: string, nullable: true}
createdBy: {type: string, nullable: true}
skuNos: {type: array, nullable: true, items: {type: string}}
totalQuantity: {type: integer, nullable: true}
shipmentId: {type: string, nullable: true}
referenceId: {type: string, nullable: true}
measurement: {$ref: '#/components/schemas/UnifiedMeasurementSummary'}
preload: {$ref: '#/components/schemas/UnifiedPreloadSummary'}
loading: {$ref: '#/components/schemas/UnifiedLoadingSummary'}
@@ -451,7 +509,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}
@@ -486,13 +544,18 @@ components:
type: object
nullable: true
properties:
loadingStatus: {type: string, nullable: true}
containerNo: {type: string, nullable: true}
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:
@@ -583,6 +646,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
@@ -590,7 +658,14 @@ components:
items: {$ref: '#/components/schemas/FirstLegOrderCargo'}
description: >-
订单保存请求中的完整字段以服务端 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]
@@ -620,7 +695,7 @@ components:
success: {type: boolean}
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
orderNo: {type: string}
inboundCode: {type: string, pattern: '^RK-[A-Za-z0-9-]+$'}
inboundCode: {type: string, pattern: '^[A-Za-z0-9._:-]+$', example: ZBYHK2608140001, description: AUTO 模式为渠道前缀+业务日期+全局票序;LEGACY_MANUAL 模式为录入的旧系统入仓码;履约拆分的子订单追加 splitSequence 后缀}
errorCode: {type: string}
errorMessage: {type: string}
FirstLegOrderCargo:
@@ -721,7 +796,7 @@ components:
type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, LOADING, LOADED, DELIVERED, WAIT_SPLIT]
UnifiedInboundCode:
{name: inboundCode, in: query, required: false, description: 客户提交后生成的入仓码,待审核订单同样可查询, schema: {type: string, maxLength: 40}}
{name: inboundCode, in: query, required: false, description: 订单创建时生成的入仓码,所有订单状态均可查询, schema: {type: string, maxLength: 40}}
UnifiedInternalOrderStatus:
name: internalOrderStatus
in: query
@@ -12,7 +12,6 @@ import static org.assertj.core.api.Assertions.assertThat;
class WarehouseFeignContractTest {
@Test void clientsUseNacosServiceNameAndInternalPathsByDefault() throws Exception {
assertClient(WarehouseClient.class, "/internal/warehouses");
assertClient(PartnerClient.class, "/internal/warehouse-partners");
}
private void assertClient(Class<?> type, String expectedPath) throws Exception {
@@ -0,0 +1,53 @@
package com.baiyue.order.config;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Value;
import java.io.IOException;
import java.lang.reflect.Constructor;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class InboundCodeModeProviderTest {
@Test
void springPlaceholderDefaultsToAutoWhenEnvironmentPropertyIsMissing() {
Constructor<?> constructor = InboundCodeModeProvider.class.getDeclaredConstructors()[0];
Value value = constructor.getParameters()[0].getAnnotation(Value.class);
assertThat(value.value())
.isEqualTo("${baiyue.order.inbound-code-mode:AUTO}");
assertThat(new InboundCodeModeProvider("AUTO").mode())
.isEqualTo(InboundCodeModeProvider.Mode.AUTO);
}
@Test
void applicationConfigurationSupportsOrderModeEnvironmentVariableWithoutNacos() throws IOException {
String applicationYaml;
try (var stream = InboundCodeModeProviderTest.class.getResourceAsStream("/application.yml")) {
assertThat(stream).isNotNull();
applicationYaml = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
}
assertThat(applicationYaml)
.contains("inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:AUTO}");
}
@Test
void parsesBothSupportedModesCaseInsensitively() {
assertThat(new InboundCodeModeProvider("auto").mode())
.isEqualTo(InboundCodeModeProvider.Mode.AUTO);
assertThat(new InboundCodeModeProvider(" legacy_manual ").legacyManual()).isTrue();
}
@Test
void rejectsMissingAndUnknownModesInsteadOfFallingBackToAutomaticGeneration() {
assertThatThrownBy(() -> new InboundCodeModeProvider(" "))
.isInstanceOf(IllegalStateException.class)
.hasMessage("baiyue.order.inbound-code-mode 必须显式配置");
assertThatThrownBy(() -> new InboundCodeModeProvider("LEGACY-MANUAL"))
.isInstanceOf(IllegalStateException.class)
.hasMessage("baiyue.order.inbound-code-mode 仅支持 AUTO 或 LEGACY_MANUAL");
}
}
@@ -0,0 +1,44 @@
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());
var platformMethod = PlatformFirstLegOrderController.class.getDeclaredMethod("inboundCodePolicy");
assertThat(platformMethod.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAnyAuthority('platform:order:list', 'platform:order:create', 'platform:order:edit', 'platform:order:submit')");
assertThat(platformMethod.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("/api/platform/v1/orders/first-leg/inbound-code-policy:")
.contains("enum: [AUTO, LEGACY_MANUAL]")
.contains("manualEntryRequired: {type: boolean}")
.contains("该策略不按客户区分")
.contains("仅 LEGACY_MANUAL 模式填写的旧系统入仓码;AUTO 模式必须省略");
}
}
@@ -3,6 +3,7 @@ package com.baiyue.order.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderInternalNoteEntity;
import com.baiyue.order.repository.OrderRepository;
import org.junit.jupiter.api.Test;
import jakarta.annotation.Resource;
@@ -18,8 +19,11 @@ import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.lang.reflect.Method;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
@SpringJUnitConfig(InternalOrderCargoController.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
class InternalOrderCargoControllerTest {
@@ -41,6 +45,10 @@ class InternalOrderCargoControllerTest {
cargo.setNetWeightKg(new java.math.BigDecimal("18.500"));
cargo.setCargoRemark("内部货物备注");
when(orders.findCargoInheritedByOrder(11L, 21L, 31L)).thenReturn(cargo);
OrderEntity order = new OrderEntity(); order.setId(21L); order.setCustomerRemark("客户订单备注");
OrderInternalNoteEntity note = new OrderInternalNoteEntity(); note.setContent("最新内部备注");
when(orders.findOwned(11L, 21L)).thenReturn(order);
when(orders.findNotes(11L, 21L)).thenReturn(List.of(note));
var response = controller.detail("internal-token", 11L, 21L, 31L);
@@ -53,6 +61,8 @@ class InternalOrderCargoControllerTest {
assertThat(response.data().totalQuantity()).isEqualTo(200);
assertThat(response.data().netWeightKg()).isEqualByComparingTo("18.500");
assertThat(response.data().cargoRemark()).isEqualTo("内部货物备注");
assertThat(response.data().customerRemark()).isEqualTo("客户订单备注");
assertThat(response.data().latestInternalNote()).isEqualTo("最新内部备注");
verify(orders).findCargoInheritedByOrder(11L, 21L, 31L);
}
@@ -96,6 +106,9 @@ class InternalOrderCargoControllerTest {
OrderCargoEntity cargo = cargo(31L, 1, 3, "60", "40", "30");
cargo.setOrderId(21L);
when(orders.findCargoInheritedByOrder(11L, 201L, 31L)).thenReturn(cargo);
OrderEntity child = new OrderEntity(); child.setId(201L);
when(orders.findOwned(11L, 201L)).thenReturn(child);
when(orders.findNotes(11L, 201L)).thenReturn(List.of());
var response = controller.detail("internal-token", 11L, 201L, 31L);
@@ -128,6 +141,57 @@ 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 measurementDeclarationHasAnExplicitStaticRouteBesideNumericCargoDetail() throws Exception {
Method declaration = InternalOrderCargoController.class.getMethod(
"measurementDeclaration", String.class, long.class, long.class);
Method cargoDetail = InternalOrderCargoController.class.getMethod(
"detail", String.class, long.class, long.class, long.class);
assertThat(declaration.getAnnotation(GetMapping.class).value())
.containsExactly("/{tenantId}/{orderId}/measurement-declaration");
assertThat(cargoDetail.getAnnotation(GetMapping.class).value())
.containsExactly("/{tenantId}/{orderId}/{cargoId}");
assertThat(declaration.getParameterCount()).isEqualTo(3);
assertThat(cargoDetail.getParameterCount()).isEqualTo(4);
}
@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();
@@ -52,7 +52,12 @@ class UnifiedOrderApiContractTest {
Map<String, Object> row = stringMap(schemas.get("UnifiedOrderRow"));
assertThat(stringMap(row.get("properties"))).containsKeys(
"orderId", "mainStatus", "inboundId", "inboundVersion", "measurement", "preload",
"loading", "delivery", "transport", "customerRemark", "latestInternalNote");
"loading", "delivery", "transport", "customerRemark", "latestInternalNote",
"expectedArrivalAt", "requestedPickupAt", "feeReviewStatus", "companyName", "businessType", "pickupMethod",
"taxMode", "containerMode", "customsInspection", "destinationType", "destinationCode",
"totalPalletCount", "chargeableWeightKg", "chargeableVolumeCbm", "volumetricWeightKg", "totalNetWeightKg",
"measurementCompletedAt", "inboundCompletedAt", "expressCompany", "expressTrackingNo",
"createdBy", "skuNos", "totalQuantity", "shipmentId", "referenceId");
assertThat(responseSchema(paths, "/api/platform/v1/orders", "get"))
.containsEntry("$ref", "#/components/schemas/UnifiedOrderPageEnvelope");
assertThat(responseSchema(paths, "/api/merchant/v1/orders/{orderId}", "get"))
@@ -54,6 +54,32 @@ class OrderRequestsValidationTest {
assertThat(validator.validate(draft)).isEmpty();
}
@Test
void validatesTheManuallyEnteredInboundCodeForSingleAndBatchCallers() {
var source = request(null, null, "TAX_INCLUDED");
var invalid = withInboundCode(source, "INVALID#CODE");
var tooLong = withInboundCode(source, "A".repeat(41));
var valid = withInboundCode(source, "NYEXX2608140001");
assertThat(validator.validate(invalid))
.anyMatch(violation -> violation.getPropertyPath().toString().equals("inboundCode"));
assertThat(validator.validate(tooLong))
.anyMatch(violation -> violation.getPropertyPath().toString().equals("inboundCode"));
assertThat(validator.validate(valid)).isEmpty();
}
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 carrierId, String carrierName, String pricingTaxMode) {
var cargo = new OrderRequests.Cargo(
"货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE,
@@ -0,0 +1,20 @@
package com.baiyue.order.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class InboundCodeGlobalUniquenessContractTest {
@Test
void orderSchemaUsesOnePlatformWideInboundCodeIndex() throws Exception {
String sql = new ClassPathResource("db/order.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("UNIQUE KEY uk_order_inbound_code (inbound_code)")
.doesNotContain("UNIQUE KEY uk_order_tenant_inbound_code (tenant_id, inbound_code)");
}
}
@@ -0,0 +1,35 @@
package com.baiyue.order.repository;
import com.baiyue.order.mapper.InboundCodeSequenceMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundCodeSequenceRepositoryTest {
@Mock private InboundCodeSequenceMapper mapper;
@Test
void incrementsAndReadsTheCounterForTheSameShanghaiBusinessDate() {
LocalDate businessDate = LocalDate.of(2026, 8, 14);
OrderRepository repository = new OrderRepository();
ReflectionTestUtils.setField(repository, "inboundCodeSequenceMapper", mapper);
when(mapper.increment(businessDate)).thenReturn(2);
when(mapper.currentValue(businessDate)).thenReturn(2L);
assertThat(repository.nextInboundCodeSequence(businessDate)).isEqualTo(2L);
InOrder ordered = inOrder(mapper);
ordered.verify(mapper).increment(businessDate);
ordered.verify(mapper).currentValue(businessDate);
}
}
@@ -0,0 +1,59 @@
package com.baiyue.order.repository;
import com.baiyue.order.mapper.InboundCodeSequenceMapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
class InboundCodeSequenceSqlContractTest {
@Test
void mapperAtomicallyIncrementsOneCounterPerBusinessDate() throws Exception {
Method increment = InboundCodeSequenceMapper.class.getMethod("increment", LocalDate.class);
String incrementSql = normalize(String.join(" ", increment.getAnnotation(Insert.class).value()));
Method current = InboundCodeSequenceMapper.class.getMethod("currentValue", LocalDate.class);
String currentSql = normalize(String.join(" ", current.getAnnotation(Select.class).value()));
assertThat(incrementSql)
.contains("business_date, current_value")
.contains("values (#{businessdate}, 1")
.contains("on duplicate key update")
.contains("current_value = current_value + 1");
assertThat(currentSql)
.contains("where business_date = #{businessdate}")
.doesNotContain("channel_code")
.doesNotContain("tenant_id");
}
@Test
void baselineAndUnifiedSqlDefineTheShanghaiDailyCounter() throws Exception {
String baseline = new ClassPathResource("db/order.sql").getContentAsString(StandardCharsets.UTF_8);
String unified = Files.readString(resolveUnified(), StandardCharsets.UTF_8);
for (String sql : java.util.List.of(baseline, unified)) {
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS ord_inbound_code_daily_sequence")
.contains("business_date DATE NOT NULL")
.contains("current_value BIGINT UNSIGNED NOT NULL")
.contains("PRIMARY KEY (business_date)")
.contains("按上海日期重新从1开始");
}
}
private static String normalize(String sql) {
return sql.replaceAll("\\s+", " ").trim().toLowerCase();
}
private static Path resolveUnified() {
Path direct = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql");
return Files.exists(direct) ? direct : Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql");
}
}
@@ -3,6 +3,7 @@ package com.baiyue.order.repository;
import com.baiyue.order.dto.MerchantInboundOrderQuery;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.mapper.InboundCodeSequenceMapper;
import com.baiyue.order.mapper.OrderCargoMapper;
import com.baiyue.order.mapper.OrderChildPurgeMapper;
import com.baiyue.order.mapper.OrderIdempotencyRecordMapper;
@@ -38,6 +39,7 @@ class MerchantInboundOrderQueryRepositoryTest {
@MockBean private OutboxEventMapper outboxMapper;
@MockBean private OrderChildPurgeMapper purgeMapper;
@MockBean private OrderIdempotencyRecordMapper idempotencyMapper;
@MockBean private InboundCodeSequenceMapper inboundCodeSequenceMapper;
@Resource private OrderRepository repository;
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
@@ -0,0 +1,37 @@
package com.baiyue.order.repository;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class OrderPickupSqlContractTest {
@Test
void moduleSchemaSupportsDraftWithoutInboundCodeAndUpgradesExistingTablesIdempotently() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/order.sql"));
assertThat(sql).contains(
"inbound_code VARCHAR(40) NULL",
"column_name = 'inbound_code' AND is_nullable = 'NO'",
"ALTER TABLE baiyue_order.ord_order MODIFY COLUMN inbound_code VARCHAR(40) NULL",
"column_name = 'requested_pickup_at'",
"ALTER TABLE baiyue_order.ord_order ADD COLUMN requested_pickup_at DATETIME(3) NULL");
}
@Test
void unifiedScriptRelaxesInboundCodeBeforeClearingDraftsAndAddsPickupColumn() throws Exception {
Path direct = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql");
Path path = Files.exists(direct) ? direct : Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql");
String sql = Files.readString(path);
int relax = sql.indexOf("ALTER TABLE baiyue_order.ord_order MODIFY COLUMN inbound_code VARCHAR(40) NULL");
int clearDraft = sql.indexOf("SET inbound_code = NULL");
assertThat(relax).isGreaterThanOrEqualTo(0).isLessThan(clearDraft);
assertThat(sql).contains(
"column_name = 'requested_pickup_at'",
"ALTER TABLE baiyue_order.ord_order ADD COLUMN requested_pickup_at DATETIME(3) NULL",
"CREATE PROCEDURE assert_submitted_order_inbound_code()",
"SIGNAL SQLSTATE '45000'");
assertThat(sql).doesNotContain("CONCAT('RK-', o.id)");
}
}
@@ -2,6 +2,7 @@ package com.baiyue.order.repository;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.mapper.InboundCodeSequenceMapper;
import com.baiyue.order.mapper.OrderCargoMapper;
import com.baiyue.order.mapper.OrderChildPurgeMapper;
import com.baiyue.order.mapper.OrderIdempotencyRecordMapper;
@@ -34,6 +35,7 @@ class PlatformOrderPageRepositoryTest {
@MockBean private OutboxEventMapper outboxMapper;
@MockBean private OrderChildPurgeMapper purgeMapper;
@MockBean private OrderIdempotencyRecordMapper idempotencyMapper;
@MockBean private InboundCodeSequenceMapper inboundCodeSequenceMapper;
@Resource private OrderRepository repository;
private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest(
null, null, null, null, null, null, 1, 20);
@@ -0,0 +1,45 @@
package com.baiyue.order.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.repository.OrderRepository;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Path;
import jakarta.validation.Validator;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class MerchantBatchDraftItemServiceTest {
@Test
void rejectsAnInvalidBatchRowBeforeCreatingTheDraft() {
OrderApplicationService orders = mock(OrderApplicationService.class);
OrderRepository repository = mock(OrderRepository.class);
Validator validator = mock(Validator.class);
@SuppressWarnings("unchecked")
ConstraintViolation<OrderRequests.SaveRequest> violation = mock(ConstraintViolation.class);
Path path = mock(Path.class);
OrderRequests.SaveRequest draft = mock(OrderRequests.SaveRequest.class);
when(path.toString()).thenReturn("inboundCode");
when(violation.getPropertyPath()).thenReturn(path);
when(violation.getMessage()).thenReturn("格式不正确");
when(validator.validate(draft)).thenReturn(Set.of(violation));
MerchantBatchDraftItemService service = new MerchantBatchDraftItemService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "validator", validator);
assertThatThrownBy(() -> service.create(1L, 2L, draft, "batch-key"))
.isInstanceOf(BizException.class)
.hasMessage("inboundCode: 格式不正确");
verify(orders, never()).createDraft(1L, 2L, draft, "batch-key");
}
}
@@ -2,8 +2,8 @@ package com.baiyue.order.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.PartnerClient;
import com.baiyue.order.client.WarehouseClient;
import com.baiyue.order.config.InboundCodeModeProvider;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderCargoEntity;
@@ -22,6 +22,8 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalTime;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
@@ -36,11 +38,12 @@ class OrderApplicationServiceStateTest {
@MockBean private OrderMasterDataGuard contextMasterData;
@MockBean private OrderPricingChannelGuard contextPricingChannelGuard;
@MockBean private ProductImageGuard contextProductImageGuard;
@MockBean private InboundCodeModeProvider contextInboundCodeModeProvider;
@Resource private OrderApplicationService springService;
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
@Bean ObjectMapper objectMapper() { return new ObjectMapper().findAndRegisterModules(); }
}
@Test
void unapprovedTenantCanCreateDraft() {
@@ -57,7 +60,7 @@ class OrderApplicationServiceStateTest {
assertThat(orderId).isNotNull();
verify(eligibility, never()).requireApproved(anyLong());
verify(repository).insertOrder(argThat(order -> order.getStatus() == OrderStatus.DRAFT
&& ("RK-" + order.getId()).equals(order.getInboundCode())));
&& order.getInboundCode() == null));
}
@Test
@@ -105,13 +108,14 @@ class OrderApplicationServiceStateTest {
OrderRequests.SaveRequest request = request();
when(masterData.validate(10001L, request)).thenReturn(snapshots());
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
when(pricing.requireAvailable(10001L, 8L, "CH", "OVERSEAS")).thenReturn("PCHWK");
OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData);
ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing);
service.createDraft(10001L, 2L, request, "validate-channel-on-create");
var ordered = inOrder(pricing, repository);
ordered.verify(pricing).requireAvailable(10001L, 8L, "CH");
ordered.verify(pricing).requireAvailable(10001L, 8L, "CH", "OVERSEAS");
ordered.verify(repository).insertOrder(argThat(order ->
"CH".equals(order.getChannelCode())
&& "TAX_INCLUDED".equals(order.getPricingTaxMode())));
@@ -133,12 +137,13 @@ class OrderApplicationServiceStateTest {
source.amazonWarehouseCode(), source.shipmentId(), source.referenceId());
when(masterData.validate(10001L, request)).thenReturn(snapshots());
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
when(pricing.requireAvailable(10001L, 8L, " ch ", "OVERSEAS")).thenReturn("PCHWK");
OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData);
ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing);
service.createDraft(10001L, 2L, request, "canonical-channel-code");
verify(pricing).requireAvailable(10001L, 8L, " ch ");
verify(pricing).requireAvailable(10001L, 8L, " ch ", "OVERSEAS");
verify(repository).insertOrder(argThat(order ->
"CH".equals(order.getChannelCode())
&& "BUY_DECLARATION".equals(order.getTaxMode())
@@ -182,7 +187,7 @@ class OrderApplicationServiceStateTest {
service.updateDraft(10001L, 2L, 1L, request);
var ordered = inOrder(pricing, repository);
ordered.verify(pricing).requireAvailable(10001L, 8L, "CH");
ordered.verify(pricing).requireAvailable(10001L, 8L, "CH", "OVERSEAS");
ordered.verify(repository).saveOrder(10001L, order);
}
@@ -244,11 +249,79 @@ class OrderApplicationServiceStateTest {
.transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null);
assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED);
assertThat(order.getInboundCode()).isEqualTo("RK-1");
assertThat(order.getInboundCode()).matches("PCHWK\\d{6}0001");
assertThat(order.getSubmittedAt()).isNotNull();
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);
useInboundCodeMode(service, InboundCodeModeProvider.Mode.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 legacyManualModeRejectsAnInboundCodeAlreadyUsedByAnotherCustomer() {
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.inboundCodeExists(eq("NYEXX2608140001"), anyLong())).thenReturn(true);
OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData);
useInboundCodeMode(service, InboundCodeModeProvider.Mode.LEGACY_MANUAL);
assertThatThrownBy(() -> service.createDraft(10001L, 2L, request, "duplicate-global-code"))
.isInstanceOf(BizException.class)
.hasMessage("入仓码已存在,请核对旧系统数据");
verify(repository, never()).insertOrder(any());
}
@Test
void automaticModeRejectsClientSuppliedInboundCodes() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderRequests.SaveRequest request = withInboundCode(request(), "NYEXX2608140001");
when(masterData.validate(10001L, request)).thenReturn(snapshots());
OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData);
assertThatThrownBy(() -> service.createDraft(10001L, 2L, request, "auto-manual-code"))
.isInstanceOf(BizException.class)
.hasMessage("当前系统由新系统自动生成入仓码,请勿人工填写");
verify(repository, never()).insertOrder(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));
useInboundCodeMode(service, InboundCodeModeProvider.Mode.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);
@@ -274,7 +347,7 @@ class OrderApplicationServiceStateTest {
when(repository.findCargo(20002L, 1L)).thenReturn(List.of(completeCargo()));
when(repository.findParties(20002L, 1L)).thenReturn(List.of());
doThrow(new BizException(ResultCode.CONFLICT, "所选渠道已下线,请重新选择"))
.when(pricing).requireAvailable(20002L, 8L, "CH");
.when(pricing).requireAvailable(20002L, 8L, "CH", "OVERSEAS");
OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class),
mock(OrderMasterDataGuard.class));
ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing);
@@ -354,14 +427,14 @@ class OrderApplicationServiceStateTest {
ReflectionTestUtils.setField(service,"repository",repository);
ReflectionTestUtils.setField(service,"eligibilityGuard",eligibility);
ReflectionTestUtils.setField(service,"masterDataGuard",mock(OrderMasterDataGuard.class));
ReflectionTestUtils.setField(service,"json",new ObjectMapper());
ReflectionTestUtils.setField(service,"json",new ObjectMapper().findAndRegisterModules());
assertThatThrownBy(() -> service.transit(10001,2,"u",1,OrderStatus.SUBMITTED,null))
.isInstanceOf(BizException.class).hasMessageContaining("重复提交");
verify(repository,never()).saveOrder(anyLong(), any());
}
@Test
void publicDestinationCanBeSavedWithoutPartnerSenderOrReceiver() {
void publicDestinationUsesAuthenticatedEnterpriseAsCustomerWithoutPartySnapshots() {
OrderRepository repository = mock(OrderRepository.class);
OrderEligibilityGuard eligibility = mock(OrderEligibilityGuard.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
@@ -372,7 +445,7 @@ class OrderApplicationServiceStateTest {
service(repository, eligibility, masterData)
.createDraft(20002L, 2L, request, "public-destination-no-parties");
verify(repository).insertOrder(argThat(order -> order.getCustomerId() == null
verify(repository).insertOrder(argThat(order -> Long.valueOf(20002L).equals(order.getCustomerId())
&& "当前企业".equals(order.getCustomerName())));
verify(repository, never()).insertParty(any());
}
@@ -486,18 +559,41 @@ class OrderApplicationServiceStateTest {
verify(repository, never()).saveOrder(anyLong(), any());
}
@Test
void inboundCodeUsesChannelPrefixShanghaiDateAndDailyTicketSequence() {
assertThat(OrderApplicationService.formatInboundCode("PCHK", LocalDate.of(2026, 8, 14), 1L))
.isEqualTo("PCHK2608140001");
assertThat(OrderApplicationService.formatInboundCode("PCHK", LocalDate.of(2026, 8, 14), 2L))
.isEqualTo("PCHK2608140002");
assertThat(OrderApplicationService.formatInboundCode("PCHK", LocalDate.of(2026, 8, 15), 1L))
.isEqualTo("PCHK2608150001");
assertThat(OrderApplicationService.formatInboundCode("HDHK", LocalDate.of(2026, 8, 14), 10000L))
.isEqualTo("HDHK26081410000");
}
private OrderApplicationService service(OrderRepository repository, OrderEligibilityGuard eligibility,
OrderMasterDataGuard masterData) {
OrderApplicationService service = springService;
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "eligibilityGuard", eligibility);
ReflectionTestUtils.setField(service, "masterDataGuard", masterData);
ReflectionTestUtils.setField(service, "pricingChannelGuard", mock(OrderPricingChannelGuard.class));
OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class);
when(pricing.requireAvailable(anyLong(), anyLong(), anyString(), anyString())).thenReturn("PCHWK");
when(repository.nextInboundCodeSequence(any(LocalDate.class))).thenReturn(1L);
ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing);
ReflectionTestUtils.setField(service, "productImageGuard", mock(ProductImageGuard.class));
ReflectionTestUtils.setField(service, "json", new ObjectMapper());
useInboundCodeMode(service, InboundCodeModeProvider.Mode.AUTO);
ReflectionTestUtils.setField(service, "json", new ObjectMapper().findAndRegisterModules());
return service;
}
private void useInboundCodeMode(OrderApplicationService service, InboundCodeModeProvider.Mode mode) {
InboundCodeModeProvider provider = mock(InboundCodeModeProvider.class);
when(provider.mode()).thenReturn(mode);
when(provider.legacyManual()).thenReturn(mode == InboundCodeModeProvider.Mode.LEGACY_MANUAL);
ReflectionTestUtils.setField(service, "inboundCodeModeProvider", provider);
}
private OrderRequests.SaveRequest request() {
var party = new OrderRequests.Party(
"公司", "联系人", "13800138000", null, "CN", null, "深圳", null, "地址", null);
@@ -508,10 +604,23 @@ class OrderApplicationServiceStateTest {
null, null, null, null, null, null);
return new OrderRequests.SaveRequest(
7L, "客户", "SEA", null, "US", null, "目的仓", null, null,
party, party, List.of(cargo), "PICKUP", 8L, "收货仓", "CH", "渠道", 9L, "船司",
party, party, List.of(cargo), "PICKUP", LocalDateTime.of(2026, 8, 20, 9, 0),
8L, "收货仓", "CH", "渠道", 9L, "船司",
"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) {
@@ -522,7 +631,9 @@ class OrderApplicationServiceStateTest {
null, null, null, null, null, null);
return new OrderRequests.SaveRequest(
customerId, "当前企业", "SEA", null, "US", null, "目的仓", null, null,
sender, receiver, List.of(cargo), pickupMethod, 8L, "收货仓", "CH", "渠道", 9L, "船司",
sender, receiver, List.of(cargo), pickupMethod,
"PICKUP".equals(pickupMethod) ? LocalDateTime.of(2026, 8, 20, 9, 0) : null,
8L, "收货仓", "CH", "渠道", 9L, "船司",
"BUY_DECLARATION", "TAX_INCLUDED", "LCL", false, destinationType, null, null, null);
}
@@ -540,15 +651,15 @@ class OrderApplicationServiceStateTest {
}
private OrderMasterDataGuard.Snapshots snapshots() {
return snapshots(new PartnerClient.Partner(7L, 0L, "客户", "ACTIVE", "SHIPPER", List.of("SHIPPER")));
return snapshots(null);
}
private OrderMasterDataGuard.Snapshots snapshots(PartnerClient.Partner partner) {
private OrderMasterDataGuard.Snapshots snapshots(Object ignored) {
var warehouse = new WarehouseClient.Warehouse(8L, 0L, "CN-SZX-01", "收货仓", "DOMESTIC", "CN", "ACTIVE");
var address = new WarehouseClient.Address("CN", "广东", "深圳", "南山", "518000", "测试路1号", null);
var capability = new WarehouseClient.Capability("RECEIVING", true, LocalTime.of(16, 0));
var receiving = new WarehouseClient.Detail(warehouse, List.of(address), List.of(capability));
return new OrderMasterDataGuard.Snapshots(partner, receiving, null);
return new OrderMasterDataGuard.Snapshots(receiving, null);
}
private OrderEntity persistedDraft(String pickupMethod, String destinationType) {
@@ -53,6 +53,11 @@ class OrderFulfillmentSplitServiceTest {
assertThat(response.loadedChild().sourceOrderId()).isEqualTo(100L);
assertThat(response.loadedChild().loadingPlanId()).isEqualTo(701L);
assertThat(response.remainderChild().containerBookingId()).isEqualTo(801L);
ArgumentCaptor<OrderEntity> childOrder = ArgumentCaptor.forClass(OrderEntity.class);
verify(orders).insert(childOrder.capture());
assertThat(childOrder.getValue().getInboundCode())
.isEqualTo("ZBYHK2608170001-1")
.isNotEqualTo(parent.getInboundCode());
ArgumentCaptor<OrderFulfillmentSplitEntity> rows = ArgumentCaptor.forClass(OrderFulfillmentSplitEntity.class);
verify(splits, times(2)).insert(rows.capture());
assertThat(rows.getAllValues()).allSatisfy(row -> {
@@ -174,6 +179,7 @@ class OrderFulfillmentSplitServiceTest {
OrderEntity child = invocation.getArgument(0);
assertThat(child.getOrderNo()).isEqualTo("ORDER-001-2");
assertThat(child.getParentOrderId()).isEqualTo(100L);
assertThat(child.getInboundCode()).isEqualTo("ZBYHK2608170001-2");
return 1;
});
when(splits.insert(any(OrderFulfillmentSplitEntity.class))).thenReturn(1);
@@ -209,6 +215,7 @@ class OrderFulfillmentSplitServiceTest {
private static OrderEntity parent() {
OrderEntity parent = new OrderEntity();
parent.setId(100L); parent.setTenantId(10L); parent.setOrderNo("ORDER-001");
parent.setInboundCode("ZBYHK2608170001");
parent.setOrderRole("MASTER"); parent.setVersion(3); parent.setStatus(OrderStatus.PROCESSING);
return parent;
}
@@ -3,6 +3,7 @@ package com.baiyue.order.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.config.InboundCodeModeProvider;
import com.baiyue.order.entity.OrderIdempotencyRecordEntity;
import com.baiyue.order.repository.OrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -41,6 +42,7 @@ class OrderIdempotencyServiceTest {
@MockBean private OrderMasterDataGuard contextMasterData;
@MockBean private OrderPricingChannelGuard contextPricingChannelGuard;
@MockBean private ProductImageGuard contextProductImageGuard;
@MockBean private InboundCodeModeProvider contextInboundCodeModeProvider;
@Resource private OrderApplicationService springService;
@Configuration(proxyBeanMethods = false)
@@ -3,241 +3,92 @@ package com.baiyue.order.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.PartnerClient;
import com.baiyue.order.client.WarehouseClient;
import com.baiyue.order.dto.OrderRequests;
import feign.FeignException;
import feign.Request;
import feign.Response;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
@DisplayName("订单主数据校验")
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(OrderMasterDataGuard.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=token")
class OrderMasterDataGuardTest {
@MockBean private WarehouseClient warehouses;
@MockBean private PartnerClient partners;
@Resource private OrderMasterDataGuard guard;
private static final long PUBLIC_TENANT_ID = 0L;
@Test
void validMasterDataReturnsTrustedServerSnapshot() {
var partner=new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"服务端客户名","ACTIVE","SHIPPER",List.of("SHIPPER"));
var address=new WarehouseClient.Address("CN","广东省","深圳市","宝安区","518000","示例路1号",null);
var capability=new WarehouseClient.Capability("RECEIVING",true,java.time.LocalTime.of(16,0));
var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,PUBLIC_TENANT_ID,"CN-SZX-01","深圳仓","DOMESTIC","CN","ACTIVE"),List.of(address),List.of(capability));
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(partner));
when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(wh));
var result=guard.validate(10001,request(7L,8L));
assertThat(result.partner().partnerName()).isEqualTo("服务端客户名");
assertThat(result.receiving().warehouse().warehouseName()).isEqualTo("深圳仓");
}
@Test
void inactiveOrCrossTenantMasterDataIsRejected() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(null));
assertThatThrownBy(() -> guard.validate(10002,request(7L,8L)))
.isInstanceOf(BizException.class).hasMessageContaining("服务暂时不可用");
}
@Test
void downstreamHttp200BusinessFailureKeepsBusinessCodeAndMessage() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(
ApiResponse.<PartnerClient.Partner>failure(ResultCode.NOT_FOUND,"往来单位不存在",null,null));
@MockBean private WarehouseClient warehouses;
@Resource private OrderMasterDataGuard guard;
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
@Test
void validatesWarehousesWithoutTreatingCustomerAsWarehousePartner() {
when(warehouses.active("token", PUBLIC_TENANT_ID, 8L))
.thenReturn(ApiResponse.success(warehouse(8L, "DOMESTIC", true)));
var result = guard.validate(20002L, request(8L, "PRIVATE", null));
assertThat(result.receiving().warehouse().warehouseName()).isEqualTo("仓库8");
verify(warehouses).active("token", PUBLIC_TENANT_ID, 8L);
}
@Test
void persistedCustomerMustMatchAuthenticatedEnterprise() {
assertThatThrownBy(() -> guard.validatePersisted(20002L, 30003L, 8L, null, "PRIVATE"))
.isInstanceOf(BizException.class)
.hasMessage("往来单位不存在")
.hasMessage("订单客户必须是当前登录企业")
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.NOT_FOUND);
verifyNoInteractions(warehouses);
.isEqualTo(ResultCode.FORBIDDEN);
}
@Test
void masterDataResponseWithNullElementReturnsControlledBusinessError() {
var partner=new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"服务端客户名","ACTIVE","SHIPPER",List.of("SHIPPER"));
var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,PUBLIC_TENANT_ID,"CN-SZX-01","深圳仓","DOMESTIC","CN","ACTIVE"),
Collections.singletonList(null), Collections.singletonList(null));
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(partner));
when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(wh));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.VALIDATION_FAILED);
@Test
void customerTenantCanUsePublicDomesticReceivingWarehouse() {
when(warehouses.active("token", PUBLIC_TENANT_ID, 8L))
.thenReturn(ApiResponse.success(warehouse(8L, "DOMESTIC", true)));
guard.validate(20002L, request(8L, "PRIVATE", null));
verify(warehouses).active("token", PUBLIC_TENANT_ID, 8L);
}
@Test
void masterDataResponseMissingSnapshotNameIsServiceUnavailable() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,null,"ACTIVE","SHIPPER",List.of("SHIPPER"))));
void destinationWarehouseTypeMustMatchDestinationType() {
when(warehouses.active("token", PUBLIC_TENANT_ID, 8L))
.thenReturn(ApiResponse.success(warehouse(8L, "DOMESTIC", true)));
when(warehouses.active("token", PUBLIC_TENANT_ID, 9L))
.thenReturn(ApiResponse.success(warehouse(9L, "OVERSEAS", false)));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
verifyNoInteractions(warehouses);
}
@Test
void downstreamNonFeignRuntimeExceptionMapsToServiceUnavailable() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(new IllegalStateException("解码失败"));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
}
@Test
void downstreamForbiddenMapsToServiceUnavailable() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(feign(403));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
}
@Test
void downstreamHttpNotFoundReturnsPlatformPartnerUnavailableMessage() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(feign(404));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.hasMessage("平台往来单位不存在或不可用")
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.NOT_FOUND);
}
@Test
void wrappedDownstreamHttpConflictMapsToServiceUnavailable() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(new IllegalStateException("wrapped", feign(409)));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
}
@Test
void downstreamServerErrorMapsToServiceUnavailable() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(feign(500));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
}
@Test
void inactiveMasterDataInSuccessfulResponseMapsToConflict() {
when(partners.active("token",PUBLIC_TENANT_ID,7))
.thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"客户","INACTIVE","SHIPPER",List.of("SHIPPER"))));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
}
@Test
void partnerWithoutShipperTypeIsRejected() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(
new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"收货单位","ACTIVE","CONSIGNEE",List.of("CONSIGNEE"))));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.VALIDATION_FAILED);
verifyNoInteractions(warehouses);
}
@Test
void customerTenantCanUseDomesticWarehouseFromPublicTenant() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(
new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"客户","ACTIVE","SHIPPER",List.of("SHIPPER"))));
when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(
warehouse(8L,"DOMESTIC",true)));
guard.validate(20002,request(7L,8L));
verify(warehouses).active("token",PUBLIC_TENANT_ID,8);
}
@Test
void customersAAndBUseTheSamePlatformWarehouseAndPartner() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(
new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"平台共享单位","ACTIVE","SHIPPER",List.of("SHIPPER"))));
when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(
warehouse(8L,"DOMESTIC",true)));
guard.validate(20002,request(7L,8L));
guard.validate(30003,request(7L,8L));
verify(partners,times(2)).active("token",PUBLIC_TENANT_ID,7);
verify(warehouses,times(2)).active("token",PUBLIC_TENANT_ID,8);
}
@Test
void orderWithoutPartnerUsesPublicWarehouseWithoutPartnerLookup() {
when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(
warehouse(8L,"DOMESTIC",true)));
var result=guard
.validate(20002,request(null,8L));
assertThat(result.partner()).isNull();
verifyNoInteractions(partners);
verify(warehouses).active("token",PUBLIC_TENANT_ID,8);
}
@Test
void fbaDestinationMustReferenceMatchingPublicWarehouse() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(
new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"客户","ACTIVE","SHIPPER",List.of("SHIPPER"))));
when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(
warehouse(8L,"DOMESTIC",true)));
when(warehouses.active("token",PUBLIC_TENANT_ID,9)).thenReturn(ApiResponse.success(
warehouse(9L,"OVERSEAS",false)));
assertThatThrownBy(() -> guard
.validate(20002,request(7L,8L,"FBA",9L)))
assertThatThrownBy(() -> guard.validate(20002L, request(8L, "FBA", 9L)))
.isInstanceOf(BizException.class)
.hasMessageContaining("目的仓库类型必须为 FBA");
}
@Test
void privateDestinationCannotReferencePublicWarehouse() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(
new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"客户","ACTIVE","SHIPPER",List.of("SHIPPER"))));
when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(
warehouse(8L,"DOMESTIC",true)));
assertThatThrownBy(() -> guard
.validate(20002,request(7L,8L,"PRIVATE",9L)))
.isInstanceOf(BizException.class)
.hasMessageContaining("私人地址不能引用公共仓库");
verify(warehouses,never()).active("token",PUBLIC_TENANT_ID,9);
private OrderRequests.SaveRequest request(long receivingWarehouseId, String destinationType,
Long destinationWarehouseId) {
var party = new OrderRequests.Party("公司", "联系人", "13800138000", null,
"CN", null, "深圳", null, "地址", null);
var cargo = new OrderRequests.Cargo("货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE,
null, null, null, null, null, false, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null);
return new OrderRequests.SaveRequest(null, "当前企业", "SEA", null, "CN", destinationWarehouseId,
"目的仓", null, null, party, party, List.of(cargo), "PICKUP", receivingWarehouseId,
"收货仓", "CH", "渠道", null, null, "BUY_DECLARATION", null, "LCL", false,
destinationType, null, null, null);
}
private FeignException feign(int status) {
Request request = Request.create(Request.HttpMethod.GET, "http://warehouse-service/internal/test",
Map.of(), null, StandardCharsets.UTF_8, null);
Response response = Response.builder().status(status).reason("test").request(request)
.headers(Map.of()).build();
return FeignException.errorStatus("masterData", response);
}
private OrderRequests.SaveRequest request(Long partner,Long warehouse) {
return request(partner,warehouse,"PRIVATE",null);
}
private OrderRequests.SaveRequest request(Long partner,Long warehouse,String destinationType,Long destinationWarehouseId) {
var party=new OrderRequests.Party("公司","联系人","13800138000",null,"CN",null,"深圳",null,"地址",null);
var cargo=new OrderRequests.Cargo("货物",null,"CARTON",1,BigDecimal.ONE,BigDecimal.ONE,
null,null,null,null,null,false,null,null,null,null,null,null,null,null,null,null,null,null,null,
null,null,null,null,null,null);
return new OrderRequests.SaveRequest(partner,"不可信客户名","SEA",null,"CN",destinationWarehouseId,"私人地址",null,null,party,party,List.of(cargo),"PICKUP",warehouse,"不可信收货仓","CH","渠道",null,null,"BUY_DECLARATION",null,"LCL",false,destinationType,null,null,null);
}
private WarehouseClient.Detail warehouse(long id,String type,boolean receiving) {
var value=new WarehouseClient.Warehouse(id,PUBLIC_TENANT_ID,"WH-"+id,"仓库"+id,type,"CN","ACTIVE");
var address=new WarehouseClient.Address("CN","广东省","深圳市",null,null,"示例地址",null);
var capabilities=receiving
? List.of(new WarehouseClient.Capability("RECEIVING",true,null))
private WarehouseClient.Detail warehouse(long id, String type, boolean receiving) {
var value = new WarehouseClient.Warehouse(id, PUBLIC_TENANT_ID, "WH-" + id, "仓库" + id,
type, "CN", "ACTIVE");
var address = new WarehouseClient.Address("CN", "广东省", "深圳市", null, null, "示例地址", null);
var capabilities = receiving
? List.of(new WarehouseClient.Capability("RECEIVING", true, null))
: List.<WarehouseClient.Capability>of();
return new WarehouseClient.Detail(value,List.of(address),capabilities);
return new WarehouseClient.Detail(value, List.of(address), capabilities);
}
}
@@ -18,6 +18,7 @@ import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -30,21 +31,34 @@ class OrderPricingChannelGuardTest {
@Test
void forwardsCustomerOriginAndChannelToFirstleg() {
var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK");
when(client.validateChannel("token", request)).thenReturn(ApiResponse.success(null));
var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK", "FBA", true);
when(client.validateChannel("token", request)).thenReturn(ApiResponse.success(
new FirstlegPricingClient.ChannelValidationResponse("MATSON_TRUCK", "ZBYHK")));
guard.requireAvailable(20002L, 8L, "MATSON_TRUCK");
assertThat(guard.requireAvailable(20002L, 8L, "MATSON_TRUCK", "FBA")).isEqualTo("ZBYHK");
verify(client).validateChannel("token", request);
}
@Test
void legacyManualModeOnlyRequiresTheChannelToRemainActive() {
var request = new FirstlegPricingClient.ChannelValidationRequest(
20002L, 8L, "LEGACY_CHANNEL", "FBA", false);
when(client.validateChannel("token", request)).thenReturn(ApiResponse.success(
new FirstlegPricingClient.ChannelValidationResponse("LEGACY_CHANNEL", null)));
guard.requireActive(20002L, 8L, "LEGACY_CHANNEL", "FBA");
verify(client).validateChannel("token", request);
}
@Test
void removedOrForgedChannelKeepsFirstlegBusinessError() {
var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "FAKE");
var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "FAKE", "FBA", true);
when(client.validateChannel("token", request)).thenReturn(
ApiResponse.failure(ResultCode.CONFLICT, "所选渠道已下线,请重新选择", null, null));
assertThatThrownBy(() -> guard.requireAvailable(20002L, 8L, "FAKE"))
assertThatThrownBy(() -> guard.requireAvailable(20002L, 8L, "FAKE", "FBA"))
.isInstanceOf(BizException.class)
.hasMessage("所选渠道已下线,请重新选择")
.extracting(exception -> ((BizException) exception).getResultCode())
@@ -53,18 +67,18 @@ class OrderPricingChannelGuardTest {
@Test
void nullResponseMapsToServiceUnavailable() {
var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK");
var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK", "FBA", true);
when(client.validateChannel("token", request)).thenReturn(null);
assertUnavailable(() -> guard.requireAvailable(20002L, 8L, "MATSON_TRUCK"));
assertUnavailable(() -> guard.requireAvailable(20002L, 8L, "MATSON_TRUCK", "FBA"));
}
@Test
void downstreamHttpFailureMapsToServiceUnavailable() {
var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK");
var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK", "FBA", true);
when(client.validateChannel("token", request)).thenThrow(feign(403));
assertUnavailable(() -> guard.requireAvailable(20002L, 8L, "MATSON_TRUCK"));
assertUnavailable(() -> guard.requireAvailable(20002L, 8L, "MATSON_TRUCK", "FBA"));
}
private void assertUnavailable(org.assertj.core.api.ThrowableAssert.ThrowingCallable invocation) {
@@ -1,6 +1,7 @@
package com.baiyue.order.service;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.config.InboundCodeModeProvider;
import com.baiyue.order.repository.OrderRepository;
import org.junit.jupiter.api.Test;
import jakarta.annotation.Resource;
@@ -20,6 +21,7 @@ class PlatformOrderPageServiceTest {
@MockBean private OrderMasterDataGuard masterDataGuard;
@MockBean private OrderPricingChannelGuard pricingChannelGuard;
@MockBean private ProductImageGuard productImageGuard;
@MockBean private InboundCodeModeProvider inboundCodeModeProvider;
@Resource private OrderApplicationService service;
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@@ -1,6 +1,7 @@
package com.baiyue.order.service;
import com.baiyue.order.repository.OrderRepository;
import com.baiyue.order.config.InboundCodeModeProvider;
import org.junit.jupiter.api.Test;
import jakarta.annotation.Resource;
import org.springframework.boot.test.mock.mockito.MockBean;
@@ -23,6 +24,7 @@ class TenantOrderCountServiceTest {
@MockBean private OrderMasterDataGuard masterDataGuard;
@MockBean private OrderPricingChannelGuard pricingChannelGuard;
@MockBean private ProductImageGuard productImageGuard;
@MockBean private InboundCodeModeProvider inboundCodeModeProvider;
@Resource private OrderApplicationService service;
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@@ -1,6 +1,7 @@
package com.baiyue.order.service;
import com.baiyue.order.dto.TenantOrderStatisticsResponse;
import com.baiyue.order.config.InboundCodeModeProvider;
import com.baiyue.order.repository.OrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
@@ -27,6 +28,7 @@ class TenantOrderStatisticsServiceTest {
@MockBean private OrderMasterDataGuard masterDataGuard;
@MockBean private OrderPricingChannelGuard pricingChannelGuard;
@MockBean private ProductImageGuard productImageGuard;
@MockBean private InboundCodeModeProvider inboundCodeModeProvider;
@Resource private OrderApplicationService service;
@Configuration(proxyBeanMethods = false)
@@ -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;
@@ -27,6 +30,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -73,12 +77,77 @@ 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(
null, null, "PENDING_REVIEW", null, null, null, "RK-77", null,
null, null, null, null, null, null, null, null, null, null,
null, null, 1, 20);
null, null, null, null, null, null, null, null, null,
null, null, null, 1, 20);
when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED))))
.thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
@@ -167,12 +236,32 @@ class UnifiedOrderQueryServiceTest {
assertThat(row.loading().loadingVersion()).isEqualTo(3);
assertThat(row.loading().pickingVersion()).isEqualTo(3);
assertThat(row.inboundCode()).isEqualTo("RK-77");
assertThat(row.recommendedPalletType()).isEqualTo("STANDARD");
assertThat(row.recommendedPalletType()).isNull();
assertThat(row.locationCodes()).isEqualTo("A0-01, B2-03");
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");
@@ -192,6 +281,61 @@ class UnifiedOrderQueryServiceTest {
assertThat(captor.getValue().mainStatus()).isEqualTo("WAIT_SPLIT");
}
@Test
void listKeepsLoadedFulfillmentAheadOfRawApprovedOrderStatus() {
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()))
.thenReturn(ApiResponse.success(List.of(summary)));
var result = service.page(10001L, query);
assertThat(result.records()).singleElement().satisfies(row -> {
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);
verify(firstleg).summaries(eq("internal-token"), captor.capture());
assertThat(captor.getValue().tenantId()).isEqualTo(10001L);
assertThat(captor.getValue().orderIds()).containsExactly(77L);
}
@Test
void detailKeepsLoadedFulfillmentAheadOfRawApprovedOrderStatus() {
OrderEntity order = order(OrderStatus.APPROVED);
when(orders.findOwned(10001L, 77L)).thenReturn(order);
when(orders.findCargo(10001L, 77L)).thenReturn(List.of());
when(orders.findParties(10001L, 77L)).thenReturn(List.of());
when(orders.findNotes(10001L, 77L)).thenReturn(List.of());
when(orders.findHistory(10001L, 77L)).thenReturn(List.of());
var fulfillmentDetail = new FirstlegUnifiedOrderClient.Detail(summary("LOADED", null), List.of());
when(firstleg.detail(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(fulfillmentDetail));
var result = service.detail(10001L, 77L, false);
assertThat(result.summary().mainStatus()).isEqualTo("LOADED");
assertThat(result.summary().internalOrderStatus()).isEqualTo(OrderStatus.APPROVED);
assertThat(result.summary().inboundId()).isEqualTo(88L);
ArgumentCaptor<FirstlegUnifiedOrderClient.DetailRequest> captor =
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.DetailRequest.class);
verify(firstleg).detail(eq("internal-token"), captor.capture());
assertThat(captor.getValue().tenantId()).isEqualTo(10001L);
assertThat(captor.getValue().orderId()).isEqualTo(77L);
}
@Test
void fulfillmentTabsForwardStatusSearchAndPaginationToFirstleg() {
List<String> statuses = List.of(
@@ -275,11 +419,33 @@ class UnifiedOrderQueryServiceTest {
}
private FirstlegUnifiedOrderClient.Summary summary(String status, String action) {
var inbound = new FirstlegUnifiedOrderClient.Inbound(
88L, 10001L, 77L, "FL-77", "RK-77", "INBOUNDED", "APPROVED", "APPROVED", 7,
30001L, "测试客户", 11L, "深圳仓", 22L, "ONT8", "美森", 10, "A0-01, B2-03", null, null, null,
null, null, null, null, null, null, null, null, null,
LocalDateTime.of(2026, 8, 6, 10, 0), LocalDateTime.of(2026, 8, 6, 11, 0));
var inbound = mock(FirstlegUnifiedOrderClient.Inbound.class);
when(inbound.id()).thenReturn(88L);
when(inbound.tenantId()).thenReturn(10001L);
when(inbound.orderId()).thenReturn(77L);
when(inbound.orderNo()).thenReturn("FL-77");
when(inbound.inboundCode()).thenReturn("RK-77");
when(inbound.processStatus()).thenReturn("INBOUNDED");
when(inbound.orderStatusSnapshot()).thenReturn("APPROVED");
when(inbound.measureReviewStatus()).thenReturn("APPROVED");
when(inbound.version()).thenReturn(7);
when(inbound.customerId()).thenReturn(30001L);
when(inbound.customerName()).thenReturn("测试客户");
when(inbound.warehouseId()).thenReturn(11L);
when(inbound.warehouseName()).thenReturn("深圳仓");
when(inbound.deliveryWarehouseId()).thenReturn(22L);
when(inbound.deliveryWarehouseName()).thenReturn("ONT8");
when(inbound.channelName()).thenReturn("美森");
when(inbound.totalPackageCount()).thenReturn(10);
when(inbound.locationCodes()).thenReturn("A0-01, B2-03");
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,
new FirstlegUnifiedOrderClient.Preload("PENDING", 1, 2, null),
new FirstlegUnifiedOrderClient.Loading("IN_PROGRESS", 3, 3, 10, 5, 1, "SEAL-1", null), null);