diff --git a/deploy/maven/settings-cn.xml b/deploy/maven/settings-cn.xml
index 332010ae1..fb58f7035 100644
--- a/deploy/maven/settings-cn.xml
+++ b/deploy/maven/settings-cn.xml
@@ -2,38 +2,4 @@
-
-
- aliyun-public
- 阿里云 Maven 公共镜像
- https://maven.aliyun.com/repository/public
- central
-
-
-
-
-
- 国内公共镜像
-
-
- central
- https://repo.maven.apache.org/maven2
- true
- false
-
-
-
-
- central
- https://repo.maven.apache.org/maven2
- true
- false
-
-
-
-
-
-
- 国内公共镜像
-
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index 6ad079e5b..654446e8a 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -8060,7 +8060,7 @@ CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_customer_dimension_config (
inner_length_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内长(厘米)',
inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)',
inner_height_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内高(厘米)',
- edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数',
+ edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '长宽高各自增加一次的厘米数',
default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ADD' COMMENT '订单外箱规默认计算方式:ADD或MULTIPLY',
remark VARCHAR(500) NULL COMMENT '备注',
initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属',
@@ -13269,7 +13269,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'bo
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'created_at', '创建时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'created_by', '创建人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'deleted', '逻辑删除');
-CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'edge_addition_cm', '每个尺寸方向的单侧加宽厘米数');
+CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'edge_addition_cm', '长宽高各自增加一次的厘米数');
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'default_calculation_mode', '订单外箱规默认计算方式:ADD或MULTIPLY');
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'id', '配置ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'initialization_key', '创建流程幂等键及补偿归属');
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java
index 1b80a9684..e12b0974f 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java
@@ -1,6 +1,6 @@
package com.baiyue.firstleg.controller;
-import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser;
+import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.SmartLoadingDtos.*; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.SmartLoadingService;
import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; import jakarta.annotation.Resource; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List;
@@ -13,7 +13,9 @@ public class PlatformSmartLoadingController {
@GetMapping("/candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse> candidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long bookingId,@RequestParam @Positive long warehouseId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.candidates(bookingId,warehouseId));}
@GetMapping("/inbound-candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
- public ApiResponse> inboundCandidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long bookingId,@RequestParam @Positive long warehouseId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.inboundCandidates(bookingId,warehouseId));}
+ public ApiResponse> inboundCandidates(@AuthenticationPrincipal AuthenticatedUser u,@Valid InboundCandidateQuery q){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.inboundCandidates(q));}
+ @GetMapping("/containers") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
+ public ApiResponse> containers(@AuthenticationPrincipal AuthenticatedUser u,@Valid WorkbenchContainerQuery q){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.containers(q));}
@GetMapping("/plans/by-booking/{bookingId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse draftByBooking(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long bookingId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.draftByBooking(bookingId));}
@GetMapping("/plans/{planId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java
index 506f7c6d6..dcada340d 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java
@@ -1,15 +1,48 @@
package com.baiyue.firstleg.dto;
-import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List;
+import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List;
public final class SmartLoadingDtos { private SmartLoadingDtos(){}
public record ChannelSummary(String channelCode, String channelName,
long candidateInboundCount, long availablePackageCount, long availableContainerCount) {}
+ public enum CandidateOrderStatus { PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING }
+ public record InboundCandidateQuery(
+ @NotEmpty @Size(max=9) List<@NotBlank String> channelGroupCodes,
+ @Positive Long warehouseId, @Positive Long customerId, @Size(max=40) String inboundCode,
+ LocalDate expectedArrivalStart, LocalDate expectedArrivalEnd, CandidateOrderStatus orderStatus,
+ @Min(1) long page, @Min(1) @Max(200) long size) {
+ public InboundCandidateQuery {
+ channelGroupCodes = channelGroupCodes == null ? List.of() : List.copyOf(channelGroupCodes);
+ if (page == 0) page = 1;
+ if (size == 0) size = 20;
+ }
+ }
+ public record GroupMemberCandidate(Long accountId, Long inboundId, Long orderId, String orderNo,
+ String inboundCode, String customerName, Integer availablePackageCount,
+ String orderStatusSnapshot, LocalDateTime expectedArrivalAt) {}
public record InboundCandidate(Long accountId, Long inboundId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsSummary,
Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg,
BigDecimal averageUnitVolumeCbm, BigDecimal availableVolumeCbm,
Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes,
- Boolean locked, String blockReason, String orderStatusSnapshot, LocalDateTime expectedArrivalAt) {}
+ Boolean locked, String blockReason, String orderStatusSnapshot, LocalDateTime expectedArrivalAt,
+ List groupMembers) {
+ public InboundCandidate { groupMembers = groupMembers == null ? List.of() : List.copyOf(groupMembers); }
+ public Long customerId() { return accountId; }
+ }
+ public record WorkbenchContainerQuery(
+ @NotEmpty @Size(max=9) List<@NotBlank String> channelGroupCodes,
+ @Positive Long warehouseId, @Positive Long customerId, @Size(max=40) String inboundCode,
+ LocalDate expectedArrivalStart, LocalDate expectedArrivalEnd, CandidateOrderStatus orderStatus,
+ @Min(1) long page, @Min(1) @Max(200) long size) {
+ public WorkbenchContainerQuery {
+ channelGroupCodes = channelGroupCodes == null ? List.of() : List.copyOf(channelGroupCodes);
+ if (page == 0) page = 1;
+ if (size == 0) size = 20;
+ }
+ }
+ public record WorkbenchContainer(Long bookingId, String bookingNo, String cabinetNo, String containerNo,
+ Long warehouseId, String warehouseName, String channelCode, String channelName, String containerType,
+ Long planId, String planNo, String planStatus, Integer version) {}
public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount,
BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm,
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerDimensionConfigEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerDimensionConfigEntity.java
index 4ed2769d6..3633b203a 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerDimensionConfigEntity.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerDimensionConfigEntity.java
@@ -10,7 +10,7 @@ import lombok.Setter;
import java.math.BigDecimal;
-/** 客户固定箱规的内径和每侧加宽配置。 */
+/** 客户固定箱规的内径和长、宽、高尺寸增加值配置。 */
@Getter
@Setter
@TableName("fl_customer_dimension_config")
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java
index 11796953d..1deeb274d 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java
@@ -1,5 +1,5 @@
package com.baiyue.firstleg.mapper;
-import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.ibatis.annotations.*; import java.util.List;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainer; import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery; import org.apache.ibatis.annotations.*; import java.util.List; import java.util.Set;
@Mapper public interface SmartLoadingCandidateMapper {
@Select("""
SELECT b.channel_code channelCode,COUNT(DISTINCT b.id) availableContainerCount
@@ -83,6 +83,95 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
""")
List candidates(@Param("warehouseId") long warehouseId);
+ @Select("""
+ SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
+ o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
+ o.warehouse_id warehouseId,o.channel_code channelCode,o.channel_name channelName,o.destination_warehouse_id destinationWarehouseId,
+ o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName,
+ o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName,
+ i.package_count-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
+ WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id
+ AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
+ -COALESCE((SELECT SUM(split_item.package_count) FROM fl_inbound_split_item split_item
+ WHERE o.inbound_role='MASTER' AND split_item.tenant_id=o.tenant_id
+ AND split_item.parent_inbound_id=o.id
+ AND split_item.source_inbound_detail_id=i.inbound_detail_id),0) availablePackageCount,
+ COALESCE(o.actual_gross_weight_kg / NULLIF(o.total_package_count, 0), 0) unitGrossWeightKg,
+ COALESCE(o.actual_net_weight_kg / NULLIF(o.total_package_count, 0), 0) unitNetWeightKg,
+ COALESCE(i.unit_volume_cbm,0) unitVolumeCbm,
+ NULL coLoadGroupId,NULL groupNo,NULL memberCount,NULL groupTotalBoxes,
+ NULL locked,NULL blockReason,o.order_status_snapshot orderStatusSnapshot,o.expected_arrival_at expectedArrivalAt
+ FROM fl_inbound_order o
+ JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id
+ AND m.status='APPROVED'
+ AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2
+ WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED')
+ JOIN (
+ SELECT tenant_id,measurement_id,inbound_detail_id,SUM(package_count) package_count,
+ COALESCE(SUM(COALESCE(total_volume_cbm,COALESCE(unit_volume_cbm,0)*package_count))
+ / NULLIF(SUM(package_count),0),0) unit_volume_cbm
+ FROM fl_inbound_measurement_item
+ WHERE inbound_detail_id IS NOT NULL AND package_count>0
+ GROUP BY tenant_id,measurement_id,inbound_detail_id
+ ) i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
+ WHERE (#{warehouseId} IS NULL OR o.warehouse_id=#{warehouseId})
+ AND o.process_status='WAIT_SPLIT'
+ AND o.actual_gross_weight_kg IS NOT NULL AND o.actual_net_weight_kg IS NOT NULL
+ AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT'))
+ AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
+ WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id
+ AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
+ +COALESCE((SELECT SUM(split_item.package_count) FROM fl_inbound_split_item split_item
+ WHERE o.inbound_role='MASTER' AND split_item.tenant_id=o.tenant_id
+ AND split_item.parent_inbound_id=o.id
+ AND split_item.source_inbound_detail_id=i.inbound_detail_id),0)
+ ORDER BY o.inbounded_at ASC, o.id ASC, i.inbound_detail_id ASC
+ """)
+ List globalCandidates(@Param("warehouseId") Long warehouseId);
+
+ @Select("""
+
+ """)
+ Page workbenchContainers(Page page,
+ @Param("query") WorkbenchContainerQuery query,
+ @Param("actualChannels") Set actualChannels,
+ @Param("orderStatus") String orderStatus);
+
@Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java
index ec03685cc..569af78e2 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java
@@ -2,6 +2,10 @@ package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
+import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainer;
+import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery;
+import com.baiyue.common.core.api.PageResult;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
@@ -19,6 +23,7 @@ import jakarta.annotation.Resource;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import java.util.List;
+import java.util.Set;
/** 智能装柜聚合的真实 MySQL 持久化边界。 */
@Repository
@@ -32,6 +37,13 @@ public class SmartLoadingRepository {
@Resource private InboundOrderMapper inbounds;
public List candidates(long warehouseId) { return candidates.candidates(warehouseId); }
+ public List globalCandidates(Long warehouseId) { return candidates.globalCandidates(warehouseId); }
+ public PageResult workbenchContainers(WorkbenchContainerQuery query,
+ Set actualChannels) {
+ return PageResult.from(candidates.workbenchContainers(
+ new Page<>(query.page(), query.size()), query, actualChannels,
+ query.orderStatus() == null ? null : query.orderStatus().name()));
+ }
public List channelContainerCounts(long warehouseId) {
return candidates.channelContainerCounts(warehouseId);
}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java
index 6dd1b7f84..7a7311f88 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java
@@ -27,7 +27,6 @@ import java.util.Map;
public class CustomerDimensionConfigService {
public static final BigDecimal DEFAULT_EDGE_ADDITION_CM = new BigDecimal("2.000");
public static final String DEFAULT_CALCULATION_MODE = "ADD";
- private static final BigDecimal TWO = new BigDecimal("2");
private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000");
@Resource
@@ -116,7 +115,7 @@ public class CustomerDimensionConfigService {
requirePositive(rule.innerLengthCm(), "箱规内长");
requirePositive(rule.innerWidthCm(), "箱规内宽");
requirePositive(rule.innerHeightCm(), "箱规内高");
- requirePositive(rule.edgeAdditionCm(), "箱规单侧加宽");
+ requirePositive(rule.edgeAdditionCm(), "箱规尺寸增加值");
if (rule.version() == null || rule.version() < 0) {
throw new BizException(ResultCode.BAD_REQUEST, "箱规版本号无效");
}
@@ -223,10 +222,10 @@ public class CustomerDimensionConfigService {
}
private static RuleResponse ruleResponse(CustomerDimensionConfigEntity value) {
- BigDecimal edgePair = value.getEdgeAdditionCm().multiply(TWO);
- BigDecimal outerLength = value.getInnerLengthCm().add(edgePair);
- BigDecimal outerWidth = value.getInnerWidthCm().add(edgePair);
- BigDecimal outerHeight = value.getInnerHeightCm().add(edgePair);
+ BigDecimal addition = value.getEdgeAdditionCm();
+ BigDecimal outerLength = value.getInnerLengthCm().add(addition);
+ BigDecimal outerWidth = value.getInnerWidthCm().add(addition);
+ BigDecimal outerHeight = value.getInnerHeightCm().add(addition);
BigDecimal volume = outerLength.multiply(outerWidth).multiply(outerHeight)
.divide(CUBIC_CENTIMETERS_PER_CBM)
.setScale(3, RoundingMode.HALF_UP);
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionWeightingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionWeightingService.java
index d829a013e..2fbc2782b 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionWeightingService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionWeightingService.java
@@ -13,10 +13,9 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
-/** Resolves measured carton dimensions to the customer's smallest containing dimension rule. */
+/** Resolves dimensions to the smallest containing rule, capped at the largest configured tier. */
@Service
public class CustomerDimensionWeightingService {
- private static final BigDecimal TWO = new BigDecimal("2");
private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000");
private final CustomerDimensionConfigRepository repository;
@@ -62,16 +61,8 @@ public class CustomerDimensionWeightingService {
.filter(rule -> rule.contains(measured))
.findFirst()
.map(Rule::weighted)
- .orElseGet(() -> actualDimensions(lengthCm, widthCm, heightCm));
- }
-
- private WeightedDimensions actualDimensions(BigDecimal lengthCm, BigDecimal widthCm,
- BigDecimal heightCm) {
- BigDecimal rawVolume = lengthCm.multiply(widthCm).multiply(heightCm)
- .divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP);
- return new WeightedDimensions(null, null, null, lengthCm, widthCm, heightCm,
- rawVolume, rawVolume.setScale(3, RoundingMode.HALF_UP),
- "ACTUAL_DIMENSION_V1");
+ .orElseGet(() -> rules.get(rules.size() - 1)
+ .weightedActualDimensions(lengthCm, widthCm, heightCm));
}
}
@@ -113,10 +104,21 @@ public class CustomerDimensionWeightingService {
}
private WeightedDimensions weighted() {
- BigDecimal edgePair = edgeAdditionCm.multiply(TWO);
- BigDecimal outerLength = innerLengthCm.add(edgePair);
- BigDecimal outerWidth = innerWidthCm.add(edgePair);
- BigDecimal outerHeight = innerHeightCm.add(edgePair);
+ BigDecimal outerLength = innerLengthCm.add(edgeAdditionCm);
+ BigDecimal outerWidth = innerWidthCm.add(edgeAdditionCm);
+ BigDecimal outerHeight = innerHeightCm.add(edgeAdditionCm);
+ BigDecimal rawVolume = outerLength.multiply(outerWidth).multiply(outerHeight)
+ .divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP);
+ BigDecimal unitVolume = rawVolume.setScale(3, RoundingMode.HALF_UP);
+ return new WeightedDimensions(boxType, version, edgeAdditionCm, outerLength, outerWidth, outerHeight,
+ rawVolume, unitVolume, "CUSTOMER_DIMENSION_" + boxType.name() + "_V" + version);
+ }
+
+ private WeightedDimensions weightedActualDimensions(BigDecimal lengthCm, BigDecimal widthCm,
+ BigDecimal heightCm) {
+ BigDecimal outerLength = lengthCm.add(edgeAdditionCm);
+ BigDecimal outerWidth = widthCm.add(edgeAdditionCm);
+ BigDecimal outerHeight = heightCm.add(edgeAdditionCm);
BigDecimal rawVolume = outerLength.multiply(outerWidth).multiply(outerHeight)
.divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP);
BigDecimal unitVolume = rawVolume.setScale(3, RoundingMode.HALF_UP);
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java
index 5d5968a79..d1918a900 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java
@@ -183,8 +183,20 @@ public class MeasurementBatchService {
if (!itemNumbers.add(source.itemNo())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细号不能重复");
}
+ if ((source.outerLengthCm() != null && source.outerLengthCm().signum() <= 0)
+ || (source.outerWidthCm() != null && source.outerWidthCm().signum() <= 0)
+ || (source.outerHeightCm() != null && source.outerHeightCm().signum() <= 0)) {
+ throw new BizException(ResultCode.VALIDATION_FAILED,
+ "仓库修改外箱尺寸必须大于0,明细 " + source.itemNo());
+ }
+ boolean hasOuterOverride = source.outerLengthCm() != null
+ || source.outerWidthCm() != null
+ || source.outerHeightCm() != null;
+ BigDecimal baseLength = source.outerLengthCm() != null ? source.outerLengthCm() : source.lengthCm();
+ BigDecimal baseWidth = source.outerWidthCm() != null ? source.outerWidthCm() : source.widthCm();
+ BigDecimal baseHeight = source.outerHeightCm() != null ? source.outerHeightCm() : source.heightCm();
CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolve(
- source.lengthCm(), source.widthCm(), source.heightCm());
+ baseLength, baseWidth, baseHeight);
String requestedMode = normalizeMode(source.coefficientMode());
CustomerChargeRuleEntity coefficientRule = "MULTIPLY".equals(requestedMode)
? requireCoefficientRule(command.tenantId(), customerId, source.chargeRuleId())
@@ -195,32 +207,16 @@ public class MeasurementBatchService {
boolean hasMatchedAddition = useDimensionRule && weighted.boxType() != null;
// ADD 只从匹配箱规取得加边值,外尺寸仍以本次实测尺寸为基准,
// 不能把箱规边界尺寸当成本次货物的实际外尺寸。
- BigDecimal outerLength = hasMatchedAddition ? source.lengthCm().add(addition) : source.lengthCm();
- BigDecimal outerWidth = hasMatchedAddition ? source.widthCm().add(addition) : source.widthCm();
- BigDecimal outerHeight = hasMatchedAddition ? source.heightCm().add(addition) : source.heightCm();
- boolean hasOuterOverride = source.outerLengthCm() != null
- || source.outerWidthCm() != null
- || source.outerHeightCm() != null;
- if ((source.outerLengthCm() != null && source.outerLengthCm().signum() <= 0)
- || (source.outerWidthCm() != null && source.outerWidthCm().signum() <= 0)
- || (source.outerHeightCm() != null && source.outerHeightCm().signum() <= 0)) {
- throw new BizException(ResultCode.VALIDATION_FAILED,
- "仓库修改外箱尺寸必须大于0,明细 " + source.itemNo());
- }
+ BigDecimal outerLength = hasMatchedAddition ? baseLength.add(addition) : baseLength;
+ BigDecimal outerWidth = hasMatchedAddition ? baseWidth.add(addition) : baseWidth;
+ BigDecimal outerHeight = hasMatchedAddition ? baseHeight.add(addition) : baseHeight;
if (coefficientRule != null) {
BigDecimal specificationCoefficient = resolveSpecificationCoefficient(
- coefficientRule, source.lengthCm(), source.widthCm(), source.heightCm());
+ coefficientRule, baseLength, baseWidth, baseHeight);
outerLength = applyCoefficient(outerLength, specificationCoefficient);
outerWidth = applyCoefficient(outerWidth, specificationCoefficient);
outerHeight = applyCoefficient(outerHeight, specificationCoefficient);
}
- if (hasOuterOverride) {
- // Each warehouse-confirmed dimension is authoritative on its own.
- // Unspecified dimensions keep their strategy-calculated values.
- if (source.outerLengthCm() != null) outerLength = source.outerLengthCm();
- if (source.outerWidthCm() != null) outerWidth = source.outerWidthCm();
- if (source.outerHeightCm() != null) outerHeight = source.outerHeightCm();
- }
BigDecimal unitVolumeRaw = outerLength.multiply(outerWidth).multiply(outerHeight)
.divide(new BigDecimal("1000000"), 6, RoundingMode.HALF_UP);
BigDecimal unitVolume = hasMatchedAddition && !hasOuterOverride
@@ -262,9 +258,13 @@ public class MeasurementBatchService {
: useDimensionRule ? weighted.calculationRuleVersion() : "ACTUAL_DIMENSION_V1");
item.setSourceImageFileId(source.intelligentImageFileId());
item.setOverLimitWarning(source.overLimitWarning());
- item.setOuterLengthCm(outerLength.setScale(3, RoundingMode.HALF_UP));
- item.setOuterWidthCm(outerWidth.setScale(3, RoundingMode.HALF_UP));
- item.setOuterHeightCm(outerHeight.setScale(3, RoundingMode.HALF_UP));
+ // Override fields retain the operator's base values so repeated saves do not compound the strategy.
+ item.setOuterLengthCm((source.outerLengthCm() != null ? source.outerLengthCm() : outerLength)
+ .setScale(3, RoundingMode.HALF_UP));
+ item.setOuterWidthCm((source.outerWidthCm() != null ? source.outerWidthCm() : outerWidth)
+ .setScale(3, RoundingMode.HALF_UP));
+ item.setOuterHeightCm((source.outerHeightCm() != null ? source.outerHeightCm() : outerHeight)
+ .setScale(3, RoundingMode.HALF_UP));
item.setRemark(source.remark());
item.setCreatedAt(LocalDateTime.now());
return item;
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java
index 399c08cc2..1a6fda028 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.api.ApiResponse;
+import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.firstleg.dto.SmartLoadingDtos.*;
@@ -38,6 +39,17 @@ import java.util.*;
@Service
public class SmartLoadingService {
private static final BigDecimal CAPACITY_LIMIT_RATIO = new BigDecimal("1.10");
+ private static final Map> WORKBENCH_CHANNEL_GROUPS = Map.of(
+ "MATSON_CLX", Set.of("MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL"),
+ "MATSON_MAX", Set.of("MATSON_MAX_TRUCK", "MATSON_MAX_PARCEL"),
+ "EXX", Set.of("EXX_TRUCK", "EXX_RAIL_NY"),
+ "HEDE", Set.of("HEDE_TRUCK", "HEDE_PARCEL"),
+ "OA_LA", Set.of("OA_LA_TRUCK", "OA_LA_FULL_TRUCK", "OA_PARCEL"),
+ "OA_CHICAGO", Set.of("OA_CHICAGO_RAIL"),
+ "OA_HOUSTON", Set.of("OA_HOUSTON_TRUCK"),
+ "OA_SAVANNAH", Set.of("OA_SAVANNAH_TRUCK"),
+ "OA_NEW_YORK_TRUCK", Set.of("OA_NEW_YORK_TRUCK")
+ );
@Resource private SmartLoadingRepository repository;
@Resource private OrderCargoClient orderCargoClient;
@Resource private LoadingPickService loadingPickService;
@@ -96,6 +108,57 @@ public class SmartLoadingService {
return grouped.values().stream().map(this::inboundCandidate).toList();
}
+ /** Global workbench candidates. A co-load group is one paging unit and can never be split across pages. */
+ public PageResult inboundCandidates(InboundCandidateQuery query) {
+ if (query.expectedArrivalStart() != null && query.expectedArrivalEnd() != null
+ && query.expectedArrivalStart().isAfter(query.expectedArrivalEnd())) {
+ throw validation("预计到货开始日期不能晚于结束日期");
+ }
+ Set actualChannels = actualChannels(query.channelGroupCodes());
+ List all = repository.globalCandidates(query.warehouseId()).stream()
+ .collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new,
+ java.util.stream.Collectors.toList()))
+ .values().stream().map(this::inboundCandidate).toList();
+ Map snapshots = new HashMap<>();
+ Map> grouped = new LinkedHashMap<>();
+ List standalone = new ArrayList<>();
+ for (InboundCandidate candidate : all) {
+ GroupSnapshot group = snapshots.computeIfAbsent(candidate.accountId() + "|" + candidate.inboundId(),
+ ignored -> group(candidate.accountId(), candidate.inboundId()));
+ if (group == null) {
+ standalone.add(candidate);
+ } else {
+ grouped.computeIfAbsent(group.groupId(), ignored -> new ArrayList<>()).add(candidate);
+ }
+ }
+ List units = new ArrayList<>(standalone);
+ for (List members : grouped.values()) {
+ GroupSnapshot group = snapshots.get(members.get(0).accountId() + "|" + members.get(0).inboundId());
+ InboundCandidate aggregate = globalGroupCandidate(group, members);
+ if (aggregate != null) units.add(aggregate);
+ }
+ units = units.stream().filter(candidate -> matches(query, actualChannels, candidate))
+ .sorted(Comparator.comparing(InboundCandidate::expectedArrivalAt,
+ Comparator.nullsLast(Comparator.naturalOrder()))
+ .thenComparing(InboundCandidate::inboundId, Comparator.nullsLast(Comparator.naturalOrder())))
+ .toList();
+ long total = units.size();
+ long offset = query.page() - 1 > Long.MAX_VALUE / query.size()
+ ? Long.MAX_VALUE : (query.page() - 1) * query.size();
+ long from = Math.min(total, offset);
+ long to = Math.min(total, from + query.size());
+ long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size();
+ return new PageResult<>(units.subList((int) from, (int) to), query.page(), query.size(), total, pages);
+ }
+
+ public PageResult containers(WorkbenchContainerQuery query) {
+ if (query.expectedArrivalStart() != null && query.expectedArrivalEnd() != null
+ && query.expectedArrivalStart().isAfter(query.expectedArrivalEnd())) {
+ throw validation("预计到货开始日期不能晚于结束日期");
+ }
+ return repository.workbenchContainers(query, actualChannels(query.channelGroupCodes()));
+ }
+
private InboundCandidate inboundCandidate(List details) {
Candidate first = details.get(0);
int packages = details.stream().map(Candidate::availablePackageCount)
@@ -114,7 +177,93 @@ public class SmartLoadingService {
first.customerName(), goods, packages, first.unitGrossWeightKg(), first.unitNetWeightKg(),
averageVolume, volume, first.coLoadGroupId(), first.groupNo(), first.memberCount(),
first.groupTotalBoxes(), first.locked(), first.blockReason(), first.orderStatusSnapshot(),
- first.expectedArrivalAt());
+ first.expectedArrivalAt(), List.of());
+ }
+
+ private InboundCandidate globalGroupCandidate(GroupSnapshot group, List availableMembers) {
+ if (group == null || !"ACTIVE".equals(group.status()) || group.lockedPlanId() != null) return null;
+ Map byInbound = availableMembers.stream()
+ .collect(java.util.stream.Collectors.toMap(InboundCandidate::inboundId, value -> value, (a, b) -> a));
+ for (Member member : group.members()) {
+ InboundCandidate candidate = byInbound.get(member.inboundId());
+ if (candidate == null || !Objects.equals(candidate.accountId(), member.tenantId())
+ || !Objects.equals(candidate.availablePackageCount(), member.fullPackageCount())) return null;
+ }
+ List ordered = group.members().stream().map(member -> byInbound.get(member.inboundId())).toList();
+ InboundCandidate first = ordered.get(0);
+ int packages = ordered.stream().map(InboundCandidate::availablePackageCount).mapToInt(Integer::intValue).sum();
+ BigDecimal volume = ordered.stream().map(InboundCandidate::availableVolumeCbm)
+ .filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add).setScale(4, RoundingMode.HALF_UP);
+ BigDecimal gross = weightedAverage(ordered, InboundCandidate::unitGrossWeightKg, packages);
+ BigDecimal net = weightedAverage(ordered, InboundCandidate::unitNetWeightKg, packages);
+ BigDecimal averageVolume = packages == 0 ? BigDecimal.ZERO
+ : volume.divide(BigDecimal.valueOf(packages), 6, RoundingMode.HALF_UP);
+ List groupMembers = ordered.stream().map(member -> new GroupMemberCandidate(
+ member.accountId(), member.inboundId(), member.orderId(), member.orderNo(), member.inboundCode(),
+ member.customerName(), member.availablePackageCount(), member.orderStatusSnapshot(),
+ member.expectedArrivalAt())).toList();
+ String customers = ordered.stream().map(InboundCandidate::customerName).filter(StringUtils::hasText)
+ .distinct().collect(java.util.stream.Collectors.joining("、"));
+ String goods = ordered.stream().map(InboundCandidate::goodsSummary).filter(StringUtils::hasText)
+ .distinct().collect(java.util.stream.Collectors.joining("、"));
+ return new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(),
+ first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(),
+ first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
+ customers, goods, packages, gross, net, averageVolume, volume, group.groupId(), group.groupNo(),
+ group.members().size(), group.fullPackageCount(), false, null, first.orderStatusSnapshot(),
+ ordered.stream().map(InboundCandidate::expectedArrivalAt).filter(Objects::nonNull).min(LocalDateTime::compareTo).orElse(null),
+ groupMembers);
+ }
+
+ private static BigDecimal weightedAverage(List members,
+ java.util.function.Function getter,
+ int packages) {
+ if (packages == 0) return BigDecimal.ZERO;
+ return members.stream().map(candidate -> unit(getter.apply(candidate)).multiply(
+ BigDecimal.valueOf(candidate.availablePackageCount())))
+ .reduce(BigDecimal.ZERO, BigDecimal::add)
+ .divide(BigDecimal.valueOf(packages), 6, RoundingMode.HALF_UP);
+ }
+
+ private static Set actualChannels(List groups) {
+ Set actual = new LinkedHashSet<>();
+ for (String value : groups) {
+ String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
+ Set mapped = WORKBENCH_CHANNEL_GROUPS.get(normalized);
+ if (mapped == null) throw validation("不支持的渠道分组: " + value);
+ actual.addAll(mapped);
+ }
+ return actual;
+ }
+
+ private static boolean matches(InboundCandidateQuery query, Set actualChannels,
+ InboundCandidate candidate) {
+ if (!actualChannels.contains(candidate.channelCode())) return false;
+ if (query.customerId() != null && !Objects.equals(candidate.accountId(), query.customerId())
+ && candidate.groupMembers().stream().noneMatch(
+ member -> Objects.equals(member.accountId(), query.customerId()))) return false;
+ String inboundCode = trim(query.inboundCode());
+ if (StringUtils.hasText(inboundCode) && candidate.groupMembers().stream()
+ .map(GroupMemberCandidate::inboundCode).filter(Objects::nonNull)
+ .noneMatch(value -> value.toLowerCase(Locale.ROOT).contains(inboundCode.toLowerCase(Locale.ROOT)))
+ && (candidate.inboundCode() == null || !candidate.inboundCode().toLowerCase(Locale.ROOT)
+ .contains(inboundCode.toLowerCase(Locale.ROOT)))) return false;
+ if (query.expectedArrivalStart() != null || query.expectedArrivalEnd() != null) {
+ boolean arrivalMatches = candidate.groupMembers().isEmpty()
+ ? inArrivalRange(candidate.expectedArrivalAt(), query)
+ : candidate.groupMembers().stream().anyMatch(member -> inArrivalRange(
+ member.expectedArrivalAt(), query));
+ if (!arrivalMatches) return false;
+ }
+ return query.orderStatus() == null || query.orderStatus() == CandidateOrderStatus.WAIT_SPLIT;
+ }
+
+ private static boolean inArrivalRange(LocalDateTime arrival, InboundCandidateQuery query) {
+ if (arrival == null) return false;
+ return (query.expectedArrivalStart() == null
+ || !arrival.toLocalDate().isBefore(query.expectedArrivalStart()))
+ && (query.expectedArrivalEnd() == null
+ || !arrival.toLocalDate().isAfter(query.expectedArrivalEnd()));
}
public Plan draftByBooking(long bookingId) {
diff --git a/firstleg/src/main/resources/db/customer-dimension-config.sql b/firstleg/src/main/resources/db/customer-dimension-config.sql
index 6b6edc7d4..42cae340c 100644
--- a/firstleg/src/main/resources/db/customer-dimension-config.sql
+++ b/firstleg/src/main/resources/db/customer-dimension-config.sql
@@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
inner_length_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内长(厘米)',
inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)',
inner_height_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内高(厘米)',
- edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数',
+ edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '长宽高各自增加一次的厘米数',
default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ADD' COMMENT '订单外箱规默认计算方式:ADD或MULTIPLY',
remark VARCHAR(500) NULL COMMENT '备注',
initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属',
diff --git a/firstleg/src/main/resources/db/firstleg.sql b/firstleg/src/main/resources/db/firstleg.sql
index e21360d74..c99f8df9b 100644
--- a/firstleg/src/main/resources/db/firstleg.sql
+++ b/firstleg/src/main/resources/db/firstleg.sql
@@ -56,7 +56,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
inner_length_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内长(厘米)',
inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)',
inner_height_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内高(厘米)',
- edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数',
+ edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '长宽高各自增加一次的厘米数',
default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ADD' COMMENT '订单外箱规默认计算方式:ADD或MULTIPLY',
remark VARCHAR(500) NULL COMMENT '备注',
initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属',
diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql
index f0f17a054..4f80e698c 100644
--- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql
+++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql
@@ -861,7 +861,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
inner_length_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内长(厘米)',
inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)',
inner_height_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内高(厘米)',
- edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数',
+ edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '长宽高各自增加一次的厘米数',
default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ADD' COMMENT '订单外箱规默认计算方式:ADD或MULTIPLY',
remark VARCHAR(500) NULL COMMENT '备注',
initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属',
diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml
index 85f60cd62..e402af29d 100644
--- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml
+++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml
@@ -483,16 +483,72 @@ paths:
'403': {description: 无智能装柜查询权限}
/api/platform/v1/first-leg/smart-loading/inbound-candidates:
get:
- summary: 按入仓码查询智能装柜候选
+ summary: 全局分页查询智能装柜候选
description: >-
- 需要 platform:firstleg:smart-loading:list 权限。每个入库单/入仓码只返回一行;
+ 需要 platform:firstleg:smart-loading:list 权限。查询不绑定柜次;渠道、仓库、客户、入仓码、
+ 预计到货日期和订单状态共同作用于全局候选。每个普通入库单返回一行;同柜不可拆组作为一个
+ 分页单元返回,groupMembers 包含完整成员,不会跨页或因单个成员命中搜索条件而拆散。
平均单箱毛重、净重按整票实测重量除以总箱数,平均单箱体积按剩余规格数量加权。
- 响应携带订单状态快照 orderStatusSnapshot 与预计到货时间 expectedArrivalAt,供平台筛选候选入仓码;
- 这些数据用于数量级计划,不表示具体箱号已核验归属。
+ 只返回当前具备实际装柜候选资格的 WAIT_SPLIT 数据;PENDING_REVIEW、WAIT_MEASURE、LOADING
+ 是统一筛选枚举,但在候选接口中返回空页。LOADED、COMPLETED 与已锁定或成员不完整的组永不返回。
+ 分配时仍由目标 planId 接口复核真实渠道、仓库、容量、组锁和并发版本。
parameters:
- - {name: bookingId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- - {name: warehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- responses: {'200': {description: 成功,data 为入仓码级候选列表}}
+ - name: channelGroupCodes
+ in: query
+ required: true
+ description: >-
+ 至少一项,可重复传参。分组到实际渠道:MATSON_CLX=[MATSON_CLX_TRUCK,MATSON_CLX_PARCEL];
+ MATSON_MAX=[MATSON_MAX_TRUCK,MATSON_MAX_PARCEL];EXX=[EXX_TRUCK,EXX_RAIL_NY];
+ HEDE=[HEDE_TRUCK,HEDE_PARCEL];OA_LA=[OA_LA_TRUCK,OA_LA_FULL_TRUCK,OA_PARCEL];
+ OA_CHICAGO=[OA_CHICAGO_RAIL];OA_HOUSTON=[OA_HOUSTON_TRUCK];
+ OA_SAVANNAH=[OA_SAVANNAH_TRUCK];OA_NEW_YORK_TRUCK=[OA_NEW_YORK_TRUCK]。
+ schema:
+ type: array
+ minItems: 1
+ maxItems: 9
+ uniqueItems: true
+ items: {type: string, enum: [MATSON_CLX, MATSON_MAX, EXX, HEDE, OA_LA, OA_CHICAGO, OA_HOUSTON, OA_SAVANNAH, OA_NEW_YORK_TRUCK]}
+ - {name: warehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
+ - {name: customerId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
+ - {name: inboundCode, in: query, required: false, schema: {type: string, maxLength: 40}}
+ - {name: expectedArrivalStart, in: query, required: false, schema: {type: string, format: date}}
+ - {name: expectedArrivalEnd, in: query, required: false, schema: {type: string, format: date}}
+ - {name: orderStatus, in: query, required: false, schema: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING]}}
+ - {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}}
+ - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
+ responses:
+ '200':
+ description: 成功,data 为标准分页;total/pages 按普通入库单或完整同柜组分页单元统计
+ content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingInboundCandidatePageEnvelope'}}}
+ /api/platform/v1/first-leg/smart-loading/containers:
+ get:
+ summary: 分页查询智能装柜工作台柜次
+ description: >-
+ 需要 platform:firstleg:smart-loading:list 权限。与全局候选使用相同的渠道、仓库、客户、入仓码、
+ 预计到货日期及订单状态筛选;客户及入仓事实以柜内任一有效装柜明细命中为准,查询不会修改或拆散同柜组。
+ 只返回 WAIT_LOADING、EDITING 或 LOADING 的 ACTIVE 柜次,COMPLETED 永不返回。
+ parameters:
+ - name: channelGroupCodes
+ in: query
+ required: true
+ schema:
+ type: array
+ minItems: 1
+ maxItems: 9
+ uniqueItems: true
+ items: {type: string, enum: [MATSON_CLX, MATSON_MAX, EXX, HEDE, OA_LA, OA_CHICAGO, OA_HOUSTON, OA_SAVANNAH, OA_NEW_YORK_TRUCK]}
+ - {name: warehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
+ - {name: customerId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
+ - {name: inboundCode, in: query, required: false, schema: {type: string, maxLength: 40}}
+ - {name: expectedArrivalStart, in: query, required: false, schema: {type: string, format: date}}
+ - {name: expectedArrivalEnd, in: query, required: false, schema: {type: string, format: date}}
+ - {name: orderStatus, in: query, required: false, schema: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING]}}
+ - {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}}
+ - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
+ responses:
+ '200':
+ description: 成功,柜次按更新时间、技术ID倒序稳定分页
+ content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingWorkbenchContainerPageEnvelope'}}}
/api/platform/v1/first-leg/loading-associations/summary:
get:
summary: 查询入仓片段的装柜与国内PDA关联摘要
@@ -1203,10 +1259,10 @@ components:
type: object
properties:
records: {type: array, items: {$ref: '#/components/schemas/UnifiedInboundSummary'}}
- page: {type: integer, format: int64, minimum: 1}
- size: {type: integer, format: int64, minimum: 1}
- total: {type: integer, format: int64, minimum: 0}
- pages: {type: integer, format: int64, minimum: 0}
+ page: {type: string, pattern: '^[1-9][0-9]*$'}
+ size: {type: string, pattern: '^[1-9][0-9]*$'}
+ total: {type: string, pattern: '^[0-9]+$'}
+ pages: {type: string, pattern: '^[0-9]+$'}
UnifiedInboundListEnvelope:
type: object
properties:
@@ -1387,7 +1443,7 @@ components:
minimum: 0.001
maximum: 100
multipleOf: 0.001
- description: 箱规长、宽、高三个方向分别使用的每侧加宽厘米数
+ description: 箱规长、宽、高三个方向分别增加一次的厘米数
example: 2.000
remark: {type: string, maxLength: 500, nullable: true}
version: {type: integer, minimum: 0, description: 乐观锁版本号}
@@ -1413,9 +1469,9 @@ components:
innerWidthCm: {type: number, minimum: 0.001, multipleOf: 0.001}
innerHeightCm: {type: number, minimum: 0.001, multipleOf: 0.001}
edgeAdditionCm: {type: number, minimum: 0.001, maximum: 100, example: 2.000}
- outerLengthCm: {type: number, minimum: 0.001, description: innerLengthCm + edgeAdditionCm * 2}
- outerWidthCm: {type: number, minimum: 0.001, description: innerWidthCm + edgeAdditionCm * 2}
- outerHeightCm: {type: number, minimum: 0.001, description: innerHeightCm + edgeAdditionCm * 2}
+ outerLengthCm: {type: number, minimum: 0.001, description: innerLengthCm + edgeAdditionCm}
+ outerWidthCm: {type: number, minimum: 0.001, description: innerWidthCm + edgeAdditionCm}
+ outerHeightCm: {type: number, minimum: 0.001, description: innerHeightCm + edgeAdditionCm}
volumeCbm: {type: number, multipleOf: 0.001, description: 使用未舍入外径完整计算后四舍五入保留3位}
remark: {type: string, nullable: true}
version: {type: integer, minimum: 0}
@@ -1880,6 +1936,103 @@ components:
candidateInboundCount: {type: integer, format: int64, minimum: 0, description: 当前可分配的唯一入仓码数量}
availablePackageCount: {type: integer, format: int64, minimum: 0, description: 当前候选规格剩余可分配箱数合计}
availableContainerCount: {type: integer, format: int64, minimum: 0, description: 当前 WAIT_LOADING、EDITING 或 LOADING 可编排柜次数量}
+ SmartLoadingInboundCandidatePageEnvelope:
+ type: object
+ required: [code, message, data]
+ properties:
+ code: {type: string, example: '200'}
+ message: {type: string}
+ data:
+ type: object
+ required: [records, page, size, total, pages]
+ properties:
+ records: {type: array, items: {$ref: '#/components/schemas/SmartLoadingInboundCandidate'}}
+ page: {type: integer, format: int64, minimum: 1}
+ size: {type: integer, format: int64, minimum: 1}
+ total: {type: integer, format: int64, minimum: 0}
+ pages: {type: integer, format: int64, minimum: 0}
+ SmartLoadingInboundCandidate:
+ type: object
+ required: [accountId, customerId, inboundId, orderId, orderNo, inboundCode, warehouseId, channelCode,
+ channelName, customerName, goodsSummary, availablePackageCount, unitGrossWeightKg,
+ unitNetWeightKg, averageUnitVolumeCbm, availableVolumeCbm, locked, groupMembers]
+ properties:
+ accountId: {type: string, pattern: '^[1-9][0-9]*$'}
+ customerId: {type: string, pattern: '^[1-9][0-9]*$', description: 与 accountId 相同的客户技术关联ID}
+ inboundId: {type: string, pattern: '^[1-9][0-9]*$', description: 普通候选ID;组聚合行中为可用于整组分配的稳定代表成员ID}
+ orderId: {type: string, pattern: '^[1-9][0-9]*$'}
+ orderNo: {type: string}
+ inboundCode: {type: string}
+ orderStatusSnapshot: {type: string, nullable: true}
+ expectedArrivalAt: {type: string, format: date-time, nullable: true}
+ warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
+ channelCode: {type: string}
+ channelName: {type: string}
+ destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
+ destinationWarehouseCode: {type: string, nullable: true}
+ destinationWarehouseName: {type: string, nullable: true}
+ customerName: {type: string}
+ goodsSummary: {type: string}
+ availablePackageCount: {type: integer, minimum: 1, description: 普通行剩余箱数;组行完整成员箱数合计}
+ unitGrossWeightKg: {type: number, minimum: 0}
+ unitNetWeightKg: {type: number, minimum: 0}
+ averageUnitVolumeCbm: {type: number, minimum: 0}
+ availableVolumeCbm: {type: number, minimum: 0}
+ coLoadGroupId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
+ groupNo: {type: string, nullable: true}
+ memberCount: {type: integer, minimum: 2, nullable: true}
+ groupTotalBoxes: {type: integer, minimum: 1, nullable: true}
+ locked: {type: boolean}
+ blockReason: {type: string, nullable: true}
+ groupMembers:
+ type: array
+ description: 同柜组聚合行的完整成员;普通候选为空数组
+ items:
+ type: object
+ required: [accountId, inboundId, orderId, orderNo, inboundCode, customerName, availablePackageCount]
+ properties:
+ accountId: {type: string, pattern: '^[1-9][0-9]*$'}
+ inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
+ orderId: {type: string, pattern: '^[1-9][0-9]*$'}
+ orderNo: {type: string}
+ inboundCode: {type: string}
+ customerName: {type: string}
+ availablePackageCount: {type: integer, minimum: 1}
+ orderStatusSnapshot: {type: string, nullable: true}
+ expectedArrivalAt: {type: string, format: date-time, nullable: true}
+ SmartLoadingWorkbenchContainerPageEnvelope:
+ type: object
+ required: [code, message, data]
+ properties:
+ code: {type: string, example: '200'}
+ message: {type: string}
+ data:
+ type: object
+ required: [records, page, size, total, pages]
+ properties:
+ records: {type: array, items: {$ref: '#/components/schemas/SmartLoadingWorkbenchContainer'}}
+ page: {type: string, pattern: '^[1-9][0-9]*$'}
+ size: {type: string, pattern: '^[1-9][0-9]*$'}
+ total: {type: string, pattern: '^[0-9]+$'}
+ pages: {type: string, pattern: '^[0-9]+$'}
+ SmartLoadingWorkbenchContainer:
+ type: object
+ required: [bookingId, bookingNo, cabinetNo, warehouseId, warehouseName, channelCode,
+ channelName, containerType, planStatus, version]
+ properties:
+ bookingId: {type: string, pattern: '^[1-9][0-9]*$', description: containerBookingId 技术关联}
+ bookingNo: {type: string, description: 货柜登记业务号}
+ cabinetNo: {type: string, description: 稳定柜次业务号}
+ containerNo: {type: string, nullable: true, description: 真实集装箱号,允许在装柜前后补}
+ warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
+ warehouseName: {type: string}
+ channelCode: {type: string}
+ channelName: {type: string}
+ containerType: {type: string}
+ planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
+ planNo: {type: string, nullable: true}
+ planStatus: {type: string, enum: [WAIT_LOADING, EDITING, LOADING]}
+ version: {type: integer, minimum: 0, description: 柜次版本}
SmartLoadingPlan:
type: object
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, items]
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java
index 9123c2cd4..0e625c0fe 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java
@@ -4,6 +4,7 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.SmartLoadingDtos.*;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
@@ -12,16 +13,27 @@ class PlatformSmartLoadingControllerContractTest {
authority("draftByBooking","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class);
authority("detail","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class);
authority("candidates","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class,long.class);
- authority("inboundCandidates","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class,long.class);
+ authority("inboundCandidates","platform:firstleg:smart-loading:list",AuthenticatedUser.class,InboundCandidateQuery.class);
+ authority("containers","platform:firstleg:smart-loading:list",AuthenticatedUser.class,WorkbenchContainerQuery.class);
authority("channels","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class);
}
- @Test void openApiRequiresBookingAndWarehouseForCandidates() throws Exception {
+ @Test void openApiFreezesFilteredWorkbenchContainers() throws Exception {
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
- assertThat(yaml).contains("/api/platform/v1/first-leg/smart-loading/candidates:")
- .contains("/api/platform/v1/first-leg/smart-loading/channels:")
- .contains("零候选或零可用柜次的有效渠道仍返回")
- .contains("name: bookingId, in: query, required: true")
- .contains("name: warehouseId, in: query, required: true");
+ assertThat(yaml).contains("/api/platform/v1/first-leg/smart-loading/containers:")
+ .contains("SmartLoadingWorkbenchContainerPageEnvelope:")
+ .contains("只返回 WAIT_LOADING、EDITING 或 LOADING");
+ }
+ @Test void openApiFreezesGlobalPagedInboundCandidates() throws Exception {
+ Method method=PlatformSmartLoadingController.class.getDeclaredMethod(
+ "inboundCandidates",AuthenticatedUser.class,InboundCandidateQuery.class);
+ assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/inbound-candidates");
+ String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
+ String contract=yaml.substring(yaml.indexOf("/api/platform/v1/first-leg/smart-loading/inbound-candidates:"),
+ yaml.indexOf("/api/platform/v1/first-leg/loading-associations/summary:"));
+ assertThat(contract).contains("name: channelGroupCodes")
+ .contains("name: warehouseId")
+ .contains("SmartLoadingInboundCandidatePageEnvelope")
+ .doesNotContain("name: bookingId");
}
@Test void everyMutationDeclaresDedicatedPermission() throws Exception {
authority("assign","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignRequest.class);
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java
index 793829de4..ee787c3c0 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java
@@ -6,6 +6,9 @@ import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
+import java.util.Set;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery;
import static org.assertj.core.api.Assertions.assertThat;
@@ -64,6 +67,35 @@ class SmartLoadingCandidateMapperContractTest {
.isLessThan(sql.indexOf("o.order_status_snapshot orderStatusSnapshot"));
}
+ @Test
+ void globalCandidateQueryHasOptionalWarehouseAndNoContainerJoin() throws Exception {
+ Method list = SmartLoadingCandidateMapper.class.getDeclaredMethod("globalCandidates", Long.class);
+ String sql = String.join(" ", list.getAnnotation(Select.class).value());
+
+ assertThat(sql)
+ .contains("#{warehouseId} IS NULL OR o.warehouse_id=#{warehouseId}")
+ .contains("o.process_status='WAIT_SPLIT'")
+ .doesNotContain("fl_container_booking")
+ .doesNotContain("bookingId");
+ }
+
+ @Test
+ void workbenchContainerQueryExcludesCompletedAndMatchesRelatedInboundFacts() throws Exception {
+ Method list = SmartLoadingCandidateMapper.class.getDeclaredMethod("workbenchContainers",
+ Page.class, WorkbenchContainerQuery.class, Set.class, String.class);
+ String sql = String.join(" ", list.getAnnotation(Select.class).value());
+
+ assertThat(sql)
+ .contains("p.plan_status IN ('EDITING','LOADING')")
+ .contains("b.channel_code IN")
+ .contains("fl_loading_plan_item filter_item")
+ .contains("fl_inbound_order filter_inbound")
+ .contains("filter_item.inbound_code LIKE")
+ .contains("filter_inbound.expected_arrival_at")
+ .contains("filter_inbound.process_status='LOADING'")
+ .doesNotContain("p.plan_status IN ('EDITING','LOADING','COMPLETED')");
+ }
+
@Test
void detailUsesSameBatchRule() throws Exception {
Method detail = SmartLoadingCandidateMapper.class.getDeclaredMethod(
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java
index 39fea23eb..6f344e119 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java
@@ -90,10 +90,10 @@ class CartonMeasurementServiceTest {
assertThat(result.scannedShipmentId()).isEqualTo("FBA-SCAN-002");
assertThat(insertedItem.get().getScannedShipmentId()).isEqualTo("FBA-SCAN-002");
assertThat(insertedItem.get().getMeasurementId()).isEqualTo(30001L);
- assertThat(insertedItem.get().getTotalVolumeCbm()).isEqualByComparingTo("0.1870");
- assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("64.000");
- assertThat(insertedItem.get().getOuterWidthCm()).isEqualByComparingTo("54.000");
- assertThat(insertedItem.get().getOuterHeightCm()).isEqualByComparingTo("54.000");
+ assertThat(insertedItem.get().getTotalVolumeCbm()).isEqualByComparingTo("0.1680");
+ assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("62.000");
+ assertThat(insertedItem.get().getOuterWidthCm()).isEqualByComparingTo("52.000");
+ assertThat(insertedItem.get().getOuterHeightCm()).isEqualByComparingTo("52.000");
assertThat(measurement.getSourceType()).isEqualTo(MeasurementBatchService.SOURCE_THIRD_PARTY);
assertThat(measurement.getSourceCode()).isEqualTo("DEVICE");
}
@@ -155,16 +155,16 @@ class CartonMeasurementServiceTest {
assertThat(result.created()).isFalse();
assertThat(result.confirmedPackageCount()).isEqualTo(4);
assertThat(existing.getLengthCm()).isEqualByComparingTo("59.500");
- assertThat(existing.getUnitVolumeCbm()).isEqualByComparingTo("0.187");
- assertThat(existing.getTotalVolumeCbm()).isEqualByComparingTo("0.7480");
- assertThat(existing.getVolumeWeightKg()).isEqualByComparingTo("124.916");
+ assertThat(existing.getUnitVolumeCbm()).isEqualByComparingTo("0.168");
+ assertThat(existing.getTotalVolumeCbm()).isEqualByComparingTo("0.6720");
+ assertThat(existing.getVolumeWeightKg()).isEqualByComparingTo("112.224");
assertThat(existing.getSourceImageFileId()).isEqualTo(99001L);
assertThat(existing.getScannedShipmentId()).isEqualTo("FBA-SCAN-002");
verify(images).delete("internal-token", 90001L, 10001L, 70001L, 88001L);
}
@Test
- void scanOutsideConfiguredWeightingRulesKeepsImageAndActualDimensions() {
+ void scanOutsideConfiguredWeightingRulesKeepsImageAndUsesLargestTierAddition() {
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0);
when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null);
when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement);
@@ -183,7 +183,7 @@ class CartonMeasurementServiceTest {
assertThat(insertedItem.get().getSourceImageFileId()).isEqualTo(99001L);
assertThat(insertedItem.get().getLengthCm()).isEqualByComparingTo("60.100");
assertThat(insertedItem.get().getCalculationRuleVersion())
- .isEqualTo("ACTUAL_DIMENSION_V1");
+ .isEqualTo("CUSTOMER_DIMENSION_EXTRA_LARGE_V0");
assertThat(insertedItem.get().getOverLimitWarning()).isNull();
verify(images, never()).delete(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE));
}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java
index bd6179c64..8913594b6 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java
@@ -95,10 +95,10 @@ class CustomerDimensionConfigServiceTest {
assertThat(result.rules()).extracting(rule -> rule.boxType())
.containsExactly(CustomerDimensionBoxType.values());
assertThat(result.rules().get(0).boxTypeName()).isEqualTo("小箱");
- assertThat(result.rules().get(0).outerLengthCm()).isEqualByComparingTo("34.000");
- assertThat(result.rules().get(0).outerWidthCm()).isEqualByComparingTo("24.000");
- assertThat(result.rules().get(0).outerHeightCm()).isEqualByComparingTo("24.000");
- assertThat(result.rules().get(0).volumeCbm()).isEqualByComparingTo("0.020");
+ assertThat(result.rules().get(0).outerLengthCm()).isEqualByComparingTo("32.000");
+ assertThat(result.rules().get(0).outerWidthCm()).isEqualByComparingTo("22.000");
+ assertThat(result.rules().get(0).outerHeightCm()).isEqualByComparingTo("22.000");
+ assertThat(result.rules().get(0).volumeCbm()).isEqualByComparingTo("0.015");
}
@Test
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java
index 33616c9f5..cd59ff673 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java
@@ -32,7 +32,7 @@ class CustomerDimensionPricingFlowTest {
var kgEstimate = pricing.estimate(rate("KG", "8.700000"), actualWeight, twoCartonsVolume);
var cbmEstimate = pricing.estimate(rate("CBM", "1400.000000"), actualWeight, twoCartonsVolume);
- assertThat(twoCartonsVolume).isEqualByComparingTo("0.040");
+ assertThat(twoCartonsVolume).isEqualByComparingTo("0.030");
assertThat(kgEstimate.quantity()).isEqualByComparingTo("24.000000");
assertThat(kgEstimate.amount()).isEqualByComparingTo("208.80");
assertThat(cbmEstimate.quantity()).isEqualByComparingTo("0.066116");
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingServiceTest.java
index c2d696991..423983954 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingServiceTest.java
@@ -26,16 +26,16 @@ class CustomerDimensionWeightingServiceTest {
}
@Test
- void measuredDimensionsUseSmallestContainingRuleAndAddEachEdgeTwice() {
+ void measuredDimensionsUseSmallestContainingRuleAndAddConfiguredValueOncePerDimension() {
var weighted = service.load(10001L).resolve(
new BigDecimal("10"), new BigDecimal("10"), new BigDecimal("10"));
assertThat(weighted.boxType()).isEqualTo(CustomerDimensionBoxType.SMALL);
- assertThat(weighted.outerLengthCm()).isEqualByComparingTo("34.000");
- assertThat(weighted.outerWidthCm()).isEqualByComparingTo("24.000");
- assertThat(weighted.outerHeightCm()).isEqualByComparingTo("24.000");
- assertThat(weighted.rawVolumeCbm()).isEqualByComparingTo("0.019584");
- assertThat(weighted.unitVolumeCbm()).isEqualByComparingTo("0.020");
+ assertThat(weighted.outerLengthCm()).isEqualByComparingTo("32.000");
+ assertThat(weighted.outerWidthCm()).isEqualByComparingTo("22.000");
+ assertThat(weighted.outerHeightCm()).isEqualByComparingTo("22.000");
+ assertThat(weighted.rawVolumeCbm()).isEqualByComparingTo("0.015488");
+ assertThat(weighted.unitVolumeCbm()).isEqualByComparingTo("0.015");
}
@Test
@@ -52,19 +52,20 @@ class CustomerDimensionWeightingServiceTest {
new BigDecimal("30.001"), new BigDecimal("20"), new BigDecimal("20"));
assertThat(weighted.boxType()).isEqualTo(CustomerDimensionBoxType.MEDIUM);
- assertThat(weighted.unitVolumeCbm()).isEqualByComparingTo("0.051");
+ assertThat(weighted.unitVolumeCbm()).isEqualByComparingTo("0.043");
}
@Test
- void dimensionsOutsideConfiguredWeightingRulesUseActualDimensions() {
+ void dimensionsOutsideConfiguredWeightingRulesUseLargestTierAdditionOnActualDimensions() {
var weighted = service.load(10001L).resolve(
new BigDecimal("60.001"), new BigDecimal("50"), new BigDecimal("50"));
- assertThat(weighted.boxType()).isNull();
- assertThat(weighted.outerLengthCm()).isEqualByComparingTo("60.001");
- assertThat(weighted.outerWidthCm()).isEqualByComparingTo("50");
- assertThat(weighted.outerHeightCm()).isEqualByComparingTo("50");
- assertThat(weighted.calculationRuleVersion()).isEqualTo("ACTUAL_DIMENSION_V1");
+ assertThat(weighted.boxType()).isEqualTo(CustomerDimensionBoxType.EXTRA_LARGE);
+ assertThat(weighted.edgeAdditionCm()).isEqualByComparingTo("2.000");
+ assertThat(weighted.outerLengthCm()).isEqualByComparingTo("62.001");
+ assertThat(weighted.outerWidthCm()).isEqualByComparingTo("52.000");
+ assertThat(weighted.outerHeightCm()).isEqualByComparingTo("52.000");
+ assertThat(weighted.calculationRuleVersion()).isEqualTo("CUSTOMER_DIMENSION_EXTRA_LARGE_V3");
}
@Test
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingTerminalRulesTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingTerminalRulesTest.java
index 46f8d80f3..8408df171 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingTerminalRulesTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingTerminalRulesTest.java
@@ -24,13 +24,13 @@ class CustomerDimensionWeightingTerminalRulesTest {
var ruleSet = service.load(10001L);
assertWeighted(ruleSet, "10", "10", "10", CustomerDimensionBoxType.SMALL,
- "34", "24", "24", "0.020");
+ "32", "22", "22", "0.015");
assertWeighted(ruleSet, "35", "25", "25", CustomerDimensionBoxType.MEDIUM,
- "44", "34", "34", "0.051");
+ "42", "32", "32", "0.043");
assertWeighted(ruleSet, "45", "35", "35", CustomerDimensionBoxType.LARGE,
- "54", "44", "44", "0.105");
+ "52", "42", "42", "0.092");
assertWeighted(ruleSet, "55", "45", "45", CustomerDimensionBoxType.EXTRA_LARGE,
- "64", "54", "54", "0.187");
+ "62", "52", "52", "0.168");
}
private static void assertWeighted(CustomerDimensionWeightingService.RuleSet rules,
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java
index 4f2647b50..dd16dbd27 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java
@@ -152,8 +152,8 @@ class MeasurementBatchServiceTest {
+ "LARGE:50,40,40;系数:1.3;大箱:—;EXTRA_LARGE:60,50,50;系数:1.4;特大箱:—");
when(pricingRepository.findDefaultRule(1L, 1L)).thenReturn(rule);
MeasurementBatchCommand.Item small = new MeasurementBatchCommand.Item(
- 80001L, "001", "A", 1, new BigDecimal("30"), new BigDecimal("20"),
- new BigDecimal("20"), 500L, null, null, null, null, null, null, "小箱");
+ 80001L, "001", "A", 1, new BigDecimal("10"), new BigDecimal("10"),
+ new BigDecimal("10"), 500L, null, null, null, null, null, null, "低于最小档");
MeasurementBatchCommand.Item medium = new MeasurementBatchCommand.Item(
80002L, "002", "B", 1, new BigDecimal("31"), new BigDecimal("20"),
new BigDecimal("20"), 501L, null, null, null, null, null, null, "中箱");
@@ -166,9 +166,9 @@ class MeasurementBatchServiceTest {
ArgumentCaptor> items = ArgumentCaptor.forClass(List.class);
verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L),
org.mockito.ArgumentMatchers.eq(100L), items.capture());
- assertThat(items.getValue().get(0).getOuterLengthCm()).isEqualByComparingTo("33.000");
- assertThat(items.getValue().get(0).getOuterWidthCm()).isEqualByComparingTo("22.000");
- assertThat(items.getValue().get(0).getOuterHeightCm()).isEqualByComparingTo("22.000");
+ assertThat(items.getValue().get(0).getOuterLengthCm()).isEqualByComparingTo("11.000");
+ assertThat(items.getValue().get(0).getOuterWidthCm()).isEqualByComparingTo("11.000");
+ assertThat(items.getValue().get(0).getOuterHeightCm()).isEqualByComparingTo("11.000");
assertThat(items.getValue().get(1).getOuterLengthCm()).isEqualByComparingTo("37.200");
assertThat(items.getValue().get(1).getOuterWidthCm()).isEqualByComparingTo("24.000");
assertThat(items.getValue().get(1).getOuterHeightCm()).isEqualByComparingTo("24.000");
@@ -221,7 +221,32 @@ class MeasurementBatchServiceTest {
}
@Test
- void warehouseOuterDimensionsOverrideCalculatedCartonAndAllDerivedValues() {
+ void dimensionsAboveLargestTierAddLargestTierValueToMeasuredDimensions() {
+ MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item(
+ 80001L, "001", "A", 2, new BigDecimal("60.001"), new BigDecimal("50"),
+ new BigDecimal("50"), 500L, null, null, null, null, null, "ADD", "超过最大箱规");
+ MeasurementBatchCommand command = new MeasurementBatchCommand(
+ 1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null,
+ LocalDateTime.now(), null, null, null, null, null, 9L, List.of(source));
+
+ service.saveBatch(command);
+
+ ArgumentCaptor> items = ArgumentCaptor.forClass(List.class);
+ verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L),
+ org.mockito.ArgumentMatchers.eq(100L), items.capture());
+ InboundMeasurementItemEntity calculated = items.getValue().get(0);
+ assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("62.001");
+ assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("52.000");
+ assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("52.000");
+ assertThat(calculated.getGirthCm()).isEqualByComparingTo("270.001");
+ assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.168");
+ assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("0.3360");
+ assertThat(calculated.getCalculationRuleVersion())
+ .isEqualTo("CUSTOMER_DIMENSION_EXTRA_LARGE_V0");
+ }
+
+ @Test
+ void warehouseOuterDimensionsBecomeBaseValuesBeforeAddStrategy() {
MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item(
80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
new BigDecimal("20"), 500L, null, new BigDecimal("50"),
@@ -239,10 +264,10 @@ class MeasurementBatchServiceTest {
assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("50.000");
assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("40.000");
assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("30.000");
- assertThat(calculated.getGirthCm()).isEqualByComparingTo("190.000");
- assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.0600");
- assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("0.1200");
- assertThat(calculated.getVolumeWeightKg()).isEqualByComparingTo("20.040");
+ assertThat(calculated.getGirthCm()).isEqualByComparingTo("200.000");
+ assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.0699");
+ assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("0.1398");
+ assertThat(calculated.getVolumeWeightKg()).isEqualByComparingTo("23.347");
assertThat(calculated.getCalculationRuleVersion()).isEqualTo("WAREHOUSE_OUTER_OVERRIDE_V1");
}
@@ -265,7 +290,47 @@ class MeasurementBatchServiceTest {
assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("50.000");
assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("32.000");
assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("22.000");
- assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.0352");
+ assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.0367");
+ assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("0.0734");
+ assertThat(calculated.getGirthCm()).isEqualByComparingTo("160.000");
+ assertThat(calculated.getCalculationRuleVersion()).isEqualTo("WAREHOUSE_OUTER_OVERRIDE_L_V1");
+ }
+
+ @Test
+ void warehouseOverrideUsesTheTierMatchedByItsBaseDimensionsBeforeMultiplyStrategy() {
+ CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
+ rule.setId(79L);
+ rule.setTenantId(1L);
+ rule.setCustomerId(1L);
+ rule.setRuleCode("TIERED-OVERRIDE");
+ rule.setRuleVersion(1);
+ rule.setStatus("ACTIVE");
+ rule.setDefaultRule(true);
+ rule.setLengthFactor(new BigDecimal("1.1"));
+ rule.setWidthFactor(new BigDecimal("1.1"));
+ rule.setHeightFactor(new BigDecimal("1.1"));
+ rule.setRemark("SMALL:30,20,20;系数:1.1;小箱:—;MEDIUM:40,30,30;系数:1.2;中箱:—");
+ when(pricingRepository.findDefaultRule(1L, 1L)).thenReturn(rule);
+ MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item(
+ 80001L, "001", "A", 1, new BigDecimal("30"), new BigDecimal("20"),
+ new BigDecimal("20"), 500L, null, new BigDecimal("31"),
+ null, null, null, "MULTIPLY", "仅修改长度");
+ MeasurementBatchCommand command = new MeasurementBatchCommand(
+ 1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null,
+ LocalDateTime.now(), null, null, null, null, null, 9L, List.of(source));
+
+ service.saveBatch(command);
+
+ ArgumentCaptor> items = ArgumentCaptor.forClass(List.class);
+ verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L),
+ org.mockito.ArgumentMatchers.eq(100L), items.capture());
+ InboundMeasurementItemEntity calculated = items.getValue().get(0);
+ assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("31.000");
+ assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("24.000");
+ assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("24.000");
+ assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.0215");
+ assertThat(calculated.getGirthCm()).isEqualByComparingTo("133.200");
+ assertThat(calculated.getChargeRuleId()).isEqualTo(79L);
assertThat(calculated.getCalculationRuleVersion()).isEqualTo("WAREHOUSE_OUTER_OVERRIDE_L_V1");
}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java
index 16b7e7409..1fc68671f 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java
@@ -7,7 +7,12 @@ import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.AdjustRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
+import com.baiyue.firstleg.dto.SmartLoadingDtos.CandidateOrderStatus;
import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest;
+import com.baiyue.firstleg.dto.SmartLoadingDtos.GroupMemberCandidate;
+import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidateQuery;
+import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainer;
+import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan;
import com.baiyue.firstleg.dto.SmartLoadingDtos.RecommendRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.WarehouseRemarkRequest;
@@ -39,6 +44,7 @@ import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
+import java.time.LocalDate;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.sql.SQLException;
@@ -144,6 +150,89 @@ class SmartLoadingServiceTest {
});
}
+ @Test
+ void pagesGlobalCandidatesWithoutBindingAContainer() {
+ Candidate clx = withChannel(candidate(9L, 10L, 20L, 30L, 4),
+ "MATSON_CLX_TRUCK", "美森正班海卡");
+ Candidate max = withChannel(candidate(19L, 11L, 21L, 31L, 6),
+ "MATSON_MAX_TRUCK", "美森加班海卡");
+ when(repository.globalCandidates(null)).thenReturn(List.of(clx, max));
+
+ var result = service.inboundCandidates(new InboundCandidateQuery(
+ List.of("MATSON_CLX"), null, null, null, null, null, null, 1, 20));
+
+ assertThat(result.total()).isEqualTo(1);
+ assertThat(result.pages()).isEqualTo(1);
+ assertThat(result.records()).singleElement().satisfies(candidate -> {
+ assertThat(candidate.inboundId()).isEqualTo(10L);
+ assertThat(candidate.channelCode()).isEqualTo("MATSON_CLX_TRUCK");
+ });
+ verify(repository).globalCandidates(null);
+ verify(repository, never()).booking(anyLong());
+ }
+
+ @Test
+ void pagesWorkbenchContainersWithTheSameGlobalFilters() {
+ WorkbenchContainer container = new WorkbenchContainer(99L, "CB-1", "CAB-1", null,
+ 100L, "义乌仓", "MATSON_CLX_TRUCK", "美森正班海卡", "40HC",
+ 1L, "LP-1", "EDITING", 2);
+ WorkbenchContainerQuery query = new WorkbenchContainerQuery(List.of("MATSON_CLX"), 100L,
+ 9L, "IN-10", LocalDate.parse("2026-09-01"), LocalDate.parse("2026-09-30"),
+ CandidateOrderStatus.WAIT_SPLIT, 1, 20);
+ when(repository.workbenchContainers(eq(query), any())).thenReturn(
+ new com.baiyue.common.core.api.PageResult<>(List.of(container), 1, 20, 1, 1));
+
+ var result = service.containers(query);
+
+ assertThat(result.records()).containsExactly(container);
+ @SuppressWarnings("unchecked") ArgumentCaptor> channels =
+ ArgumentCaptor.forClass(java.util.Set.class);
+ verify(repository).workbenchContainers(eq(query), channels.capture());
+ assertThat(channels.getValue()).containsExactlyInAnyOrder(
+ "MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL");
+ }
+
+ @Test
+ void treatsWholeColoadGroupAsOneStablePagingUnit() {
+ Candidate first = withArrival(withChannel(candidate(9L, 10L, 20L, 30L, 4),
+ "MATSON_CLX_TRUCK", "美森正班海卡"), "2026-09-01T09:00:00");
+ Candidate second = withArrival(withChannel(candidate(19L, 11L, 21L, 31L, 6),
+ "MATSON_CLX_TRUCK", "美森正班海卡"), "2026-09-10T09:00:00");
+ GroupSnapshot group = group("ACTIVE", null,
+ new Member(9L, 30L, 10L, "I10", 4), new Member(19L, 31L, 11L, "I11", 6));
+ when(repository.globalCandidates(100L)).thenReturn(List.of(first, second));
+ when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group);
+ when(coloadGroups.findByInbound(19L, 11L)).thenReturn(group);
+
+ var result = service.inboundCandidates(new InboundCandidateQuery(
+ List.of("MATSON_CLX"), 100L, null, null, LocalDate.parse("2026-09-10"),
+ LocalDate.parse("2026-09-10"), null, 1, 1));
+
+ assertThat(result.total()).isEqualTo(1);
+ assertThat(result.records()).singleElement().satisfies(candidate -> {
+ assertThat(candidate.coLoadGroupId()).isEqualTo(700L);
+ assertThat(candidate.availablePackageCount()).isEqualTo(10);
+ assertThat(candidate.groupMembers()).extracting(GroupMemberCandidate::inboundId)
+ .containsExactly(10L, 11L);
+ });
+ }
+
+ @Test
+ void excludesIncompleteColoadGroupInsteadOfPublishingPartialMember() {
+ Candidate first = withChannel(candidate(9L, 10L, 20L, 30L, 4),
+ "MATSON_CLX_TRUCK", "美森正班海卡");
+ GroupSnapshot group = group("ACTIVE", null,
+ new Member(9L, 30L, 10L, "I10", 4), new Member(19L, 31L, 11L, "I11", 6));
+ when(repository.globalCandidates(100L)).thenReturn(List.of(first));
+ when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group);
+
+ var result = service.inboundCandidates(new InboundCandidateQuery(
+ List.of("MATSON_CLX"), 100L, null, null, null, null, null, 1, 20));
+
+ assertThat(result.records()).isEmpty();
+ assertThat(result.total()).isZero();
+ }
+
@Test
void channelDirectoryKeepsActiveZeroStatChannelAndAggregatesCandidateAndContainerFacts() {
ChannelRow maxTruck = channel("MATSON_MAX_TRUCK", "美森加班海卡");
@@ -1124,6 +1213,16 @@ class SmartLoadingServiceTest {
value.orderStatusSnapshot(), value.expectedArrivalAt());
}
+ private static Candidate withArrival(Candidate value, String expectedArrivalAt) {
+ return new Candidate(value.accountId(), value.inboundId(), value.inboundDetailId(), value.orderId(),
+ value.orderNo(), value.inboundCode(), value.warehouseId(), value.channelCode(), value.channelName(),
+ value.destinationWarehouseId(), value.destinationWarehouseCode(), value.destinationWarehouseName(),
+ value.customerName(), value.goodsName(), value.availablePackageCount(), value.unitGrossWeightKg(),
+ value.unitNetWeightKg(), value.unitVolumeCbm(), value.coLoadGroupId(), value.groupNo(),
+ value.memberCount(), value.groupTotalBoxes(), value.locked(), value.blockReason(),
+ value.orderStatusSnapshot(), LocalDateTime.parse(expectedArrivalAt));
+ }
+
private static ChannelRow channel(String channelCode, String channelName) {
ChannelRow value = new ChannelRow();
value.setChannelCode(channelCode); value.setChannelName(channelName);
diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml
index 3e9ea4fb9..f55de4006 100644
--- a/order/src/main/resources/openapi/order-v1.yaml
+++ b/order/src/main/resources/openapi/order-v1.yaml
@@ -212,6 +212,7 @@ paths:
description: >-
需要 platform:order:declaration:edit。货物使用订单内 itemNo 定位,不接受货物数据库 ID;
不允许修改客户、国内收货仓、订单号、入仓码、状态、仓库/PDA/设备实测、测量审核或计费事实。
+ 仅待审核、待测量数据、待分柜阶段允许修改;进入装柜中及后续阶段后拒绝修改。
已有测量或计费事实不阻断客户申报快照更新,详情继续以最新申报与既有实测分别展示;
只有渠道发生变化时才在写入前校验同柜/分柜/装柜可逆性和重新计价资格,任何校验失败均不写入。
正式门禁分别为 /internal/firstleg/v1/inbounds/order-channel-changes/validate 与
diff --git a/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java b/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java
index ef6969a27..32d5b7c70 100644
--- a/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java
+++ b/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java
@@ -68,6 +68,7 @@ class OrderDeclarationUpdateContractTest {
assertThat(map(row.get("actualVolumeCbm")).get("description").toString()).contains("仅 Firstleg 实测");
assertThat(map(row.get("estimatedVolumeCbm")).get("description").toString()).contains("平台运营");
assertThat(source).contains("ORDER_DECLARATION_UPDATED_V1", "platform:order:declaration:edit", "A0409",
+ "仅待审核、待测量数据、待分柜阶段允许修改",
"/internal/firstleg/v1/inbounds/order-channel-changes/validate",
"/internal/firstleg/v1/pricing/order-channel-changes/validate",
"/internal/firstleg/v1/pricing/order-channel-changes/apply");
diff --git a/order/src/test/java/com/baiyue/order/service/OrderDeclarationEditGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderDeclarationEditGuardTest.java
new file mode 100644
index 000000000..09e878869
--- /dev/null
+++ b/order/src/test/java/com/baiyue/order/service/OrderDeclarationEditGuardTest.java
@@ -0,0 +1,77 @@
+package com.baiyue.order.service;
+
+import com.baiyue.common.core.api.ApiResponse;
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.order.client.FirstlegUnifiedOrderClient;
+import com.baiyue.order.entity.OrderEntity;
+import com.baiyue.order.entity.OrderStatus;
+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.util.List;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@SpringJUnitConfig(OrderDeclarationEditGuard.class)
+@TestPropertySource(properties = "baiyue.security.internal-service-token=token")
+class OrderDeclarationEditGuardTest {
+ @MockBean private FirstlegUnifiedOrderClient firstleg;
+ @Resource private OrderDeclarationEditGuard guard;
+
+ @Test
+ void allowsPendingReviewWithoutFirstlegAndWaitSplitWithAuthoritativeSummary() {
+ OrderEntity pendingReview = order(OrderStatus.SUBMITTED);
+ assertThatCode(() -> guard.requireEditable(pendingReview)).doesNotThrowAnyException();
+
+ OrderEntity waitSplit = order(OrderStatus.APPROVED);
+ var request = new FirstlegUnifiedOrderClient.BatchRequest(11L, List.of(21L));
+ var waitSplitSummary = summary("WAIT_SPLIT");
+ when(firstleg.summaries("token", request)).thenReturn(ApiResponse.success(List.of(waitSplitSummary)));
+ assertThatCode(() -> guard.requireEditable(waitSplit)).doesNotThrowAnyException();
+ verify(firstleg).summaries("token", request);
+ }
+
+ @Test
+ void rejectsLoadingAndLaterStatuses() {
+ OrderEntity order = order(OrderStatus.APPROVED);
+ var request = new FirstlegUnifiedOrderClient.BatchRequest(11L, List.of(21L));
+ for (String status : List.of("LOADING", "LOADED", "DELIVERED")) {
+ var fulfillmentSummary = summary(status);
+ when(firstleg.summaries("token", request)).thenReturn(ApiResponse.success(List.of(fulfillmentSummary)));
+ assertThatThrownBy(() -> guard.requireEditable(order))
+ .isInstanceOf(BizException.class).hasMessageContaining("已进入装柜或后续阶段");
+ }
+ }
+
+ @Test
+ void failsClosedWhenFirstlegSummaryIsMissing() {
+ OrderEntity order = order(OrderStatus.APPROVED);
+ var request = new FirstlegUnifiedOrderClient.BatchRequest(11L, List.of(21L));
+ when(firstleg.summaries("token", request)).thenReturn(ApiResponse.success(List.of()));
+
+ assertThatThrownBy(() -> guard.requireEditable(order))
+ .isInstanceOf(BizException.class).hasMessageContaining("状态校验服务暂时不可用");
+ }
+
+ private FirstlegUnifiedOrderClient.Summary summary(String mainStatus) {
+ var inbound = mock(FirstlegUnifiedOrderClient.Inbound.class);
+ when(inbound.orderId()).thenReturn(21L);
+ var summary = mock(FirstlegUnifiedOrderClient.Summary.class);
+ when(summary.inbound()).thenReturn(inbound);
+ when(summary.mainStatus()).thenReturn(mainStatus);
+ return summary;
+ }
+
+ private OrderEntity order(OrderStatus status) {
+ OrderEntity order = new OrderEntity();
+ order.setId(21L); order.setTenantId(11L); order.setStatus(status);
+ return order;
+ }
+}
diff --git a/order/src/test/java/com/baiyue/order/service/OrderDeclarationUpdateServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderDeclarationUpdateServiceTest.java
index 00534af09..67e7d6530 100644
--- a/order/src/test/java/com/baiyue/order/service/OrderDeclarationUpdateServiceTest.java
+++ b/order/src/test/java/com/baiyue/order/service/OrderDeclarationUpdateServiceTest.java
@@ -58,6 +58,7 @@ class OrderDeclarationUpdateServiceTest {
.contains("channelCode", "channelName", "customerRemark", "expectedArrivalAt", "estimatedVolumeCbm",
"cargoName", "packageCount", "totalQuantity", "lengthCm", "grossWeightKg");
verify(pricingChannelGuard).requireAvailable(100L, 31L, "NEW", "FBA");
+ verify(declarationEditGuard).requireEditable(order);
verify(repository).insertHistory(any());
verify(repository, never()).insertOutbox(any());
assertThat(cargo.getPackageCount()).isEqualTo(20);
@@ -129,6 +130,23 @@ class OrderDeclarationUpdateServiceTest {
any(), any(), any(), any(), any(), anyBoolean());
}
+ @Test
+ void rejectsDeclarationUpdateWhenFulfillmentHasReachedLoading() {
+ OrderEntity order = order(OrderStatus.APPROVED, 3);
+ when(repository.findOwned(100L, 200L)).thenReturn(order);
+ org.mockito.Mockito.doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
+ "订单已进入装柜或后续阶段,不能修改客户申报资料"))
+ .when(declarationEditGuard).requireEditable(order);
+
+ assertThatThrownBy(() -> service.update(100L, 200L, 700L, "平台操作员",
+ request(3, "OLD", "旧渠道", "备注")))
+ .isInstanceOf(BizException.class).hasMessageContaining("已进入装柜");
+
+ verify(repository, never()).findCargo(anyLong(), anyLong());
+ verify(repository, never()).updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(),
+ any(), any(), any(), any(), any(), anyBoolean());
+ }
+
@Test
void updatesPlanningFieldsWithCurrentDeclarationSnapshotAndVersion() {
OrderEntity order = order(OrderStatus.SUBMITTED, 3);