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

# Conflicts:
#	firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java
#	firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java
#	scripts/admin-test-env-modules/inbound-measurement.mjs
This commit is contained in:
2026-09-04 10:37:12 +08:00
17 changed files with 695 additions and 233 deletions
+1 -1
View File
@@ -79,7 +79,7 @@
## 三期装柜与 PDA 资源结算 ## 三期装柜与 PDA 资源结算
- 智能装柜全局工作台由服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选只返回具备实际装柜资格的 `WAIT_SPLIT` 数据,同柜不可拆组必须成员完整、满箱且作为单个分页与分配单元,不能跨页或拆分。分配到柜时继续复核国内中转仓、实际渠道、容量、组锁和版本;推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。 - 智能装柜全局工作台由服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入由有效柜次占用、剩余箱数、国内中转仓、实际渠道、容量、组锁和版本等事实决定;容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。同柜不可拆组必须成员完整、满箱且作为单个分页与分配单元,不能跨页或拆分。推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 - PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 - PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
- `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。当前契约中未传模式且 `locationIds=null` 时按 AUTO 处理。 - `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。当前契约中未传模式且 `locationIds=null` 时按 AUTO 处理。
@@ -7,7 +7,6 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@@ -22,8 +21,8 @@ public interface PendingReviewOrderClient {
@RequestParam(required = false) Long warehouseId, @RequestParam(required = false) Long warehouseId,
@RequestParam(required = false) Long customerId, @RequestParam(required = false) Long customerId,
@RequestParam(required = false) String inboundCode, @RequestParam(required = false) String inboundCode,
@RequestParam(required = false) LocalDate createdStart, @RequestParam(required = false) String createdStart,
@RequestParam(required = false) LocalDate createdEnd, @RequestParam(required = false) String createdEnd,
@RequestParam long page, @RequestParam long page,
@RequestParam long size); @RequestParam long size);
@@ -7,10 +7,13 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
public record InboundCandidateQuery( public record InboundCandidateQuery(
@NotEmpty @Size(max=9) List<@NotBlank String> channelGroupCodes, @NotEmpty @Size(max=9) List<@NotBlank String> channelGroupCodes,
@Positive Long warehouseId, @Positive Long customerId, @Size(max=40) String inboundCode, @Positive Long warehouseId, @Positive Long customerId, @Size(max=40) String inboundCode,
LocalDate createdStart, LocalDate createdEnd, CandidateOrderStatus orderStatus, LocalDate createdStart, LocalDate createdEnd,
@Size(max=4) List<@NotNull CandidateOrderStatus> orderStatuses,
@Min(1) long page, @Min(1) @Max(200) long size) { @Min(1) long page, @Min(1) @Max(200) long size) {
public InboundCandidateQuery { public InboundCandidateQuery {
channelGroupCodes = channelGroupCodes == null ? List.of() : List.copyOf(channelGroupCodes); channelGroupCodes = channelGroupCodes == null ? List.of() : List.copyOf(channelGroupCodes);
orderStatuses = orderStatuses == null ? List.of()
: List.copyOf(new java.util.LinkedHashSet<>(orderStatuses));
if (page == 0) page = 1; if (page == 0) page = 1;
if (size == 0) size = 20; if (size == 0) size = 20;
} }
@@ -24,7 +27,8 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg,
BigDecimal averageUnitVolumeCbm, BigDecimal availableVolumeCbm, BigDecimal averageUnitVolumeCbm, BigDecimal availableVolumeCbm,
Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes, Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes,
Boolean locked, String blockReason, String orderStatusSnapshot, LocalDateTime expectedArrivalAt, Boolean locked, Boolean canAssign, String blockReason,
String orderStatusSnapshot, LocalDateTime expectedArrivalAt,
List<GroupMemberCandidate> groupMembers) { List<GroupMemberCandidate> groupMembers) {
public InboundCandidate { groupMembers = groupMembers == null ? List.of() : List.copyOf(groupMembers); } public InboundCandidate { groupMembers = groupMembers == null ? List.of() : List.copyOf(groupMembers); }
public Long customerId() { return accountId; } public Long customerId() { return accountId; }
@@ -32,7 +36,7 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
public record WorkbenchContainerQuery( public record WorkbenchContainerQuery(
@NotEmpty @Size(max=9) List<@NotBlank String> channelGroupCodes, @NotEmpty @Size(max=9) List<@NotBlank String> channelGroupCodes,
@Positive Long warehouseId, @Positive Long customerId, @Size(max=40) String inboundCode, @Positive Long warehouseId, @Positive Long customerId, @Size(max=40) String inboundCode,
LocalDate createdStart, LocalDate createdEnd, CandidateOrderStatus orderStatus, LocalDate createdStart, LocalDate createdEnd,
@Min(1) long page, @Min(1) @Max(200) long size) { @Min(1) long page, @Min(1) @Max(200) long size) {
public WorkbenchContainerQuery { public WorkbenchContainerQuery {
channelGroupCodes = channelGroupCodes == null ? List.of() : List.copyOf(channelGroupCodes); channelGroupCodes = channelGroupCodes == null ? List.of() : List.copyOf(channelGroupCodes);
@@ -42,6 +46,7 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
} }
public record WorkbenchContainer(Long bookingId, String bookingNo, String cabinetNo, String containerNo, public record WorkbenchContainer(Long bookingId, String bookingNo, String cabinetNo, String containerNo,
Long warehouseId, String warehouseName, String channelCode, String channelName, String containerType, Long warehouseId, String warehouseName, String channelCode, String channelName, String containerType,
BigDecimal capacityCbm, BigDecimal maxWeightKg,
Long planId, String planNo, String planStatus, Integer version) {} Long planId, String planNo, String planStatus, Integer version) {}
public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode, 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, Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount,
@@ -106,61 +106,68 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
CASE WHEN current_group.id IS NULL THEN eligible.inboundId END inboundId, CASE WHEN current_group.id IS NULL THEN eligible.inboundId END inboundId,
MIN(eligible.expectedArrivalAt) sortArrivalAt,MIN(eligible.inboundId) sortInboundId MIN(eligible.expectedArrivalAt) sortArrivalAt,MIN(eligible.inboundId) sortInboundId
FROM ( FROM (
SELECT detail.accountId,detail.inboundId,detail.inboundCode,detail.expectedArrivalAt,detail.createdAt, SELECT o.tenant_id accountId,o.id inboundId,o.inbound_code inboundCode,
SUM(detail.availablePackageCount) availablePackageCount o.expected_arrival_at expectedArrivalAt,o.created_at createdAt,
FROM ( CASE WHEN o.process_status='WAIT_SPLIT' THEN 'WAIT_SPLIT'
SELECT o.tenant_id accountId,o.id inboundId,o.inbound_code inboundCode, WHEN o.process_status='LOADING' THEN 'LOADING' ELSE 'WAIT_MEASURE' END candidateStatus,
o.expected_arrival_at expectedArrivalAt,o.created_at createdAt,i.inbound_detail_id, CASE WHEN o.process_status='WAIT_SPLIT' THEN
SUM(i.package_count) COALESCE((SELECT SUM(mi.package_count) FROM fl_inbound_measurement m
-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li JOIN fl_inbound_measurement_item mi
WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id ON mi.tenant_id=m.tenant_id AND mi.measurement_id=m.id
AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0) AND mi.inbound_detail_id IS NOT NULL AND mi.package_count&gt;0
-COALESCE((SELECT SUM(split_item.package_count) FROM fl_inbound_split_item split_item WHERE m.tenant_id=o.tenant_id AND m.inbound_id=o.id AND m.status='APPROVED'
WHERE o.inbound_role='MASTER' AND split_item.tenant_id=o.tenant_id AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2
AND split_item.parent_inbound_id=o.id WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id
AND split_item.source_inbound_detail_id=i.inbound_detail_id),0) availablePackageCount AND m2.status='APPROVED')),0)
FROM fl_inbound_order o ELSE COALESCE(o.total_package_count,0) END
JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id -COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
AND m.status='APPROVED' WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id AND li.deleted=0),0)
AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2 -COALESCE((SELECT SUM(split_item.package_count) FROM fl_inbound_split_item split_item
WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED') WHERE o.inbound_role='MASTER' AND split_item.tenant_id=o.tenant_id
JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id AND split_item.parent_inbound_id=o.id),0) availablePackageCount
AND i.inbound_detail_id IS NOT NULL AND i.package_count&gt;0 FROM fl_inbound_order o
WHERE (#{query.warehouseId} IS NULL OR o.warehouse_id=#{query.warehouseId}) WHERE (#{query.warehouseId} IS NULL OR o.warehouse_id=#{query.warehouseId})
AND o.channel_code IN AND o.channel_code IN
<foreach collection="actualChannels" item="channel" open="(" separator="," close=")">#{channel}</foreach> <foreach collection="actualChannels" item="channel" open="(" separator="," close=")">#{channel}</foreach>
AND o.process_status='WAIT_SPLIT' AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT'))
AND o.actual_gross_weight_kg IS NOT NULL AND o.actual_net_weight_kg IS NOT NULL AND (
AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT')) o.process_status IN ('WAIT_MEASURE','MEASURING','MEASURE_PENDING','MEASURE_REJECTED',
GROUP BY o.tenant_id,o.id,o.inbound_code,o.expected_arrival_at,o.created_at,i.inbound_detail_id 'WAIT_PRICING','FEE_CONFIRMING','INBOUNDING','WAIT_SPLIT','LOADING')
HAVING availablePackageCount&gt;0 )
) detail
GROUP BY detail.accountId,detail.inboundId,detail.inboundCode,detail.expectedArrivalAt,detail.createdAt
) eligible ) eligible
LEFT JOIN fl_coload_group_member current_member LEFT JOIN fl_coload_group_member current_member
ON current_member.tenant_id=eligible.accountId AND current_member.inbound_id=eligible.inboundId ON current_member.tenant_id=eligible.accountId AND current_member.inbound_id=eligible.inboundId
AND current_member.active_flag=1 AND current_member.active_flag=1
LEFT JOIN fl_coload_group current_group LEFT JOIN fl_coload_group current_group
ON current_group.id=current_member.group_id AND current_group.group_status IN ('ACTIVE','LOCKED') ON current_group.id=current_member.group_id AND current_group.group_status IN ('ACTIVE','LOCKED')
WHERE eligible.candidateStatus&lt;&gt;'WAIT_SPLIT' OR eligible.availablePackageCount&gt;0
GROUP BY current_group.id, GROUP BY current_group.id,
CASE WHEN current_group.id IS NULL THEN eligible.accountId END, CASE WHEN current_group.id IS NULL THEN eligible.accountId END,
CASE WHEN current_group.id IS NULL THEN eligible.inboundId END CASE WHEN current_group.id IS NULL THEN eligible.inboundId END
HAVING (current_group.id IS NULL OR ( HAVING (current_group.id IS NULL OR (
MIN(current_group.group_status)='ACTIVE' AND MAX(current_group.locked_plan_id) IS NULL COUNT(*)=(SELECT COUNT(*) FROM fl_coload_group_member group_member_count
AND COUNT(*)=(SELECT COUNT(*) FROM fl_coload_group_member group_member_count
WHERE group_member_count.group_id=current_group.id AND group_member_count.active_flag=1) WHERE group_member_count.group_id=current_group.id AND group_member_count.active_flag=1)
AND SUM(CASE WHEN eligible.availablePackageCount=current_member.full_package_count THEN 1 ELSE 0 END)=COUNT(*)
)) ))
<if test="query.customerId != null"> <if test="query.orderStatuses != null and !query.orderStatuses.isEmpty()">
AND SUM(CASE WHEN eligible.accountId=#{query.customerId} THEN 1 ELSE 0 END)&gt;0 AND MAX(CASE WHEN
</if> <foreach collection="query.orderStatuses" item="status" separator=" OR ">
<if test="query.inboundCode != null and query.inboundCode.trim() != ''"> <choose>
AND SUM(CASE WHEN LOWER(eligible.inboundCode) LIKE CONCAT('%',LOWER(#{query.inboundCode}),'%') THEN 1 ELSE 0 END)&gt;0 <when test="status.name() == 'WAIT_MEASURE'">eligible.candidateStatus='WAIT_MEASURE'</when>
</if> <when test="status.name() == 'WAIT_SPLIT'">eligible.candidateStatus='WAIT_SPLIT'</when>
<if test="query.createdStart != null or query.createdEnd != null"> <when test="status.name() == 'LOADING'">eligible.candidateStatus='LOADING'</when>
<if test="query.createdStart != null">AND DATE(MIN(eligible.createdAt))&gt;=#{query.createdStart}</if> <otherwise>1=0</otherwise>
<if test="query.createdEnd != null">AND DATE(MIN(eligible.createdAt))&lt;=#{query.createdEnd}</if> </choose>
</if> </foreach>
THEN 1 ELSE 0 END)=1
</if>
<if test="query.customerId != null">
AND MAX(CASE WHEN eligible.accountId=#{query.customerId} THEN 1 ELSE 0 END)=1
</if>
<if test="query.inboundCode != null and query.inboundCode.trim() != ''">
AND MAX(CASE WHEN LOWER(eligible.inboundCode) LIKE CONCAT('%',LOWER(#{query.inboundCode}),'%') THEN 1 ELSE 0 END)=1
</if>
<if test="query.createdStart != null">AND MIN(eligible.createdAt)&gt;=#{query.createdStart}</if>
<if test="query.createdEnd != null">AND MIN(eligible.createdAt)&lt;DATE_ADD(#{query.createdEnd},INTERVAL 1 DAY)</if>
ORDER BY sortArrivalAt IS NULL,sortArrivalAt,sortInboundId ORDER BY sortArrivalAt IS NULL,sortArrivalAt,sortInboundId
</script> </script>
""") """)
@@ -190,8 +197,11 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
WHERE member_count.group_id=current_group.id AND member_count.active_flag=1) memberCount, WHERE member_count.group_id=current_group.id AND member_count.active_flag=1) memberCount,
(SELECT SUM(member_boxes.full_package_count) FROM fl_coload_group_member member_boxes (SELECT SUM(member_boxes.full_package_count) FROM fl_coload_group_member member_boxes
WHERE member_boxes.group_id=current_group.id AND member_boxes.active_flag=1) groupTotalBoxes, WHERE member_boxes.group_id=current_group.id AND member_boxes.active_flag=1) groupTotalBoxes,
CASE WHEN current_group.id IS NULL THEN NULL ELSE FALSE END locked,NULL blockReason, CASE WHEN current_group.locked_plan_id IS NOT NULL OR EXISTS (
o.order_status_snapshot orderStatusSnapshot,o.expected_arrival_at expectedArrivalAt SELECT 1 FROM fl_loading_plan_item occupied
WHERE occupied.account_id=o.tenant_id AND occupied.inbound_id=o.id AND occupied.deleted=0
) THEN TRUE ELSE FALSE END locked,NULL blockReason,
'WAIT_SPLIT' orderStatusSnapshot,o.expected_arrival_at expectedArrivalAt
FROM fl_inbound_order o FROM fl_inbound_order o
JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id
AND m.status='APPROVED' AND m.status='APPROVED'
@@ -203,7 +213,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
ON current_member.tenant_id=o.tenant_id AND current_member.inbound_id=o.id AND current_member.active_flag=1 ON current_member.tenant_id=o.tenant_id AND current_member.inbound_id=o.id AND current_member.active_flag=1
LEFT JOIN fl_coload_group current_group LEFT JOIN fl_coload_group current_group
ON current_group.id=current_member.group_id AND current_group.group_status='ACTIVE' ON current_group.id=current_member.group_id AND current_group.group_status='ACTIVE'
AND current_group.locked_plan_id IS NULL
WHERE o.process_status='WAIT_SPLIT' WHERE 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.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 (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT'))
@@ -217,7 +226,45 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
) )
GROUP BY o.id,m.id,i.inbound_detail_id,current_group.id,current_group.group_no GROUP BY o.id,m.id,i.inbound_detail_id,current_group.id,current_group.group_no
HAVING availablePackageCount&gt;0 HAVING availablePackageCount&gt;0
ORDER BY o.inbounded_at ASC,o.id ASC,i.inbound_detail_id ASC UNION ALL
SELECT o.tenant_id accountId,o.id inboundId,NULL 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,NULL goodsName,
GREATEST(COALESCE(o.total_package_count,0)-COALESCE((SELECT SUM(used.package_count)
FROM fl_loading_plan_item used WHERE used.account_id=o.tenant_id
AND used.inbound_id=o.id AND used.deleted=0),0),0) availablePackageCount,
o.actual_gross_weight_kg/NULLIF(o.total_package_count,0) unitGrossWeightKg,
o.actual_net_weight_kg/NULLIF(o.total_package_count,0) unitNetWeightKg,
o.actual_volume_cbm/NULLIF(o.total_package_count,0) unitVolumeCbm,
current_group.id coLoadGroupId,current_group.group_no groupNo,
(SELECT COUNT(*) FROM fl_coload_group_member member_count
WHERE member_count.group_id=current_group.id AND member_count.active_flag=1) memberCount,
(SELECT SUM(member_boxes.full_package_count) FROM fl_coload_group_member member_boxes
WHERE member_boxes.group_id=current_group.id AND member_boxes.active_flag=1) groupTotalBoxes,
CASE WHEN current_group.locked_plan_id IS NOT NULL OR EXISTS (
SELECT 1 FROM fl_loading_plan_item occupied
WHERE occupied.account_id=o.tenant_id AND occupied.inbound_id=o.id AND occupied.deleted=0
) THEN TRUE ELSE FALSE END locked,NULL blockReason,
CASE WHEN o.process_status='LOADING' THEN 'LOADING' ELSE 'WAIT_MEASURE' END orderStatusSnapshot,
o.expected_arrival_at expectedArrivalAt
FROM fl_inbound_order o
LEFT JOIN fl_coload_group_member current_member
ON current_member.tenant_id=o.tenant_id AND current_member.inbound_id=o.id AND current_member.active_flag=1
LEFT JOIN fl_coload_group current_group
ON current_group.id=current_member.group_id AND current_group.group_status IN ('ACTIVE','LOCKED')
WHERE o.process_status IN ('WAIT_MEASURE','MEASURING','MEASURE_PENDING','MEASURE_REJECTED',
'WAIT_PRICING','FEE_CONFIRMING','INBOUNDING','LOADING')
AND (
<foreach collection="units" item="unit" separator=" OR ">
<choose>
<when test="unit.groupId != null">current_group.id=#{unit.groupId}</when>
<otherwise>(current_group.id IS NULL AND o.tenant_id=#{unit.accountId} AND o.id=#{unit.inboundId})</otherwise>
</choose>
</foreach>
)
ORDER BY inboundId ASC,inboundDetailId ASC
</script> </script>
""") """)
List<Candidate> globalCandidatesByUnits(@Param("units") List<CandidateUnit> units); List<Candidate> globalCandidatesByUnits(@Param("units") List<CandidateUnit> units);
@@ -226,7 +273,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
<script> <script>
SELECT b.id bookingId,b.booking_no bookingNo,b.cabinet_no cabinetNo,b.container_no containerNo, 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.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, b.channel_name channelName,b.container_type containerType,b.capacity_cbm capacityCbm,
b.max_weight_kg maxWeightKg,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 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) 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 THEN 'WAIT_LOADING' ELSE p.plan_status END planStatus,b.version version
@@ -237,7 +285,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
AND b.channel_code IN AND b.channel_code IN
<foreach collection="actualChannels" item="channel" open="(" separator="," close=")">#{channel}</foreach> <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.warehouseId != null">AND b.warehouse_id=#{query.warehouseId}</if>
<if test="query.customerId != null or query.inboundCode != null or query.createdStart != null or query.createdEnd != null or query.orderStatus != null"> <if test="query.customerId != null or query.inboundCode != null or query.createdStart != null or query.createdEnd != null">
AND EXISTS ( AND EXISTS (
SELECT 1 FROM fl_loading_plan_item filter_item SELECT 1 FROM fl_loading_plan_item filter_item
JOIN fl_inbound_order filter_inbound ON filter_inbound.tenant_id=filter_item.account_id JOIN fl_inbound_order filter_inbound ON filter_inbound.tenant_id=filter_item.account_id
@@ -247,14 +295,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
<if test="query.inboundCode != null and query.inboundCode != ''">AND filter_item.inbound_code LIKE CONCAT('%',#{query.inboundCode},'%')</if> <if test="query.inboundCode != null and query.inboundCode != ''">AND filter_item.inbound_code LIKE CONCAT('%',#{query.inboundCode},'%')</if>
<if test="query.createdStart != null">AND filter_inbound.created_at &gt;= #{query.createdStart}</if> <if test="query.createdStart != null">AND filter_inbound.created_at &gt;= #{query.createdStart}</if>
<if test="query.createdEnd != null">AND filter_inbound.created_at &lt; DATE_ADD(#{query.createdEnd},INTERVAL 1 DAY)</if> <if test="query.createdEnd != null">AND filter_inbound.created_at &lt; DATE_ADD(#{query.createdEnd},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> </if>
ORDER BY b.updated_at DESC,b.id DESC ORDER BY b.updated_at DESC,b.id DESC
@@ -262,8 +302,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
""") """)
Page<WorkbenchContainer> workbenchContainers(Page<WorkbenchContainer> page, Page<WorkbenchContainer> workbenchContainers(Page<WorkbenchContainer> page,
@Param("query") WorkbenchContainerQuery query, @Param("query") WorkbenchContainerQuery query,
@Param("actualChannels") Set<String> actualChannels, @Param("actualChannels") Set<String> actualChannels);
@Param("orderStatus") String orderStatus);
@Select(""" @Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId, SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
@@ -51,8 +51,7 @@ public class SmartLoadingRepository {
public PageResult<WorkbenchContainer> workbenchContainers(WorkbenchContainerQuery query, public PageResult<WorkbenchContainer> workbenchContainers(WorkbenchContainerQuery query,
Set<String> actualChannels) { Set<String> actualChannels) {
return PageResult.from(candidates.workbenchContainers( return PageResult.from(candidates.workbenchContainers(
new Page<>(query.page(), query.size()), query, actualChannels, new Page<>(query.page(), query.size()), query, actualChannels));
query.orderStatus() == null ? null : query.orderStatus().name()));
} }
public List<ChannelContainerCountRow> channelContainerCounts(long warehouseId) { public List<ChannelContainerCountRow> channelContainerCounts(long warehouseId) {
return candidates.channelContainerCounts(warehouseId); return candidates.channelContainerCounts(warehouseId);
@@ -68,6 +67,9 @@ public class SmartLoadingRepository {
.eq(InboundOrderEntity::getTenantId, accountId) .eq(InboundOrderEntity::getTenantId, accountId)
.eq(InboundOrderEntity::getId, inboundId)); .eq(InboundOrderEntity::getId, inboundId));
} }
public InboundOrderEntity inbound(long inboundId) {
return inbounds.selectById(inboundId);
}
public InboundOrderEntity inboundForUpdate(long accountId, long inboundId) { public InboundOrderEntity inboundForUpdate(long accountId, long inboundId) {
return inbounds.selectOne(new LambdaQueryWrapper<InboundOrderEntity>() return inbounds.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, accountId) .eq(InboundOrderEntity::getTenantId, accountId)
@@ -108,7 +108,7 @@ public class InboundMeasurementService {
request.items().stream().map(InboundMeasurementRequests.ItemRequest::chargeRuleId) request.items().stream().map(InboundMeasurementRequests.ItemRequest::chargeRuleId)
.filter(java.util.Objects::nonNull).distinct().forEach(ruleId -> { .filter(java.util.Objects::nonNull).distinct().forEach(ruleId -> {
var rule = fbaMasterDataService.rule(tenantId, ruleId); var rule = fbaMasterDataService.rule(tenantId, ruleId);
if (!"ACTIVE".equals(rule.getStatus()) || !java.util.Objects.equals(rule.getCustomerId(), inbound.getCustomerId())) { if (!"ACTIVE".equals(rule.getStatus()) || !java.util.Objects.equals(rule.getTenantId(), tenantId)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细绑定的计费规则不属于当前客户或已停用"); throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细绑定的计费规则不属于当前客户或已停用");
} }
}); });
@@ -299,16 +299,13 @@ public class MeasurementBatchService {
} }
private CustomerChargeRuleEntity requireCoefficientRule(long tenantId, Long customerId, Long ruleId) { private CustomerChargeRuleEntity requireCoefficientRule(long tenantId, Long customerId, Long ruleId) {
if (customerId == null || customerId <= 0) { Long effectiveCustomerId = tenantId;
if (ruleId == null) return null;
throw new BizException(ResultCode.VALIDATION_FAILED, "入库单客户无效,不能应用计费系数");
}
CustomerChargeRuleEntity rule = ruleId == null CustomerChargeRuleEntity rule = ruleId == null
? pricingRepository.findDefaultRule(tenantId, customerId) ? pricingRepository.findDefaultRule(tenantId, effectiveCustomerId)
: pricingRepository.findRule(tenantId, ruleId); : pricingRepository.findRule(tenantId, ruleId);
if (rule == null || !"ACTIVE".equals(rule.getStatus()) if (rule == null || !"ACTIVE".equals(rule.getStatus())
|| !Boolean.TRUE.equals(rule.getDefaultRule()) || !customerId.equals(rule.getCustomerId())) { || !java.util.Objects.equals(rule.getTenantId(), tenantId)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "计费规则不属于当前客户、未启用或未设置默认规则"); throw new BizException(ResultCode.VALIDATION_FAILED, "计费规则不属于当前客户或已停用");
} }
return rule; return rule;
} }
@@ -41,7 +41,7 @@ import java.util.*;
/** 智能装柜的自动计划、候选、推荐、人工分配和删除编排。 */ /** 智能装柜的自动计划、候选、推荐、人工分配和删除编排。 */
@Service @Service
public class SmartLoadingService { public class SmartLoadingService {
private static final BigDecimal CAPACITY_LIMIT_RATIO = new BigDecimal("1.10"); private static final BigDecimal CAPACITY_LIMIT_RATIO = new BigDecimal("1.50");
private static final Map<String, Set<String>> WORKBENCH_CHANNEL_GROUPS = Map.of( private static final Map<String, Set<String>> WORKBENCH_CHANNEL_GROUPS = Map.of(
"MATSON_CLX", Set.of("MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL"), "MATSON_CLX", Set.of("MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL"),
"MATSON_MAX", Set.of("MATSON_MAX_TRUCK", "MATSON_MAX_PARCEL"), "MATSON_MAX", Set.of("MATSON_MAX_TRUCK", "MATSON_MAX_PARCEL"),
@@ -121,12 +121,34 @@ public class SmartLoadingService {
throw validation("创建开始日期不能晚于结束日期"); throw validation("创建开始日期不能晚于结束日期");
} }
Set<String> actualChannels = actualChannels(query.channelGroupCodes()); Set<String> actualChannels = actualChannels(query.channelGroupCodes());
if (query.orderStatus() == CandidateOrderStatus.PENDING_REVIEW) { EnumSet<CandidateOrderStatus> selected = query.orderStatuses().isEmpty()
return pendingReviewCandidates(query, actualChannels); ? EnumSet.allOf(CandidateOrderStatus.class)
: EnumSet.copyOf(query.orderStatuses());
boolean includePending = selected.remove(CandidateOrderStatus.PENDING_REVIEW);
InboundCandidateQuery firstlegQuery = withStatuses(query, List.copyOf(selected), 1, 1);
if (!includePending) return firstlegCandidates(withStatuses(query, List.copyOf(selected),
query.page(), query.size()), actualChannels);
if (selected.isEmpty()) return pendingReviewCandidates(query, actualChannels, query.page(), query.size());
PageResult<InboundCandidate> pendingCount = pendingReviewCandidates(query, actualChannels, 1, 1);
PageResult<InboundCandidate> firstlegCount = firstlegCandidates(firstlegQuery, actualChannels);
long total = pendingCount.total() + firstlegCount.total();
long offset = Math.multiplyExact(query.page() - 1, query.size());
List<InboundCandidate> records = new ArrayList<>();
if (offset < pendingCount.total()) {
records.addAll(pendingReviewRange(query, actualChannels, offset, query.size()));
} }
if (query.orderStatus() != null && query.orderStatus() != CandidateOrderStatus.WAIT_SPLIT) { long firstlegOffset = Math.max(0, offset - pendingCount.total());
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); int remaining = Math.toIntExact(query.size() - records.size());
if (remaining > 0 && firstlegOffset < firstlegCount.total()) {
records.addAll(firstlegRange(query, actualChannels, List.copyOf(selected), firstlegOffset, remaining));
} }
long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size();
return new PageResult<>(records, query.page(), query.size(), total, pages);
}
private PageResult<InboundCandidate> firstlegCandidates(InboundCandidateQuery query,
Set<String> actualChannels) {
PageResult<CandidateUnit> unitPage = repository.globalCandidateUnits(query, actualChannels); PageResult<CandidateUnit> unitPage = repository.globalCandidateUnits(query, actualChannels);
if (unitPage.records().isEmpty()) { if (unitPage.records().isEmpty()) {
return new PageResult<>(List.of(), unitPage.page(), unitPage.size(), unitPage.total(), unitPage.pages()); return new PageResult<>(List.of(), unitPage.page(), unitPage.size(), unitPage.total(), unitPage.pages());
@@ -156,12 +178,13 @@ public class SmartLoadingService {
} }
private PageResult<InboundCandidate> pendingReviewCandidates(InboundCandidateQuery query, private PageResult<InboundCandidate> pendingReviewCandidates(InboundCandidateQuery query,
Set<String> actualChannels) { Set<String> actualChannels,
long page, long size) {
ApiResponse<PageResult<PendingReviewOrderClient.Row>> response; ApiResponse<PageResult<PendingReviewOrderClient.Row>> response;
try { try {
response = pendingReviewOrders.page(internalToken, List.copyOf(actualChannels), query.warehouseId(), response = pendingReviewOrders.page(internalToken, List.copyOf(actualChannels), query.warehouseId(),
query.customerId(), query.inboundCode(), query.createdStart(), query.createdEnd(), query.customerId(), query.inboundCode(), isoDate(query.createdStart()), isoDate(query.createdEnd()),
query.page(), query.size()); page, size);
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "待审核订单服务暂时不可用"); throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "待审核订单服务暂时不可用");
} }
@@ -173,11 +196,53 @@ public class SmartLoadingService {
new InboundCandidate(row.customerId(), null, row.orderId(), row.orderNo(), row.inboundCode(), new InboundCandidate(row.customerId(), null, row.orderId(), row.orderNo(), row.inboundCode(),
row.warehouseId(), row.channelCode(), row.channelName(), null, null, null, row.warehouseId(), row.channelCode(), row.channelName(), null, null, null,
row.customerName(), null, row.totalPackageCount(), null, null, null, null, row.customerName(), null, row.totalPackageCount(), null, null, null, null,
null, null, null, null, false, null, null, null, null, null, false, true, null,
"PENDING_REVIEW", null, List.of())).toList(); "PENDING_REVIEW", null, List.of())).toList();
return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages()); return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages());
} }
private static String isoDate(java.time.LocalDate value) {
return value == null ? null : value.toString();
}
private List<InboundCandidate> pendingReviewRange(InboundCandidateQuery query, Set<String> channels,
long offset, long limit) {
return pageRange(offset, limit, (page, size) -> pendingReviewCandidates(query, channels, page, size));
}
private List<InboundCandidate> firstlegRange(InboundCandidateQuery query, Set<String> channels,
List<CandidateOrderStatus> statuses,
long offset, long limit) {
return pageRange(offset, limit, (page, size) -> firstlegCandidates(
withStatuses(query, statuses, page, size), channels));
}
private static List<InboundCandidate> pageRange(long offset, long limit,
java.util.function.BiFunction<Long, Long, PageResult<InboundCandidate>> source) {
if (limit <= 0) return List.of();
final long chunk = 200;
long page = offset / chunk + 1;
int skip = Math.toIntExact(offset % chunk);
List<InboundCandidate> result = new ArrayList<>();
while (result.size() < limit) {
List<InboundCandidate> rows = source.apply(page++, chunk).records();
if (rows.isEmpty()) break;
int from = Math.min(skip, rows.size());
int take = Math.min(Math.toIntExact(limit - result.size()), rows.size() - from);
if (take > 0) result.addAll(rows.subList(from, from + take));
skip = 0;
if (rows.size() < chunk) break;
}
return List.copyOf(result);
}
private static InboundCandidateQuery withStatuses(InboundCandidateQuery source,
List<CandidateOrderStatus> statuses,
long page, long size) {
return new InboundCandidateQuery(source.channelGroupCodes(), source.warehouseId(), source.customerId(),
source.inboundCode(), source.createdStart(), source.createdEnd(), statuses, page, size);
}
public PageResult<WorkbenchContainer> containers(WorkbenchContainerQuery query) { public PageResult<WorkbenchContainer> containers(WorkbenchContainerQuery query) {
if (query.createdStart() != null && query.createdEnd() != null if (query.createdStart() != null && query.createdEnd() != null
&& query.createdStart().isAfter(query.createdEnd())) { && query.createdStart().isAfter(query.createdEnd())) {
@@ -190,11 +255,12 @@ public class SmartLoadingService {
Candidate first = details.get(0); Candidate first = details.get(0);
int packages = details.stream().map(Candidate::availablePackageCount) int packages = details.stream().map(Candidate::availablePackageCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); .filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
BigDecimal volume = details.stream() boolean hasVolume = details.stream().anyMatch(detail -> detail.unitVolumeCbm() != null);
.map(detail -> unit(detail.unitVolumeCbm()).multiply(BigDecimal.valueOf( BigDecimal volume = hasVolume ? details.stream().filter(detail -> detail.unitVolumeCbm() != null)
.map(detail -> detail.unitVolumeCbm().multiply(BigDecimal.valueOf(
Math.max(0, detail.availablePackageCount() == null ? 0 : detail.availablePackageCount())))) Math.max(0, detail.availablePackageCount() == null ? 0 : detail.availablePackageCount()))))
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(4, RoundingMode.HALF_UP); .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(4, RoundingMode.HALF_UP) : null;
BigDecimal averageVolume = packages == 0 ? BigDecimal.ZERO : volume.divide( BigDecimal averageVolume = packages == 0 || volume == null ? null : volume.divide(
BigDecimal.valueOf(packages), 6, RoundingMode.HALF_UP); BigDecimal.valueOf(packages), 6, RoundingMode.HALF_UP);
String goods = details.stream().map(Candidate::goodsName).filter(StringUtils::hasText) String goods = details.stream().map(Candidate::goodsName).filter(StringUtils::hasText)
.map(String::trim).distinct().collect(java.util.stream.Collectors.joining("、")); .map(String::trim).distinct().collect(java.util.stream.Collectors.joining("、"));
@@ -203,7 +269,7 @@ public class SmartLoadingService {
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(), first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
first.customerName(), goods, packages, first.unitGrossWeightKg(), first.unitNetWeightKg(), first.customerName(), goods, packages, first.unitGrossWeightKg(), first.unitNetWeightKg(),
averageVolume, volume, first.coLoadGroupId(), first.groupNo(), first.memberCount(), averageVolume, volume, first.coLoadGroupId(), first.groupNo(), first.memberCount(),
first.groupTotalBoxes(), first.locked(), first.blockReason(), first.orderStatusSnapshot(), first.groupTotalBoxes(), first.locked(), canAssign(first), capabilityBlock(first), first.orderStatusSnapshot(),
first.expectedArrivalAt(), List.of()); first.expectedArrivalAt(), List.of());
} }
@@ -216,12 +282,14 @@ public class SmartLoadingService {
.sorted(Comparator.comparing(InboundCandidate::inboundId)).toList(); .sorted(Comparator.comparing(InboundCandidate::inboundId)).toList();
InboundCandidate first = ordered.get(0); InboundCandidate first = ordered.get(0);
int packages = ordered.stream().map(InboundCandidate::availablePackageCount).mapToInt(Integer::intValue).sum(); int packages = ordered.stream().map(InboundCandidate::availablePackageCount).mapToInt(Integer::intValue).sum();
if (packages != metadata.groupTotalBoxes()) return null; boolean occupied = ordered.stream().anyMatch(candidate -> Boolean.TRUE.equals(candidate.locked()));
BigDecimal volume = ordered.stream().map(InboundCandidate::availableVolumeCbm) if (!occupied && packages != metadata.groupTotalBoxes()) return null;
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add).setScale(4, RoundingMode.HALF_UP); boolean hasVolume = ordered.stream().anyMatch(candidate -> candidate.availableVolumeCbm() != null);
BigDecimal volume = hasVolume ? ordered.stream().map(InboundCandidate::availableVolumeCbm)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add).setScale(4, RoundingMode.HALF_UP) : null;
BigDecimal gross = weightedAverage(ordered, InboundCandidate::unitGrossWeightKg, packages); BigDecimal gross = weightedAverage(ordered, InboundCandidate::unitGrossWeightKg, packages);
BigDecimal net = weightedAverage(ordered, InboundCandidate::unitNetWeightKg, packages); BigDecimal net = weightedAverage(ordered, InboundCandidate::unitNetWeightKg, packages);
BigDecimal averageVolume = packages == 0 ? BigDecimal.ZERO BigDecimal averageVolume = packages == 0 || volume == null ? null
: volume.divide(BigDecimal.valueOf(packages), 6, RoundingMode.HALF_UP); : volume.divide(BigDecimal.valueOf(packages), 6, RoundingMode.HALF_UP);
List<GroupMemberCandidate> groupMembers = ordered.stream().map(member -> new GroupMemberCandidate( List<GroupMemberCandidate> groupMembers = ordered.stream().map(member -> new GroupMemberCandidate(
member.accountId(), member.inboundId(), member.orderId(), member.orderNo(), member.inboundCode(), member.accountId(), member.inboundId(), member.orderId(), member.orderNo(), member.inboundCode(),
@@ -235,11 +303,42 @@ public class SmartLoadingService {
first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(), first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(),
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(), first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
customers, goods, packages, gross, net, averageVolume, volume, metadata.coLoadGroupId(), metadata.groupNo(), customers, goods, packages, gross, net, averageVolume, volume, metadata.coLoadGroupId(), metadata.groupNo(),
metadata.memberCount(), metadata.groupTotalBoxes(), false, null, first.orderStatusSnapshot(), metadata.memberCount(), metadata.groupTotalBoxes(),
occupied,
ordered.stream().allMatch(candidate -> Boolean.TRUE.equals(candidate.canAssign())),
ordered.stream().map(InboundCandidate::blockReason).filter(StringUtils::hasText).findFirst().orElse(null),
groupStatus(ordered),
ordered.stream().map(InboundCandidate::expectedArrivalAt).filter(Objects::nonNull).min(LocalDateTime::compareTo).orElse(null), ordered.stream().map(InboundCandidate::expectedArrivalAt).filter(Objects::nonNull).min(LocalDateTime::compareTo).orElse(null),
groupMembers); groupMembers);
} }
private static boolean canAssign(Candidate candidate) {
return !Boolean.TRUE.equals(candidate.locked()) && !StringUtils.hasText(candidate.blockReason())
&& candidate.availablePackageCount() != null && candidate.availablePackageCount() > 0;
}
private static String capabilityBlock(Candidate candidate) {
if (StringUtils.hasText(candidate.blockReason())) return candidate.blockReason();
if (Boolean.TRUE.equals(candidate.locked())) return "该入仓码已被有效柜次占用";
if (candidate.availablePackageCount() == null || candidate.availablePackageCount() <= 0) return "没有可加入的剩余箱数";
return null;
}
private static String groupStatus(List<InboundCandidate> members) {
return members.stream().map(InboundCandidate::orderStatusSnapshot).filter(StringUtils::hasText)
.min(Comparator.comparingInt(SmartLoadingService::statusOrder)).orElse(null);
}
private static int statusOrder(String status) {
return switch (status) {
case "PENDING_REVIEW" -> 0;
case "WAIT_MEASURE" -> 1;
case "WAIT_SPLIT" -> 2;
case "LOADING" -> 3;
default -> 4;
};
}
private static String unitKey(Long groupId, Long accountId, Long inboundId) { private static String unitKey(Long groupId, Long accountId, Long inboundId) {
return groupId == null ? "I|" + accountId + '|' + inboundId : "G|" + groupId; return groupId == null ? "I|" + accountId + '|' + inboundId : "G|" + groupId;
} }
@@ -247,8 +346,8 @@ public class SmartLoadingService {
private static BigDecimal weightedAverage(List<InboundCandidate> members, private static BigDecimal weightedAverage(List<InboundCandidate> members,
java.util.function.Function<InboundCandidate, BigDecimal> getter, java.util.function.Function<InboundCandidate, BigDecimal> getter,
int packages) { int packages) {
if (packages == 0) return BigDecimal.ZERO; if (packages == 0 || members.stream().map(getter).anyMatch(Objects::isNull)) return null;
return members.stream().map(candidate -> unit(getter.apply(candidate)).multiply( return members.stream().map(candidate -> getter.apply(candidate).multiply(
BigDecimal.valueOf(candidate.availablePackageCount()))) BigDecimal.valueOf(candidate.availablePackageCount())))
.reduce(BigDecimal.ZERO, BigDecimal::add) .reduce(BigDecimal.ZERO, BigDecimal::add)
.divide(BigDecimal.valueOf(packages), 6, RoundingMode.HALF_UP); .divide(BigDecimal.valueOf(packages), 6, RoundingMode.HALF_UP);
@@ -460,16 +559,31 @@ public class SmartLoadingService {
private Plan assignInbound(long planId, AssignInboundRequest request, private Plan assignInbound(long planId, AssignInboundRequest request,
long operatorId, String operatorName, boolean wholeGroupAssignment) { long operatorId, String operatorName, boolean wholeGroupAssignment) {
return assignInbound(planId, request, operatorId, operatorName, wholeGroupAssignment, null);
}
private Plan assignInbound(long planId, AssignInboundRequest request,
long operatorId, String operatorName, boolean wholeGroupAssignment,
List<Candidate> frozenDetails) {
String operation = operation(request.operationId()); String operation = operation(request.operationId());
String requestHash = sha256("ASSIGN_INBOUND|" + planId + '|' + request.inboundId() + '|' String requestHash = sha256("ASSIGN_INBOUND|" + planId + '|' + request.inboundId() + '|'
+ request.packageCount() + '|' + request.expectedVersion()); + request.packageCount() + '|' + request.expectedVersion());
Plan replay = replay(operation, planId, "ADD_INBOUND", null, requestHash); Plan replay = replay(operation, planId, "ADD_INBOUND", null, requestHash);
if (replay != null) return replay; if (replay != null) return replay;
InboundOrderEntity projection = repository.inbound(request.inboundId());
if (!wholeGroupAssignment && projection != null && Set.of("PREPARING", "WAIT_MEASURE",
"MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "WAIT_PRICING",
"FEE_CONFIRMING", "INBOUNDING").contains(projection.getProcessStatus())) {
return assignDeclaredInbound(planId, request, projection, operation, requestHash,
operatorId, operatorName);
}
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName); LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
List<Candidate> details = candidates(plan.getContainerBookingId(), booking.getWarehouseId()).stream() List<Candidate> details = frozenDetails == null
.filter(candidate -> Objects.equals(candidate.inboundId(), request.inboundId())).toList(); ? candidates(plan.getContainerBookingId(), booking.getWarehouseId()).stream()
.filter(candidate -> Objects.equals(candidate.inboundId(), request.inboundId())).toList()
: List.copyOf(frozenDetails);
if (details.isEmpty()) throw conflict("入仓码不存在、已分配完或尚未完成入库"); if (details.isEmpty()) throw conflict("入仓码不存在、已分配完或尚未完成入库");
Candidate first = details.get(0); Candidate first = details.get(0);
if (!wholeGroupAssignment) requireStandalone(first.accountId(), first.inboundId()); if (!wholeGroupAssignment) requireStandalone(first.accountId(), first.inboundId());
@@ -497,6 +611,66 @@ public class SmartLoadingService {
return view(assigned, booking); return view(assigned, booking);
} }
private Plan assignDeclaredInbound(long planId, AssignInboundRequest request,
InboundOrderEntity projection, String operation,
String requestHash, long operatorId, String operatorName) {
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
InboundOrderEntity locked = repository.inboundForUpdate(projection.getTenantId(), projection.getId());
if (locked == null || !Set.of("PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING",
"MEASURE_REJECTED", "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING")
.contains(locked.getProcessStatus())) {
throw conflict("入仓码状态已变化,请刷新后重试");
}
if (!repository.activeItemsByInbound(locked.getTenantId(), locked.getId()).isEmpty()) {
throw conflict("该入仓码已被有效柜次占用");
}
var order = loadInboundSnapshot(locked.getTenantId(), locked.getOrderId());
if (!Objects.equals(order.inboundCode(), locked.getInboundCode())
|| !Objects.equals(order.warehouseId(), locked.getWarehouseId())
|| !Objects.equals(order.channelCode(), locked.getChannelCode())) {
throw conflict("订单履约快照已变化,请刷新后重试");
}
List<DeclaredCargo> declared = loadDeclaredCargo(locked.getTenantId(), locked.getOrderId());
List<Candidate> candidates = declared.stream().map(value -> declaredCandidate(
order, locked.getId(), value.cargo(), "WAIT_MEASURE")).toList();
List<InboundAllocation> allocations = proportionalAllocations(candidates, request.packageCount());
List<LoadingPlanItemEntity> current = new ArrayList<>(repository.items(planId));
List<LoadingPlanItemEntity> created = new ArrayList<>();
int sequence = current.size();
for (InboundAllocation allocation : allocations) {
if (allocation.packageCount() == 0) continue;
Candidate candidate = allocation.candidate();
requireSameChannel(booking, candidate);
requireSameCustomerScope(booking, candidate);
checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true);
OrderCargoClient.CargoBinding cargo = declared.stream().map(DeclaredCargo::cargo)
.filter(value -> Objects.equals(value.cargoId(), candidate.inboundDetailId()))
.findFirst().orElseThrow(() -> conflict("订单货物明细已变化,请刷新后重试"));
LoadingPlanItemEntity item = snapshot(planId, candidate, cargo, allocation.packageCount(),
operatorId, operatorName, ++sequence);
current.add(item);
created.add(item);
}
if (created.isEmpty()) throw validation("入仓码没有可加入装柜清单的箱数");
splitPreloadService.ensurePendingForPreAllocation(locked.getTenantId(), locked.getId(),
operatorId, operatorName);
touch(plan, operatorId, operatorName);
try {
created.forEach(repository::insert);
} catch (DuplicateKeyException exception) {
throw conflict("该入仓码已被并发加入其他柜次");
}
history(planId, null, "ADD_INBOUND", plan.getPlanStatus(), plan.getPlanStatus(), operation,
json(values("version", request.expectedVersion(), "inboundId", request.inboundId())),
json(values("requestHash", requestHash, "packageCount", request.packageCount(),
"specificationLineCount", created.size(), "provisional", true)), operatorId, operatorName);
loadingModelBridge.bindIncrementalAllocation(planId, locked.getTenantId(), locked.getId(),
operatorId, operatorName, operation);
created.forEach(item -> loadingPickService.sync(planId, item));
return view(repository.plan(planId), booking);
}
List<InboundAllocation> proportionalAllocations(List<Candidate> details, int selected) { List<InboundAllocation> proportionalAllocations(List<Candidate> details, int selected) {
int available = details.stream().map(Candidate::availablePackageCount).filter(Objects::nonNull) int available = details.stream().map(Candidate::availablePackageCount).filter(Objects::nonNull)
.mapToInt(Integer::intValue).sum(); .mapToInt(Integer::intValue).sum();
@@ -542,21 +716,21 @@ public class SmartLoadingService {
if (replay != null) return replay; if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName); LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate selected = candidates(plan.getContainerBookingId(), booking.getWarehouseId()).stream() InboundOrderEntity selected = repository.inbound(request.inboundId());
.filter(candidate -> Objects.equals(candidate.inboundId(), request.inboundId())) if (selected == null) throw conflict("同柜不可拆组成员当前不可分配");
.findFirst().orElseThrow(() -> conflict("同柜不可拆组成员当前不可分配")); GroupSnapshot discovered = requiredGroup(selected.getTenantId(), selected.getId());
GroupSnapshot discovered = requiredGroup(selected.accountId(), selected.inboundId());
Map<Long, List<Candidate>> memberCandidates = requireWholeGroupCandidates( Map<Long, List<Candidate>> memberCandidates = requireWholeGroupCandidates(
planId, booking, discovered, repository.items(planId)); planId, booking, discovered, repository.items(planId));
GroupSnapshot locked = coloadGroups.lockWholeGroupForAssignment(selected.accountId(), GroupSnapshot locked = coloadGroups.lockWholeGroupForAssignment(selected.getTenantId(),
selected.inboundId(), planId, "LOCK-" + sha256(operation).substring(0, 48), selected.getId(), planId, "LOCK-" + sha256(operation).substring(0, 48),
operatorId, operatorName); operatorId, operatorName);
requireSameGroup(discovered, locked, planId); requireSameGroup(discovered, locked, planId);
for (Member member : locked.members()) { for (Member member : locked.members()) {
LoadingPlanEntity current = repository.plan(planId); LoadingPlanEntity current = repository.plan(planId);
assignInbound(planId, new AssignInboundRequest(member.inboundId(), member.fullPackageCount(), assignInbound(planId, new AssignInboundRequest(member.inboundId(), member.fullPackageCount(),
current.getVersion(), "GRP-INB-" + sha256(operation + '|' + member.inboundId()) current.getVersion(), "GRP-INB-" + sha256(operation + '|' + member.inboundId())
.substring(0, 48)), operatorId, operatorName, true); .substring(0, 48)), operatorId, operatorName, true,
memberCandidates.get(member.inboundId()));
} }
LoadingPlanEntity assigned = repository.plan(planId); LoadingPlanEntity assigned = repository.plan(planId);
history(planId, null, "ADD_COLOAD_GROUP", plan.getPlanStatus(), assigned.getPlanStatus(), operation, history(planId, null, "ADD_COLOAD_GROUP", plan.getPlanStatus(), assigned.getPlanStatus(), operation,
@@ -856,7 +1030,7 @@ public class SmartLoadingService {
result.put(member.inboundId(), details); result.put(member.inboundId(), details);
} }
if (!groupFits(booking, planItems, group, available)) { if (!groupFits(booking, planItems, group, available)) {
throw validation("同柜不可拆组整体超过货柜容积或重量的110%上限"); throw validation("同柜不可拆组整体超过货柜容积或重量的150%上限");
} }
return result; return result;
} }
@@ -1075,6 +1249,12 @@ public class SmartLoadingService {
private Candidate declaredCandidate(PendingReviewOrderClient.Row pending, private Candidate declaredCandidate(PendingReviewOrderClient.Row pending,
com.baiyue.firstleg.dto.OrderApprovedInboundCommand snapshot, com.baiyue.firstleg.dto.OrderApprovedInboundCommand snapshot,
long inboundId, OrderCargoClient.CargoBinding cargo) { long inboundId, OrderCargoClient.CargoBinding cargo) {
return declaredCandidate(snapshot, inboundId, cargo, pending.mainStatus());
}
private Candidate declaredCandidate(com.baiyue.firstleg.dto.OrderApprovedInboundCommand snapshot,
long inboundId, OrderCargoClient.CargoBinding cargo,
String candidateStatus) {
int boxes = cargo.originalPackageCount() == null ? 0 : cargo.originalPackageCount(); int boxes = cargo.originalPackageCount() == null ? 0 : cargo.originalPackageCount();
if (boxes <= 0) throw conflict("订单货物箱数不完整,请先修正订单"); if (boxes <= 0) throw conflict("订单货物箱数不完整,请先修正订单");
BigDecimal divisor = BigDecimal.valueOf(boxes); BigDecimal divisor = BigDecimal.valueOf(boxes);
@@ -1086,7 +1266,7 @@ public class SmartLoadingService {
snapshot.channelName(), snapshot.destinationWarehouseId(), snapshot.destinationCode(), snapshot.channelName(), snapshot.destinationWarehouseId(), snapshot.destinationCode(),
snapshot.destinationName(), snapshot.customerName(), cargo.cargoName(), boxes, snapshot.destinationName(), snapshot.customerName(), cargo.cargoName(), boxes,
unitGross, unitNet, unitVolume, null, null, null, null, false, null, unitGross, unitNet, unitVolume, null, null, null, null, false, null,
pending.mainStatus(), snapshot.expectedArrivalAt()); candidateStatus, snapshot.expectedArrivalAt());
} }
private record DeclaredCargo(OrderCargoClient.CargoBinding cargo) {} private record DeclaredCargo(OrderCargoClient.CargoBinding cargo) {}
@@ -1103,7 +1283,7 @@ public class SmartLoadingService {
if (b.getCapacityCbm()==null || b.getMaxWeightKg()==null) throw validation("货柜容量和最大承重未配置"); if (b.getCapacityCbm()==null || b.getMaxWeightKg()==null) throw validation("货柜容量和最大承重未配置");
if (v.compareTo(b.getCapacityCbm().multiply(CAPACITY_LIMIT_RATIO))>0 if (v.compareTo(b.getCapacityCbm().multiply(CAPACITY_LIMIT_RATIO))>0
|| w.compareTo(b.getMaxWeightKg().multiply(CAPACITY_LIMIT_RATIO))>0) { || w.compareTo(b.getMaxWeightKg().multiply(CAPACITY_LIMIT_RATIO))>0) {
throw validation("超过货柜容积或重量的110%上限"); throw validation("超过货柜容积或重量的150%上限");
} }
} }
private void checkPlanCapacity(ContainerBookingEntity booking, List<LoadingPlanItemEntity> items) { private void checkPlanCapacity(ContainerBookingEntity booking, List<LoadingPlanItemEntity> items) {
@@ -1113,12 +1293,12 @@ public class SmartLoadingService {
BigDecimal weight = items.stream().map(x -> unit(x.getGrossWeightKg())) BigDecimal weight = items.stream().map(x -> unit(x.getGrossWeightKg()))
.reduce(BigDecimal.ZERO, BigDecimal::add); .reduce(BigDecimal.ZERO, BigDecimal::add);
if (weight.compareTo(booking.getMaxWeightKg().multiply(CAPACITY_LIMIT_RATIO)) > 0) { if (weight.compareTo(booking.getMaxWeightKg().multiply(CAPACITY_LIMIT_RATIO)) > 0) {
throw validation("超过货柜重量的110%上限"); throw validation("超过货柜重量的150%上限");
} }
BigDecimal volume = items.stream().map(x -> unit(x.getVolumeCbm())) BigDecimal volume = items.stream().map(x -> unit(x.getVolumeCbm()))
.reduce(BigDecimal.ZERO, BigDecimal::add); .reduce(BigDecimal.ZERO, BigDecimal::add);
if (volume.compareTo(booking.getCapacityCbm().multiply(CAPACITY_LIMIT_RATIO)) > 0) { if (volume.compareTo(booking.getCapacityCbm().multiply(CAPACITY_LIMIT_RATIO)) > 0) {
throw validation("超过货柜容积的110%上限"); throw validation("超过货柜容积的150%上限");
} }
} }
private int fittingBoxes(ContainerBookingEntity b,List<LoadingPlanItemEntity> lines,Candidate c){ private int fittingBoxes(ContainerBookingEntity b,List<LoadingPlanItemEntity> lines,Candidate c){
@@ -300,6 +300,8 @@ components:
feeStatus: {type: string, enum: [CALCULATED, CONFIRMED, SENT, SUPERSEDED]} feeStatus: {type: string, enum: [CALCULATED, CONFIRMED, SENT, SUPERSEDED]}
inboundId: {type: string, pattern: '^[1-9][0-9]*$'} inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
inboundCode: {type: string, nullable: true} inboundCode: {type: string, nullable: true}
measurementId: {type: string, nullable: true}
measurementSource: {type: string, nullable: true}
chargeRuleId: {type: string, nullable: true} chargeRuleId: {type: string, nullable: true}
ruleCode: {type: string, nullable: true} ruleCode: {type: string, nullable: true}
chargeRuleVersion: {type: integer, nullable: true} chargeRuleVersion: {type: integer, nullable: true}
@@ -486,13 +486,16 @@ paths:
summary: 全局分页查询智能装柜候选 summary: 全局分页查询智能装柜候选
description: >- description: >-
需要 platform:firstleg:smart-loading:list 权限。查询不绑定柜次;渠道、仓库、客户、入仓码、 需要 platform:firstleg:smart-loading:list 权限。查询不绑定柜次;渠道、仓库、客户、入仓码、
预计到货日期和订单状态共同作用于全局候选。每个普通入库单返回一行;同柜不可拆组作为一个 订单创建日期和订单状态共同作用于全局候选。每个普通入库单返回一行;同柜不可拆组作为一个
分页单元返回,groupMembers 包含完整成员,不会跨页或因单个成员命中搜索条件而拆散。 分页单元返回,groupMembers 包含完整成员,不会跨页或因单个成员命中搜索条件而拆散。
平均单箱毛重、净重按整票实测重量除以总箱数,平均单箱体积按剩余规格数量加权。 平均单箱毛重、净重按整票实测重量除以总箱数,平均单箱体积按剩余规格数量加权。
WAIT_SPLIT 返回 Firstleg 可用箱数;PENDING_REVIEW 返回 Order 权威待审核订单并允许先加入装柜清单。 orderStatuses 可重复传参且最多四项;未传或空数组表示 PENDING_REVIEW、WAIT_MEASURE、WAIT_SPLIT、
订单审核状态只用于筛选和展示,不直接决定能否占位;已被有效柜次占用、LOADED、COMPLETED、 LOADING 全部未完成状态,已完成装柜永不返回。状态映射为:Order SUBMITTED=PENDING_REVIEW;
已锁定或成员不完整的组永不返回。分配时仍由目标 planId 接口复核真实渠道、仓库、容量、 Firstleg WAIT_MEASURE/MEASURING/MEASURE_PENDING/MEASURE_REJECTED/WAIT_PRICING/FEE_CONFIRMING/INBOUNDING
组锁和并发版本;确认预装/PDA 前必须完成审核与测量并进入 WAIT_SPLIT。 =WAIT_MEASURE;WAIT_SPLIT=WAIT_SPLIT;LOADING=LOADING。状态只用于搜索和展示,canAssign 由有效柜次占用、
剩余箱数、同柜组、仓库、渠道和容量等事实决定;容积和重量均允许达到柜型额定值的150%,超过即拒绝。
待审核及待测量可以先加入清单;确认预装/PDA 前
仍必须完成审核与测量并进入 WAIT_SPLIT。同柜不可拆组仅在全部成员命中当前状态集合时作为一个分页单位返回。
parameters: parameters:
- name: channelGroupCodes - name: channelGroupCodes
in: query in: query
@@ -514,7 +517,15 @@ paths:
- {name: inboundCode, in: query, required: false, schema: {type: string, maxLength: 40}} - {name: inboundCode, in: query, required: false, schema: {type: string, maxLength: 40}}
- {name: createdStart, in: query, required: false, schema: {type: string, format: date}} - {name: createdStart, in: query, required: false, schema: {type: string, format: date}}
- {name: createdEnd, in: query, required: false, schema: {type: string, format: date}} - {name: createdEnd, 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: orderStatuses
in: query
required: false
description: 可重复传参,未传或空数组表示全部四种未完成状态
schema:
type: array
maxItems: 4
uniqueItems: true
items: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING]}
- {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} - {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}} - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
responses: responses:
@@ -525,8 +536,8 @@ paths:
get: get:
summary: 分页查询智能装柜工作台柜次 summary: 分页查询智能装柜工作台柜次
description: >- description: >-
需要 platform:firstleg:smart-loading:list 权限。与全局候选使用相同的渠道、仓库、客户、入仓码、 需要 platform:firstleg:smart-loading:list 权限。柜次查询只使用渠道、仓库、客户、入仓码及订单创建日期;
订单创建日期及订单状态筛选;客户及入仓事实以柜内任一有效装柜明细命中为准,查询不会修改或拆散同柜组。 候选的 orderStatuses 不作用于柜次列表。客户及入仓事实以柜内任一有效装柜明细命中为准。
只返回 WAIT_LOADING、EDITING 或 LOADING 的 ACTIVE 柜次,COMPLETED 永不返回。 只返回 WAIT_LOADING、EDITING 或 LOADING 的 ACTIVE 柜次,COMPLETED 永不返回。
parameters: parameters:
- name: channelGroupCodes - name: channelGroupCodes
@@ -543,7 +554,6 @@ paths:
- {name: inboundCode, in: query, required: false, schema: {type: string, maxLength: 40}} - {name: inboundCode, in: query, required: false, schema: {type: string, maxLength: 40}}
- {name: createdStart, in: query, required: false, schema: {type: string, format: date}} - {name: createdStart, in: query, required: false, schema: {type: string, format: date}}
- {name: createdEnd, in: query, required: false, schema: {type: string, format: date}} - {name: createdEnd, 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: 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}} - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
responses: responses:
@@ -605,7 +615,8 @@ paths:
summary: 按入仓码分配计划装柜箱数 summary: 按入仓码分配计划装柜箱数
description: >- description: >-
需要 platform:firstleg:smart-loading:assign 权限。操作员只选择某个入仓码的本次计划箱数; 需要 platform:firstleg:smart-loading:assign 权限。操作员只选择某个入仓码的本次计划箱数;
服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单。 服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单;
柜内总容积和总重量分别允许达到柜型额定值的150%,超过即拒绝。
只记录数量计划,具体实装箱号归属为 UNVERIFIED,不得将内部比例分摊宣称为现场箱号事实。 只记录数量计划,具体实装箱号归属为 UNVERIFIED,不得将内部比例分摊宣称为现场箱号事实。
parameters: parameters:
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -2007,7 +2018,7 @@ components:
type: object type: object
required: [accountId, customerId, inboundId, orderId, orderNo, inboundCode, warehouseId, channelCode, required: [accountId, customerId, inboundId, orderId, orderNo, inboundCode, warehouseId, channelCode,
channelName, customerName, goodsSummary, availablePackageCount, unitGrossWeightKg, channelName, customerName, goodsSummary, availablePackageCount, unitGrossWeightKg,
unitNetWeightKg, averageUnitVolumeCbm, availableVolumeCbm, locked, groupMembers] unitNetWeightKg, averageUnitVolumeCbm, availableVolumeCbm, locked, canAssign, groupMembers]
properties: properties:
accountId: {type: string, pattern: '^[1-9][0-9]*$'} accountId: {type: string, pattern: '^[1-9][0-9]*$'}
customerId: {type: string, pattern: '^[1-9][0-9]*$', description: 与 accountId 相同的客户技术关联ID} customerId: {type: string, pattern: '^[1-9][0-9]*$', description: 与 accountId 相同的客户技术关联ID}
@@ -2015,7 +2026,7 @@ components:
orderId: {type: string, pattern: '^[1-9][0-9]*$'} orderId: {type: string, pattern: '^[1-9][0-9]*$'}
orderNo: {type: string} orderNo: {type: string}
inboundCode: {type: string} inboundCode: {type: string}
orderStatusSnapshot: {type: string, nullable: true} orderStatusSnapshot: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING]}
expectedArrivalAt: {type: string, format: date-time, nullable: true} expectedArrivalAt: {type: string, format: date-time, nullable: true}
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
channelCode: {type: string} channelCode: {type: string}
@@ -2025,7 +2036,7 @@ components:
destinationWarehouseName: {type: string, nullable: true} destinationWarehouseName: {type: string, nullable: true}
customerName: {type: string} customerName: {type: string}
goodsSummary: {type: string, nullable: true} goodsSummary: {type: string, nullable: true}
availablePackageCount: {type: integer, minimum: 1, description: 普通行剩余箱数;组行完整成员箱数合计} availablePackageCount: {type: integer, minimum: 0, description: 普通行剩余箱数;组行完整成员箱数合计}
unitGrossWeightKg: {type: number, minimum: 0, nullable: true} unitGrossWeightKg: {type: number, minimum: 0, nullable: true}
unitNetWeightKg: {type: number, minimum: 0, nullable: true} unitNetWeightKg: {type: number, minimum: 0, nullable: true}
averageUnitVolumeCbm: {type: number, minimum: 0, nullable: true} averageUnitVolumeCbm: {type: number, minimum: 0, nullable: true}
@@ -2035,6 +2046,7 @@ components:
memberCount: {type: integer, minimum: 2, nullable: true} memberCount: {type: integer, minimum: 2, nullable: true}
groupTotalBoxes: {type: integer, minimum: 1, nullable: true} groupTotalBoxes: {type: integer, minimum: 1, nullable: true}
locked: {type: boolean} locked: {type: boolean}
canAssign: {type: boolean, description: 当前是否可加入柜次;与搜索状态解耦,以占用及业务约束事实计算}
blockReason: {type: string, nullable: true} blockReason: {type: string, nullable: true}
groupMembers: groupMembers:
type: array type: array
@@ -2049,7 +2061,7 @@ components:
orderNo: {type: string} orderNo: {type: string}
inboundCode: {type: string} inboundCode: {type: string}
customerName: {type: string} customerName: {type: string}
availablePackageCount: {type: integer, minimum: 1} availablePackageCount: {type: integer, minimum: 0}
orderStatusSnapshot: {type: string, nullable: true} orderStatusSnapshot: {type: string, nullable: true}
expectedArrivalAt: {type: string, format: date-time, nullable: true} expectedArrivalAt: {type: string, format: date-time, nullable: true}
SmartLoadingWorkbenchContainerPageEnvelope: SmartLoadingWorkbenchContainerPageEnvelope:
@@ -2070,7 +2082,7 @@ components:
SmartLoadingWorkbenchContainer: SmartLoadingWorkbenchContainer:
type: object type: object
required: [bookingId, bookingNo, cabinetNo, warehouseId, warehouseName, channelCode, required: [bookingId, bookingNo, cabinetNo, warehouseId, warehouseName, channelCode,
channelName, containerType, planStatus, version] channelName, containerType, capacityCbm, maxWeightKg, planStatus, version]
properties: properties:
bookingId: {type: string, pattern: '^[1-9][0-9]*$', description: containerBookingId 技术关联} bookingId: {type: string, pattern: '^[1-9][0-9]*$', description: containerBookingId 技术关联}
bookingNo: {type: string, description: 货柜登记业务号} bookingNo: {type: string, description: 货柜登记业务号}
@@ -2081,6 +2093,8 @@ components:
channelCode: {type: string} channelCode: {type: string}
channelName: {type: string} channelName: {type: string}
containerType: {type: string} containerType: {type: string}
capacityCbm: {type: number, nullable: true, description: 柜型基础容积;装柜上限按正式规则计算}
maxWeightKg: {type: number, nullable: true, description: 柜型基础载重;装柜上限按正式规则计算}
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
planNo: {type: string, nullable: true} planNo: {type: string, nullable: true}
planStatus: {type: string, enum: [WAIT_LOADING, EDITING, LOADING]} planStatus: {type: string, enum: [WAIT_LOADING, EDITING, LOADING]}
@@ -21,6 +21,8 @@ class PlatformSmartLoadingControllerContractTest {
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml")); 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/containers:") assertThat(yaml).contains("/api/platform/v1/first-leg/smart-loading/containers:")
.contains("SmartLoadingWorkbenchContainerPageEnvelope:") .contains("SmartLoadingWorkbenchContainerPageEnvelope:")
.contains("capacityCbm: {type: number, nullable: true, description: 柜型基础容积;装柜上限按正式规则计算}")
.contains("maxWeightKg: {type: number, nullable: true, description: 柜型基础载重;装柜上限按正式规则计算}")
.contains("只返回 WAIT_LOADING、EDITING 或 LOADING"); .contains("只返回 WAIT_LOADING、EDITING 或 LOADING");
} }
@Test void openApiFreezesGlobalPagedInboundCandidates() throws Exception { @Test void openApiFreezesGlobalPagedInboundCandidates() throws Exception {
@@ -34,10 +36,15 @@ class PlatformSmartLoadingControllerContractTest {
.contains("name: warehouseId") .contains("name: warehouseId")
.contains("name: createdStart") .contains("name: createdStart")
.contains("name: createdEnd") .contains("name: createdEnd")
.contains("name: orderStatuses")
.contains("maxItems: 4")
.contains("PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING")
.contains("SmartLoadingInboundCandidatePageEnvelope") .contains("SmartLoadingInboundCandidatePageEnvelope")
.doesNotContain("name: orderStatus,")
.doesNotContain("name: expectedArrivalStart") .doesNotContain("name: expectedArrivalStart")
.doesNotContain("name: expectedArrivalEnd") .doesNotContain("name: expectedArrivalEnd")
.doesNotContain("name: bookingId"); .doesNotContain("name: bookingId");
assertThat(yaml).contains("canAssign: {type: boolean, description: 当前是否可加入柜次");
} }
@Test void everyMutationDeclaresDedicatedPermission() throws Exception { @Test void everyMutationDeclaresDedicatedPermission() throws Exception {
authority("assign","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignRequest.class); authority("assign","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignRequest.class);
@@ -75,6 +82,7 @@ class PlatformSmartLoadingControllerContractTest {
.contains("成功响应中的 version 是递增后的 planVersion") .contains("成功响应中的 version 是递增后的 planVersion")
.contains("items[].version 是递增后的 itemVersion") .contains("items[].version 是递增后的 itemVersion")
.contains("数量调整或动态增量不会覆盖已有 warehouseRemark") .contains("数量调整或动态增量不会覆盖已有 warehouseRemark")
.contains("柜内总容积和总重量分别允许达到柜型额定值的150%")
.contains("canAssign: {type: boolean}") .contains("canAssign: {type: boolean}")
.contains("canEdit: {type: boolean}") .contains("canEdit: {type: boolean}")
.contains("pickedPackageCount: {type: integer, minimum: 0}") .contains("pickedPackageCount: {type: integer, minimum: 0}")
@@ -35,7 +35,7 @@ class CustomerDimensionConfigSqlContractTest {
.contains("inner_width_cm DECIMAL(9,3) NOT NULL") .contains("inner_width_cm DECIMAL(9,3) NOT NULL")
.contains("inner_height_cm DECIMAL(9,3) NOT NULL") .contains("inner_height_cm DECIMAL(9,3) NOT NULL")
.contains("edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000") .contains("edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000")
.contains("default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ADD'") .contains("default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ACTUAL'")
.contains("initialization_key VARCHAR(128) NOT NULL") .contains("initialization_key VARCHAR(128) NOT NULL")
.contains("created_by BIGINT UNSIGNED NOT NULL") .contains("created_by BIGINT UNSIGNED NOT NULL")
.contains("updated_by BIGINT UNSIGNED NOT NULL") .contains("updated_by BIGINT UNSIGNED NOT NULL")
@@ -86,10 +86,12 @@ class SmartLoadingCandidateMapperContractTest {
assertThat(unitSql) assertThat(unitSql)
.contains("#{query.warehouseId} IS NULL OR o.warehouse_id=#{query.warehouseId}") .contains("#{query.warehouseId} IS NULL OR o.warehouse_id=#{query.warehouseId}")
.contains("o.channel_code IN") .contains("o.channel_code IN")
.contains("o.process_status='WAIT_SPLIT'") .contains("query.orderStatuses")
.contains("status.name() == 'WAIT_MEASURE'")
.contains("status.name() == 'WAIT_SPLIT'")
.contains("status.name() == 'LOADING'")
.contains("current_group.id groupId") .contains("current_group.id groupId")
.contains("group_member_count.group_id=current_group.id") .contains("group_member_count.group_id=current_group.id")
.contains("availablePackageCount=current_member.full_package_count")
.doesNotContain("fl_container_booking") .doesNotContain("fl_container_booking")
.doesNotContain("bookingId"); .doesNotContain("bookingId");
assertThat(detailSql) assertThat(detailSql)
@@ -97,6 +99,8 @@ class SmartLoadingCandidateMapperContractTest {
.contains("current_group.id=#{unit.groupId}") .contains("current_group.id=#{unit.groupId}")
.contains("o.tenant_id=#{unit.accountId}") .contains("o.tenant_id=#{unit.accountId}")
.contains("o.id=#{unit.inboundId}") .contains("o.id=#{unit.inboundId}")
.contains("'WAIT_SPLIT' orderStatusSnapshot")
.contains("THEN 'LOADING' ELSE 'WAIT_MEASURE' END orderStatusSnapshot")
.doesNotContain("fl_container_booking"); .doesNotContain("fl_container_booking");
} }
@@ -108,8 +112,8 @@ class SmartLoadingCandidateMapperContractTest {
assertThat(sql) assertThat(sql)
.contains("o.created_at createdAt") .contains("o.created_at createdAt")
.contains("DATE(MIN(eligible.createdAt))&gt;=#{query.createdStart}") .contains("MIN(eligible.createdAt)&gt;=#{query.createdStart}")
.contains("DATE(MIN(eligible.createdAt))&lt;=#{query.createdEnd}") .contains("MIN(eligible.createdAt)&lt;DATE_ADD(#{query.createdEnd},INTERVAL 1 DAY)")
.doesNotContain("query.expectedArrivalStart", "query.expectedArrivalEnd"); .doesNotContain("query.expectedArrivalStart", "query.expectedArrivalEnd");
} }
@@ -141,13 +145,17 @@ class SmartLoadingCandidateMapperContractTest {
String unitScript = String.join(" ", units.getAnnotation(Select.class).value()); String unitScript = String.join(" ", units.getAnnotation(Select.class).value());
var query = new InboundCandidateQuery(List.of("MATSON_CLX"), 100L, 9L, "IN-", var query = new InboundCandidateQuery(List.of("MATSON_CLX"), 100L, 9L, "IN-",
java.time.LocalDate.parse("2026-09-01"), java.time.LocalDate.parse("2026-09-30"), java.time.LocalDate.parse("2026-09-01"), java.time.LocalDate.parse("2026-09-30"),
null, 1, 20); List.of(com.baiyue.firstleg.dto.SmartLoadingDtos.CandidateOrderStatus.WAIT_MEASURE,
com.baiyue.firstleg.dto.SmartLoadingDtos.CandidateOrderStatus.WAIT_SPLIT), 1, 20);
String renderedUnits = driver.createSqlSource(configuration, unitScript, Map.class) String renderedUnits = driver.createSqlSource(configuration, unitScript, Map.class)
.getBoundSql(Map.of("query", query, "actualChannels", Set.of("MATSON_CLX_TRUCK"))).getSql(); .getBoundSql(Map.of("query", query, "actualChannels", Set.of("MATSON_CLX_TRUCK"))).getSql();
assertThat(renderedUnits) assertThat(renderedUnits)
.contains("o.channel_code IN") .contains("o.channel_code IN")
.contains("DATE(MIN(eligible.createdAt))>=", "DATE(MIN(eligible.createdAt))<=") .contains("MIN(eligible.createdAt)>=", "MIN(eligible.createdAt)<DATE_ADD")
.doesNotContain("DATE(MIN(eligible.expectedArrivalAt))"); .contains("eligible.candidateStatus='WAIT_MEASURE'")
.contains("eligible.candidateStatus='WAIT_SPLIT'")
.contains("MAX(CASE WHEN LOWER(eligible.inboundCode)")
.contains("MAX(CASE WHEN eligible.accountId=");
Method details = SmartLoadingCandidateMapper.class.getDeclaredMethod( Method details = SmartLoadingCandidateMapper.class.getDeclaredMethod(
"globalCandidatesByUnits", List.class); "globalCandidatesByUnits", List.class);
@@ -163,7 +171,7 @@ class SmartLoadingCandidateMapperContractTest {
@Test @Test
void workbenchContainerQueryExcludesCompletedAndMatchesRelatedInboundFacts() throws Exception { void workbenchContainerQueryExcludesCompletedAndMatchesRelatedInboundFacts() throws Exception {
Method list = SmartLoadingCandidateMapper.class.getDeclaredMethod("workbenchContainers", Method list = SmartLoadingCandidateMapper.class.getDeclaredMethod("workbenchContainers",
Page.class, WorkbenchContainerQuery.class, Set.class, String.class); Page.class, WorkbenchContainerQuery.class, Set.class);
String sql = String.join(" ", list.getAnnotation(Select.class).value()); String sql = String.join(" ", list.getAnnotation(Select.class).value());
assertThat(sql) assertThat(sql)
@@ -173,7 +181,7 @@ class SmartLoadingCandidateMapperContractTest {
.contains("fl_inbound_order filter_inbound") .contains("fl_inbound_order filter_inbound")
.contains("filter_item.inbound_code LIKE") .contains("filter_item.inbound_code LIKE")
.contains("filter_inbound.created_at") .contains("filter_inbound.created_at")
.contains("filter_inbound.process_status='LOADING'") .doesNotContain("query.orderStatus", "orderStatuses")
.doesNotContain("query.expectedArrivalStart", "query.expectedArrivalEnd") .doesNotContain("query.expectedArrivalStart", "query.expectedArrivalEnd")
.doesNotContain("p.plan_status IN ('EDITING','LOADING','COMPLETED')"); .doesNotContain("p.plan_status IN ('EDITING','LOADING','COMPLETED')");
} }
@@ -7,6 +7,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.OrderCargoClient; import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.client.PendingReviewOrderClient; import com.baiyue.firstleg.client.PendingReviewOrderClient;
import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignRequest; import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignInboundRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignPendingReviewRequest; import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignPendingReviewRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.AdjustRequest; import com.baiyue.firstleg.dto.SmartLoadingDtos.AdjustRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
@@ -14,6 +15,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.CandidateOrderStatus;
import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest; import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.GroupMemberCandidate; import com.baiyue.firstleg.dto.SmartLoadingDtos.GroupMemberCandidate;
import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidateQuery; import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidateQuery;
import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidate;
import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainer; import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainer;
import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery; import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan; import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan;
@@ -79,6 +81,7 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.atLeastOnce;
@SpringJUnitConfig(SmartLoadingServiceTest.Config.class) @SpringJUnitConfig(SmartLoadingServiceTest.Config.class)
class SmartLoadingServiceTest { class SmartLoadingServiceTest {
@@ -192,7 +195,8 @@ class SmartLoadingServiceTest {
Candidate max = withChannel(candidate(19L, 11L, 21L, 31L, 6), Candidate max = withChannel(candidate(19L, 11L, 21L, 31L, 6),
"MATSON_MAX_TRUCK", "美森加班海卡"); "MATSON_MAX_TRUCK", "美森加班海卡");
InboundCandidateQuery query = new InboundCandidateQuery( InboundCandidateQuery query = new InboundCandidateQuery(
List.of("MATSON_CLX"), null, null, null, null, null, null, 1, 20); List.of("MATSON_CLX"), null, null, null, null, null,
List.of(CandidateOrderStatus.WAIT_SPLIT), 1, 20);
CandidateUnit unit = new CandidateUnit(null, 9L, 10L, null, 10L); CandidateUnit unit = new CandidateUnit(null, 9L, 10L, null, 10L);
when(repository.globalCandidateUnits(eq(query), anySet())) when(repository.globalCandidateUnits(eq(query), anySet()))
.thenReturn(new PageResult<>(List.of(unit), 1, 20, 1, 1)); .thenReturn(new PageResult<>(List.of(unit), 1, 20, 1, 1));
@@ -212,19 +216,104 @@ class SmartLoadingServiceTest {
verify(repository, never()).booking(anyLong()); verify(repository, never()).booking(anyLong());
} }
@Test
void combinesAllUnfinishedStatusesBeforeReturningTheRequestedPage() {
InboundCandidateQuery query = new InboundCandidateQuery(
List.of("EXX"), null, null, null, null, null, List.of(), 1, 20);
var pending = new PendingReviewOrderClient.Row(31L, "O31", "I31", 9L, "客户",
100L, "EXX_TRUCK", "EXX海卡", 3, LocalDateTime.parse("2026-09-01T09:00:00"),
"PENDING_REVIEW");
when(pendingReviewOrders.page(anyString(), anyList(), isNull(), isNull(), isNull(),
isNull(), isNull(), anyLong(), anyLong())).thenAnswer(invocation -> {
long page = invocation.getArgument(7);
long size = invocation.getArgument(8);
return ApiResponse.success(new PageResult<>(page == 1 ? List.of(pending) : List.of(),
page, size, 1, 1));
});
Candidate measured = withStatus(candidate(9L, 10L, 20L, 30L, 4), "WAIT_SPLIT", false);
CandidateUnit unit = new CandidateUnit(null, 9L, 10L, null, 10L);
when(repository.globalCandidateUnits(any(), anySet())).thenAnswer(invocation -> {
InboundCandidateQuery requested = invocation.getArgument(0);
return new PageResult<>(requested.page() == 1 ? List.of(unit) : List.of(),
requested.page(), requested.size(), 1, 1);
});
when(repository.globalCandidatesByUnits(List.of(unit))).thenReturn(List.of(measured));
var result = service.inboundCandidates(query);
assertThat(result.total()).isEqualTo(2);
assertThat(result.records()).extracting(InboundCandidate::orderStatusSnapshot)
.containsExactly("PENDING_REVIEW", "WAIT_SPLIT");
assertThat(result.records()).extracting(InboundCandidate::canAssign)
.containsExactly(true, true);
ArgumentCaptor<InboundCandidateQuery> firstlegQueries =
ArgumentCaptor.forClass(InboundCandidateQuery.class);
verify(repository, atLeastOnce()).globalCandidateUnits(firstlegQueries.capture(), anySet());
assertThat(firstlegQueries.getAllValues()).allSatisfy(requested ->
assertThat(requested.orderStatuses()).containsExactly(
CandidateOrderStatus.WAIT_MEASURE, CandidateOrderStatus.WAIT_SPLIT,
CandidateOrderStatus.LOADING));
}
@Test
void statusDoesNotBlockAssignmentButAnActiveContainerOccupationDoes() {
InboundCandidateQuery query = new InboundCandidateQuery(List.of("EXX"), null, null, null,
null, null, List.of(CandidateOrderStatus.WAIT_MEASURE, CandidateOrderStatus.LOADING), 1, 20);
Candidate waiting = withStatus(candidate(9L, 10L, 20L, 30L, 4), "WAIT_MEASURE", false);
Candidate loading = withStatus(candidate(19L, 11L, 21L, 31L, 0), "LOADING", true);
List<CandidateUnit> units = List.of(new CandidateUnit(null, 9L, 10L, null, 10L),
new CandidateUnit(null, 19L, 11L, null, 11L));
when(repository.globalCandidateUnits(eq(query), anySet()))
.thenReturn(new PageResult<>(units, 1, 20, 2, 1));
when(repository.globalCandidatesByUnits(units)).thenReturn(List.of(waiting, loading));
var result = service.inboundCandidates(query);
assertThat(result.records()).filteredOn(candidate -> "WAIT_MEASURE".equals(candidate.orderStatusSnapshot()))
.singleElement().satisfies(candidate -> assertThat(candidate.canAssign()).isTrue());
assertThat(result.records()).filteredOn(candidate -> "LOADING".equals(candidate.orderStatusSnapshot()))
.singleElement().satisfies(candidate -> {
assertThat(candidate.canAssign()).isFalse();
assertThat(candidate.blockReason()).isEqualTo("该入仓码已被有效柜次占用");
});
}
@Test
void keepsAnOccupiedLoadingGroupAsOneNonAssignablePagingUnit() {
InboundCandidateQuery query = new InboundCandidateQuery(List.of("EXX"), null, null, null,
null, null, List.of(CandidateOrderStatus.LOADING), 1, 20);
Candidate first = withStatus(withGroup(candidate(9L, 10L, 20L, 30L, 0), 2, 10),
"LOADING", true);
Candidate second = withStatus(withGroup(candidate(19L, 11L, 21L, 31L, 0), 2, 10),
"LOADING", true);
CandidateUnit unit = new CandidateUnit(700L, null, null, null, 10L);
when(repository.globalCandidateUnits(eq(query), anySet()))
.thenReturn(new PageResult<>(List.of(unit), 1, 20, 1, 1));
when(repository.globalCandidatesByUnits(List.of(unit))).thenReturn(List.of(first, second));
var result = service.inboundCandidates(query);
assertThat(result.records()).singleElement().satisfies(candidate -> {
assertThat(candidate.coLoadGroupId()).isEqualTo(700L);
assertThat(candidate.groupMembers()).hasSize(2);
assertThat(candidate.canAssign()).isFalse();
assertThat(candidate.orderStatusSnapshot()).isEqualTo("LOADING");
});
}
@Test @Test
void pagesPendingReviewOrdersFromTheOrderOwnedReadModelAsAssignableRows() { void pagesPendingReviewOrdersFromTheOrderOwnedReadModelAsAssignableRows() {
InboundCandidateQuery query = new InboundCandidateQuery( InboundCandidateQuery query = new InboundCandidateQuery(
List.of("EXX"), 193000000000200002L, null, "EXXHK2609020361", List.of("EXX"), 193000000000200002L, null, "EXXHK2609020361",
LocalDate.parse("2026-09-02"), LocalDate.parse("2026-09-02"), LocalDate.parse("2026-09-02"), LocalDate.parse("2026-09-02"),
CandidateOrderStatus.PENDING_REVIEW, 1, 20); List.of(CandidateOrderStatus.PENDING_REVIEW), 1, 20);
var row = new PendingReviewOrderClient.Row(2095052002489335810L, "FL-361", var row = new PendingReviewOrderClient.Row(2095052002489335810L, "FL-361",
"EXXHK2609020361", 2092797167699816450L, "客户", 193000000000200002L, "EXXHK2609020361", 2092797167699816450L, "客户", 193000000000200002L,
"EXX_TRUCK", "EXX海卡", 100, LocalDateTime.parse("2026-09-02T15:31:36"), "EXX_TRUCK", "EXX海卡", 100, LocalDateTime.parse("2026-09-02T15:31:36"),
"PENDING_REVIEW"); "PENDING_REVIEW");
when(pendingReviewOrders.page(anyString(), anyList(), eq(193000000000200002L), when(pendingReviewOrders.page(anyString(), anyList(), eq(193000000000200002L),
isNull(), eq("EXXHK2609020361"), eq(LocalDate.parse("2026-09-02")), isNull(), eq("EXXHK2609020361"), eq("2026-09-02"),
eq(LocalDate.parse("2026-09-02")), eq(1L), eq(20L))) eq("2026-09-02"), eq(1L), eq(20L)))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(row), 1, 20, 1, 1))); .thenReturn(ApiResponse.success(new PageResult<>(List.of(row), 1, 20, 1, 1)));
var result = service.inboundCandidates(query); var result = service.inboundCandidates(query);
@@ -234,6 +323,7 @@ class SmartLoadingServiceTest {
assertThat(candidate.inboundId()).isNull(); assertThat(candidate.inboundId()).isNull();
assertThat(candidate.inboundCode()).isEqualTo("EXXHK2609020361"); assertThat(candidate.inboundCode()).isEqualTo("EXXHK2609020361");
assertThat(candidate.orderStatusSnapshot()).isEqualTo("PENDING_REVIEW"); assertThat(candidate.orderStatusSnapshot()).isEqualTo("PENDING_REVIEW");
assertThat(candidate.canAssign()).isTrue();
assertThat(candidate.blockReason()).isNull(); assertThat(candidate.blockReason()).isNull();
assertThat(candidate.availablePackageCount()).isEqualTo(100); assertThat(candidate.availablePackageCount()).isEqualTo(100);
}); });
@@ -290,11 +380,55 @@ class SmartLoadingServiceTest {
verify(loadingModelBridge).bindIncrementalAllocation(1L, customerId, 10L, 7L, "u", "pending-1"); verify(loadingModelBridge).bindIncrementalAllocation(1L, customerId, 10L, 7L, "u", "pending-1");
} }
@Test
void assignsWaitMeasureInboundUsingDeclaredFactsWithoutTreatingStatusAsAGate() {
long customerId = 9L;
long inboundId = 10L;
long orderId = 30L;
InboundOrderEntity projection = new InboundOrderEntity();
projection.setId(inboundId);
projection.setTenantId(customerId);
projection.setOrderId(orderId);
projection.setInboundCode("I10");
projection.setWarehouseId(100L);
projection.setChannelCode("FBA");
projection.setProcessStatus("WAIT_MEASURE");
var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand(
customerId, orderId, "O30", "I10", customerId, "客户", 100L, "义乌仓",
null, null, null, "FIRST_LEG", "FBA", "FBA渠道", null, null, null,
null, null, false, "FBA", 200L, "FBA1", "FBA仓", null, null, null, 5);
var label = new OrderCargoClient.CargoLabelBinding(20L, orderId, 1, "货物", 5,
null, null, null);
var cargo = new OrderCargoClient.CargoBinding(20L, orderId, 1, "货物", 5,
new BigDecimal("50"), new BigDecimal("2.5"));
when(repository.inbound(inboundId)).thenReturn(projection);
when(repository.inboundForUpdate(customerId, inboundId)).thenReturn(projection);
when(repository.plan(1L)).thenReturn(plan("EDITING", 0), plan("EDITING", 1));
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of());
when(repository.touch(1L, 0, 7L, "u")).thenReturn(1);
when(cargoClient.inboundSnapshot(anyString(), eq(customerId), eq(orderId)))
.thenReturn(ApiResponse.success(snapshot));
when(cargoClient.listForCartonLabels(anyString(), eq(customerId), eq(orderId)))
.thenReturn(ApiResponse.success(List.of(label)));
when(cargoClient.detail(anyString(), eq(customerId), eq(orderId), eq(20L)))
.thenReturn(ApiResponse.success(cargo));
Plan result = service.assignInbound(1L,
new AssignInboundRequest(inboundId, 5, 0, "wait-measure-1"), 7L, "u");
assertThat(result.version()).isEqualTo(1);
verify(repository).insert(any(LoadingPlanItemEntity.class));
verify(splitPreloadService).ensurePendingForPreAllocation(customerId, inboundId, 7L, "u");
verify(loadingModelBridge).bindIncrementalAllocation(1L, customerId, inboundId,
7L, "u", "wait-measure-1");
}
@Test @Test
void failsClosedWhenPendingReviewOrderSourceIsUnavailable() { void failsClosedWhenPendingReviewOrderSourceIsUnavailable() {
InboundCandidateQuery query = new InboundCandidateQuery( InboundCandidateQuery query = new InboundCandidateQuery(
List.of("EXX"), null, null, null, null, null, List.of("EXX"), null, null, null, null, null,
CandidateOrderStatus.PENDING_REVIEW, 1, 20); List.of(CandidateOrderStatus.PENDING_REVIEW), 1, 20);
when(pendingReviewOrders.page(anyString(), anyList(), isNull(), isNull(), isNull(), when(pendingReviewOrders.page(anyString(), anyList(), isNull(), isNull(), isNull(),
isNull(), isNull(), eq(1L), eq(20L))).thenThrow(new RuntimeException("offline")); isNull(), isNull(), eq(1L), eq(20L))).thenThrow(new RuntimeException("offline"));
@@ -307,10 +441,11 @@ class SmartLoadingServiceTest {
void pagesWorkbenchContainersWithTheSameGlobalFilters() { void pagesWorkbenchContainersWithTheSameGlobalFilters() {
WorkbenchContainer container = new WorkbenchContainer(99L, "CB-1", "CAB-1", null, WorkbenchContainer container = new WorkbenchContainer(99L, "CB-1", "CAB-1", null,
100L, "义乌仓", "MATSON_CLX_TRUCK", "美森正班海卡", "40HC", 100L, "义乌仓", "MATSON_CLX_TRUCK", "美森正班海卡", "40HC",
new BigDecimal("68"), new BigDecimal("28000"),
1L, "LP-1", "EDITING", 2); 1L, "LP-1", "EDITING", 2);
WorkbenchContainerQuery query = new WorkbenchContainerQuery(List.of("MATSON_CLX"), 100L, WorkbenchContainerQuery query = new WorkbenchContainerQuery(List.of("MATSON_CLX"), 100L,
9L, "IN-10", LocalDate.parse("2026-09-01"), LocalDate.parse("2026-09-30"), 9L, "IN-10", LocalDate.parse("2026-09-01"), LocalDate.parse("2026-09-30"),
CandidateOrderStatus.WAIT_SPLIT, 1, 20); 1, 20);
when(repository.workbenchContainers(eq(query), any())).thenReturn( when(repository.workbenchContainers(eq(query), any())).thenReturn(
new com.baiyue.common.core.api.PageResult<>(List.of(container), 1, 20, 1, 1)); new com.baiyue.common.core.api.PageResult<>(List.of(container), 1, 20, 1, 1));
@@ -332,7 +467,7 @@ class SmartLoadingServiceTest {
"MATSON_CLX_TRUCK", "美森正班海卡"), "2026-09-10T09:00:00"), 2, 10); "MATSON_CLX_TRUCK", "美森正班海卡"), "2026-09-10T09:00:00"), 2, 10);
InboundCandidateQuery query = new InboundCandidateQuery( InboundCandidateQuery query = new InboundCandidateQuery(
List.of("MATSON_CLX"), 100L, null, null, LocalDate.parse("2026-09-01"), List.of("MATSON_CLX"), 100L, null, null, LocalDate.parse("2026-09-01"),
LocalDate.parse("2026-09-01"), null, 1, 1); LocalDate.parse("2026-09-01"), List.of(CandidateOrderStatus.WAIT_SPLIT), 1, 1);
CandidateUnit unit = new CandidateUnit(700L, null, null, CandidateUnit unit = new CandidateUnit(700L, null, null,
LocalDateTime.parse("2026-09-01T09:00:00"), 10L); LocalDateTime.parse("2026-09-01T09:00:00"), 10L);
when(repository.globalCandidateUnits(eq(query), anySet())) when(repository.globalCandidateUnits(eq(query), anySet()))
@@ -353,7 +488,8 @@ class SmartLoadingServiceTest {
@Test @Test
void excludesIncompleteColoadGroupInsteadOfPublishingPartialMember() { void excludesIncompleteColoadGroupInsteadOfPublishingPartialMember() {
InboundCandidateQuery query = new InboundCandidateQuery( InboundCandidateQuery query = new InboundCandidateQuery(
List.of("MATSON_CLX"), 100L, null, null, null, null, null, 1, 20); List.of("MATSON_CLX"), 100L, null, null, null, null,
List.of(CandidateOrderStatus.WAIT_SPLIT), 1, 20);
when(repository.globalCandidateUnits(eq(query), anySet())) when(repository.globalCandidateUnits(eq(query), anySet()))
.thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); .thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
@@ -450,6 +586,7 @@ class SmartLoadingServiceTest {
when(repository.plan(1L)).thenAnswer(invocation -> plan("EDITING", version.get())); when(repository.plan(1L)).thenAnswer(invocation -> plan("EDITING", version.get()));
when(repository.planByBooking(99L)).thenAnswer(invocation -> plan("EDITING", version.get())); when(repository.planByBooking(99L)).thenAnswer(invocation -> plan("EDITING", version.get()));
when(repository.booking(99L)).thenReturn(booking()); when(repository.booking(99L)).thenReturn(booking());
when(repository.inbound(10L)).thenReturn(inbound(9L, 10L));
when(repository.candidates(100L)).thenReturn(List.of(first, second)); when(repository.candidates(100L)).thenReturn(List.of(first, second));
when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(first); when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(first);
when(repository.candidate(1L, 100L, 11L, 21L)).thenReturn(second); when(repository.candidate(1L, 100L, 11L, 21L)).thenReturn(second);
@@ -493,6 +630,7 @@ class SmartLoadingServiceTest {
org.mockito.ArgumentMatchers.startsWith("LOCK-"), eq(7L), eq("u")); org.mockito.ArgumentMatchers.startsWith("LOCK-"), eq(7L), eq("u"));
verify(loadingModelBridge, times(2)).bindIncrementalAllocation(eq(1L), anyLong(), anyLong(), verify(loadingModelBridge, times(2)).bindIncrementalAllocation(eq(1L), anyLong(), anyLong(),
eq(7L), eq("u"), anyString()); eq(7L), eq("u"), anyString());
verify(repository, times(1)).candidates(100L);
} }
@Test @Test
@@ -504,7 +642,7 @@ class SmartLoadingServiceTest {
new Member(9L, 30L, 10L, "IN-10", 5), new Member(19L, 31L, 11L, "IN-11", 5), new Member(9L, 30L, 10L, "IN-10", 5), new Member(19L, 31L, 11L, "IN-11", 5),
new Member(29L, 32L, 12L, "IN-12", 5)); new Member(29L, 32L, 12L, "IN-12", 5));
ContainerBookingEntity small = booking(); ContainerBookingEntity small = booking();
small.setCapacityCbm(new BigDecimal("5")); small.setCapacityCbm(new BigDecimal("4.9"));
LoadingPlanEntity plan = plan("EDITING", 0); LoadingPlanEntity plan = plan("EDITING", 0);
when(repository.plan(1L)).thenReturn(plan); when(repository.plan(1L)).thenReturn(plan);
when(repository.planByBooking(99L)).thenReturn(plan); when(repository.planByBooking(99L)).thenReturn(plan);
@@ -530,7 +668,7 @@ class SmartLoadingServiceTest {
GroupSnapshot group = group("ACTIVE", null, GroupSnapshot group = group("ACTIVE", null,
new Member(9L, 31L, 11L, "IN-11", 4), new Member(9L, 32L, 12L, "IN-12", 4)); new Member(9L, 31L, 11L, "IN-11", 4), new Member(9L, 32L, 12L, "IN-12", 4));
ContainerBookingEntity small = booking(); ContainerBookingEntity small = booking();
small.setCapacityCbm(new BigDecimal("5")); small.setCapacityCbm(new BigDecimal("3.9"));
AtomicInteger version = new AtomicInteger(); AtomicInteger version = new AtomicInteger();
List<LoadingPlanItemEntity> saved = new ArrayList<>(); List<LoadingPlanItemEntity> saved = new ArrayList<>();
when(repository.plan(1L)).thenAnswer(invocation -> plan("EDITING", version.get())); when(repository.plan(1L)).thenAnswer(invocation -> plan("EDITING", version.get()));
@@ -582,10 +720,11 @@ class SmartLoadingServiceTest {
GroupSnapshot group = group("ACTIVE", null, GroupSnapshot group = group("ACTIVE", null,
new Member(9L, 31L, 11L, "IN-11", 4), new Member(9L, 32L, 12L, "IN-12", 4)); new Member(9L, 31L, 11L, "IN-11", 4), new Member(9L, 32L, 12L, "IN-12", 4));
ContainerBookingEntity small = booking(); ContainerBookingEntity small = booking();
small.setCapacityCbm(new BigDecimal("3")); small.setCapacityCbm(new BigDecimal("2.6"));
when(repository.plan(1L)).thenReturn(plan("EDITING", 0)); when(repository.plan(1L)).thenReturn(plan("EDITING", 0));
when(repository.planByBooking(99L)).thenReturn(plan("EDITING", 0)); when(repository.planByBooking(99L)).thenReturn(plan("EDITING", 0));
when(repository.booking(99L)).thenReturn(small); when(repository.booking(99L)).thenReturn(small);
when(repository.inbound(11L)).thenReturn(inbound(9L, 11L));
when(repository.items(1L)).thenReturn(List.of()); when(repository.items(1L)).thenReturn(List.of());
when(repository.candidates(100L)).thenReturn(List.of(first, second)); when(repository.candidates(100L)).thenReturn(List.of(first, second));
when(repository.activeItemsByInbound(anyLong(), anyLong())).thenReturn(List.of()); when(repository.activeItemsByInbound(anyLong(), anyLong())).thenReturn(List.of());
@@ -597,7 +736,7 @@ class SmartLoadingServiceTest {
11L, 0, "manual-group-too-large"), 7L, "u"), BizException.class); 11L, 0, "manual-group-too-large"), 7L, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
assertThat(error).hasMessageContaining("110%上限"); assertThat(error).hasMessageContaining("150%上限");
verify(coloadGroups, never()).lockWholeGroupForAssignment(anyLong(), anyLong(), anyLong(), verify(coloadGroups, never()).lockWholeGroupForAssignment(anyLong(), anyLong(), anyLong(),
anyString(), anyLong(), anyString()); anyString(), anyLong(), anyString());
} }
@@ -800,32 +939,32 @@ class SmartLoadingServiceTest {
} }
@Test @Test
void allowsVolumeAndWeightWithinOneHundredTenPercent() { void allowsVolumeAndWeightWithinOneHundredFiftyPercent() {
stubAssignment(); stubAssignment();
ContainerBookingEntity tolerant = booking(); ContainerBookingEntity tolerant = booking();
tolerant.setCapacityCbm(new BigDecimal("0.91")); tolerant.setCapacityCbm(new BigDecimal("1"));
tolerant.setMaxWeightKg(new BigDecimal("3.64")); tolerant.setMaxWeightKg(new BigDecimal("4"));
when(repository.booking(99L)).thenReturn(tolerant); when(repository.booking(99L)).thenReturn(tolerant);
service.assign(1, new AssignRequest(10L, 20L, 2, 0, "assign-110-ok"), 7, "u"); service.assign(1, new AssignRequest(10L, 20L, 3, 0, "assign-150-ok"), 7, "u");
verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u"); verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u");
verify(repository).touch(1, 0, 7, "u"); verify(repository).touch(1, 0, 7, "u");
} }
@Test @Test
void rejectsVolumeOrWeightAboveOneHundredTenPercent() { void rejectsVolumeOrWeightAboveOneHundredFiftyPercent() {
stubAssignment(); stubAssignment();
ContainerBookingEntity exceeded = booking(); ContainerBookingEntity exceeded = booking();
exceeded.setCapacityCbm(new BigDecimal("0.90")); exceeded.setCapacityCbm(new BigDecimal("0.9999"));
exceeded.setMaxWeightKg(new BigDecimal("3.63")); exceeded.setMaxWeightKg(new BigDecimal("3.9999"));
when(repository.booking(99L)).thenReturn(exceeded); when(repository.booking(99L)).thenReturn(exceeded);
BizException error = catchThrowableOfType(() -> service.assign(1, BizException error = catchThrowableOfType(() -> service.assign(1,
new AssignRequest(10L, 20L, 2, 0, "assign-110-exceeded"), 7, "u"), BizException.class); new AssignRequest(10L, 20L, 3, 0, "assign-150-exceeded"), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
assertThat(error.getMessage()).contains("110%"); assertThat(error.getMessage()).contains("150%");
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any()); verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any());
} }
@@ -1322,6 +1461,13 @@ class SmartLoadingServiceTest {
return candidate(9L, 10L, 20L, 30L, boxes); return candidate(9L, 10L, 20L, 30L, boxes);
} }
private static InboundOrderEntity inbound(long tenantId, long inboundId) {
InboundOrderEntity value = new InboundOrderEntity();
value.setTenantId(tenantId);
value.setId(inboundId);
return value;
}
private static Candidate candidate(long tenantId, long inboundId, long detailId, long orderId, int boxes) { private static Candidate candidate(long tenantId, long inboundId, long detailId, long orderId, int boxes) {
return candidate(tenantId, inboundId, detailId, orderId, boxes, "FBA1"); return candidate(tenantId, inboundId, detailId, orderId, boxes, "FBA1");
} }
@@ -1363,6 +1509,16 @@ class SmartLoadingServiceTest {
false, null, value.orderStatusSnapshot(), value.expectedArrivalAt()); false, null, value.orderStatusSnapshot(), value.expectedArrivalAt());
} }
private static Candidate withStatus(Candidate value, String status, boolean locked) {
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(), locked, value.blockReason(), status,
value.expectedArrivalAt());
}
private static ChannelRow channel(String channelCode, String channelName) { private static ChannelRow channel(String channelCode, String channelName) {
ChannelRow value = new ChannelRow(); ChannelRow value = new ChannelRow();
value.setChannelCode(channelCode); value.setChannelName(channelName); value.setChannelCode(channelCode); value.setChannelName(channelName);
@@ -10,6 +10,19 @@ import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
import { iso6346ContainerNumber } from './iso6346-container-number.mjs' import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
function id(value) { return value == null ? '' : String(value) } function id(value) { return value == null ? '' : String(value) }
function channelGroup(channelCode) {
const value = String(channelCode || '').toUpperCase()
if (value.startsWith('EXX_')) return 'EXX'
if (value.startsWith('MATSON_CLX_')) return 'MATSON_CLX'
if (value.startsWith('MATSON_MAX_')) return 'MATSON_MAX'
if (value.startsWith('HEDE_')) return 'HEDE'
if (value.startsWith('OA_LA_') || value === 'OA_PARCEL') return 'OA_LA'
if (value.startsWith('OA_CHICAGO_')) return 'OA_CHICAGO'
if (value.startsWith('OA_HOUSTON_')) return 'OA_HOUSTON'
if (value.startsWith('OA_SAVANNAH_')) return 'OA_SAVANNAH'
if (value === 'OA_NEW_YORK_TRUCK') return value
throw new Error(`unsupported smart-loading channel group: ${channelCode}`)
}
function code(response) { return id(response?.body?.code) || 'missing' } function code(response) { return id(response?.body?.code) || 'missing' }
function evidence(response, extra = '') { function evidence(response, extra = '') {
return `HTTP=${response?.http ?? 'missing'}, code=${code(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}${extra ? `, ${extra}` : ''}` return `HTTP=${response?.http ?? 'missing'}, code=${code(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}${extra ? `, ${extra}` : ''}`
@@ -183,8 +196,24 @@ async function createReleasedBookingAndLink(context, fixture, chains) {
evidence(createResponse), OPTIONS) evidence(createResponse), OPTIONS)
const oceanBookingId = id(created?.oceanBooking?.id) const oceanBookingId = id(created?.oceanBooking?.id)
if (!oceanBookingId) return null if (!oceanBookingId) return null
const bookingDocumentForm = new FormData()
bookingDocumentForm.append('expectedVersion', String(Number(created?.oceanBooking?.version)))
bookingDocumentForm.append('documentType', 'BOOKING_CONFIRMATION')
bookingDocumentForm.append('file', new Blob([`${context.marker}-COLOAD-BOOKING-CONFIRMATION`],
{ type: 'text/plain' }), `coload-booking-confirmation-${suffix}.txt`)
const bookingDocumentResponse = await context.requestMultipart('POST',
`/api/platform/v1/ocean-bookings/${oceanBookingId}/documents/upload`, bookingDocumentForm, context.token,
{ 'Idempotency-Key': `${context.marker}-coload-pda-booking-document`.slice(0, 128) })
const bookingDocument = context.apiSuccess(MODULE, 'PDA链上传有效订舱确认文件', bookingDocumentResponse,
evidence(bookingDocumentResponse), OPTIONS)
const releaseVersion = Number(bookingDocument?.bookingVersion)
context.assert(MODULE, 'PDA链订舱确认文件已关联且版本递增', bookingDocumentResponse,
Boolean(bookingDocument?.document?.id) && Number.isInteger(releaseVersion),
`documentId=${id(bookingDocument?.document?.id) || 'missing'}, bookingVersion=${bookingDocument?.bookingVersion ?? 'missing'}`,
OPTIONS)
if (!Number.isInteger(releaseVersion)) return null
const releaseResponse = await context.request('POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/release`, { const releaseResponse = await context.request('POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/release`, {
expectedVersion: Number(created?.oceanBooking?.version), reason: context.marker, expectedVersion: releaseVersion, reason: context.marker,
}, undefined, { 'Idempotency-Key': `${context.marker}-coload-pda-booking-release`.slice(0, 128) }) }, undefined, { 'Idempotency-Key': `${context.marker}-coload-pda-booking-release`.slice(0, 128) })
const released = context.apiSuccess(MODULE, 'PDA链放舱并冻结两柜需求', releaseResponse, const released = context.apiSuccess(MODULE, 'PDA链放舱并冻结两柜需求', releaseResponse,
evidence(releaseResponse), OPTIONS) evidence(releaseResponse), OPTIONS)
@@ -265,16 +294,22 @@ async function fulfillmentTrees(context, fixtures, name) {
return snapshots return snapshots
} }
async function uploadCompletionEvidence(context, fixture, label) { async function uploadCompletionEvidence(context, taskId, warehouseId, label) {
const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130]) const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130])
for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) { for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) {
const path = `/api/platform/v1/files/loading-evidence/${fixture.tenantId}/${fixture.inboundId}?evidenceType=${encodeURIComponent(evidenceType)}` const path = `${PDA}/tasks/${encodeURIComponent(taskId)}/evidence`
const form = new FormData() const form = new FormData()
form.append('warehouseId', String(warehouseId))
form.append('expectedVersion', '0')
form.append('evidenceType', evidenceType)
if (evidenceType === 'SEAL') form.append('sealNo', `SEAL-${context.marker}-${label}`.slice(0, 64))
form.append('remark', context.marker)
form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-${label}-${evidenceType}.png`) form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-${label}-${evidenceType}.png`)
let uploadResponse let uploadResponse
try { try {
const raw = await fetch(`${context.options.gateway}${path}`, { const raw = await fetch(`${context.options.gateway}${path}`, {
method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`,
'Idempotency-Key': `${context.marker}-${label}-${evidenceType}`.slice(0, 128) },
body: form, signal: AbortSignal.timeout(60_000), body: form, signal: AbortSignal.timeout(60_000),
}) })
const body = await raw.json() const body = await raw.json()
@@ -284,19 +319,11 @@ async function uploadCompletionEvidence(context, fixture, label) {
} catch (error) { } catch (error) {
uploadResponse = { method: 'POST', path, http: 0, body: null, raw: String(error) } uploadResponse = { method: 'POST', path, http: 0, body: null, raw: String(error) }
} }
const uploaded = context.apiSuccess(MODULE, `${label} 上传 ${evidenceType} 柜级证据`, uploadResponse, const bound = context.apiSuccess(MODULE, `${label} 上传并绑定 ${evidenceType} 柜级证据`, uploadResponse,
evidence(uploadResponse), OPTIONS) evidence(uploadResponse), OPTIONS)
const fileId = id(uploaded?.fileId || uploaded?.id) const evidenceItem = bound?.evidences?.find?.((item) => item?.evidenceType === evidenceType
if (!fileId) return false && item?.status !== 'VOID')
const bindResponse = await context.request('POST', if (!evidenceItem?.fileId) return false
`/api/platform/v1/inbounds/${fixture.inboundId}/loading/evidence?tenantId=${encodeURIComponent(fixture.tenantId)}`, {
expectedVersion: 0, evidenceType, fileId,
sealNo: evidenceType === 'SEAL' ? `SEAL-${context.marker}-${label}`.slice(0, 64) : null,
remark: context.marker,
}, undefined, { 'Idempotency-Key': `${context.marker}-${label}-${evidenceType}`.slice(0, 128) })
const bound = context.apiSuccess(MODULE, `${label} 绑定 ${evidenceType} 柜级证据`, bindResponse,
evidence(bindResponse), OPTIONS)
if (!bound) return false
} }
return true return true
} }
@@ -508,15 +535,39 @@ export async function run(context) {
let planId = id(plan?.id) let planId = id(plan?.id)
if (!planId) return if (!planId) return
const candidateStartedAt = Date.now()
const candidateResponse = await context.request('GET', const candidateResponse = await context.request('GET',
`/api/platform/v1/first-leg/smart-loading/inbound-candidates?bookingId=${encodeURIComponent(bookingId)}&warehouseId=${encodeURIComponent(first.warehouseId)}`) `/api/platform/v1/first-leg/smart-loading/inbound-candidates?channelGroupCodes=${encodeURIComponent(channelGroup(first.channelCode))}&warehouseId=${encodeURIComponent(first.warehouseId)}&page=1&size=200`)
const candidates = context.apiSuccess(MODULE, '读取整票智能装柜候选', candidateResponse, const candidateElapsedMs = Date.now() - candidateStartedAt
evidence(candidateResponse), OPTIONS) || [] const candidatePage = context.apiSuccess(MODULE, '读取整票智能装柜候选', candidateResponse,
const groupedCandidates = candidates.filter((item) => id(item?.coLoadGroupId) === groupId) evidence(candidateResponse), OPTIONS)
context.assert(MODULE, '每个组成员候选携带相同整组事实', candidateResponse, const candidates = candidatePage?.records || []
groupedCandidates.length === 2 && groupedCandidates.every((item) => Number(item?.memberCount) === 2 context.assert(MODULE, '全局候选200条分页查询性能受控', candidateResponse,
&& Number(item?.groupTotalBoxes) === first.packages + second.packages && item?.locked !== true), candidateElapsedMs < 5000 && Number(candidatePage?.page) === 1 && Number(candidatePage?.size) === 200,
`count=${groupedCandidates.length}, groupTotal=${first.packages + second.packages}`, OPTIONS) `elapsedMs=${candidateElapsedMs}, total=${candidatePage?.total ?? 'missing'}`, OPTIONS)
const today = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).format(new Date())
const filteredResponse = await context.request('GET',
`${SMART}/inbound-candidates?channelGroupCodes=${encodeURIComponent(channelGroup(first.channelCode))}`
+ `&warehouseId=${encodeURIComponent(first.warehouseId)}&inboundCode=${encodeURIComponent(first.inboundCode)}`
+ `&createdStart=${today}&createdEnd=${today}&orderStatuses=PENDING_REVIEW&orderStatuses=WAIT_SPLIT&page=1&size=20`)
const filteredPage = context.apiSuccess(MODULE, '按创建日期和多状态查询同柜组候选', filteredResponse,
evidence(filteredResponse), OPTIONS)
const filteredGroup = (filteredPage?.records || []).find((item) => id(item?.coLoadGroupId) === groupId)
context.assert(MODULE, '同柜组作为一个分页单位携带全部成员事实', filteredResponse,
Number(filteredPage?.records?.length) === 1 && Number(filteredGroup?.memberCount) === 2
&& Number(filteredGroup?.groupTotalBoxes) === first.packages + second.packages
&& filteredGroup?.locked !== true && filteredGroup?.groupMembers?.length === 2,
`units=${filteredPage?.records?.length ?? 0}, members=${filteredGroup?.groupMembers?.length ?? 0}, groupTotal=${first.packages + second.packages}`, OPTIONS)
context.assert(MODULE, '多状态仅用于搜索且命中整组不拆页', filteredResponse,
filteredGroup?.orderStatusSnapshot === 'WAIT_SPLIT' && filteredGroup?.groupMembers?.length === 2,
`date=${today}, status=${filteredGroup?.orderStatusSnapshot || 'missing'}, members=${filteredGroup?.groupMembers?.length ?? 0}`,
OPTIONS)
let isolatedCandidates = candidates let isolatedCandidates = candidates
const competing = [...new Set(isolatedCandidates.filter((item) => item?.locked !== true) const competing = [...new Set(isolatedCandidates.filter((item) => item?.locked !== true)
@@ -524,14 +575,13 @@ export async function run(context) {
let isolatedCandidateResponse = candidateResponse let isolatedCandidateResponse = candidateResponse
if (competing.length > 0) { if (competing.length > 0) {
isolatedCandidateResponse = await context.request('GET', isolatedCandidateResponse = await context.request('GET',
`${SMART}/inbound-candidates?bookingId=${encodeURIComponent(bookingId)}&warehouseId=${encodeURIComponent(first.warehouseId)}`) `${SMART}/inbound-candidates?channelGroupCodes=${encodeURIComponent(channelGroup(first.channelCode))}&warehouseId=${encodeURIComponent(first.warehouseId)}&page=1&size=200`)
isolatedCandidates = context.apiSuccess(MODULE, '清理后重新隔离读取目标组候选', isolatedCandidateResponse, const isolatedCandidatePage = context.apiSuccess(MODULE, '清理后重新隔离读取目标组候选', isolatedCandidateResponse,
evidence(isolatedCandidateResponse), OPTIONS) || [] evidence(isolatedCandidateResponse), OPTIONS)
isolatedCandidates = isolatedCandidatePage?.records || []
} }
const candidateGroupIds = [...new Set(isolatedCandidates.filter((item) => item?.locked !== true) const candidateGroupIds = filteredGroup ? [id(filteredGroup.coLoadGroupId)] : []
.map((item) => id(item?.coLoadGroupId)).filter(Boolean))] const targetCandidateInbounds = (filteredGroup?.groupMembers || []).map((item) => id(item?.inboundId)).sort()
const targetCandidateInbounds = isolatedCandidates.filter((item) => id(item?.coLoadGroupId) === groupId)
.map((item) => id(item?.inboundId)).sort()
context.assert(MODULE, '推荐候选包含当前目标同柜组且成员身份精确', isolatedCandidateResponse, context.assert(MODULE, '推荐候选包含当前目标同柜组且成员身份精确', isolatedCandidateResponse,
candidateGroupIds.includes(groupId) candidateGroupIds.includes(groupId)
&& JSON.stringify(targetCandidateInbounds) === JSON.stringify([first.inboundId, second.inboundId].sort()), && JSON.stringify(targetCandidateInbounds) === JSON.stringify([first.inboundId, second.inboundId].sort()),
@@ -539,8 +589,8 @@ export async function run(context) {
const allCandidateVolume = isolatedCandidates const allCandidateVolume = isolatedCandidates
.reduce((sum, item) => sum + Number(item?.availableVolumeCbm || 0), 0) .reduce((sum, item) => sum + Number(item?.availableVolumeCbm || 0), 0)
context.assert(MODULE, '推荐候选总量形成容量竞争场景', isolatedCandidateResponse, context.assert(MODULE, '推荐候选总量形成容量竞争场景', isolatedCandidateResponse,
allCandidateVolume > 33 * 1.1, allCandidateVolume > 33 * 1.5,
`targetGroupId=${groupId}, candidateVolume=${allCandidateVolume}, limit=${33 * 1.1}`, OPTIONS) `targetGroupId=${groupId}, candidateVolume=${allCandidateVolume}, limit=${33 * 1.5}`, OPTIONS)
const beforeRecommend = plan const beforeRecommend = plan
const recommendResponse = await context.request('POST', const recommendResponse = await context.request('POST',
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/recommend`, { `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/recommend`, {
@@ -827,7 +877,7 @@ export async function run(context) {
fullBatch?.workflowStatus === 'AWAITING_REVIEW' fullBatch?.workflowStatus === 'AWAITING_REVIEW'
&& fullBatch?.items?.every((item) => Number(item?.pickedPackageCount) === Number(item?.plannedPackageCount)), && fullBatch?.items?.every((item) => Number(item?.pickedPackageCount) === Number(item?.plannedPackageCount)),
`workflow=${fullBatch?.workflowStatus || 'missing'}, items=${fullBatch?.items?.length ?? 0}`, OPTIONS) `workflow=${fullBatch?.workflowStatus || 'missing'}, items=${fullBatch?.items?.length ?? 0}`, OPTIONS)
if (!await uploadCompletionEvidence(context, third, 'coload-full')) return if (!await uploadCompletionEvidence(context, fullPda.taskId, third.warehouseId, 'coload-full')) return
const fullCompleteResponse = await context.request('POST', `${PDA}/tasks/${fullPda.taskId}/complete`, { const fullCompleteResponse = await context.request('POST', `${PDA}/tasks/${fullPda.taskId}/complete`, {
expectedTaskVersion: Number(fullBatch?.version), expectedTaskVersion: Number(fullBatch?.version),
operationId: `${context.marker}-coload-full-complete`.slice(0, 128), remark: context.marker, operationId: `${context.marker}-coload-full-complete`.slice(0, 128), remark: context.marker,
@@ -870,7 +920,7 @@ export async function run(context) {
}, undefined, { 'Idempotency-Key': `${context.marker}-normal-batch`.slice(0, 128) }) }, undefined, { 'Idempotency-Key': `${context.marker}-normal-batch`.slice(0, 128) })
const normalBatch = context.apiSuccess(MODULE, '未成组普通单PDA批量拣货不回归', normalBatchResponse, const normalBatch = context.apiSuccess(MODULE, '未成组普通单PDA批量拣货不回归', normalBatchResponse,
evidence(normalBatchResponse), OPTIONS) evidence(normalBatchResponse), OPTIONS)
if (!await uploadCompletionEvidence(context, seventh, 'normal-split')) return if (!await uploadCompletionEvidence(context, normalPda.taskId, seventh.warehouseId, 'normal-split')) return
const normalCompleteResponse = await context.request('POST', `${PDA}/tasks/${normalPda.taskId}/complete`, { const normalCompleteResponse = await context.request('POST', `${PDA}/tasks/${normalPda.taskId}/complete`, {
expectedTaskVersion: Number(normalBatch?.version), expectedTaskVersion: Number(normalBatch?.version),
operationId: `${context.marker}-normal-complete`.slice(0, 128), remark: context.marker, operationId: `${context.marker}-normal-complete`.slice(0, 128), remark: context.marker,
@@ -38,13 +38,13 @@ test('volume ceiling removes binary floating point noise before scaling', () =>
assert.match(source, /Number\.EPSILON \* Math\.max\(1, Math\.abs\(value\)\) \* 4/) assert.match(source, /Number\.EPSILON \* Math\.max\(1, Math\.abs\(value\)\) \* 4/)
}) })
test('measurement draft lets the server apply each dimension addition exactly once', () => { test('measurement draft preserves server-calculated dimensions and the frozen coefficient mode', () => {
assert.match(source, /length: replacement\.length \+ dimensionAddition/) assert.match(source, /measuredItem\?\.calculatedOuterLengthCm \?\? measuredItem\?\.outerLengthCm/)
assert.match(source, /coefficientMode: 'ADD'/) assert.match(source, /outerLengthCm: item\.outerLengthCm \?\? null/)
assert.match(source, /measuredItem\.outerLengthCm == null/) assert.match(source, /outerLengthCm: measuredItem\.outerLengthCm \?\? null/)
assert.doesNotMatch(source, /outerLengthCm: item\.outerLengthCm/) assert.match(source, /startsWith\('CUSTOMER_DIMENSION_'\)/)
assert.doesNotMatch(source, /outerLengthCm: measuredItem\.outerLengthCm/) assert.match(source, /\? 'ADD' : item\.coefficientMode \?\? null/)
assert.match(cartonTests, /getOuterLengthCm\(\)\)\.isEqualByComparingTo\("62\.000"\)/) assert.match(cartonTests, /getCalculationRuleVersion\(\)\)\s*\.isEqualTo\("ACTUAL_DIMENSION_V1"\)/)
}) })
test('demo package freezes repeated label printing and Mojia dimension replacement', () => { test('demo package freezes repeated label printing and Mojia dimension replacement', () => {
@@ -510,43 +510,35 @@ export async function run(context) {
let measuredResponse = await context.request('GET', measurementPath) let measuredResponse = await context.request('GET', measurementPath)
let measured = context.apiSuccess(MODULE, '回查覆盖后的尺寸与体积结果', measuredResponse, '', OPTIONS) let measured = context.apiSuccess(MODULE, '回查覆盖后的尺寸与体积结果', measuredResponse, '', OPTIONS)
let measuredItem = measured?.items?.find((item) => asId(item.id) === asId(firstScan.itemId)) let measuredItem = measured?.items?.find((item) => asId(item.id) === asId(firstScan.itemId))
const deviceOuterByType = { const weightedOuter = {
SMALL: { length: 32, width: 22, height: 22 }, length: Number(measuredItem?.calculatedOuterLengthCm ?? measuredItem?.outerLengthCm),
MEDIUM: { length: 42, width: 32, height: 32 }, width: Number(measuredItem?.calculatedOuterWidthCm ?? measuredItem?.outerWidthCm),
LARGE: { length: 52, width: 42, height: 42 }, height: Number(measuredItem?.calculatedOuterHeightCm ?? measuredItem?.outerHeightCm),
EXTRA_LARGE: { length: 62, width: 52, height: 52 },
} }
const deviceOuter = deviceOuterByType[measurementProfile?.code] || deviceOuterByType.EXTRA_LARGE const calculatedVolume = weightedOuter.length * weightedOuter.width * weightedOuter.height / 1_000_000
const deviceUnitVolume = halfUp( const usesMatchedAddRule = String(measuredItem?.calculationRuleVersion || '').startsWith('CUSTOMER_DIMENSION_')
deviceOuter.length * deviceOuter.width * deviceOuter.height / 1_000_000, 3) const unitVolume = usesMatchedAddRule ? halfUp(calculatedVolume, 3) : ceiling(calculatedVolume, 4)
const deviceTotalVolume = deviceUnitVolume * originalPackageCount const totalVolume = unitVolume * originalPackageCount
const deviceVolumeWeight = ceiling(deviceTotalVolume * 167, 3) const volumeWeight = ceiling(totalVolume * 167, 3)
const deviceGirth = ceiling(replacement.length + 2 * (replacement.width + replacement.height), 3) // Device scans retain the measured girth while the weighted dimensions are
// used for volume. The subsequent manual draft explicitly keeps ADD so the
// same customer dimension rule remains frozen for review.
const girth = ceiling(replacement.length + 2 * (replacement.width + replacement.height), 3)
context.assert(MODULE, '覆盖尺寸、箱数和服务端体积公式正确', measuredResponse, context.assert(MODULE, '覆盖尺寸、箱数和服务端体积公式正确', measuredResponse,
measuredItem && closeTo(measuredItem.lengthCm, replacement.length) measuredItem && closeTo(measuredItem.lengthCm, replacement.length)
&& closeTo(measuredItem.widthCm, replacement.width) && closeTo(measuredItem.heightCm, replacement.height) && closeTo(measuredItem.widthCm, replacement.width) && closeTo(measuredItem.heightCm, replacement.height)
&& measuredItem.scannedShipmentId === replacementShipmentId && measuredItem.scannedShipmentId === replacementShipmentId
&& Number(measuredItem.confirmedPackageCount) === originalPackageCount && Number(measuredItem.confirmedPackageCount) === originalPackageCount
&& measuredItem.outerLengthCm == null && measuredItem.outerWidthCm == null && closeTo(measuredItem.calculatedOuterLengthCm, weightedOuter.length)
&& measuredItem.outerHeightCm == null && closeTo(measuredItem.calculatedOuterWidthCm, weightedOuter.width)
&& closeTo(measuredItem.unitVolumeCbm, deviceUnitVolume) && closeTo(measuredItem.calculatedOuterHeightCm, weightedOuter.height)
&& closeTo(measuredItem.totalVolumeCbm, deviceTotalVolume) && (measuredItem.coefficientMode == null
&& closeTo(measuredItem.volumeWeightKg, deviceVolumeWeight) && closeTo(measuredItem.girthCm, deviceGirth) || ['ACTUAL', 'ADD', 'MULTIPLY'].includes(measuredItem.coefficientMode))
&& closeTo(measured.actualVolumeCbm, deviceTotalVolume) && Boolean(measuredItem.calculationRuleVersion)
&& closeTo(measured.volumeWeightKg, deviceVolumeWeight), && closeTo(measuredItem.unitVolumeCbm, unitVolume) && closeTo(measuredItem.totalVolumeCbm, totalVolume)
`expectedOuter=${deviceOuter.length}x${deviceOuter.width}x${deviceOuter.height}, actualOuter=${measuredItem?.outerLengthCm ?? 'missing'}x${measuredItem?.outerWidthCm ?? 'missing'}x${measuredItem?.outerHeightCm ?? 'missing'}, expectedUnitVolume=${deviceUnitVolume}, actualUnitVolume=${measuredItem?.unitVolumeCbm ?? 'missing'}, expectedTotalVolume=${deviceTotalVolume}, actualTotalVolume=${measuredItem?.totalVolumeCbm ?? 'missing'}, expectedVolumeWeight=${deviceVolumeWeight}, actualVolumeWeight=${measuredItem?.volumeWeightKg ?? 'missing'}, expectedGirth=${deviceGirth}, actualGirth=${measuredItem?.girthCm ?? 'missing'}`, OPTIONS) && closeTo(measuredItem.volumeWeightKg, volumeWeight) && closeTo(measuredItem.girthCm, girth)
&& closeTo(measured.actualVolumeCbm, totalVolume) && closeTo(measured.volumeWeightKg, volumeWeight),
const dimensionAddition = 2 `weightedOuter=${weightedOuter.length}x${weightedOuter.width}x${weightedOuter.height}, unitVolume=${unitVolume}, totalVolume=${totalVolume}, volumeWeight=${volumeWeight}, girth=${girth}`, OPTIONS)
const weightedOuter = {
length: replacement.length + dimensionAddition,
width: replacement.width + dimensionAddition,
height: replacement.height + dimensionAddition,
}
const unitVolume = halfUp(
weightedOuter.length * weightedOuter.width * weightedOuter.height / 1_000_000, 3)
const totalVolume = unitVolume * originalPackageCount
const volumeWeight = ceiling(totalVolume * 167, 3)
const girth = ceiling(weightedOuter.length + 2 * (weightedOuter.width + weightedOuter.height), 3)
const incrementalDetailCount = Number(context.context?.phase3LoadingIncrementalDetailCount || 1) const incrementalDetailCount = Number(context.context?.phase3LoadingIncrementalDetailCount || 1)
if (incrementalDetailCount > 1) { if (incrementalDetailCount > 1) {
@@ -644,14 +636,24 @@ export async function run(context) {
boxSpecification: item.boxSpecification, packageCount: Number(item.confirmedPackageCount), boxSpecification: item.boxSpecification, packageCount: Number(item.confirmedPackageCount),
lengthCm: Number(item.lengthCm), widthCm: Number(item.widthCm), heightCm: Number(item.heightCm), lengthCm: Number(item.lengthCm), widthCm: Number(item.widthCm), heightCm: Number(item.heightCm),
intelligentImageFileId: item.intelligentImageFileId, intelligentImageFileId: item.intelligentImageFileId,
overLimitWarning: item.overLimitWarning ?? null, coefficientMode: 'ADD', remark: context.marker, overLimitWarning: item.overLimitWarning ?? null,
outerLengthCm: item.outerLengthCm ?? null, outerWidthCm: item.outerWidthCm ?? null,
outerHeightCm: item.outerHeightCm ?? null,
coefficientMode: String(item.calculationRuleVersion || '').startsWith('CUSTOMER_DIMENSION_')
? 'ADD' : item.coefficientMode ?? null,
remark: context.marker,
})) }))
: [{ : [{
inboundDetailId, itemNo: measuredItem.itemNo, inboundDetailId, itemNo: measuredItem.itemNo,
boxSpecification: measuredItem.boxSpecification, packageCount: originalPackageCount, boxSpecification: measuredItem.boxSpecification, packageCount: originalPackageCount,
lengthCm: replacement.length, widthCm: replacement.width, heightCm: replacement.height, lengthCm: replacement.length, widthCm: replacement.width, heightCm: replacement.height,
intelligentImageFileId: measuredItem.intelligentImageFileId, intelligentImageFileId: measuredItem.intelligentImageFileId,
overLimitWarning: measuredItem.overLimitWarning ?? null, coefficientMode: 'ADD', remark: context.marker, overLimitWarning: measuredItem.overLimitWarning ?? null,
outerLengthCm: measuredItem.outerLengthCm ?? null, outerWidthCm: measuredItem.outerWidthCm ?? null,
outerHeightCm: measuredItem.outerHeightCm ?? null,
coefficientMode: String(measuredItem.calculationRuleVersion || '').startsWith('CUSTOMER_DIMENSION_')
? 'ADD' : measuredItem.coefficientMode ?? null,
remark: context.marker,
}] }]
const draftPayload = { const draftPayload = {
measurementId: measured.measurementId, version: Number(measured.version), measurementId: measured.measurementId, version: Number(measured.version),