This commit is contained in:
2026-08-31 10:31:51 +08:00
parent d6f47e650c
commit b386f35ab4
11 changed files with 115 additions and 42 deletions
@@ -34,6 +34,7 @@ public final class ColoadGroupModels {
long tenantId,
long orderId,
long inboundId,
String inboundCode,
int fullPackageCount
) {
}
@@ -66,9 +66,15 @@ public interface ColoadGroupMapper {
+ "FROM fl_coload_group WHERE id=#{groupId} FOR UPDATE")
GroupRow lockGroup(@Param("groupId") long groupId);
@Select("SELECT tenant_id tenantId,order_id orderId,inbound_id inboundId,"
+ "full_package_count fullPackageCount FROM fl_coload_group_member "
+ "WHERE group_id=#{groupId} ORDER BY order_id,inbound_id")
@Select("""
SELECT member.tenant_id tenantId,member.order_id orderId,member.inbound_id inboundId,
inbound.inbound_code inboundCode,member.full_package_count fullPackageCount
FROM fl_coload_group_member member
LEFT JOIN fl_inbound_order inbound
ON inbound.tenant_id=member.tenant_id AND inbound.id=member.inbound_id AND inbound.deleted=0
WHERE member.group_id=#{groupId}
ORDER BY member.order_id,member.inbound_id
""")
List<MemberRow> members(@Param("groupId") long groupId);
@Select("""
@@ -98,13 +104,12 @@ public interface ColoadGroupMapper {
List<InboundGroupLookupRow> groupsByInboundCode(@Param("inboundCode") String inboundCode);
@Select("""
SELECT DISTINCT inbound.inbound_code inboundCode
SELECT member.id memberId,member.inbound_id inboundId,inbound.inbound_code inboundCode
FROM fl_coload_group_member member
JOIN fl_inbound_order inbound
ON inbound.tenant_id=member.tenant_id AND inbound.id=member.inbound_id
WHERE member.group_id=#{groupId} AND inbound.deleted=0
AND inbound.inbound_code IS NOT NULL AND TRIM(inbound.inbound_code)<>''
ORDER BY inbound.inbound_code
LEFT JOIN fl_inbound_order inbound
ON inbound.tenant_id=member.tenant_id AND inbound.id=member.inbound_id AND inbound.deleted=0
WHERE member.group_id=#{groupId}
ORDER BY member.id
""")
List<MemberCodeRow> effectiveMemberCodes(@Param("groupId") long groupId);
@@ -44,6 +44,7 @@ public final class ColoadPersistenceRows {
private Long tenantId;
private Long orderId;
private Long inboundId;
private String inboundCode;
private Integer fullPackageCount;
}
@@ -57,6 +58,8 @@ public final class ColoadPersistenceRows {
@Getter
@Setter
public static class MemberCodeRow {
private Long memberId;
private Long inboundId;
private String inboundCode;
}
@@ -104,14 +104,17 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
if (distinctGroups > 1) throw conflict("入仓码关联多个有效同柜不可拆组");
long groupId = groups.get(0).getGroupId();
String status = groups.get(0).getGroupStatus();
List<BusinessMember> members = mapper.effectiveMemberCodes(groupId).stream()
.map(row -> trim(row.getInboundCode()))
.filter(Objects::nonNull)
.distinct()
.sorted()
.map(BusinessMember::new)
.toList();
if (members.isEmpty()) throw conflict("同柜不可拆组不存在有效入仓片段");
var memberRows = mapper.effectiveMemberCodes(groupId);
if (memberRows.isEmpty()) throw conflict("同柜不可拆组不存在有效入仓片段");
if (memberRows.stream().anyMatch(row -> row.getMemberId() == null || row.getInboundId() == null
|| trim(row.getInboundCode()) == null)) {
throw conflict("同柜不可拆组存在缺失或无效的入仓片段业务号");
}
List<String> inboundCodes = memberRows.stream().map(row -> trim(row.getInboundCode())).toList();
if (inboundCodes.stream().distinct().count() != inboundCodes.size()) {
throw conflict("同柜不可拆组存在重复入仓片段业务号");
}
List<BusinessMember> members = inboundCodes.stream().sorted().map(BusinessMember::new).toList();
return new BusinessSummary(status, members.size(), members);
}
@@ -271,9 +274,20 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
}
private GroupSnapshot snapshot(GroupRow row) {
List<Member> members = mapper.members(row.getId()).stream()
var memberRows = mapper.members(row.getId());
if (memberRows.isEmpty() || memberRows.stream().anyMatch(value -> value.getTenantId() == null
|| value.getOrderId() == null || value.getInboundId() == null
|| trim(value.getInboundCode()) == null || value.getFullPackageCount() == null
|| value.getFullPackageCount() <= 0)) {
throw conflict("同柜不可拆组存在缺失或无效的冻结成员业务事实");
}
List<String> codes = memberRows.stream().map(value -> trim(value.getInboundCode())).toList();
if (codes.stream().distinct().count() != codes.size()) {
throw conflict("同柜不可拆组存在重复入仓片段业务号");
}
List<Member> members = memberRows.stream()
.map(value -> new Member(value.getTenantId(), value.getOrderId(), value.getInboundId(),
value.getFullPackageCount()))
trim(value.getInboundCode()), value.getFullPackageCount()))
.toList();
return new GroupSnapshot(row.getId(), row.getGroupNo(), row.getGroupStatus(), row.getWarehouseId(),
row.getChannelCode(), row.getLockedPlanId(), row.getVersion(), members,
@@ -40,7 +40,8 @@ paths:
summary: 按入仓码查询同柜组业务摘要
description: >-
需要 platform:firstleg:smart-loading:list。使用任一有效成员的全局唯一 inboundCode 查询;
仅返回组业务状态、有效成员数和去重后的全部成员入仓码。页面不持有或展示 groupId、groupNo、orderId。
仅返回组业务状态、有效成员数和全部唯一成员入仓码。任一冻结成员缺少有效 inboundCode 时失败关闭;
页面不持有或展示 groupId、groupNo、orderId,也不得从技术ID或订单号推导入仓码。
parameters:
- name: inboundCode
in: path
@@ -107,11 +108,12 @@ components:
ColoadGroupMember:
type: object
additionalProperties: false
required: [tenantId, orderId, inboundId, fullPackageCount]
required: [tenantId, orderId, inboundId, inboundCode, fullPackageCount]
properties:
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
inboundCode: {type: string, minLength: 1, maxLength: 40, description: 冻结成员的全局唯一入仓码,禁止由技术ID推导}
fullPackageCount: {type: integer, minimum: 1}
ColoadGroup:
type: object
@@ -39,6 +39,7 @@ class ColoadGroupOpenApiContractTest {
.contains("fullPackageCount")
.contains("platform:firstleg:smart-loading:assign")
.contains("platform:firstleg:smart-loading:list")
.contains("任一冻结成员缺少有效 inboundCode 时失败关闭")
.contains("全部 WAIT_SPLIT")
.contains("国内仓和渠道一致")
.doesNotContain("destinationType")
@@ -39,7 +39,8 @@ class ColoadGroupServiceTest {
@Test
void readsCrossCustomerTwoMemberGroupByEitherInboundCodeWithoutInternalIdentifiers() {
when(mapper.groupsByInboundCode("IN-A")).thenReturn(List.of(lookup(500L, "ACTIVE")));
when(mapper.effectiveMemberCodes(500L)).thenReturn(List.of(code("IN-A"), code("IN-B")));
when(mapper.effectiveMemberCodes(500L)).thenReturn(List.of(
code(1L, 1001L, "IN-A"), code(2L, 1002L, "IN-B")));
var result = service.queryByInboundCode(" IN-A ");
@@ -50,10 +51,11 @@ class ColoadGroupServiceTest {
}
@Test
void returnsEveryEffectiveSplitFragmentAndEliminatesDuplicateInboundCodes() {
void returnsEveryEffectiveSplitFragmentWithItsUniqueInboundCode() {
when(mapper.groupsByInboundCode("MASTER-A-2")).thenReturn(List.of(lookup(500L, "LOCKED")));
when(mapper.effectiveMemberCodes(500L)).thenReturn(List.of(
code("MASTER-A-3"), code("MASTER-A-2"), code("MASTER-A-2"), code("ORDER-B")));
code(1L, 1001L, "MASTER-A-3"), code(2L, 1002L, "MASTER-A-2"),
code(3L, 1003L, "ORDER-B")));
var result = service.queryByInboundCode("MASTER-A-2");
@@ -66,7 +68,8 @@ class ColoadGroupServiceTest {
@Test
void preservesConsumedStatusWhileReadingClosedFrozenMemberships() {
when(mapper.groupsByInboundCode("IN-A")).thenReturn(List.of(lookup(500L, "CONSUMED")));
when(mapper.effectiveMemberCodes(500L)).thenReturn(List.of(code("IN-A"), code("IN-B")));
when(mapper.effectiveMemberCodes(500L)).thenReturn(List.of(
code(1L, 1001L, "IN-A"), code(2L, 1002L, "IN-B")));
assertThat(service.queryByInboundCode("IN-A").status()).isEqualTo("CONSUMED");
}
@@ -84,6 +87,21 @@ class ColoadGroupServiceTest {
.isInstanceOf(BizException.class).hasMessageContaining("多个有效");
}
@Test
void failsClosedInsteadOfReturningMissingOrDuplicateMemberInboundCode() {
when(mapper.groupsByInboundCode("BROKEN")).thenReturn(List.of(lookup(500L, "ACTIVE")));
when(mapper.effectiveMemberCodes(500L)).thenReturn(List.of(
code(1L, 1001L, "IN-A"), code(2L, 1002L, " ")));
when(mapper.groupsByInboundCode("DUPLICATE")).thenReturn(List.of(lookup(501L, "LOCKED")));
when(mapper.effectiveMemberCodes(501L)).thenReturn(List.of(
code(3L, 1003L, "IN-C"), code(4L, 1004L, "IN-C")));
assertThatThrownBy(() -> service.queryByInboundCode("BROKEN"))
.isInstanceOf(BizException.class).hasMessageContaining("缺失或无效");
assertThatThrownBy(() -> service.queryByInboundCode("DUPLICATE"))
.isInstanceOf(BizException.class).hasMessageContaining("重复");
}
@Test
void internalOrderAggregationKeepsTechnicalGroupSnapshotOffPlatformContract() {
when(mapper.linksByOrderIds(List.of(11L, 12L))).thenReturn(List.of(
@@ -273,6 +291,7 @@ class ColoadGroupServiceTest {
private static MemberRow member(long tenantId, long orderId, long inboundId, int packages) {
MemberRow value = new MemberRow();
value.setTenantId(tenantId); value.setOrderId(orderId); value.setInboundId(inboundId);
value.setInboundCode("IN-" + inboundId);
value.setFullPackageCount(packages);
return value;
}
@@ -284,8 +303,10 @@ class ColoadGroupServiceTest {
return value;
}
private static MemberCodeRow code(String inboundCode) {
private static MemberCodeRow code(long memberId, long inboundId, String inboundCode) {
MemberCodeRow value = new MemberCodeRow();
value.setMemberId(memberId);
value.setInboundId(inboundId);
value.setInboundCode(inboundCode);
return value;
}
@@ -69,7 +69,8 @@ class ColoadGroupSqlContractTest {
.contains("'ACTIVE','LOCKED','CONSUMED'")
.doesNotContain("group_no", "order_id=#{");
assertThat(memberSql)
.contains("SELECT DISTINCT inbound.inbound_code inboundCode")
.contains("member.id memberId", "member.inbound_id inboundId", "inbound.inbound_code inboundCode")
.contains("LEFT JOIN fl_inbound_order inbound")
.contains("member.group_id=#{groupId}")
.contains("inbound.deleted=0")
.doesNotContain("member.active_flag=1", "inbound_role", "split_status", "split_purpose");
@@ -822,7 +822,7 @@ class LoadingPickServiceTest {
complete.setPickedPackageCount(5);
complete.setItemStatus("PICKED");
GroupSnapshot group = coloadGroup("LOCKED", 10L,
new Member(101L, 301L, 50L, 5), new Member(101L, 301L, 60L, 5));
new Member(101L, 301L, 50L, "IN-50", 5), new Member(101L, 301L, 60L, "IN-60", 5));
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.inboundOwners(20L)).thenReturn(List.of(
@@ -883,8 +883,8 @@ class LoadingPickServiceTest {
when(repository.completeAccepted(20L, 9L, "operator")).thenReturn(1);
when(completionCommands.complete(903L, "worker-multi")).thenReturn(true);
GroupSnapshot group = coloadGroup("LOCKED", 10L,
new Member(101L, 301L, 50L, 1), new Member(101L, 302L, 60L, 1),
new Member(202L, 303L, 70L, 1), new Member(202L, 304L, 80L, 1));
new Member(101L, 301L, 50L, "IN-50", 1), new Member(101L, 302L, 60L, "IN-60", 1),
new Member(202L, 303L, 70L, "IN-70", 1), new Member(202L, 304L, 80L, "IN-80", 1));
when(coloadGroups.findByInbound(anyLong(), anyLong())).thenReturn(group);
service.processCompletion(completionCommand(903L, "async-multi"), "worker-multi");
@@ -144,7 +144,7 @@ class SmartLoadingServiceTest {
Candidate first = candidate(9L, 10L, 20L, 30L, 4);
Candidate second = candidate(19L, 11L, 21L, 31L, 6);
GroupSnapshot group = group("ACTIVE", null,
new Member(9L, 30L, 10L, 4), new Member(19L, 31L, 11L, 6));
new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6));
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidates(100L)).thenReturn(List.of(first, second));
when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group);
@@ -166,7 +166,7 @@ class SmartLoadingServiceTest {
void rejectsIndividualAssignmentForColoadMemberWithoutWriting() {
stubAssignment();
GroupSnapshot group = group("ACTIVE", null,
new Member(9L, 30L, 10L, 10), new Member(19L, 31L, 11L, 5));
new Member(9L, 30L, 10L, "IN-10", 10), new Member(19L, 31L, 11L, "IN-11", 5));
when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group);
assertThatThrownBy(() -> service.assign(1L,
@@ -183,9 +183,9 @@ class SmartLoadingServiceTest {
Candidate first = candidate(9L, 10L, 20L, 30L, 4);
Candidate second = candidate(9L, 11L, 21L, 30L, 6, "FBA2");
GroupSnapshot active = group("ACTIVE", null,
new Member(9L, 30L, 10L, 4), new Member(9L, 30L, 11L, 6));
new Member(9L, 30L, 10L, "IN-10", 4), new Member(9L, 30L, 11L, "IN-11", 6));
GroupSnapshot locked = group("LOCKED", 1L,
new Member(9L, 30L, 10L, 4), new Member(9L, 30L, 11L, 6));
new Member(9L, 30L, 10L, "IN-10", 4), new Member(9L, 30L, 11L, "IN-11", 6));
AtomicInteger version = new AtomicInteger();
List<LoadingPlanItemEntity> saved = new ArrayList<>();
when(repository.plan(1L)).thenAnswer(invocation -> plan("EDITING", version.get()));
@@ -242,8 +242,8 @@ class SmartLoadingServiceTest {
Candidate second = candidate(19L, 11L, 21L, 31L, 5);
Candidate third = candidate(29L, 12L, 22L, 32L, 5);
GroupSnapshot group = group("ACTIVE", null,
new Member(9L, 30L, 10L, 5), new Member(19L, 31L, 11L, 5),
new Member(29L, 32L, 12L, 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));
ContainerBookingEntity small = booking();
small.setCapacityCbm(new BigDecimal("5"));
LoadingPlanEntity plan = plan("EDITING", 0);
@@ -269,7 +269,7 @@ class SmartLoadingServiceTest {
Candidate groupSecond = candidate(9L, 12L, 22L, 32L, 4);
Candidate later = candidate(9L, 13L, 23L, 33L, 1);
GroupSnapshot group = group("ACTIVE", null,
new Member(9L, 31L, 11L, 4), new Member(9L, 32L, 12L, 4));
new Member(9L, 31L, 11L, "IN-11", 4), new Member(9L, 32L, 12L, "IN-12", 4));
ContainerBookingEntity small = booking();
small.setCapacityCbm(new BigDecimal("5"));
AtomicInteger version = new AtomicInteger();
@@ -321,7 +321,7 @@ class SmartLoadingServiceTest {
Candidate first = candidate(9L, 11L, 21L, 31L, 4);
Candidate second = candidate(9L, 12L, 22L, 32L, 4);
GroupSnapshot group = group("ACTIVE", null,
new Member(9L, 31L, 11L, 4), new Member(9L, 32L, 12L, 4));
new Member(9L, 31L, 11L, "IN-11", 4), new Member(9L, 32L, 12L, "IN-12", 4));
ContainerBookingEntity small = booking();
small.setCapacityCbm(new BigDecimal("3"));
when(repository.plan(1L)).thenReturn(plan("EDITING", 0));
@@ -348,7 +348,7 @@ class SmartLoadingServiceTest {
LoadingPlanEntity plan = plan("EDITING", 0);
LoadingPlanItemEntity item = item(8L, 9L, 20L, 4);
GroupSnapshot group = group("LOCKED", 1L,
new Member(9L, 30L, 10L, 4), new Member(19L, 31L, 11L, 6));
new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6));
when(repository.plan(1L)).thenReturn(plan);
when(repository.item(1L, 8L)).thenReturn(item);
when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group);
@@ -370,7 +370,7 @@ class SmartLoadingServiceTest {
LoadingPlanEntity plan = plan("EDITING", 0);
LoadingPlanItemEntity first = item(8L, 9L, 20L, 4);
GroupSnapshot group = group("LOCKED", 1L,
new Member(9L, 30L, 10L, 4), new Member(19L, 31L, 11L, 6));
new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6));
when(repository.plan(1L)).thenReturn(plan);
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(first));
@@ -396,7 +396,7 @@ class SmartLoadingServiceTest {
LoadingPlanItemEntity second = item(9L, 19L, 21L, 6);
second.setInboundId(11L);
GroupSnapshot group = group("LOCKED", 1L,
new Member(9L, 30L, 10L, 4), new Member(19L, 31L, 11L, 6));
new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6));
when(repository.plan(1L)).thenReturn(plan, after);
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(first, second), List.of());
@@ -407,7 +407,7 @@ class SmartLoadingServiceTest {
when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group);
when(coloadGroups.findByInbound(19L, 11L)).thenReturn(group);
GroupSnapshot unlocked = group("ACTIVE", null,
new Member(9L, 30L, 10L, 4), new Member(19L, 31L, 11L, 6));
new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6));
when(coloadGroups.unlockWholeGroupAfterRemoval(eq(700L), eq(1L),
anyString(), eq(7L), eq("u"))).thenReturn(unlocked);
@@ -304,6 +304,31 @@ class UnifiedOrderQueryServiceTest {
verify(orders, never()).findByIds(eq(null), any());
}
@Test
void platformPageUsesOneInternalBatchForTwentyRowsAndKeepsMissingGroupsNull() {
UnifiedOrderQuery query = query("PENDING_REVIEW");
List<OrderEntity> pageOrders = IntStream.rangeClosed(1, 20).mapToObj(index -> {
OrderEntity order = order(OrderStatus.SUBMITTED);
order.setId((long) index);
order.setOrderNo("FL-" + index);
order.setInboundCode("RK-" + index);
return order;
}).toList();
when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED))))
.thenReturn(new PageResult<>(pageOrders, 1, 20, 20, 1));
when(coloadGroups.batchQuery(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(List.of()));
var result = service.platformPage(10001L, query);
assertThat(result.records()).hasSize(20).allSatisfy(row -> assertThat(row.coLoadGroup()).isNull());
ArgumentCaptor<FirstlegColoadGroupClient.BatchQueryRequest> request =
ArgumentCaptor.forClass(FirstlegColoadGroupClient.BatchQueryRequest.class);
verify(coloadGroups).batchQuery(eq("internal-token"), request.capture());
assertThat(request.getValue().orderIds()).containsExactlyElementsOf(
pageOrders.stream().map(OrderEntity::getId).toList());
}
@Test
void platformDetailIncludesColoadBusinessSummary() {
OrderEntity order = order(OrderStatus.APPROVED);