fix: keep dropship pallets atomic in smart loading

This commit is contained in:
2026-09-14 14:35:29 +08:00
parent de9516d19e
commit 1d258ab27b
3 changed files with 88 additions and 6 deletions
+1 -1
View File
@@ -86,7 +86,7 @@
## 国内货柜履约装柜与 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 全部明细处理完成后自动完成。
- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏其余未分配托盘。装柜完成事件必须按计划项 `palletId` 精确冻结每托箱唛与 SKU 清单,不得仅按入仓单或货物明细关联而造成多托交叉重复。
- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏或禁用其余未分配托盘。智能推荐必须逐个调用整托分配,容量不足时跳过整托,不得回退按箱分配;单行删除必须按该行 `palletId` 整托移除。装柜完成事件必须按计划项 `palletId` 精确冻结每托箱唛与 SKU 清单,不得仅按入仓单或货物明细关联而造成多托交叉重复。
- 客户入库费用只在登录客户范围、测量审核通过、`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 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
@@ -273,15 +273,19 @@ public class SmartLoadingService {
String goods = details.stream().map(Candidate::goodsName).filter(StringUtils::hasText)
.map(String::trim).distinct().collect(java.util.stream.Collectors.joining("、"));
List<PalletCandidate> pallets=dropshipPallets.palletCandidates(first.inboundId());
if(!pallets.isEmpty()) return pallets.stream().map(pallet -> new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(),
if(!pallets.isEmpty()) return pallets.stream().map(pallet -> {
String palletBlock = StringUtils.hasText(first.blockReason()) ? first.blockReason()
: pallet.cartonCount() == null || pallet.cartonCount() <= 0 ? "托盘没有可加入的箱数" : null;
return new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(),
first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(),
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
first.customerName(), goods, pallet.cartonCount(),
pallet.cartonCount()==null||pallet.cartonCount()==0?null:pallet.weightKg().divide(BigDecimal.valueOf(pallet.cartonCount()),6,RoundingMode.HALF_UP),
first.unitNetWeightKg(), pallet.cartonCount()==null||pallet.cartonCount()==0?null:pallet.volumeCbm().divide(BigDecimal.valueOf(pallet.cartonCount()),6,RoundingMode.HALF_UP),
pallet.volumeCbm(),"SEALED_PALLET",pallet.palletId(),pallet.palletNo(),true,
first.coLoadGroupId(), first.groupNo(), first.memberCount(),first.groupTotalBoxes(), first.locked(), canAssign(first), capabilityBlock(first),
first.orderStatusSnapshot(),first.expectedArrivalAt(),List.of())).toList();
first.coLoadGroupId(), first.groupNo(), first.memberCount(),first.groupTotalBoxes(), false, !StringUtils.hasText(palletBlock), palletBlock,
first.orderStatusSnapshot(),first.expectedArrivalAt(),List.of());
}).toList();
return List.of(new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(),
first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(),
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
@@ -903,6 +907,7 @@ public class SmartLoadingService {
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Set<Long> visitedGroups = new HashSet<>();
Set<Long> visitedPallets = new HashSet<>();
for (InboundCandidate candidate : recommendationCandidates(booking)) {
if (candidate.coLoadGroupId() != null) {
if (!visitedGroups.add(candidate.coLoadGroupId()) || StringUtils.hasText(candidate.blockReason())) {
@@ -923,6 +928,18 @@ public class SmartLoadingService {
}
if (StringUtils.hasText(booking.getDestinationWarehouseCode())
&& !Objects.equals(booking.getDestinationWarehouseCode(), candidate.destinationWarehouseCode())) continue;
if ("SEALED_PALLET".equals(candidate.unitType())) {
if (candidate.palletId() == null || !visitedPallets.add(candidate.palletId())
|| !Boolean.TRUE.equals(candidate.canAssign())) continue;
int fitting = fittingBoxes(booking, repository.items(planId), candidate.availablePackageCount(),
candidate.averageUnitVolumeCbm(), candidate.unitGrossWeightKg());
if (fitting != candidate.availablePackageCount()) continue;
plan = repository.plan(planId);
assignPallet(planId, new AssignPalletRequest(candidate.palletId(), plan.getVersion(),
"REC-PAL-" + sha256(operation + '|' + candidate.palletId()).substring(0, 48)),
operatorId, operatorName);
continue;
}
int count = fittingBoxes(booking, repository.items(planId), candidate.availablePackageCount(),
candidate.averageUnitVolumeCbm(), candidate.unitGrossWeightKg());
if (count > 0) {
@@ -227,8 +227,8 @@ class SmartLoadingServiceTest {
@Test
void pagesEachAvailableDropshipPalletAsAnIndependentCandidate() {
Candidate dropship = withChannel(candidate(9L, 10L, 20L, 30L, 12),
"EXX_TRUCK", "EXX海卡");
Candidate dropship = withStatus(withChannel(candidate(9L, 10L, 20L, 30L, 12),
"EXX_TRUCK", "EXX海卡"), "LOADING", true);
InboundCandidateQuery query = new InboundCandidateQuery(
List.of("EXX"), null, null, "EXXHWK2609140003", null, null,
null, null, List.of(CandidateOrderStatus.WAIT_SPLIT), 1, 20);
@@ -255,6 +255,9 @@ class SmartLoadingServiceTest {
assertThat(result.records()).allSatisfy(candidate -> {
assertThat(candidate.unitType()).isEqualTo("SEALED_PALLET");
assertThat(candidate.indivisible()).isTrue();
assertThat(candidate.locked()).isFalse();
assertThat(candidate.canAssign()).isTrue();
assertThat(candidate.blockReason()).isNull();
});
}
@@ -769,6 +772,53 @@ class SmartLoadingServiceTest {
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString());
}
@Test
void recommendAssignsDropshipCandidateThroughWholePalletCommand() {
Candidate dropship = candidate(9L, 10L, 20L, 30L, 10);
AtomicInteger version = new AtomicInteger();
List<LoadingPlanItemEntity> saved = new ArrayList<>();
InboundOrderEntity inbound = inbound(9L, 10L);
inbound.setFulfillmentType("ONE_CLICK_DROPSHIP");
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.candidates(null)).thenReturn(List.of(dropship));
when(repository.inbound(9L, 10L)).thenReturn(inbound);
when(repository.activeItemsByPallet(801L)).thenReturn(List.of());
when(repository.activePalletItem(1L, 801L, 20L)).thenReturn(null);
when(dropshipPallets.palletCandidates(10L)).thenReturn(List.of(
new PalletCandidate(801L, "DP-260914-0021", "SEALED_PALLET", true,
10, 1, new BigDecimal("20"), new BigDecimal("5"))));
when(dropshipPallets.sealedPalletLines(801L)).thenReturn(List.of(
palletLine(801L, "DP-260914-0021", 10)));
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.recommend(1L,
new RecommendRequest(0, "recommend-dropship-pallet"), 7L, "u");
assertThat(result.version()).isEqualTo(1);
assertThat(saved).singleElement().satisfies(item -> {
assertThat(item.getUnitType()).isEqualTo("SEALED_PALLET");
assertThat(item.getPalletId()).isEqualTo(801L);
assertThat(item.getPalletNo()).isEqualTo("DP-260914-0021");
assertThat(item.getPackageCount()).isEqualTo(10);
assertThat(item.getIndivisible()).isTrue();
});
verify(repository, never()).candidate(anyLong(), anyLong(), anyLong());
}
@Test
void recommendRechecksCurrentCapacitySkipsStaleGroupAndContinuesWithLaterCandidate() {
Candidate ordinary = candidate(9L, 10L, 20L, 30L, 4);
@@ -1742,6 +1792,21 @@ class SmartLoadingServiceTest {
value.orderStatusSnapshot(), value.expectedArrivalAt());
}
private static DropshipPalletizationMapper.PalletLine palletLine(long palletId, String palletNo,
int cartonCount) {
DropshipPalletizationMapper.PalletLine value = new DropshipPalletizationMapper.PalletLine();
value.setPalletId(palletId); value.setPalletNo(palletNo); value.setPalletStatus("SEALED");
value.setAccountId(9L); value.setInboundId(10L); value.setInboundDetailId(20L); value.setOrderId(30L);
value.setOrderNo("O30"); value.setInboundCode("I10"); value.setWarehouseId(100L);
value.setChannelCode("MATSON_CLX_TRUCK"); value.setChannelName("美森正班海卡");
value.setDestinationWarehouseId(200L); value.setDestinationWarehouseCode("FBA1");
value.setDestinationWarehouseName("FBA仓"); value.setCustomerName("客户"); value.setCargoName("货物");
value.setCartonCount(cartonCount); value.setActualSkuQuantity(cartonCount);
value.setUnitGrossWeightKg(new BigDecimal("2")); value.setUnitNetWeightKg(new BigDecimal("1.5"));
value.setUnitVolumeCbm(new BigDecimal("0.5"));
return value;
}
private static Candidate withWarehouse(Candidate value, long warehouseId) {
return new Candidate(value.accountId(), value.inboundId(), value.inboundDetailId(), value.orderId(),
value.orderNo(), value.inboundCode(), warehouseId, value.channelCode(), value.channelName(),