This commit is contained in:
2026-09-16 17:49:20 +08:00
parent 392f5b7386
commit 8488d4d989
10 changed files with 148 additions and 60 deletions
+1 -1
View File
@@ -86,7 +86,7 @@
## 国内货柜履约装柜与 PDA 资源结算 ## 国内货柜履约装柜与 PDA 资源结算
- 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。 - 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。真实集装箱号允许在关联已放舱订舱时暂缺,但确认预装及创建 PDA 任务前必须补齐;历史已建但缺号的 PDA 任务可在预装详情首次补录,补录后按柜次当前真实集装箱号恢复作业。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏或禁用其余未分配托盘。智能推荐必须逐个调用整托分配,容量不足时跳过整托,不得回退按箱分配;全局工作台传入当前 `customerId/inboundCode` 筛选范围时,服务端只能推荐同时命中的候选,同一次推荐按受影响客户统一重算费用,不得逐托重复重算。单行删除必须按该行 `palletId` 整托移除。装柜完成事件必须按计划项 `palletId` 精确冻结每托箱唛与 SKU 清单,不得仅按入仓单或货物明细关联而造成多托交叉重复。 - 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏或禁用其余未分配托盘。智能推荐必须逐个调用整托分配,容量不足时跳过整托,不得回退按箱分配;全局工作台传入当前 `customerId/inboundCode` 筛选范围时,服务端只能推荐同时命中的候选,同一次推荐按受影响客户统一重算费用,不得逐托重复重算。单行删除必须按该行 `palletId` 整托移除。装柜完成事件必须按计划项 `palletId` 精确冻结每托箱唛与 SKU 清单,不得仅按入仓单或货物明细关联而造成多托交叉重复。
- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 - 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
- PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`、用该托任一成员 `boxCode` 反查所属整托,也可在明细卡片手工确认整托;三种入口最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 - PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`、用该托任一成员 `boxCode` 反查所属整托,也可在明细卡片手工确认整托;三种入口最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
@@ -216,12 +216,19 @@ public interface DropshipPalletizationMapper {
""") List<CartonRow> cartons(@Param("palletId") long palletId); """) List<CartonRow> cartons(@Param("palletId") long palletId);
@Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.task_id=#{taskId} AND c.pallet_id IS NULL ORDER BY c.counted_at,c.id") List<CartonRow> unboundCartons(@Param("taskId") long taskId); @Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.task_id=#{taskId} AND c.pallet_id IS NULL ORDER BY c.counted_at,c.id") List<CartonRow> unboundCartons(@Param("taskId") long taskId);
@Select(""" @Select("""
SELECT COALESCE(SUM(o.actual_gross_weight_kg/NULLIF(o.total_package_count,0)),0) weight_kg, <script>
COALESCE(SUM(o.actual_volume_cbm/NULLIF(o.total_package_count,0)),0) volume_cbm SELECT c.pallet_id,COUNT(DISTINCT c.id) carton_count,
FROM fl_dropship_pallet_carton c COALESCE(SUM(o.actual_gross_weight_kg/NULLIF(o.total_package_count,0)),0) gross_weight_kg,
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id COALESCE(SUM(o.actual_net_weight_kg/NULLIF(o.total_package_count,0)),0) net_weight_kg,
WHERE c.pallet_id=#{palletId}""") COALESCE(SUM(o.actual_volume_cbm/NULLIF(o.total_package_count,0)),0) volume_cbm
PalletMetrics metrics(@Param("palletId") long palletId); FROM fl_dropship_pallet_carton c
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
WHERE c.pallet_id IN
<foreach collection="palletIds" item="palletId" open="(" separator="," close=")">#{palletId}</foreach>
GROUP BY c.pallet_id
</script>
""")
List<PalletMetrics> physicalMetricsByPalletIds(@Param("palletIds") Collection<Long> palletIds);
@Select(""" @Select("""
SELECT c.tenant_id,c.inbound_id,o.inbound_code, SELECT c.tenant_id,c.inbound_id,o.inbound_code,
@@ -340,7 +347,8 @@ public interface DropshipPalletizationMapper {
@Getter @Setter class LabelSkuRow { private Long inboundDetailId; private String skuCode,cargoName; private Integer expectedQuantity,lineNo; } @Getter @Setter class LabelSkuRow { private Long inboundDetailId; private String skuCode,cargoName; private Integer expectedQuantity,lineNo; }
@Getter @Setter class CartonRow { private Long id,taskId,palletId,inboundId,inboundDetailId; private String palletNo,inboundCode,skuCode,cargoName,boxCode,discrepancyStatus,discrepancyRemark; private Integer expectedSkuQuantity,actualSkuQuantity; } @Getter @Setter class CartonRow { private Long id,taskId,palletId,inboundId,inboundDetailId; private String palletNo,inboundCode,skuCode,cargoName,boxCode,discrepancyStatus,discrepancyRemark; private Integer expectedSkuQuantity,actualSkuQuantity; }
@Getter @Setter class CartonSkuRow { private Long inboundDetailId; private String skuCode,cargoName,discrepancyStatus,discrepancyRemark; private Integer expectedQuantity,actualQuantity,lineNo; } @Getter @Setter class CartonSkuRow { private Long inboundDetailId; private String skuCode,cargoName,discrepancyStatus,discrepancyRemark; private Integer expectedQuantity,actualQuantity,lineNo; }
record PalletMetrics(BigDecimal weightKg,BigDecimal volumeCbm) {} record PalletMetrics(Long palletId,Integer cartonCount,BigDecimal grossWeightKg,
BigDecimal netWeightKg,BigDecimal volumeCbm) {}
@Getter @Setter class PrintInboundRow { private Long tenantId,inboundId; private String inboundCode,destinationWarehouse; private Integer cartonCount,itemQuantity; } @Getter @Setter class PrintInboundRow { private Long tenantId,inboundId; private String inboundCode,destinationWarehouse; private Integer cartonCount,itemQuantity; }
@Getter @Setter class PrintSkuRow { private String skuCode; private Integer cartonCount,itemQuantity,minUnitsPerCarton,maxUnitsPerCarton; } @Getter @Setter class PrintSkuRow { private String skuCode; private Integer cartonCount,itemQuantity,minUnitsPerCarton,maxUnitsPerCarton; }
@Getter @Setter class PalletLine { private Long palletId,tenantId,accountId,inboundId,orderId,warehouseId,destinationWarehouseId,inboundDetailId; private String palletNo,palletStatus,orderNo,inboundCode,channelCode,channelName,destinationWarehouseCode,destinationWarehouseName,customerName,skuCode,cargoName; private Integer cartonCount,actualSkuQuantity; private BigDecimal unitGrossWeightKg,unitNetWeightKg,unitVolumeCbm; } @Getter @Setter class PalletLine { private Long palletId,tenantId,accountId,inboundId,orderId,warehouseId,destinationWarehouseId,inboundDetailId; private String palletNo,palletStatus,orderNo,inboundCode,channelCode,channelName,destinationWarehouseCode,destinationWarehouseName,customerName,skuCode,cargoName; private Integer cartonCount,actualSkuQuantity; private BigDecimal unitGrossWeightKg,unitNetWeightKg,unitVolumeCbm; }
@@ -45,10 +45,33 @@ public interface LoadingListQueryMapper {
(SELECT pick_task.task_status FROM fl_loading_pick_task pick_task (SELECT pick_task.task_status FROM fl_loading_pick_task pick_task
WHERE pick_task.loading_plan_id = p.id LIMIT 1) AS pda_task_status, WHERE pick_task.loading_plan_id = p.id LIMIT 1) AS pda_task_status,
COUNT(DISTINCT NULLIF(i.sku_code, '')) AS sku_count, COUNT(DISTINCT NULLIF(i.sku_code, '')) AS sku_count,
COALESCE(SUM(i.package_count), 0) AS total_package_count, COALESCE(SUM(CASE WHEN i.pallet_id IS NULL THEN i.package_count ELSE 0 END), 0)
COALESCE(SUM(i.gross_weight_kg), 0) AS total_gross_weight_kg, + COALESCE((SELECT COUNT(DISTINCT carton.id)
COALESCE(SUM(i.net_weight_kg), 0) AS total_net_weight_kg, FROM fl_dropship_pallet_carton carton
COALESCE(SUM(i.volume_cbm), 0) AS total_volume_cbm, WHERE EXISTS (SELECT 1 FROM fl_loading_plan_item pallet_item
WHERE pallet_item.loading_plan_id=p.id AND pallet_item.deleted=0
AND pallet_item.pallet_id=carton.pallet_id)), 0) AS total_package_count,
COALESCE(SUM(CASE WHEN i.pallet_id IS NULL THEN i.gross_weight_kg ELSE 0 END), 0)
+ COALESCE((SELECT SUM(inbound.actual_gross_weight_kg/NULLIF(inbound.total_package_count,0))
FROM fl_dropship_pallet_carton carton
JOIN fl_inbound_order inbound ON inbound.id=carton.inbound_id AND inbound.tenant_id=carton.tenant_id
WHERE EXISTS (SELECT 1 FROM fl_loading_plan_item pallet_item
WHERE pallet_item.loading_plan_id=p.id AND pallet_item.deleted=0
AND pallet_item.pallet_id=carton.pallet_id)), 0) AS total_gross_weight_kg,
COALESCE(SUM(CASE WHEN i.pallet_id IS NULL THEN i.net_weight_kg ELSE 0 END), 0)
+ COALESCE((SELECT SUM(inbound.actual_net_weight_kg/NULLIF(inbound.total_package_count,0))
FROM fl_dropship_pallet_carton carton
JOIN fl_inbound_order inbound ON inbound.id=carton.inbound_id AND inbound.tenant_id=carton.tenant_id
WHERE EXISTS (SELECT 1 FROM fl_loading_plan_item pallet_item
WHERE pallet_item.loading_plan_id=p.id AND pallet_item.deleted=0
AND pallet_item.pallet_id=carton.pallet_id)), 0) AS total_net_weight_kg,
COALESCE(SUM(CASE WHEN i.pallet_id IS NULL THEN i.volume_cbm ELSE 0 END), 0)
+ COALESCE((SELECT SUM(inbound.actual_volume_cbm/NULLIF(inbound.total_package_count,0))
FROM fl_dropship_pallet_carton carton
JOIN fl_inbound_order inbound ON inbound.id=carton.inbound_id AND inbound.tenant_id=carton.tenant_id
WHERE EXISTS (SELECT 1 FROM fl_loading_plan_item pallet_item
WHERE pallet_item.loading_plan_id=p.id AND pallet_item.deleted=0
AND pallet_item.pallet_id=carton.pallet_id)), 0) AS total_volume_cbm,
COALESCE(SUM(i.product_quantity), 0) AS total_quantity COALESCE(SUM(i.product_quantity), 0) AS total_quantity
"""; """;
@@ -158,11 +158,12 @@ public class LoadingListRepository {
java.util.ArrayList<String> blockers = new java.util.ArrayList<>(); java.util.ArrayList<String> blockers = new java.util.ArrayList<>();
if (!editing) blockers.add("LOADING_PLAN_NOT_EDITING"); if (!editing) blockers.add("LOADING_PLAN_NOT_EDITING");
if (linkCount == 0) blockers.add("PRELOAD_LINK_MISSING"); if (linkCount == 0) blockers.add("PRELOAD_LINK_MISSING");
if (!containerReady) blockers.add("CONTAINER_NO_MISSING");
if (!oceanLinked) blockers.add("OCEAN_BOOKING_NOT_LINKED"); if (!oceanLinked) blockers.add("OCEAN_BOOKING_NOT_LINKED");
else if (!released) blockers.add("OCEAN_BOOKING_NOT_RELEASED"); else if (!released) blockers.add("OCEAN_BOOKING_NOT_RELEASED");
if (taskCreated) blockers.add("PDA_TASK_ALREADY_CREATED"); if (taskCreated) blockers.add("PDA_TASK_ALREADY_CREATED");
boolean allConfirmed = linkCount > 0 && confirmedCount == linkCount; boolean allConfirmed = linkCount > 0 && confirmedCount == linkCount;
boolean prerequisitesReady = editing && linkCount > 0 && released; boolean prerequisitesReady = editing && linkCount > 0 && containerReady && released;
boolean canConfirm = prerequisitesReady && !taskCreated && !allConfirmed; boolean canConfirm = prerequisitesReady && !taskCreated && !allConfirmed;
if (allConfirmed) blockers.add("PRELOAD_ALREADY_CONFIRMED"); if (allConfirmed) blockers.add("PRELOAD_ALREADY_CONFIRMED");
String confirmationStatus = allConfirmed ? "CONFIRMED" String confirmationStatus = allConfirmed ? "CONFIRMED"
@@ -231,10 +231,10 @@ public class DropshipPalletizationService {
values.stream().mapToInt(v->v.line().getActualQuantity()==null?0:v.line().getActualQuantity()).sum(), values.stream().mapToInt(v->v.line().getActualQuantity()==null?0:v.line().getActualQuantity()).sum(),
values.stream().map(v->v.carton().getBoxCode()).toList());}).toList(); values.stream().map(v->v.carton().getBoxCode()).toList());}).toList();
int discrepancies=(int)cartons.stream().filter(c->"ACCEPTED_VARIANCE".equals(c.getDiscrepancyStatus())).count(); int discrepancies=(int)cartons.stream().filter(c->"ACCEPTED_VARIANCE".equals(c.getDiscrepancyStatus())).count();
PalletMetrics metrics=mapper.metrics(row.getId()); PalletMetrics metrics=mapper.physicalMetricsByPalletIds(List.of(row.getId())).stream().findFirst().orElse(null);
int skuCount=(int)items.stream().map(SkuItem::skuCode).distinct().count(); int skuCount=(int)items.stream().map(SkuItem::skuCode).distinct().count();
return new Pallet(row.getId(),row.getPalletNo(),row.getPalletStatus(),cartons.size(),skuCount,discrepancies, return new Pallet(row.getId(),row.getPalletNo(),row.getPalletStatus(),cartons.size(),skuCount,discrepancies,
metrics==null?BigDecimal.ZERO:metrics.weightKg(),metrics==null?BigDecimal.ZERO:metrics.volumeCbm(),row.getVersion(),items, metrics==null?BigDecimal.ZERO:metrics.grossWeightKg(),metrics==null?BigDecimal.ZERO:metrics.volumeCbm(),row.getVersion(),items,
cartons.stream().map(c->cartonView(c,c.getInboundCode(),false,null)).toList(),row.getCompletedAt()); cartons.stream().map(c->cartonView(c,c.getInboundCode(),false,null)).toList(),row.getCompletedAt());
} }
@@ -116,6 +116,7 @@ public class LoadingPickService {
public void beginIfReady(long containerBookingId, long operatorId, String operatorName) { public void beginIfReady(long containerBookingId, long operatorId, String operatorName) {
var booking = smartLoadingRepository.booking(containerBookingId); var booking = smartLoadingRepository.booking(containerBookingId);
if (booking == null) return; if (booking == null) return;
if (!StringUtils.hasText(booking.getContainerNo())) return;
var plan = smartLoadingRepository.planByBooking(containerBookingId); var plan = smartLoadingRepository.planByBooking(containerBookingId);
if (plan == null || !"EDITING".equals(plan.getPlanStatus())) return; if (plan == null || !"EDITING".equals(plan.getPlanStatus())) return;
inboundHoldGuard.requirePlanNotHeld(plan.getId()); inboundHoldGuard.requirePlanNotHeld(plan.getId());
@@ -137,6 +138,10 @@ public class LoadingPickService {
if (!Set.of("PENDING", "CONFIRMING").contains(link.preloadStatus())) { if (!Set.of("PENDING", "CONFIRMING").contains(link.preloadStatus())) {
throw conflict("预装清单已确认或当前状态不允许确认"); throw conflict("预装清单已确认或当前状态不允许确认");
} }
var booking = smartLoadingRepository.booking(link.containerBookingId());
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
throw conflict("确认预装前必须录入真实集装箱号");
}
var allocation = oceanBookingRepository.allocationByContainer(link.containerBookingId()); var allocation = oceanBookingRepository.allocationByContainer(link.containerBookingId());
if (allocation == null) throw conflict("确认预装前必须关联真实订舱柜"); if (allocation == null) throw conflict("确认预装前必须关联真实订舱柜");
var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId()); var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId());
@@ -476,19 +476,28 @@ public class SmartLoadingService {
var first = lines.get(0); var first = lines.get(0);
inboundHoldGuard.requireInboundNotHeld(first.getAccountId(), first.getInboundId()); inboundHoldGuard.requireInboundNotHeld(first.getAccountId(), first.getInboundId());
if (!repository.activeItemsByPallet(request.palletId()).isEmpty()) throw conflict("托盘已被有效柜次占用"); if (!repository.activeItemsByPallet(request.palletId()).isEmpty()) throw conflict("托盘已被有效柜次占用");
com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletMetrics physical =
dropshipPallets.physicalMetricsByPalletIds(List.of(request.palletId())).stream().findFirst()
.orElseThrow(() -> conflict("托盘缺少物理箱明细,不能加入预装清单"));
int sequence = 0; int sequence = 0;
for (var line : lines) { for (var line : lines) {
boolean primaryPalletLine = sequence == 0;
BigDecimal lineBoxes = BigDecimal.valueOf(line.getCartonCount());
LoadingPlanEntity current = repository.plan(planId); LoadingPlanEntity current = repository.plan(planId);
Candidate candidate = new Candidate(line.getAccountId(), line.getInboundId(), line.getInboundDetailId(), Candidate candidate = new Candidate(line.getAccountId(), line.getInboundId(), line.getInboundDetailId(),
line.getOrderId(), line.getOrderNo(), line.getInboundCode(), line.getWarehouseId(), line.getChannelCode(), line.getOrderId(), line.getOrderNo(), line.getInboundCode(), line.getWarehouseId(), line.getChannelCode(),
line.getChannelName(), line.getDestinationWarehouseId(), line.getDestinationWarehouseCode(), line.getChannelName(), line.getDestinationWarehouseId(), line.getDestinationWarehouseCode(),
line.getDestinationWarehouseName(), line.getCustomerName(), line.getCargoName(), line.getCartonCount(), line.getDestinationWarehouseName(), line.getCustomerName(), line.getCargoName(), line.getCartonCount(),
line.getUnitGrossWeightKg(), line.getUnitNetWeightKg(), line.getUnitVolumeCbm(), null, null, null, null, primaryPalletLine ? unit(physical.grossWeightKg()).divide(lineBoxes, 6, RoundingMode.HALF_UP) : BigDecimal.ZERO,
primaryPalletLine ? unit(physical.netWeightKg()).divide(lineBoxes, 6, RoundingMode.HALF_UP) : BigDecimal.ZERO,
primaryPalletLine ? unit(physical.volumeCbm()).divide(lineBoxes, 6, RoundingMode.HALF_UP) : BigDecimal.ZERO,
null, null, null, null,
false, null, "WAIT_SPLIT", null); false, null, "WAIT_SPLIT", null);
String childOperation = "PAL-" + sha256(operation + '|' + line.getInboundDetailId()).substring(0, 48); String childOperation = "PAL-" + sha256(operation + '|' + line.getInboundDetailId()).substring(0, 48);
assign(planId, new AssignRequest(line.getInboundId(), line.getInboundDetailId(), line.getCartonCount(), assign(planId, new AssignRequest(line.getInboundId(), line.getInboundDetailId(), line.getCartonCount(),
current.getVersion(), childOperation), operatorId, operatorName, true, request.palletId(), current.getVersion(), childOperation), operatorId, operatorName, true, request.palletId(),
line.getPalletNo(), line.getActualSkuQuantity(), sequence++ == 0, candidate, false); line.getPalletNo(), line.getActualSkuQuantity(), primaryPalletLine, candidate, false);
sequence++;
} }
LoadingPlanEntity assigned = repository.plan(planId); LoadingPlanEntity assigned = repository.plan(planId);
history(planId, null, "ADD_PALLET", original.getPlanStatus(), assigned.getPlanStatus(), operation, history(planId, null, "ADD_PALLET", original.getPlanStatus(), assigned.getPlanStatus(), operation,
@@ -1689,19 +1698,26 @@ public class SmartLoadingService {
Map<Long,List<LoadingPlanItemEntity>> pallets = items.stream().filter(i -> i.getPalletId() != null) Map<Long,List<LoadingPlanItemEntity>> pallets = items.stream().filter(i -> i.getPalletId() != null)
.collect(java.util.stream.Collectors.groupingBy(LoadingPlanItemEntity::getPalletId, .collect(java.util.stream.Collectors.groupingBy(LoadingPlanItemEntity::getPalletId,
LinkedHashMap::new, java.util.stream.Collectors.toList())); LinkedHashMap::new, java.util.stream.Collectors.toList()));
Map<Long,com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletMetrics> physicalByPallet =
pallets.isEmpty() ? Map.of() : dropshipPallets.physicalMetricsByPalletIds(pallets.keySet()).stream()
.collect(java.util.stream.Collectors.toMap(
com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletMetrics::palletId,
java.util.function.Function.identity()));
items.stream().filter(i -> i.getPalletId() == null).forEach(result::add); items.stream().filter(i -> i.getPalletId() == null).forEach(result::add);
for (List<LoadingPlanItemEntity> lines : pallets.values()) { for (List<LoadingPlanItemEntity> lines : pallets.values()) {
LoadingPlanItemEntity unit = lines.get(0); LoadingPlanItemEntity unit = lines.get(0);
unit.setPackageCount(lines.stream().map(LoadingPlanItemEntity::getPackageCount) var physical = physicalByPallet.get(unit.getPalletId());
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); if (physical == null) throw conflict("托盘缺少物理箱明细,不能汇总预装清单");
unit.setPackageCount(physical.cartonCount());
unit.setProductQuantity(lines.stream().map(LoadingPlanItemEntity::getProductQuantity) unit.setProductQuantity(lines.stream().map(LoadingPlanItemEntity::getProductQuantity)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); .filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
unit.setPalletCount(1); unit.setPalletCount(1);
unit.setGrossWeightKg(sumDecimal(lines, LoadingPlanItemEntity::getGrossWeightKg)); unit.setGrossWeightKg(physical.grossWeightKg());
unit.setNetWeightKg(sumDecimal(lines, LoadingPlanItemEntity::getNetWeightKg)); unit.setNetWeightKg(physical.netWeightKg());
unit.setVolumeCbm(sumDecimal(lines, LoadingPlanItemEntity::getVolumeCbm)); unit.setVolumeCbm(physical.volumeCbm());
unit.setPickedPackageCount(lines.stream().map(LoadingPlanItemEntity::getPickedPackageCount) unit.setPickedPackageCount(Math.min(physical.cartonCount(), lines.stream()
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); .map(LoadingPlanItemEntity::getPickedPackageCount).filter(Objects::nonNull)
.mapToInt(Integer::intValue).sum()));
unit.setTotalPackageCount(unit.getPackageCount()); unit.setTotalPackageCount(unit.getPackageCount());
unit.setRemainingPackageCount(0); unit.setRemainingPackageCount(0);
unit.setCanAdjust(false); unit.setCanAdjust(false);
@@ -1713,10 +1729,6 @@ public class SmartLoadingService {
.thenComparing(LoadingPlanItemEntity::getId, Comparator.nullsLast(Long::compareTo))) .thenComparing(LoadingPlanItemEntity::getId, Comparator.nullsLast(Long::compareTo)))
.toList(); .toList();
} }
private static BigDecimal sumDecimal(List<LoadingPlanItemEntity> lines,
java.util.function.Function<LoadingPlanItemEntity,BigDecimal> getter) {
return lines.stream().map(getter).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
}
private int adjustmentTotal(LoadingPlanItemEntity item) { private int adjustmentTotal(LoadingPlanItemEntity item) {
int current = item.getPackageCount() == null ? 0 : item.getPackageCount(); int current = item.getPackageCount() == null ? 0 : item.getPackageCount();
try { try {
@@ -74,7 +74,7 @@ class LoadingListRepositoryTest {
} }
@Test @Test
void missingContainerNumberDoesNotBlockPreloadConfirmationOrPdaReadiness() { void missingContainerNumberBlocksPreloadConfirmationAndPdaReadiness() {
AggregateRow row = new AggregateRow(); AggregateRow row = new AggregateRow();
row.setPlanId(41L); row.setPlanId(41L);
row.setContainerBookingId(61L); row.setContainerBookingId(61L);
@@ -90,12 +90,12 @@ class LoadingListRepositoryTest {
var pending = repository.preloadDetail(row).summary().readiness(); var pending = repository.preloadDetail(row).summary().readiness();
assertThat(pending.containerNoReady()).isFalse(); assertThat(pending.containerNoReady()).isFalse();
assertThat(pending.canConfirm()).isTrue(); assertThat(pending.canConfirm()).isFalse();
assertThat(pending.blockingReasons()).doesNotContain("CONTAINER_NO_MISSING"); assertThat(pending.blockingReasons()).contains("CONTAINER_NO_MISSING");
row.setConfirmedPreloadCount(2L); row.setConfirmedPreloadCount(2L);
var confirmed = repository.preloadDetail(row).summary().readiness(); var confirmed = repository.preloadDetail(row).summary().readiness();
assertThat(confirmed.readyForPda()).isTrue(); assertThat(confirmed.readyForPda()).isFalse();
} }
@Test @Test
@@ -158,30 +158,15 @@ class LoadingPickServiceTest {
} }
@Test @Test
void opensTaskAfterReleasedBookingWithoutWaitingForRealContainerNumber() { void doesNotOpenTaskWithoutRealContainerNumber() {
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setId(40L);
var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity();
plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING");
var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L);
var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("RELEASED");
when(smartLoadingRepository.booking(40L)).thenReturn(booking); when(smartLoadingRepository.booking(40L)).thenReturn(booking);
when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan);
when(smartLoadingRepository.plan(10L)).thenReturn(plan);
when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of(
new Link(1L, 1L, 50L, 2L, "CONFIRMED", 1, 10L, 40L, null, "EDITING", 1, 1, null)));
when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation);
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
when(repository.taskByPlan(10L)).thenReturn(null);
when(smartLoadingRepository.items(10L)).thenReturn(List.of());
doAnswer(invocation -> {
invocation.getArgument(0, LoadingPickTaskEntity.class).setId(20L);
return null;
}).when(repository).insert(any(LoadingPickTaskEntity.class));
service.beginIfReady(40L, 7L, "admin"); service.beginIfReady(40L, 7L, "admin");
verify(repository).insert(any(LoadingPickTaskEntity.class)); verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
verify(smartLoadingRepository, never()).planByBooking(40L);
} }
@Test @Test
@@ -222,11 +207,11 @@ class LoadingPickServiceTest {
} }
@Test @Test
void planConfirmationGateRequiresReleasedBookingButNotRealContainerNumber() { void planConfirmationGateRequiresReleasedBookingAndRealContainerNumber() {
Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1, Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1,
10L, 40L, "MSCU-001", "EDITING", 3, 2, null); 10L, 40L, "MSCU-001", "EDITING", 3, 2, null);
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setId(40L); booking.setContainerNo("MSCU-001");
var allocation = new OceanBookingContainerAllocationEntity(); var allocation = new OceanBookingContainerAllocationEntity();
allocation.setOceanBookingId(90L); allocation.setOceanBookingId(90L);
var ocean = new OceanBookingEntity(); var ocean = new OceanBookingEntity();
@@ -240,21 +225,17 @@ class LoadingPickServiceTest {
} }
@Test @Test
void planConfirmationGateAllowsMissingRealContainerNumber() { void planConfirmationGateRejectsMissingRealContainerNumber() {
Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1, Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1,
10L, 40L, null, "EDITING", 3, 2, null); 10L, 40L, null, "EDITING", 3, 2, null);
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setId(40L);
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setOceanBookingId(90L);
var ocean = new OceanBookingEntity();
ocean.setId(90L); ocean.setStatus("RELEASED");
when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link)); when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link));
when(smartLoadingRepository.booking(40L)).thenReturn(booking); when(smartLoadingRepository.booking(40L)).thenReturn(booking);
when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation);
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
assertThat(service.requirePreloadConfirmationReady(10L)).containsExactly(link); assertThatThrownBy(() -> service.requirePreloadConfirmationReady(10L))
.isInstanceOf(BizException.class).hasMessageContaining("真实集装箱号");
verify(oceanBookingRepository, never()).allocationByContainer(40L);
} }
@Test @Test
@@ -9,6 +9,7 @@ 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.AssignInboundRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignPendingReviewRequest; import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignPendingReviewRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignPalletRequest;
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;
import com.baiyue.firstleg.dto.SmartLoadingDtos.CandidateOrderStatus; import com.baiyue.firstleg.dto.SmartLoadingDtos.CandidateOrderStatus;
@@ -62,6 +63,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -76,6 +78,7 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
@@ -119,6 +122,11 @@ class SmartLoadingServiceTest {
when(fbaPricingService.recalculateUnlockedContainerCustomerFees(anyLong(), anyLong(), anyString())) when(fbaPricingService.recalculateUnlockedContainerCustomerFees(anyLong(), anyLong(), anyString()))
.thenReturn(new FbaPricingService.ContainerFeeRecalculationResult( .thenReturn(new FbaPricingService.ContainerFeeRecalculationResult(
FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY, 0)); FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY, 0));
when(dropshipPallets.physicalMetricsByPalletIds(any())).thenAnswer(invocation -> {
Collection<Long> palletIds = invocation.getArgument(0);
return palletIds.stream().map(palletId -> new DropshipPalletizationMapper.PalletMetrics(
palletId, 10, new BigDecimal("20"), new BigDecimal("15"), new BigDecimal("5"))).toList();
});
} }
@Test @Test
@@ -181,6 +189,9 @@ class SmartLoadingServiceTest {
secondMember.setCustomerName("客户"); secondMember.setAvailablePackageCount(5); secondMember.setCustomerName("客户"); secondMember.setAvailablePackageCount(5);
when(dropshipPallets.palletInboundMembersByPalletIds(List.of(801L))) when(dropshipPallets.palletInboundMembersByPalletIds(List.of(801L)))
.thenReturn(List.of(firstMember,secondMember)); .thenReturn(List.of(firstMember,secondMember));
doReturn(List.of(new DropshipPalletizationMapper.PalletMetrics(
801L, 11, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO)))
.when(dropshipPallets).physicalMetricsByPalletIds(any());
Plan result=service.detail(1L); Plan result=service.detail(1L);
@@ -887,6 +898,53 @@ class SmartLoadingServiceTest {
eq(99L), eq(9L), anyString()); eq(99L), eq(9L), anyString());
} }
@Test
void multiSkuPalletKeepsSkuLinesButCountsPhysicalCartonsAndVolumeOnce() {
AtomicInteger version = new AtomicInteger();
List<LoadingPlanItemEntity> saved = new ArrayList<>();
DropshipPalletizationMapper.PalletLine first = palletLine(801L, "DP-260916-0009", 20);
DropshipPalletizationMapper.PalletLine second = palletLine(801L, "DP-260916-0009", 20);
second.setInboundDetailId(21L);
when(repository.plan(1L)).thenAnswer(invocation -> plan("EDITING", version.get()));
when(repository.planByBooking(99L)).thenAnswer(invocation -> plan("EDITING", version.get()));
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenAnswer(invocation -> List.copyOf(saved));
when(repository.activeItemsByPallet(801L)).thenReturn(List.of());
when(repository.activePalletItem(eq(1L), eq(801L), anyLong())).thenReturn(null);
when(repository.inbound(9L, 10L)).thenReturn(inbound(9L, 10L));
when(dropshipPallets.sealedPalletLines(801L)).thenReturn(List.of(first, second));
doReturn(List.of(
new DropshipPalletizationMapper.PalletMetrics(801L, 20, new BigDecimal("197"),
new BigDecimal("180"), new BigDecimal("3.365"))))
.when(dropshipPallets).physicalMetricsByPalletIds(any());
when(repository.touch(eq(1L), anyInt(), eq(7L), eq("u"))).thenAnswer(invocation -> {
int expected = invocation.getArgument(1);
if (expected != version.get()) return 0;
version.incrementAndGet();
return 1;
});
doAnswer(invocation -> {
LoadingPlanItemEntity item = invocation.getArgument(0);
item.setId(800L + saved.size());
saved.add(item);
return null;
}).when(repository).insert(any(LoadingPlanItemEntity.class));
Plan result = service.assignPallet(1L,
new AssignPalletRequest(801L, 0, "assign-multi-sku-pallet"), 7L, "u");
assertThat(saved).hasSize(2);
assertThat(saved).extracting(LoadingPlanItemEntity::getInboundDetailId)
.containsExactly(20L, 21L);
assertThat(saved).extracting(LoadingPlanItemEntity::getVolumeCbm)
.containsExactly(new BigDecimal("3.365000"), new BigDecimal("0.000000"));
assertThat(result.items()).singleElement().satisfies(item -> {
assertThat(item.getPackageCount()).isEqualTo(20);
assertThat(item.getGrossWeightKg()).isEqualByComparingTo("197");
assertThat(item.getVolumeCbm()).isEqualByComparingTo("3.365");
});
}
@Test @Test
void recommendSkipsStalePalletCandidateInsteadOfFailingTheWholeRecommendation() { void recommendSkipsStalePalletCandidateInsteadOfFailingTheWholeRecommendation() {
Candidate dropship = candidate(9L, 10L, 20L, 30L, 10); Candidate dropship = candidate(9L, 10L, 20L, 30L, 10);