This commit is contained in:
2026-09-14 11:12:29 +08:00
parent 481ab348ff
commit 035537e5cc
5 changed files with 74 additions and 15 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` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。
- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏其余未分配托盘。
- 客户入库费用只在登录客户范围、测量审核通过、`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 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
@@ -143,9 +143,11 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
SELECT current_group.id groupId,
CASE WHEN current_group.id IS NULL THEN eligible.accountId END accountId,
CASE WHEN current_group.id IS NULL THEN eligible.inboundId END inboundId,
CASE WHEN current_group.id IS NULL THEN eligible.palletId END palletId,
MIN(eligible.expectedArrivalAt) sortArrivalAt,MIN(eligible.inboundId) sortInboundId
FROM (
SELECT o.tenant_id accountId,o.id inboundId,o.inbound_code inboundCode,
available_pallet.palletId palletId,
o.expected_arrival_at expectedArrivalAt,o.created_at createdAt,
CASE WHEN o.process_status='WAIT_SPLIT' THEN 'WAIT_SPLIT'
WHEN o.process_status='LOADING' THEN 'LOADING' ELSE 'WAIT_MEASURE' END candidateStatus,
@@ -165,6 +167,18 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
WHERE o.inbound_role='MASTER' AND split_item.tenant_id=o.tenant_id
AND split_item.parent_inbound_id=o.id),0) availablePackageCount
FROM fl_inbound_order o
LEFT JOIN (
SELECT DISTINCT carton.tenant_id accountId,carton.inbound_id inboundId,pallet.id palletId
FROM fl_dropship_pallet pallet
JOIN fl_dropship_pallet_carton carton ON carton.pallet_id=pallet.id
WHERE pallet.pallet_status='SEALED'
AND NOT EXISTS (
SELECT 1 FROM fl_loading_plan_item pallet_item
WHERE pallet_item.pallet_id=pallet.id AND pallet_item.deleted=0
)
) available_pallet
ON available_pallet.accountId=o.tenant_id AND available_pallet.inboundId=o.id
AND o.fulfillment_type='ONE_CLICK_DROPSHIP'
WHERE (#{query.warehouseId} IS NULL OR o.warehouse_id=#{query.warehouseId})
AND COALESCE(o.hold_flag,0)=0
AND o.channel_code IN
@@ -174,11 +188,11 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
o.process_status IN ('WAIT_MEASURE','MEASURING','MEASURE_PENDING','MEASURE_REJECTED',
'WAIT_PRICING','FEE_CONFIRMING','INBOUNDING','WAIT_SPLIT','LOADING')
)
AND NOT EXISTS (
SELECT 1 FROM fl_loading_plan_item active_item
WHERE active_item.account_id=o.tenant_id AND active_item.inbound_id=o.id
AND active_item.deleted=0
)
AND ((COALESCE(o.fulfillment_type,'STANDARD')<>'ONE_CLICK_DROPSHIP' AND NOT EXISTS (
SELECT 1 FROM fl_loading_plan_item active_item
WHERE active_item.account_id=o.tenant_id AND active_item.inbound_id=o.id
AND active_item.deleted=0
)) OR (o.fulfillment_type='ONE_CLICK_DROPSHIP' AND available_pallet.palletId IS NOT NULL))
) eligible
LEFT JOIN fl_coload_group_member current_member
ON current_member.tenant_id=eligible.accountId AND current_member.inbound_id=eligible.inboundId
@@ -188,9 +202,10 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
WHERE eligible.candidateStatus<>'WAIT_SPLIT' OR eligible.availablePackageCount>0
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.inboundId END
CASE WHEN current_group.id IS NULL THEN eligible.inboundId END,
CASE WHEN current_group.id IS NULL THEN eligible.palletId END
HAVING (current_group.id IS NULL OR (
COUNT(*)=(SELECT COUNT(*) FROM fl_coload_group_member group_member_count
COUNT(DISTINCT eligible.accountId,eligible.inboundId)=(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)
))
<if test="query.orderStatuses != null and !query.orderStatuses.isEmpty()">
@@ -444,8 +459,13 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
private String channelCode;
private Long availableContainerCount;
}
record CandidateUnit(Long groupId,Long accountId,Long inboundId,
LocalDateTime sortArrivalAt,Long sortInboundId) {}
record CandidateUnit(Long groupId,Long accountId,Long inboundId,Long palletId,
LocalDateTime sortArrivalAt,Long sortInboundId) {
public CandidateUnit(Long groupId,Long accountId,Long inboundId,
LocalDateTime sortArrivalAt,Long sortInboundId) {
this(groupId,accountId,inboundId,null,sortArrivalAt,sortInboundId);
}
}
record PlanColoadMemberRow(Long groupId,String groupNo,Long lockedPlanId,
Long orderId,Long inboundId,Integer fullPackageCount) {}
}
@@ -175,15 +175,15 @@ public class SmartLoadingService {
LinkedHashMap::new, java.util.stream.Collectors.toList()));
for (InboundCandidate candidate : all) {
if (candidate.coLoadGroupId() == null) {
units.put(unitKey(null, candidate.accountId(), candidate.inboundId()), candidate);
units.put(unitKey(null, candidate.accountId(), candidate.inboundId(), candidate.palletId()), candidate);
}
}
for (var entry : grouped.entrySet()) {
InboundCandidate aggregate = globalGroupCandidate(entry.getValue());
if (aggregate != null) units.put(unitKey(entry.getKey(), null, null), aggregate);
if (aggregate != null) units.put(unitKey(entry.getKey(), null, null, null), aggregate);
}
List<InboundCandidate> records = unitPage.records().stream()
.map(unit -> units.get(unitKey(unit.groupId(), unit.accountId(), unit.inboundId())))
.map(unit -> units.get(unitKey(unit.groupId(), unit.accountId(), unit.inboundId(), unit.palletId())))
.filter(Objects::nonNull).toList();
return new PageResult<>(records, unitPage.page(), unitPage.size(), unitPage.total(), unitPage.pages());
}
@@ -357,8 +357,8 @@ public class SmartLoadingService {
};
}
private static String unitKey(Long groupId, Long accountId, Long inboundId) {
return groupId == null ? "I|" + accountId + '|' + inboundId : "G|" + groupId;
private static String unitKey(Long groupId, Long accountId, Long inboundId, Long palletId) {
return groupId == null ? "I|" + accountId + '|' + inboundId + '|' + palletId : "G|" + groupId;
}
private static BigDecimal weightedAverage(List<InboundCandidate> members,
@@ -91,6 +91,11 @@ class SmartLoadingCandidateMapperContractTest {
.contains("status.name() == 'WAIT_SPLIT'")
.contains("status.name() == 'LOADING'")
.contains("NOT EXISTS (", "active_item.inbound_id=o.id", "active_item.deleted=0")
.contains("available_pallet.palletId palletId")
.contains("pallet.pallet_status='SEALED'")
.contains("pallet_item.pallet_id=pallet.id", "pallet_item.deleted=0")
.contains("o.fulfillment_type='ONE_CLICK_DROPSHIP' AND available_pallet.palletId IS NOT NULL")
.contains("CASE WHEN current_group.id IS NULL THEN eligible.palletId END palletId")
.contains("current_group.id groupId")
.contains("group_member_count.group_id=current_group.id")
.doesNotContain("fl_container_booking")
@@ -16,6 +16,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.GroupMemberCandidate;
import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidateQuery;
import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidate;
import com.baiyue.firstleg.dto.SmartLoadingDtos.PalletCandidate;
import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainer;
import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan;
@@ -224,6 +225,39 @@ class SmartLoadingServiceTest {
verify(repository, never()).booking(anyLong());
}
@Test
void pagesEachAvailableDropshipPalletAsAnIndependentCandidate() {
Candidate dropship = withChannel(candidate(9L, 10L, 20L, 30L, 12),
"EXX_TRUCK", "EXX海卡");
InboundCandidateQuery query = new InboundCandidateQuery(
List.of("EXX"), null, null, "EXXHWK2609140003", null, null,
null, null, List.of(CandidateOrderStatus.WAIT_SPLIT), 1, 20);
List<CandidateUnit> units = List.of(
new CandidateUnit(null, 9L, 10L, 801L, null, 10L),
new CandidateUnit(null, 9L, 10L, 802L, null, 10L));
when(repository.globalCandidateUnits(eq(query), anySet()))
.thenReturn(new PageResult<>(units, 1, 20, 2, 1));
when(repository.globalCandidatesByUnits(units)).thenReturn(List.of(dropship));
when(dropshipPallets.palletCandidates(10L)).thenReturn(List.of(
new PalletCandidate(801L, "DP-260914-0004", "SEALED_PALLET", true,
8, 1, new BigDecimal("79.2"), new BigDecimal("4.4")),
new PalletCandidate(802L, "DP-260914-0005", "SEALED_PALLET", true,
4, 1, new BigDecimal("39.6"), new BigDecimal("2.2"))));
var result = service.inboundCandidates(query);
assertThat(result.total()).isEqualTo(2);
assertThat(result.records()).extracting(InboundCandidate::palletId,
InboundCandidate::palletNo, InboundCandidate::availablePackageCount)
.containsExactly(
org.assertj.core.groups.Tuple.tuple(801L, "DP-260914-0004", 8),
org.assertj.core.groups.Tuple.tuple(802L, "DP-260914-0005", 4));
assertThat(result.records()).allSatisfy(candidate -> {
assertThat(candidate.unitType()).isEqualTo("SEALED_PALLET");
assertThat(candidate.indivisible()).isTrue();
});
}
@Test
void combinesAllUnfinishedStatusesBeforeReturningTheRequestedPage() {
InboundCandidateQuery query = new InboundCandidateQuery(