fix(firstleg): explain coload group eligibility

This commit is contained in:
2026-08-31 15:40:24 +08:00
parent 17d81b0b94
commit ba8e3595e4
7 changed files with 123 additions and 29 deletions
@@ -14,9 +14,11 @@ import static com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRo
@Mapper @Mapper
public interface ColoadGroupMapper { public interface ColoadGroupMapper {
@Select({"<script>", """ @Select({"<script>", """
SELECT o.tenant_id tenantId,o.order_id orderId,o.id inboundId, SELECT o.tenant_id tenantId,o.order_id orderId,o.order_no orderNo,
o.process_status processStatus,o.warehouse_id warehouseId, o.id inboundId,o.inbound_code inboundCode,o.process_status processStatus,
o.channel_code channelCode,o.total_package_count fullPackageCount, o.warehouse_id warehouseId,o.warehouse_name warehouseName,
o.channel_code channelCode,o.channel_name channelName,
o.total_package_count fullPackageCount,
(SELECT COUNT(*) FROM fl_loading_plan_item item (SELECT COUNT(*) FROM fl_loading_plan_item item
WHERE item.account_id=o.tenant_id AND item.inbound_id=o.id AND item.deleted=0) WHERE item.account_id=o.tenant_id AND item.inbound_id=o.id AND item.deleted=0)
activeAllocationCount, activeAllocationCount,
@@ -15,10 +15,14 @@ public final class ColoadPersistenceRows {
public static class InboundCandidateRow { public static class InboundCandidateRow {
private Long tenantId; private Long tenantId;
private Long orderId; private Long orderId;
private String orderNo;
private Long inboundId; private Long inboundId;
private String inboundCode;
private String processStatus; private String processStatus;
private Long warehouseId; private Long warehouseId;
private String warehouseName;
private String channelCode; private String channelCode;
private String channelName;
private Integer fullPackageCount; private Integer fullPackageCount;
private Integer activeAllocationCount; private Integer activeAllocationCount;
private Long activeGroupId; private Long activeGroupId;
@@ -237,35 +237,55 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
.computeIfAbsent(row.getOrderId(), ignored -> new ArrayList<>()).add(row)); .computeIfAbsent(row.getOrderId(), ignored -> new ArrayList<>()).add(row));
List<InboundCandidateRow> resolved = new ArrayList<>(); List<InboundCandidateRow> resolved = new ArrayList<>();
for (Long orderId : orderIds) { for (Long orderId : orderIds) {
List<InboundCandidateRow> eligible = byOrder.getOrDefault(orderId, List.of()).stream() List<InboundCandidateRow> rows = byOrder.getOrDefault(orderId, List.of());
.filter(row -> eligible(row, allowedGroupId)).toList(); if (rows.isEmpty()) throw conflict("所选订单不存在或已失效");
List<InboundCandidateRow> eligible = rows.stream()
.filter(row -> unavailableReason(row, allowedGroupId) == null).toList();
if (eligible.isEmpty()) { if (eligible.isEmpty()) {
throw conflict("订单 " + orderId + " 不存在可创建同柜不可拆组的可用入库片段"); String order = StringUtils.hasText(rows.get(0).getOrderNo())
? "订单“" + rows.get(0).getOrderNo().trim() + "”" : "所选订单";
String reasons = rows.stream().map(row -> candidateLabel(row) + unavailableReason(row, allowedGroupId))
.distinct().limit(10).collect(java.util.stream.Collectors.joining(";"));
throw conflict(order + "当前没有可建组的入仓片段:" + reasons);
} }
resolved.addAll(eligible); resolved.addAll(eligible);
} }
return resolved; return resolved;
} }
private boolean eligible(InboundCandidateRow row, Long allowedGroupId) { private String unavailableReason(InboundCandidateRow row, Long allowedGroupId) {
return row.getTenantId() != null && row.getTenantId() > 0 if (row.getTenantId() == null || row.getTenantId() <= 0
&& row.getInboundId() != null && row.getInboundId() > 0 || row.getInboundId() == null || row.getInboundId() <= 0
&& "WAIT_SPLIT".equals(row.getProcessStatus()) || !StringUtils.hasText(row.getInboundCode())) return "权威业务快照缺失";
&& row.getFullPackageCount() != null && row.getFullPackageCount() > 0 if (!"WAIT_SPLIT".equals(row.getProcessStatus())) return "状态非待分柜";
&& Objects.equals(row.getActiveAllocationCount(), 0) if (!Objects.equals(row.getActiveAllocationCount(), 0)) return "已分配装柜计划";
&& (row.getActiveGroupId() == null || Objects.equals(row.getActiveGroupId(), allowedGroupId)); if (row.getActiveGroupId() != null && !Objects.equals(row.getActiveGroupId(), allowedGroupId)) {
return "已在其他同柜组";
}
if (row.getFullPackageCount() == null || row.getFullPackageCount() <= 0) return "箱数无效";
if (row.getWarehouseId() == null || row.getWarehouseId() <= 0
|| !StringUtils.hasText(row.getWarehouseName())) return "缺少平台收货仓权威快照";
if (!StringUtils.hasText(row.getChannelCode()) || !StringUtils.hasText(row.getChannelName())) {
return "缺少渠道权威快照";
}
return null;
}
private static String candidateLabel(InboundCandidateRow row) {
return StringUtils.hasText(row.getInboundCode())
? "入仓码“" + row.getInboundCode().trim() + "”" : "入仓码缺失的片段";
} }
private void validateSameRoute(List<InboundCandidateRow> rows) { private void validateSameRoute(List<InboundCandidateRow> rows) {
InboundCandidateRow first = rows.get(0); InboundCandidateRow first = rows.get(0);
if (first.getWarehouseId() == null || first.getWarehouseId() <= 0
|| !StringUtils.hasText(first.getChannelCode())) {
throw conflict("订单缺少国内仓或渠道权威快照");
}
for (InboundCandidateRow row : rows) { for (InboundCandidateRow row : rows) {
if (!Objects.equals(first.getWarehouseId(), row.getWarehouseId()) if (!Objects.equals(first.getWarehouseId(), row.getWarehouseId())) {
|| !normalized(first.getChannelCode()).equals(normalized(row.getChannelCode()))) { throw conflict(candidateLabel(first) + "(" + first.getWarehouseName() + ")与"
throw conflict("同柜不可拆组成员必须属于同一国内仓和渠道"); + candidateLabel(row) + "(" + row.getWarehouseName() + ")平台收货仓不同");
}
if (!normalized(first.getChannelCode()).equals(normalized(row.getChannelCode()))) {
throw conflict(candidateLabel(first) + "(" + first.getChannelName() + ")与"
+ candidateLabel(row) + "(" + row.getChannelName() + ")渠道不同");
} }
} }
} }
@@ -360,11 +380,11 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
} }
private static List<Long> normalizedOrderIds(List<Long> source, int minimum, int maximum) { private static List<Long> normalizedOrderIds(List<Long> source, int minimum, int maximum) {
if (source == null) throw badRequest("orderIds不能为空"); if (source == null) throw badRequest("请选择需要设置为同柜不可拆组的订单");
List<Long> values = source.stream().filter(Objects::nonNull).distinct().sorted().toList(); List<Long> values = source.stream().filter(Objects::nonNull).distinct().sorted().toList();
if (values.size() != source.size() || values.size() < minimum || values.size() > maximum if (values.size() != source.size() || values.size() < minimum || values.size() > maximum
|| values.stream().anyMatch(value -> value <= 0)) { || values.stream().anyMatch(value -> value <= 0)) {
throw badRequest("orderIds数量、重复项或ID值不合法"); throw badRequest("请选择" + minimum + "至" + maximum + "个不同的有效订单");
} }
return values; return values;
} }
@@ -10,6 +10,9 @@ paths:
description: >- description: >-
需要 platform:firstleg:smart-loading:assign。orderIds 至少2项;服务端解析每个订单全部 WAIT_SPLIT、 需要 platform:firstleg:smart-loading:assign。orderIds 至少2项;服务端解析每个订单全部 WAIT_SPLIT、
未分配且未属于其他活动组的可用入库片段,要求国内仓和渠道一致,并冻结全部片段及其满箱数。 未分配且未属于其他活动组的可用入库片段,要求国内仓和渠道一致,并冻结全部片段及其满箱数。
同一订单部分片段不可用时只纳入全部当前可用片段;全部不可用时按入仓码明确返回状态非待分柜、
已分配装柜计划、已在其他同柜组、箱数无效或仓库/渠道权威快照缺失。跨成员仓库或渠道不一致时
返回入仓码及仓库名/渠道名;平台错误不得展示 orderId、inboundId 或 groupId。
parameters: parameters:
- $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/IdempotencyKey'
requestBody: requestBody:
@@ -41,6 +41,10 @@ class ColoadGroupOpenApiContractTest {
.contains("任一冻结成员缺少有效 inboundCode 时失败关闭") .contains("任一冻结成员缺少有效 inboundCode 时失败关闭")
.contains("全部 WAIT_SPLIT") .contains("全部 WAIT_SPLIT")
.contains("国内仓和渠道一致") .contains("国内仓和渠道一致")
.contains("状态非待分柜")
.contains("已分配装柜计划")
.contains("已在其他同柜组")
.contains("平台错误不得展示 orderId、inboundId 或 groupId")
.doesNotContain("destinationType") .doesNotContain("destinationType")
.doesNotContain("destinationWarehouseId") .doesNotContain("destinationWarehouseId")
.doesNotContain("destinationCode") .doesNotContain("destinationCode")
@@ -123,9 +123,11 @@ class ColoadGroupServiceTest {
@Test @Test
void createsCrossCustomerGroupAndFreezesAllAvailableFragmentsForEveryOrder() { void createsCrossCustomerGroupAndFreezesAllAvailableFragmentsForEveryOrder() {
InboundCandidateRow alreadyAllocatedFragment = candidate(101L, 11L, 1004L, 40);
alreadyAllocatedFragment.setActiveAllocationCount(1);
when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of( when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(
candidate(101L, 11L, 1001L, 30), candidate(101L, 11L, 1003L, 20), candidate(101L, 11L, 1001L, 30), candidate(101L, 11L, 1003L, 20),
candidate(202L, 12L, 1002L, 50))); alreadyAllocatedFragment, candidate(202L, 12L, 1002L, 50)));
when(mapper.insertGroup(anyLong(), anyString(), eq(9L), eq("CH"), when(mapper.insertGroup(anyLong(), anyString(), eq(9L), eq("CH"),
eq(7L), eq("operator"))).thenReturn(1); eq(7L), eq("operator"))).thenReturn(1);
when(mapper.insertMember(anyLong(), anyLong(), anyLong(), anyLong(), anyLong(), anyInt())) when(mapper.insertMember(anyLong(), anyLong(), anyLong(), anyLong(), anyLong(), anyInt()))
@@ -152,13 +154,16 @@ class ColoadGroupServiceTest {
verify(mapper).insertMember(anyLong(), eq(groupId.getValue()), eq(101L), eq(11L), eq(1001L), eq(30)); verify(mapper).insertMember(anyLong(), eq(groupId.getValue()), eq(101L), eq(11L), eq(1001L), eq(30));
verify(mapper).insertMember(anyLong(), eq(groupId.getValue()), eq(101L), eq(11L), eq(1003L), eq(20)); verify(mapper).insertMember(anyLong(), eq(groupId.getValue()), eq(101L), eq(11L), eq(1003L), eq(20));
verify(mapper).insertMember(anyLong(), eq(groupId.getValue()), eq(202L), eq(12L), eq(1002L), eq(50)); verify(mapper).insertMember(anyLong(), eq(groupId.getValue()), eq(202L), eq(12L), eq(1002L), eq(50));
verify(mapper, never()).insertMember(anyLong(), eq(groupId.getValue()), eq(101L), eq(11L),
eq(1004L), anyInt());
} }
@Test @Test
void rejectsSingleOrderBeforeAnyDatabaseWrite() { void rejectsSingleOrderBeforeAnyDatabaseWrite() {
assertThatThrownBy(() -> service.create(List.of(11L), "key", 7L, "operator")) assertThatThrownBy(() -> service.create(List.of(11L), "key", 7L, "operator"))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
.hasMessageContaining("orderIds"); .hasMessageContaining("请选择2至100个不同的有效订单")
.hasMessageNotContaining("orderId");
verify(mapper, never()).insertCommand(anyLong(), anyLong(), anyString(), anyString(), anyString()); verify(mapper, never()).insertCommand(anyLong(), anyLong(), anyString(), anyString(), anyString());
} }
@@ -173,7 +178,11 @@ class ColoadGroupServiceTest {
assertThatThrownBy(() -> service.create(List.of(11L, 12L), "key", 7L, "operator")) assertThatThrownBy(() -> service.create(List.of(11L, 12L), "key", 7L, "operator"))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
.hasMessageContaining("不存在可创建"); .hasMessageContaining("订单“FO-11”")
.hasMessageContaining("入仓码“IN-1001”已分配装柜计划")
.hasMessageNotContaining("订单 11")
.hasMessageNotContaining("groupId")
.hasMessageNotContaining("inboundId");
verify(mapper, never()).insertGroup(anyLong(), anyString(), anyLong(), anyString(), verify(mapper, never()).insertGroup(anyLong(), anyString(), anyLong(), anyString(),
anyLong(), any()); anyLong(), any());
} }
@@ -182,14 +191,51 @@ class ColoadGroupServiceTest {
void rejectsDifferentWarehouseOrChannel() { void rejectsDifferentWarehouseOrChannel() {
InboundCandidateRow second = candidate(202L, 12L, 1002L, 50); InboundCandidateRow second = candidate(202L, 12L, 1002L, 50);
second.setWarehouseId(10L); second.setWarehouseId(10L);
when(mapper.insertCommand(anyLong(), eq(7L), eq("CREATE"), eq("route-key"), anyString())) second.setWarehouseName("东莞仓");
when(mapper.insertCommand(anyLong(), eq(7L), eq("CREATE"), anyString(), anyString()))
.thenReturn(1); .thenReturn(1);
when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of( when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(
candidate(101L, 11L, 1001L, 30), second)); candidate(101L, 11L, 1001L, 30), second));
assertThatThrownBy(() -> service.create(List.of(11L, 12L), "route-key", 7L, "operator")) assertThatThrownBy(() -> service.create(List.of(11L, 12L), "route-key", 7L, "operator"))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
.hasMessageContaining("同一国内仓和渠道"); .hasMessageContaining("入仓码“IN-1001”(义乌仓)")
.hasMessageContaining("入仓码“IN-1002”(东莞仓)")
.hasMessageContaining("平台收货仓不同");
InboundCandidateRow otherChannel = candidate(202L, 12L, 1002L, 50);
otherChannel.setChannelCode("EXX"); otherChannel.setChannelName("EXX海卡");
when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(
candidate(101L, 11L, 1001L, 30), otherChannel));
assertThatThrownBy(() -> service.create(List.of(11L, 12L), "channel-key", 7L, "operator"))
.isInstanceOf(BizException.class)
.hasMessageContaining("入仓码“IN-1001”(美森海卡)")
.hasMessageContaining("入仓码“IN-1002”(EXX海卡)")
.hasMessageContaining("渠道不同");
}
@Test
void reportsEveryUnavailableReasonUsingBusinessIdentifiers() {
when(mapper.insertCommand(anyLong(), eq(7L), eq("CREATE"), anyString(), anyString()))
.thenReturn(1);
InboundCandidateRow status = candidate(101L, 11L, 1001L, 30);
status.setProcessStatus("LOADING");
assertUnavailable(status, "state-key", "状态非待分柜");
InboundCandidateRow grouped = candidate(101L, 11L, 1001L, 30);
grouped.setActiveGroupId(900L);
assertUnavailable(grouped, "group-key", "已在其他同柜组");
InboundCandidateRow boxes = candidate(101L, 11L, 1001L, 0);
assertUnavailable(boxes, "boxes-key", "箱数无效");
InboundCandidateRow warehouse = candidate(101L, 11L, 1001L, 30);
warehouse.setWarehouseName(" ");
assertUnavailable(warehouse, "warehouse-key", "缺少平台收货仓权威快照");
InboundCandidateRow channel = candidate(101L, 11L, 1001L, 30);
channel.setChannelName(null);
assertUnavailable(channel, "channel-key", "缺少渠道权威快照");
} }
@Test @Test
@@ -342,12 +388,25 @@ class ColoadGroupServiceTest {
private static InboundCandidateRow candidate(long tenantId, long orderId, long inboundId, int packages) { private static InboundCandidateRow candidate(long tenantId, long orderId, long inboundId, int packages) {
InboundCandidateRow value = new InboundCandidateRow(); InboundCandidateRow value = new InboundCandidateRow();
value.setTenantId(tenantId); value.setOrderId(orderId); value.setInboundId(inboundId); value.setTenantId(tenantId); value.setOrderId(orderId); value.setOrderNo("FO-" + orderId);
value.setProcessStatus("WAIT_SPLIT"); value.setWarehouseId(9L); value.setChannelCode("CH"); value.setInboundId(inboundId); value.setInboundCode("IN-" + inboundId);
value.setProcessStatus("WAIT_SPLIT"); value.setWarehouseId(9L); value.setWarehouseName("义乌仓");
value.setChannelCode("CH"); value.setChannelName("美森海卡");
value.setFullPackageCount(packages); value.setActiveAllocationCount(0); value.setFullPackageCount(packages); value.setActiveAllocationCount(0);
return value; return value;
} }
private void assertUnavailable(InboundCandidateRow row, String key, String reason) {
when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(
row, candidate(202L, 12L, 1002L, 50)));
assertThatThrownBy(() -> service.create(List.of(11L, 12L), key, 7L, "operator"))
.isInstanceOf(BizException.class)
.hasMessageContaining("订单“FO-11”", "入仓码“IN-1001”", reason)
.hasMessageNotContaining("订单 11")
.hasMessageNotContaining("groupId")
.hasMessageNotContaining("inboundId");
}
private static GroupRow group(long id, String status, Long planId, int version) { private static GroupRow group(long id, String status, Long planId, int version) {
GroupRow value = new GroupRow(); GroupRow value = new GroupRow();
value.setId(id); value.setGroupNo("CLG-" + id); value.setGroupStatus(status); value.setId(id); value.setGroupNo("CLG-" + id); value.setGroupStatus(status);
@@ -42,6 +42,8 @@ class ColoadGroupSqlContractTest {
String sql = String.join(" ", method.getAnnotation(Select.class).value()); String sql = String.join(" ", method.getAnnotation(Select.class).value());
assertThat(sql) assertThat(sql)
.contains("FROM fl_inbound_order o") .contains("FROM fl_inbound_order o")
.contains("o.order_no orderNo", "o.inbound_code inboundCode")
.contains("o.warehouse_name warehouseName", "o.channel_name channelName")
.contains("FROM fl_loading_plan_item item") .contains("FROM fl_loading_plan_item item")
.contains("item.deleted=0") .contains("item.deleted=0")
.contains("member.active_flag=1") .contains("member.active_flag=1")