Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	deploy/maven/settings-cn.xml
This commit is contained in:
2026-09-03 15:23:55 +08:00
28 changed files with 867 additions and 156 deletions
-34
View File
@@ -2,38 +2,4 @@
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd">
<mirrors>
<mirror>
<id>aliyun-public</id>
<name>阿里云 Maven 公共镜像</name>
<url>https://maven.aliyun.com/repository/public</url>
<mirrorOf>central</mirrorOf>
</mirror>
</mirrors>
<profiles>
<profile>
<id>国内公共镜像</id>
<repositories>
<repository>
<id>central</id>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>central</id>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</pluginRepository>
</pluginRepositories>
</profile>
</profiles>
<activeProfiles>
<activeProfile>国内公共镜像</activeProfile>
</activeProfiles>
</settings>
@@ -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', '创建流程幂等键及补偿归属');
@@ -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<List<Candidate>> 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<List<InboundCandidate>> inboundCandidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long bookingId,@RequestParam @Positive long warehouseId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.inboundCandidates(bookingId,warehouseId));}
public ApiResponse<PageResult<InboundCandidate>> 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<PageResult<WorkbenchContainer>> 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<Plan> 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')")
@@ -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<GroupMemberCandidate> 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,
@@ -10,7 +10,7 @@ import lombok.Setter;
import java.math.BigDecimal;
/** 客户固定箱规的内径和每侧加宽配置。 */
/** 客户固定箱规的内径和长、宽、高尺寸增加值配置。 */
@Getter
@Setter
@TableName("fl_customer_dimension_config")
@@ -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<Candidate> 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<Candidate> globalCandidates(@Param("warehouseId") Long warehouseId);
@Select("""
<script>
SELECT b.id bookingId,b.booking_no bookingNo,b.cabinet_no cabinetNo,b.container_no containerNo,
b.warehouse_id warehouseId,b.warehouse_name warehouseName,b.channel_code channelCode,
b.channel_name channelName,b.container_type containerType,p.id planId,p.plan_no planNo,
CASE WHEN p.id IS NULL OR NOT EXISTS (SELECT 1 FROM fl_loading_plan_item state_item
WHERE state_item.loading_plan_id=p.id AND state_item.deleted=0)
THEN 'WAIT_LOADING' ELSE p.plan_status END planStatus,b.version version
FROM fl_container_booking b
LEFT JOIN fl_loading_plan p ON p.container_booking_id=b.id
WHERE b.record_status='ACTIVE'
AND (p.id IS NULL OR p.plan_status IN ('EDITING','LOADING'))
AND b.channel_code IN
<foreach collection="actualChannels" item="channel" open="(" separator="," close=")">#{channel}</foreach>
<if test="query.warehouseId != null">AND b.warehouse_id=#{query.warehouseId}</if>
<if test="query.customerId != null or query.inboundCode != null or query.expectedArrivalStart != null or query.expectedArrivalEnd != null or query.orderStatus != null">
AND EXISTS (
SELECT 1 FROM fl_loading_plan_item filter_item
JOIN fl_inbound_order filter_inbound ON filter_inbound.tenant_id=filter_item.account_id
AND filter_inbound.id=filter_item.inbound_id
WHERE filter_item.loading_plan_id=p.id AND filter_item.deleted=0
<if test="query.customerId != null">AND filter_item.account_id=#{query.customerId}</if>
<if test="query.inboundCode != null and query.inboundCode != ''">AND filter_item.inbound_code LIKE CONCAT('%',#{query.inboundCode},'%')</if>
<if test="query.expectedArrivalStart != null">AND filter_inbound.expected_arrival_at &gt;= #{query.expectedArrivalStart}</if>
<if test="query.expectedArrivalEnd != null">AND filter_inbound.expected_arrival_at &lt; DATE_ADD(#{query.expectedArrivalEnd},INTERVAL 1 DAY)</if>
<if test="orderStatus != null">
<choose>
<when test="orderStatus == 'PENDING_REVIEW'">AND filter_inbound.order_status_snapshot IN ('SUBMITTED','PENDING_REVIEW')</when>
<when test="orderStatus == 'WAIT_MEASURE'">AND filter_inbound.process_status IN ('WAIT_MEASURE','MEASURING','MEASURE_PENDING','MEASURE_REJECTED','WAIT_PRICING','FEE_CONFIRMING','INBOUNDING')</when>
<when test="orderStatus == 'WAIT_SPLIT'">AND filter_inbound.process_status='WAIT_SPLIT'</when>
<when test="orderStatus == 'LOADING'">AND filter_inbound.process_status='LOADING'</when>
</choose>
</if>
)
</if>
ORDER BY b.updated_at DESC,b.id DESC
</script>
""")
Page<WorkbenchContainer> workbenchContainers(Page<WorkbenchContainer> page,
@Param("query") WorkbenchContainerQuery query,
@Param("actualChannels") Set<String> 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,
@@ -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<Candidate> candidates(long warehouseId) { return candidates.candidates(warehouseId); }
public List<Candidate> globalCandidates(Long warehouseId) { return candidates.globalCandidates(warehouseId); }
public PageResult<WorkbenchContainer> workbenchContainers(WorkbenchContainerQuery query,
Set<String> actualChannels) {
return PageResult.from(candidates.workbenchContainers(
new Page<>(query.page(), query.size()), query, actualChannels,
query.orderStatus() == null ? null : query.orderStatus().name()));
}
public List<ChannelContainerCountRow> channelContainerCounts(long warehouseId) {
return candidates.channelContainerCounts(warehouseId);
}
@@ -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);
@@ -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);
@@ -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;
@@ -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<String, Set<String>> 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<InboundCandidate> inboundCandidates(InboundCandidateQuery query) {
if (query.expectedArrivalStart() != null && query.expectedArrivalEnd() != null
&& query.expectedArrivalStart().isAfter(query.expectedArrivalEnd())) {
throw validation("预计到货开始日期不能晚于结束日期");
}
Set<String> actualChannels = actualChannels(query.channelGroupCodes());
List<InboundCandidate> 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<String, GroupSnapshot> snapshots = new HashMap<>();
Map<Long, List<InboundCandidate>> grouped = new LinkedHashMap<>();
List<InboundCandidate> 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<InboundCandidate> units = new ArrayList<>(standalone);
for (List<InboundCandidate> 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<WorkbenchContainer> 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<Candidate> 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<InboundCandidate> availableMembers) {
if (group == null || !"ACTIVE".equals(group.status()) || group.lockedPlanId() != null) return null;
Map<Long, InboundCandidate> 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<InboundCandidate> 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<GroupMemberCandidate> 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<InboundCandidate> members,
java.util.function.Function<InboundCandidate, BigDecimal> 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<String> actualChannels(List<String> groups) {
Set<String> actual = new LinkedHashSet<>();
for (String value : groups) {
String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
Set<String> mapped = WORKBENCH_CHANNEL_GROUPS.get(normalized);
if (mapped == null) throw validation("不支持的渠道分组: " + value);
actual.addAll(mapped);
}
return actual;
}
private static boolean matches(InboundCandidateQuery query, Set<String> 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) {
@@ -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 '创建流程幂等键及补偿归属',
+1 -1
View File
@@ -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 '创建流程幂等键及补偿归属',
@@ -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 '创建流程幂等键及补偿归属',
@@ -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]
@@ -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);
@@ -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(
@@ -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));
}
@@ -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
@@ -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");
@@ -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
@@ -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,
@@ -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<List<InboundMeasurementItemEntity>> 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<List<InboundMeasurementItemEntity>> 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<List<InboundMeasurementItemEntity>> 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");
}
@@ -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<java.util.Set<String>> 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);
@@ -212,6 +212,7 @@ paths:
description: >-
需要 platform:order:declaration:edit。货物使用订单内 itemNo 定位,不接受货物数据库 ID;
不允许修改客户、国内收货仓、订单号、入仓码、状态、仓库/PDA/设备实测、测量审核或计费事实。
仅待审核、待测量数据、待分柜阶段允许修改;进入装柜中及后续阶段后拒绝修改。
已有测量或计费事实不阻断客户申报快照更新,详情继续以最新申报与既有实测分别展示;
只有渠道发生变化时才在写入前校验同柜/分柜/装柜可逆性和重新计价资格,任何校验失败均不写入。
正式门禁分别为 /internal/firstleg/v1/inbounds/order-channel-changes/validate 与
@@ -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");
@@ -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;
}
}
@@ -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);