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