diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/controller/InternalColoadGroupController.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/controller/InternalColoadGroupController.java new file mode 100644 index 000000000..f236fa593 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/controller/InternalColoadGroupController.java @@ -0,0 +1,47 @@ +package com.baiyue.firstleg.loading.coload.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.InternalBatchQueryRequest; +import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.InternalOrderGroupInfo; +import com.baiyue.firstleg.loading.coload.service.ColoadGroupService; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.List; + +/** Order 聚合使用的内部同柜组端口,不是 Platform 页面协议。 */ +@RestController +@RequestMapping("/internal/firstleg/v1/coload-groups") +public class InternalColoadGroupController { + private final ColoadGroupService service; + @Value("${baiyue.security.internal-service-token}") + private String expectedToken; + + public InternalColoadGroupController(ColoadGroupService service) { + this.service = service; + } + + @PostMapping("/batch-query") + public ApiResponse> batchQuery( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody InternalBatchQueryRequest request) { + requireToken(token); + return ApiResponse.success(service.queryInternalByOrderIds(request.orderIds())); + } + + private void requireToken(String token) { + if (token == null || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), + expectedToken.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效"); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/model/ColoadGroupModels.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/model/ColoadGroupModels.java index d5bf0f24a..e3f9a74d7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/model/ColoadGroupModels.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/model/ColoadGroupModels.java @@ -24,6 +24,12 @@ public final class ColoadGroupModels { ) { } + /** 仅供 Order 服务聚合使用,不属于 Platform 页面识别契约。 */ + public record InternalBatchQueryRequest( + @NotEmpty @Size(max = 200) List<@NotNull @Min(1) Long> orderIds + ) { + } + public record Member( long tenantId, long orderId, @@ -74,4 +80,8 @@ public final class ColoadGroupModels { memberCount = members.size(); } } + + /** 内部服务聚合投影;技术标识不得透传为页面主展示。 */ + public record InternalOrderGroupInfo(long orderId, GroupSnapshot group) { + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupMapper.java index be3687bfe..bd5e51ecc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupMapper.java @@ -108,6 +108,16 @@ public interface ColoadGroupMapper { """) List effectiveMemberCodes(@Param("groupId") long groupId); + @Select({""}) + List linksByOrderIds(@Param("orderIds") List orderIds); + @Update(""" UPDATE fl_coload_group SET group_status='CANCELLED',cancelled_by=#{operatorId}, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadPersistenceRows.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadPersistenceRows.java index b549529cd..b515c4744 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadPersistenceRows.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadPersistenceRows.java @@ -60,6 +60,13 @@ public final class ColoadPersistenceRows { private String inboundCode; } + @Getter + @Setter + public static class OrderGroupLinkRow { + private Long orderId; + private Long groupId; + } + @Getter @Setter public static class CommandRow { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java index 28bac1578..cee95cab3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java @@ -7,6 +7,7 @@ import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.GroupSnapshot; import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.Member; import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.BusinessMember; import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.BusinessSummary; +import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.InternalOrderGroupInfo; import com.baiyue.firstleg.loading.coload.persistence.ColoadGroupMapper; import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.CommandRow; import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.GroupRow; @@ -114,6 +115,20 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort { return new BusinessSummary(status, members.size(), members); } + @Transactional(readOnly = true) + public List queryInternalByOrderIds(List requestedOrderIds) { + List orderIds = normalizedOrderIds(requestedOrderIds, 1, 200); + Map latest = new LinkedHashMap<>(); + Map groups = new LinkedHashMap<>(); + for (var link : mapper.linksByOrderIds(orderIds)) { + if (latest.containsKey(link.getOrderId())) continue; + GroupSnapshot group = groups.computeIfAbsent(link.getGroupId(), this::requiredSnapshot); + if (!CURRENT_GROUP_STATUSES.contains(group.status())) continue; + latest.put(link.getOrderId(), new InternalOrderGroupInfo(link.getOrderId(), group)); + } + return orderIds.stream().map(latest::get).filter(Objects::nonNull).toList(); + } + @Override @Transactional(readOnly = true) public GroupSnapshot findByInbound(long tenantId, long inboundId) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupContractTest.java index ddd25ddb1..628858041 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupContractTest.java @@ -2,8 +2,10 @@ package com.baiyue.firstleg.loading.coload; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.loading.coload.controller.PlatformColoadGroupController; +import com.baiyue.firstleg.loading.coload.controller.InternalColoadGroupController; import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.CancelRequest; import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.CreateRequest; +import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.InternalBatchQueryRequest; import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; @@ -51,6 +53,16 @@ class ColoadGroupContractTest { long.class, String.class)).isNotNull(); } + @Test + void orderAggregationUsesSeparateInternalServiceTokenEndpoint() throws Exception { + RequestMapping base = InternalColoadGroupController.class.getAnnotation(RequestMapping.class); + assertThat(base.value()).containsExactly("/internal/firstleg/v1/coload-groups"); + Method method = InternalColoadGroupController.class.getDeclaredMethod( + "batchQuery", String.class, InternalBatchQueryRequest.class); + assertThat(method.getAnnotation(PostMapping.class).value()).containsExactly("/batch-query"); + assertThat(method.getAnnotation(PreAuthorize.class)).isNull(); + } + private void assertEndpoint(String methodName, String path, Class... parameterTypes) throws Exception { Method method = PlatformColoadGroupController.class.getDeclaredMethod(methodName, parameterTypes); if (path.isEmpty()) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java index 0f98cfc82..635f9e484 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java @@ -7,6 +7,7 @@ import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.Inbo import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.MemberRow; import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.InboundGroupLookupRow; import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.MemberCodeRow; +import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.OrderGroupLinkRow; import com.baiyue.firstleg.loading.coload.service.ColoadGroupService; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; @@ -83,6 +84,23 @@ class ColoadGroupServiceTest { .isInstanceOf(BizException.class).hasMessageContaining("多个有效"); } + @Test + void internalOrderAggregationKeepsTechnicalGroupSnapshotOffPlatformContract() { + when(mapper.linksByOrderIds(List.of(11L, 12L))).thenReturn(List.of( + link(11L, 500L), link(12L, 500L))); + when(mapper.group(500L)).thenReturn(group(500L, "LOCKED", 700L, 1)); + when(mapper.members(500L)).thenReturn(List.of( + member(101L, 11L, 1001L, 30), member(202L, 12L, 1002L, 50))); + + var result = service.queryInternalByOrderIds(List.of(12L, 11L)); + + assertThat(result).extracting("orderId").containsExactly(11L, 12L); + assertThat(result).allSatisfy(info -> { + assertThat(info.group().groupId()).isEqualTo(500L); + assertThat(info.group().status()).isEqualTo("LOCKED"); + }); + } + @Test void createsCrossCustomerGroupAndFreezesAllAvailableFragmentsForEveryOrder() { when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of( @@ -272,6 +290,13 @@ class ColoadGroupServiceTest { return value; } + private static OrderGroupLinkRow link(long orderId, long groupId) { + OrderGroupLinkRow value = new OrderGroupLinkRow(); + value.setOrderId(orderId); + value.setGroupId(groupId); + return value; + } + private static org.assertj.core.groups.Tuple tuple(Object... values) { return org.assertj.core.groups.Tuple.tuple(values); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupSqlContractTest.java index 45cd43afc..e226daf44 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupSqlContractTest.java @@ -75,6 +75,16 @@ class ColoadGroupSqlContractTest { .doesNotContain("member.active_flag=1", "inbound_role", "split_status", "split_purpose"); } + @Test + void internalOrderAggregationExcludesCancelledGroupsWithoutChangingPlatformReadKey() throws Exception { + Method method = ColoadGroupMapper.class.getDeclaredMethod("linksByOrderIds", java.util.List.class); + String sql = String.join(" ", method.getAnnotation(Select.class).value()); + assertThat(sql) + .contains("member.order_id IN") + .contains("group_row.group_status<>'CANCELLED'") + .doesNotContain("inbound_code=#{inboundCode}"); + } + private static String tableDefinition(String source, String tableName) { int start = source.indexOf("CREATE TABLE IF NOT EXISTS " + tableName + " ("); int end = source.indexOf(';', start); diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegColoadGroupClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegColoadGroupClient.java index 419381208..03f3ee4cf 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegColoadGroupClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegColoadGroupClient.java @@ -8,16 +8,16 @@ import org.springframework.web.bind.annotation.RequestHeader; import java.util.List; -/** Platform-scoped read contract for Firstleg co-load group aggregation. */ +/** Internal read contract for Firstleg co-load group aggregation. */ @FeignClient(name = "firstleg-service", contextId = "orderFirstlegColoadGroupClient", - url = "${baiyue.clients.firstleg.url:}", path = "/api/platform/v1/first-leg/coload-groups") + url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/coload-groups") public interface FirstlegColoadGroupClient { @PostMapping("/batch-query") - ApiResponse> batchQuery(@RequestHeader("Authorization") String authorization, + ApiResponse> batchQuery(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody BatchQueryRequest request); record BatchQueryRequest(List orderIds) {} record OrderGroupInfo(Long orderId, GroupSnapshot group) {} - record GroupSnapshot(Long groupId, String groupNo, String status, List members) {} - record Member(Long tenantId, Long orderId, Long inboundId, Integer fullPackageCount) {} + record GroupSnapshot(String status, List members) {} + record Member(Long orderId) {} } diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java index 33736b773..705ecf92e 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java @@ -24,7 +24,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; import java.util.List; @@ -42,12 +41,11 @@ public class PlatformUnifiedOrderController { @Operation(summary = "统一订单列表", description = "返回七态产品主状态及测量、预装、装柜、派送摘要。") public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, - @RequestHeader("Authorization") String authorization, @RequestParam(required = false) Long tenantId, @Valid UnifiedOrderQuery query) { OrderScopeGuard.platform(user); if (tenantId != null) OrderScopeGuard.platform(user, tenantId); - return ApiResponse.success(service.platformPage(tenantId, query, authorization)); + return ApiResponse.success(service.platformPage(tenantId, query)); } @GetMapping("/{orderId}") @@ -55,11 +53,9 @@ public class PlatformUnifiedOrderController { @Operation(summary = "统一订单详情", description = "合并订单和履约时间线;平台响应包含内部备注。") public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, - @RequestHeader("Authorization") String authorization, @RequestParam long tenantId, @PathVariable long orderId) { - return ApiResponse.success(service.platformDetail( - OrderScopeGuard.platform(user, tenantId), orderId, authorization)); + return ApiResponse.success(service.platformDetail(OrderScopeGuard.platform(user, tenantId), orderId)); } @GetMapping("/{orderId}/fulfillment-tree") diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java index b11ee23f5..93e5ae5f0 100644 --- a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java @@ -40,10 +40,9 @@ public final class UnifiedOrderResponse { public record OrderRelationSummary(String role, Long rootOrderId, Long parentOrderId, Integer splitSequence, Integer childCount) {} - /** Platform-only Firstleg co-load group view; identifiers stay strings for routing only. */ - public record CoLoadGroupSummary(String groupId, String groupNo, String status, - Integer memberCount, List members) {} - public record CoLoadGroupMemberSummary(String orderId, String inboundCode) {} + /** Platform-only Firstleg co-load business view; no group or member internal IDs are exposed. */ + public record CoLoadGroupSummary(String status, Integer memberCount, + List memberInboundCodes) {} public record Detail(Row summary, OrderDetailResponse order, List timeline) {} diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index 36b50aacd..d0bd0e2f7 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -25,6 +25,7 @@ import java.util.ArrayList; import java.time.LocalDateTime; import java.util.Comparator; import java.util.EnumSet; +import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -49,22 +50,21 @@ public class UnifiedOrderQueryService { @Value("${baiyue.security.internal-service-token}") private String internalToken; public PageResult page(Long tenantId, UnifiedOrderQuery query) { - return page(tenantId, query, null); + return page(tenantId, query, false); } - public PageResult platformPage(Long tenantId, UnifiedOrderQuery query, - String authorization) { - return page(tenantId, query, authorization); + public PageResult platformPage(Long tenantId, UnifiedOrderQuery query) { + return page(tenantId, query, true); } private PageResult page(Long tenantId, UnifiedOrderQuery query, - String platformAuthorization) { + boolean includeCoLoadGroups) { validate(query); if (requiresFirstlegQuery(query)) { if ("PENDING_REVIEW".equals(query.mainStatus())) { return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); } - return pageFromFirstleg(tenantId, query, platformAuthorization); + return pageFromFirstleg(tenantId, query, includeCoLoadGroups); } List statuses = "PENDING_REVIEW".equals(query.mainStatus()) ? List.of(OrderStatus.SUBMITTED) : ACTIVE_ORDER_STATUSES; @@ -77,7 +77,7 @@ public class UnifiedOrderQueryService { source.records().stream().map(OrderEntity::getId).toList()); Map childCounts = childCounts(source.records()); Map coLoadByOrder = coLoadGroups( - source.records().stream().map(OrderEntity::getId).toList(), platformAuthorization); + source.records().stream().map(OrderEntity::getId).toList(), includeCoLoadGroups); List rows = source.records().stream() .map(order -> row(order, inboundByOrder.get(order.getId()), latestNotes.get(order.getId()), childCounts, cargoSummaries.get(order.getId()), coLoadByOrder.get(order.getId()))).toList(); @@ -85,15 +85,15 @@ public class UnifiedOrderQueryService { } public UnifiedOrderResponse.Detail detail(long tenantId, long orderId, boolean includeInternalNotes) { - return detail(tenantId, orderId, includeInternalNotes, null); + return detail(tenantId, orderId, includeInternalNotes, false); } - public UnifiedOrderResponse.Detail platformDetail(long tenantId, long orderId, String authorization) { - return detail(tenantId, orderId, true, authorization); + public UnifiedOrderResponse.Detail platformDetail(long tenantId, long orderId) { + return detail(tenantId, orderId, true, true); } private UnifiedOrderResponse.Detail detail(long tenantId, long orderId, boolean includeInternalNotes, - String platformAuthorization) { + boolean includeCoLoadGroups) { OrderEntity order = orders.findOwned(tenantId, orderId); if (order == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在"); OrderDetailResponse orderDetail = new OrderDetailResponse(order, orders.findCargo(tenantId, orderId), @@ -114,7 +114,7 @@ public class UnifiedOrderQueryService { .orElse(null)); UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary(), latestNote, childCounts(List.of(order)), CargoSummary.from(orderDetail.cargoes()), creatorName, - coLoadGroups(List.of(orderId), platformAuthorization).get(orderId)); + coLoadGroups(List.of(orderId), includeCoLoadGroups).get(orderId)); List timeline = new ArrayList<>(); orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent( "ORDER", "STATUS", orderMainStatus(item.getToStatus()), item.getAction(), item.getReason(), @@ -130,7 +130,7 @@ public class UnifiedOrderQueryService { } private PageResult pageFromFirstleg(Long tenantId, UnifiedOrderQuery query, - String platformAuthorization) { + boolean includeCoLoadGroups) { List candidateOrderIds = StringUtils.hasText(query.fbaCode()) ? orders.findOrderIdsBySkuNo(tenantId, query.fbaCode()) : null; if (candidateOrderIds != null && candidateOrderIds.isEmpty()) { @@ -151,8 +151,8 @@ public class UnifiedOrderQueryService { Map latestNotes = latestNotes(tenantId, orderIds); Map cargoSummaries = cargoSummaries(tenantId, orderIds); Map childCounts = childCounts(List.copyOf(orderById.values())); - Map coLoadByOrder = - coLoadGroups(orderIds, platformAuthorization); + Map coLoadByOrder = coLoadGroups( + orderIds, includeCoLoadGroups); List records = source.records().stream() .map(summary -> row(summary, orderById.get(summary.inbound().orderId()), latestNotes.get(summary.inbound().orderId()), childCounts, @@ -197,37 +197,38 @@ public class UnifiedOrderQueryService { } private Map coLoadGroups( - List orderIds, String platformAuthorization) { - if (!StringUtils.hasText(platformAuthorization) || orderIds.isEmpty()) return Map.of(); + List orderIds, boolean includeCoLoadGroups) { + if (!includeCoLoadGroups || orderIds.isEmpty()) return Map.of(); List data = require( - call(() -> coloadGroups.batchQuery(platformAuthorization, + call(() -> coloadGroups.batchQuery(internalToken, new FirstlegColoadGroupClient.BatchQueryRequest(orderIds.stream().distinct().toList()))), "同柜组摘要响应无效"); - List memberOrderIds = data.stream() - .filter(Objects::nonNull).map(FirstlegColoadGroupClient.OrderGroupInfo::group) - .filter(Objects::nonNull).flatMap(group -> safeMembers(group).stream()) + List memberOrderIds = data.stream().filter(Objects::nonNull) + .map(FirstlegColoadGroupClient.OrderGroupInfo::group).filter(Objects::nonNull) + .flatMap(group -> safeMembers(group).stream()) .map(FirstlegColoadGroupClient.Member::orderId).filter(Objects::nonNull).distinct().toList(); - Map inboundCodeByOrder = orders.findByIds(null, memberOrderIds).stream() - .collect(Collectors.toMap(OrderEntity::getId, OrderEntity::getInboundCode, (left, right) -> left)); + Map inboundCodeByOrder = new HashMap<>(); + if (!memberOrderIds.isEmpty()) { + orders.findByIds(null, memberOrderIds).forEach(order -> + inboundCodeByOrder.putIfAbsent(order.getId(), order.getInboundCode())); + } return data.stream().filter(Objects::nonNull) .filter(info -> info.orderId() != null && info.group() != null) .collect(Collectors.toMap(FirstlegColoadGroupClient.OrderGroupInfo::orderId, info -> coLoadGroup(info.group(), inboundCodeByOrder), (left, right) -> left)); } - private List safeMembers(FirstlegColoadGroupClient.GroupSnapshot group) { - return group.members() == null ? List.of() : group.members(); - } - private UnifiedOrderResponse.CoLoadGroupSummary coLoadGroup( FirstlegColoadGroupClient.GroupSnapshot group, Map inboundCodeByOrder) { - List members = safeMembers(group).stream() - .filter(member -> member.orderId() != null) - .map(member -> new UnifiedOrderResponse.CoLoadGroupMemberSummary( - string(member.orderId()), inboundCodeByOrder.get(member.orderId()))) - .toList(); - return new UnifiedOrderResponse.CoLoadGroupSummary( - string(group.groupId()), group.groupNo(), group.status(), members.size(), members); + List members = safeMembers(group); + List inboundCodes = members.stream().map(FirstlegColoadGroupClient.Member::orderId) + .map(inboundCodeByOrder::get).toList(); + return new UnifiedOrderResponse.CoLoadGroupSummary(group.status(), members.size(), inboundCodes); + } + + private List safeMembers( + FirstlegColoadGroupClient.GroupSnapshot group) { + return group.members() == null ? List.of() : group.members(); } private UnifiedOrderResponse.OrderRelationSummary relation(OrderEntity order, diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 86376894f..b3211da15 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -49,6 +49,7 @@ paths: 指定其他主状态时由 Firstleg 精确分页。旧 /orders/first-leg 接口保持兼容。 列表行同时返回 customerRemark(客户下单备注)、latestInternalNote(最新一条平台内部备注)和 relation(仅有关联时返回;PARENT 含 childCount,CHILD 含 rootOrderId、parentOrderId 与 splitSequence)。 + coLoadGroup 仅平台响应返回,由 Order 经 Firstleg 内部批量只读契约聚合;成员业务标识由 Order 权威订单片段补齐。 parameters: - {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - $ref: '#/components/parameters/UnifiedKeyword' @@ -83,7 +84,7 @@ paths: /api/platform/v1/orders/{orderId}: get: summary: 平台统一订单详情 - description: 合并 Order 与 Firstleg 时间线并按发生时间升序返回;所有状态直接返回 Order 创建时生成的 inboundCode;平台可见内部备注。 + description: 合并 Order 与 Firstleg 时间线并按发生时间升序返回;返回当前订单片段的 inboundCode 与同柜组摘要;平台可见内部备注。 parameters: - $ref: '#/components/parameters/UnifiedOrderId' - $ref: '#/components/parameters/UnifiedTenantId' @@ -483,7 +484,7 @@ components: destinationWarehouseName: {type: string, nullable: true} inboundId: {type: integer, format: int64, nullable: true} inboundVersion: {type: integer, nullable: true, description: C04 loading/complete 的 expectedVersion} - inboundCode: {type: string, nullable: true} + inboundCode: {type: string, nullable: true, description: 当前订单片段的权威入仓码;缺失时为 null,不得回退为 orderId 或 orderNo} expectedShipDate: {type: string, format: date, nullable: true} totalPackageCount: {type: integer, nullable: true, description: 无入仓事实时为当前订单有效货物箱数合计;存在入仓业务单元后为该单元履约总箱数,拆分后不以父单原始箱数覆盖} recommendedPalletType: {type: string, nullable: true, description: 当前无列表级权威事实时返回 null,不得返回固定托盘类型} @@ -520,6 +521,7 @@ components: loading: {$ref: '#/components/schemas/UnifiedLoadingSummary'} delivery: {$ref: '#/components/schemas/UnifiedDeliverySummary'} transport: {$ref: '#/components/schemas/UnifiedTransportSummary'} + coLoadGroup: {$ref: '#/components/schemas/UnifiedCoLoadGroupSummary'} customerRemark: {type: string, nullable: true} latestInternalNote: {type: string, nullable: true, description: 客户详情不返回平台内部备注} createdAt: {type: string, format: date-time, nullable: true} @@ -533,6 +535,16 @@ components: parentOrderId: {type: integer, format: int64, nullable: true} splitSequence: {type: integer, nullable: true} childCount: {type: integer, nullable: true} + UnifiedCoLoadGroupSummary: + type: object + nullable: true + description: 仅平台统一订单列表与详情返回;未加入同柜组时为 null + properties: + status: {type: string} + memberCount: {type: integer, minimum: 0} + memberInboundCodes: + type: array + items: {type: string, nullable: true, description: 同柜组成员订单片段的权威入仓码;缺失时保留 null,不回退内部ID} UnifiedMeasurementSummary: type: object nullable: true diff --git a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java index 563303a4d..bc4453c07 100644 --- a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java @@ -1,6 +1,7 @@ package com.baiyue.order.controller; import com.baiyue.order.client.FirstlegUnifiedOrderClient; +import com.baiyue.order.client.FirstlegColoadGroupClient; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import org.springframework.cloud.openfeign.FeignClient; @@ -57,7 +58,13 @@ class UnifiedOrderApiContractTest { "taxMode", "containerMode", "customsInspection", "destinationType", "destinationCode", "totalPalletCount", "chargeableWeightKg", "chargeableVolumeCbm", "volumetricWeightKg", "totalNetWeightKg", "measurementCompletedAt", "inboundCompletedAt", "expressCompany", "expressTrackingNo", - "createdBy", "skuNos", "totalQuantity", "shipmentId", "referenceId"); + "createdBy", "skuNos", "totalQuantity", "shipmentId", "referenceId", "coLoadGroup"); + Map inboundCode = stringMap(stringMap(row.get("properties")).get("inboundCode")); + assertThat(inboundCode).containsEntry("nullable", true); + assertThat(inboundCode.get("description").toString()).contains("不得回退"); + Map coLoadGroup = stringMap(schemas.get("UnifiedCoLoadGroupSummary")); + assertThat(stringMap(coLoadGroup.get("properties"))) + .containsOnlyKeys("status", "memberCount", "memberInboundCodes"); assertThat(responseSchema(paths, "/api/platform/v1/orders", "get")) .containsEntry("$ref", "#/components/schemas/UnifiedOrderPageEnvelope"); assertThat(responseSchema(paths, "/api/merchant/v1/orders/{orderId}", "get")) @@ -74,6 +81,16 @@ class UnifiedOrderApiContractTest { assertThat(page.getAnnotation(PostMapping.class).value()).containsExactly("/query"); } + @Test + void keepsFirstlegInternalColoadBatchQueryPath() throws Exception { + FeignClient client = FirstlegColoadGroupClient.class.getAnnotation(FeignClient.class); + assertThat(client.path()).isEqualTo("/internal/firstleg/v1/coload-groups"); + + Method method = FirstlegColoadGroupClient.class.getMethod( + "batchQuery", String.class, FirstlegColoadGroupClient.BatchQueryRequest.class); + assertThat(method.getAnnotation(PostMapping.class).value()).containsExactly("/batch-query"); + } + @SuppressWarnings("unchecked") private static Map stringMap(Object value) { return (Map) value; diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index e151bbdf9..2e2f4e1fe 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.exception.BizException; import com.baiyue.order.client.FirstlegUnifiedOrderClient; +import com.baiyue.order.client.FirstlegColoadGroupClient; import com.baiyue.order.dto.UnifiedOrderQuery; import com.baiyue.order.dto.UnifiedOrderResponse; import com.baiyue.order.entity.OrderCargoEntity; @@ -40,6 +41,7 @@ import static org.mockito.Mockito.when; class UnifiedOrderQueryServiceTest { @MockBean private OrderRepository orders; @MockBean private FirstlegUnifiedOrderClient firstleg; + @MockBean private FirstlegColoadGroupClient coloadGroups; @Resource private UnifiedOrderQueryService service; @BeforeEach @@ -216,6 +218,89 @@ class UnifiedOrderQueryServiceTest { })); } + @Test + void splitChildKeepsItsOwnEffectiveInboundCode() { + UnifiedOrderQuery query = query("PENDING_REVIEW"); + OrderEntity child = order(OrderStatus.SUBMITTED); + child.setId(78L); + child.setOrderNo("FL-77-1"); + child.setInboundCode("RK-77-1"); + child.setParentOrderId(77L); + child.setRootOrderId(77L); + child.setSplitSequence(1); + when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED)))) + .thenReturn(new PageResult<>(List.of(child), 1, 20, 1, 1)); + + assertThat(service.page(10001L, query).records()).singleElement().satisfies(row -> { + assertThat(row.orderId()).isEqualTo(78L); + assertThat(row.orderNo()).isEqualTo("FL-77-1"); + assertThat(row.inboundCode()).isEqualTo("RK-77-1"); + }); + } + + @Test + void missingInboundCodeRemainsNullWithoutIdentifierFallback() { + UnifiedOrderQuery query = query("PENDING_REVIEW"); + OrderEntity order = order(OrderStatus.SUBMITTED); + order.setInboundCode(null); + when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED)))) + .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); + + assertThat(service.page(10001L, query).records()).singleElement().satisfies(row -> { + assertThat(row.orderId()).isEqualTo(77L); + assertThat(row.orderNo()).isEqualTo("FL-77"); + assertThat(row.inboundCode()).isNull(); + }); + } + + @Test + void platformPageAggregatesColoadStatusAndMemberInboundCodes() { + UnifiedOrderQuery query = query("PENDING_REVIEW"); + OrderEntity current = order(OrderStatus.SUBMITTED); + current.setInboundCode("RK-77"); + OrderEntity otherCustomerMember = order(OrderStatus.APPROVED); + otherCustomerMember.setId(78L); + otherCustomerMember.setTenantId(20002L); + otherCustomerMember.setInboundCode("RK-OTHER-CUSTOMER"); + var group = new FirstlegColoadGroupClient.GroupSnapshot("ACTIVE", List.of( + new FirstlegColoadGroupClient.Member(77L), new FirstlegColoadGroupClient.Member(78L))); + when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED)))) + .thenReturn(new PageResult<>(List.of(current), 1, 20, 1, 1)); + when(coloadGroups.batchQuery(eq("internal-token"), any())).thenReturn(ApiResponse.success( + List.of(new FirstlegColoadGroupClient.OrderGroupInfo(77L, group)))); + when(orders.findByIds(null, List.of(77L, 78L))).thenReturn(List.of(current, otherCustomerMember)); + + assertThat(service.platformPage(10001L, query).records()) + .singleElement().satisfies(row -> { + assertThat(row.coLoadGroup()).isNotNull(); + assertThat(row.coLoadGroup().status()).isEqualTo("ACTIVE"); + assertThat(row.coLoadGroup().memberCount()).isEqualTo(2); + assertThat(row.coLoadGroup().memberInboundCodes()) + .containsExactly("RK-77", "RK-OTHER-CUSTOMER"); + }); + verify(coloadGroups).batchQuery(eq("internal-token"), any()); + verify(orders).findByIds(null, List.of(77L, 78L)); + } + + @Test + void platformPageKeepsMissingInboundCodeNullDuringColoadAggregation() { + UnifiedOrderQuery query = query("PENDING_REVIEW"); + OrderEntity order = order(OrderStatus.SUBMITTED); + order.setInboundCode(null); + when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED)))) + .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); + + when(coloadGroups.batchQuery(eq("internal-token"), any())) + .thenReturn(ApiResponse.success(List.of())); + assertThat(service.platformPage(10001L, query).records()) + .singleElement().satisfies(row -> { + assertThat(row.inboundCode()).isNull(); + assertThat(row.coLoadGroup()).isNull(); + }); + verify(coloadGroups).batchQuery(eq("internal-token"), any()); + verify(orders, never()).findByIds(eq(null), any()); + } + @Test void allStatusInboundCodeFilterIncludesPendingOrdersFromOrderQuery() { UnifiedOrderQuery query = new UnifiedOrderQuery( diff --git a/scripts/admin-test-env-modules/coload-groups-contract.test.mjs b/scripts/admin-test-env-modules/coload-groups-contract.test.mjs index 833047745..14d8fa4e7 100644 --- a/scripts/admin-test-env-modules/coload-groups-contract.test.mjs +++ b/scripts/admin-test-env-modules/coload-groups-contract.test.mjs @@ -6,7 +6,7 @@ test('同柜不可拆组HTTP验收锁定公开接口与防绕过矩阵', async ( const source = await readFile(new URL('./coload-groups.mjs', import.meta.url), 'utf8') for (const fragment of [ '/api/platform/v1/first-leg/coload-groups', - '/api/platform/v1/first-leg/coload-groups/batch-query', + '/api/platform/v1/first-leg/coload-groups/by-inbound-code/', '/group-assignments', '/inbound-assignments', '/push-preload', @@ -14,11 +14,11 @@ test('同柜不可拆组HTTP验收锁定公开接口与防绕过矩阵', async ( '创建同键异载荷返回并发冲突', '不同目的仓港仍可创建同柜不可拆组', '跨国内仓或跨渠道创建整组被拒绝且不落组', - '批查只返回组内订单且长ID保持字符串', + '业务摘要只返回状态、成员数和完整入仓码', '/recommend', '第二柜智能推荐请求成功', '智能推荐不会只取同柜组部分成员或部分箱数', - '智能推荐全不取后整组仍ACTIVE且未锁定', + '智能推荐全不取后整组仍ACTIVE', '组成员不能单独调整箱数', '组成员不能单独删除', '整组原子移除', diff --git a/scripts/admin-test-env-modules/coload-groups.mjs b/scripts/admin-test-env-modules/coload-groups.mjs index 035dae0de..acd738a66 100644 --- a/scripts/admin-test-env-modules/coload-groups.mjs +++ b/scripts/admin-test-env-modules/coload-groups.mjs @@ -62,6 +62,18 @@ async function readPlan(context, planId, name) { return { response, data: context.apiSuccess(MODULE, name, response, `planId=${planId}`, OPTIONS) } } +async function readGroupSummary(context, inboundCode, name) { + const response = await context.request('GET', + `/api/platform/v1/first-leg/coload-groups/by-inbound-code/${encodeURIComponent(inboundCode)}`) + return { response, data: context.apiSuccess(MODULE, name, response, + evidence(response, `inboundCode=${inboundCode}`), OPTIONS) } +} + +function summaryInboundCodes(summary) { + return [...(summary?.members || [])].map((member) => String(member?.inboundCode || '')) + .filter(Boolean).sort() +} + async function createSecondGroup(context, third, fourth) { const response = await context.request('POST', '/api/platform/v1/first-leg/coload-groups', { orderIds: [third.orderId, fourth.orderId] }, undefined, @@ -475,23 +487,19 @@ export async function run(context) { { 'Idempotency-Key': `${context.marker}-coload-route-conflict`.slice(0, 128) }) conflict(context, '跨国内仓或跨渠道创建整组被拒绝且不落组', routeConflictResponse, `baseOrder=${third.orderId}, mismatchOrder=${differentRoute.orderId}`) - const zeroDriftResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query', - { orderIds: [third.orderId, differentRoute.orderId] }) - const zeroDrift = context.apiSuccess(MODULE, '回读跨线路拒绝后的组事实', zeroDriftResponse, - evidence(zeroDriftResponse), OPTIONS) || [] - context.assert(MODULE, '跨线路拒绝后两个订单均无组事实', zeroDriftResponse, zeroDrift.length === 0, - `count=${zeroDrift.length}`, OPTIONS) + const zeroDriftResponses = await Promise.all([third, differentRoute].map((fixture) => context.request('GET', + `/api/platform/v1/first-leg/coload-groups/by-inbound-code/${encodeURIComponent(fixture.inboundCode)}`))) + context.assert(MODULE, '跨线路拒绝后两个入仓码均无组事实', zeroDriftResponses[0], + zeroDriftResponses.every((response) => code(response) === 'A0404'), + `codes=${zeroDriftResponses.map((response) => code(response)).join('|')}`, OPTIONS) } - const queryResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query', - { orderIds: [fourth.orderId, second.orderId, first.orderId, third.orderId] }) - const queried = context.apiSuccess(MODULE, '按订单批量回读组事实', queryResponse, - evidence(queryResponse), OPTIONS) || [] - context.assert(MODULE, '批查只返回组内订单且长ID保持字符串', queryResponse, - queried.length === 2 && queried.every((item) => groupOrders.includes(id(item?.orderId)) - && id(item?.group?.groupId) === groupId && typeof item?.group?.groupId === 'string' - && JSON.stringify(memberFacts(item?.group)) === JSON.stringify(frozen)), - `count=${queried.length}, orderIds=${queried.map((item) => id(item?.orderId)).join(',')}`, OPTIONS) + const queried = await readGroupSummary(context, first.inboundCode, '按入仓码回读组业务摘要') + const frozenCodes = [first.inboundCode, second.inboundCode].sort() + context.assert(MODULE, '业务摘要只返回状态、成员数和完整入仓码', queried.response, + queried.data?.status === 'ACTIVE' && Number(queried.data?.memberCount) === 2 + && JSON.stringify(summaryInboundCodes(queried.data)) === JSON.stringify(frozenCodes), + `status=${queried.data?.status || 'missing'}, members=${summaryInboundCodes(queried.data).join('|')}`, OPTIONS) const simulationResponse = await context.request('POST', '/api/platform/v1/containers', { channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '20GP', @@ -522,23 +530,6 @@ export async function run(context) { let isolatedCandidates = candidates const competing = [...new Set(isolatedCandidates.filter((item) => item?.locked !== true) .map((item) => id(item?.coLoadGroupId)).filter((value) => value && value !== groupId))] - for (const competingGroupId of competing) { - const competingOrders = isolatedCandidates.filter((item) => id(item?.coLoadGroupId) === competingGroupId) - .map((item) => id(item?.orderId)).filter(Boolean) - const competingQueryResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query', - { orderIds: competingOrders }) - const competingQuery = context.apiSuccess(MODULE, '清理前回读旧验收活动组', competingQueryResponse, - evidence(competingQueryResponse, `groupId=${competingGroupId}`), OPTIONS) || [] - const competingGroup = competingQuery.find((item) => id(item?.group?.groupId) === competingGroupId)?.group - if (competingGroup?.status === 'ACTIVE') { - const cleanupResponse = await context.request('POST', - `/api/platform/v1/first-leg/coload-groups/${encodeURIComponent(competingGroupId)}/cancel`, { - expectedVersion: Number(competingGroup.version), reason: '清理旧HTTP验收活动组以隔离容量候选', - }, undefined, { 'Idempotency-Key': `${context.marker}-cleanup-${competingGroupId}`.slice(0, 128) }) - context.apiSuccess(MODULE, '通过公开接口取消旧验收活动组', cleanupResponse, - evidence(cleanupResponse, `groupId=${competingGroupId}`), OPTIONS) - } - } let isolatedCandidateResponse = candidateResponse if (competing.length > 0) { isolatedCandidateResponse = await context.request('GET', @@ -622,14 +613,10 @@ export async function run(context) { context.apiSuccess(MODULE, '智能推荐整组验收后原子移除并释放组锁', recommendRemoveResponse, evidence(recommendRemoveResponse), OPTIONS) } else { - const recommendGroupResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query', - { orderIds: groupOrders }) - const recommendGroupRows = context.apiSuccess(MODULE, '智能推荐全不取后回读组锁', recommendGroupResponse, - evidence(recommendGroupResponse), OPTIONS) || [] - context.assert(MODULE, '智能推荐全不取后整组仍ACTIVE且未锁定', recommendGroupResponse, - recommendGroupRows.length === 2 && recommendGroupRows.every((item) => item?.group?.status === 'ACTIVE' - && !item?.group?.lockedPlanId), - `rows=${recommendGroupRows.length}, status=${recommendGroupRows[0]?.group?.status || 'missing'}`, OPTIONS) + const recommendGroup = await readGroupSummary(context, first.inboundCode, '智能推荐全不取后回读组状态') + context.assert(MODULE, '智能推荐全不取后整组仍ACTIVE', recommendGroup.response, + recommendGroup.data?.status === 'ACTIVE', + `status=${recommendGroup.data?.status || 'missing'}`, OPTIONS) } const workingSimulationResponse = await context.request('POST', '/api/platform/v1/containers', { @@ -676,14 +663,10 @@ export async function run(context) { [first, second].every((fixture) => assignedTotals.get(fixture.inboundId) === fixture.packages), `inboundTotals=${JSON.stringify(Object.fromEntries(assignedTotals))}`, OPTIONS) - const lockedQueryResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query', - { orderIds: groupOrders }) - const lockedQuery = context.apiSuccess(MODULE, '回读整组锁定事实', lockedQueryResponse, - evidence(lockedQueryResponse), OPTIONS) || [] - context.assert(MODULE, '整组只锁定到当前计划一次', lockedQueryResponse, - lockedQuery.length === 2 && lockedQuery.every((item) => item?.group?.status === 'LOCKED' - && id(item?.group?.lockedPlanId) === planId), - `status=${lockedQuery[0]?.group?.status || 'missing'}, lockedPlanId=${id(lockedQuery[0]?.group?.lockedPlanId)}`, OPTIONS) + const lockedQuery = await readGroupSummary(context, first.inboundCode, '回读整组锁定事实') + context.assert(MODULE, '整组业务状态只单调推进至LOCKED', lockedQuery.response, + lockedQuery.data?.status === 'LOCKED', + `status=${lockedQuery.data?.status || 'missing'}`, OPTIONS) const targetItem = assigned[0] const beforeMutation = planSignature(plan) @@ -706,7 +689,7 @@ export async function run(context) { const cancelLockedResponse = await context.request('POST', `/api/platform/v1/first-leg/coload-groups/${encodeURIComponent(groupId)}/cancel`, { - expectedVersion: Number(lockedQuery[0]?.group?.version), reason: '锁定组取消反向验收', + expectedVersion: Number(group?.version) + 1, reason: '锁定组取消反向验收', }, undefined, { 'Idempotency-Key': `${context.marker}-coload-cancel-locked`.slice(0, 128) }) conflict(context, '已锁定组不能取消', cancelLockedResponse) @@ -721,14 +704,10 @@ export async function run(context) { !(plan?.items || []).some((item) => [first.inboundId, second.inboundId].includes(id(item?.inboundId))), `remainingItems=${plan?.items?.length ?? 'missing'}`, OPTIONS) - const activeQueryResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query', - { orderIds: groupOrders }) - const activeQuery = context.apiSuccess(MODULE, '回读整组解锁事实', activeQueryResponse, - evidence(activeQueryResponse), OPTIONS) || [] - context.assert(MODULE, '整组移除后恢复ACTIVE且不残留计划锁', activeQueryResponse, - activeQuery.length === 2 && activeQuery.every((item) => item?.group?.status === 'ACTIVE' - && item?.group?.lockedPlanId == null), - `status=${activeQuery[0]?.group?.status || 'missing'}, lockedPlanId=${id(activeQuery[0]?.group?.lockedPlanId) || 'null'}`, OPTIONS) + const activeQuery = await readGroupSummary(context, first.inboundCode, '回读整组解锁事实') + context.assert(MODULE, '整组移除后业务状态恢复ACTIVE', activeQuery.response, + activeQuery.data?.status === 'ACTIVE', + `status=${activeQuery.data?.status || 'missing'}`, OPTIONS) const manualGroupResponse = await context.request('POST', `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/group-assignments`, { inboundId: first.inboundId, expectedVersion: Number(plan?.version), @@ -772,13 +751,11 @@ export async function run(context) { }, undefined, { 'Idempotency-Key': `${context.marker}-coload-active-cancel`.slice(0, 128) }) const cancelled = context.apiSuccess(MODULE, 'ACTIVE同柜组整组取消成功', cancelResponse, evidence(cancelResponse), OPTIONS) - const cancelledQueryResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query', - { orderIds: [fifth.orderId, sixth.orderId] }) - const cancelledQuery = context.apiSuccess(MODULE, '取消后回读成员活动组事实', cancelledQueryResponse, - evidence(cancelledQueryResponse), OPTIONS) || [] + const cancelledQueryResponse = await context.request('GET', + `/api/platform/v1/first-leg/coload-groups/by-inbound-code/${encodeURIComponent(fifth.inboundCode)}`) context.assert(MODULE, '取消组进入CANCELLED且成员不再受活动组约束', cancelResponse, - cancelled?.status === 'CANCELLED' && cancelledQuery.length === 0, - `status=${cancelled?.status || 'missing'}, activeRows=${cancelledQuery.length}`, OPTIONS) + cancelled?.status === 'CANCELLED' && code(cancelledQueryResponse) === 'A0404', + `status=${cancelled?.status || 'missing'}, queryCode=${code(cancelledQueryResponse)}`, OPTIONS) const partialChain = { label: '同柜短拣链', key: 'coload-partial', groupId, containerBookingId: bookingId, planId, @@ -842,17 +819,13 @@ export async function run(context) { const partialTaskAfter = context.apiSuccess(MODULE, '短拣完成拒绝后回读PDA任务', partialTaskAfterResponse, evidence(partialTaskAfterResponse), OPTIONS) const partialTreesAfter = await fulfillmentTrees(context, partialChain.fixtures, '短拣拒绝后') - const partialGroupResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query', - { orderIds: partialChain.fixtures.map((fixture) => fixture.orderId) }) - const partialGroup = context.apiSuccess(MODULE, '短拣拒绝后回读组状态', partialGroupResponse, - evidence(partialGroupResponse), OPTIONS) || [] + const partialGroup = await readGroupSummary(context, first.inboundCode, '短拣拒绝后回读组状态') context.assert(MODULE, '短拣完成拒绝后任务、父子树和组锁零漂移', partialTaskAfterResponse, partialTaskBeforeComplete === taskSignature(partialTaskAfter) && partialTreesBefore.every((before, index) => before.signature === partialTreesAfter[index]?.signature && (partialTreesAfter[index]?.tree?.children || []).length === 0) - && partialGroup.length === 2 && partialGroup.every((item) => item?.group?.status === 'LOCKED' - && id(item?.group?.lockedPlanId) === partialChain.planId), - `taskStable=${partialTaskBeforeComplete === taskSignature(partialTaskAfter)}, children=${partialTreesAfter.map((item) => item.tree?.children?.length ?? -1).join('|')}, groupStatus=${partialGroup[0]?.group?.status || 'missing'}`, OPTIONS) + && partialGroup.data?.status === 'LOCKED', + `taskStable=${partialTaskBeforeComplete === taskSignature(partialTaskAfter)}, children=${partialTreesAfter.map((item) => item.tree?.children?.length ?? -1).join('|')}, groupStatus=${partialGroup.data?.status || 'missing'}`, OPTIONS) const fullTreesBefore = await fulfillmentTrees(context, fullChain.fixtures, '足量完成前') const fullBatchItems = (fullPda.task?.items || []).map((item) => ({ @@ -884,10 +857,7 @@ export async function run(context) { const fullFinal = await pollCompletedTask(context, fullPda.taskId, third.warehouseId, '同柜组足量') if (fullFinal.task?.taskStatus !== 'COMPLETED') return const fullTreesAfter = await fulfillmentTrees(context, fullChain.fixtures, '足量完成后') - const consumedGroupResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query', - { orderIds: fullChain.fixtures.map((fixture) => fixture.orderId) }) - const consumedGroup = context.apiSuccess(MODULE, '足量完成后回读组消费事实', consumedGroupResponse, - evidence(consumedGroupResponse), OPTIONS) || [] + const consumedGroup = await readGroupSummary(context, third.inboundCode, '足量完成后回读组消费事实') const completedInboundStates = [] for (const fixture of fullChain.fixtures) { const response = await context.request('GET', @@ -898,11 +868,11 @@ export async function run(context) { } const fullRemainders = fullTreesAfter.flatMap((item) => item?.tree?.children || []) .filter((item) => item?.splitRole === 'REMAINDER') - context.assert(MODULE, '足量完成整组已消费、全部入库完成且不生成余量子单', consumedGroupResponse, - consumedGroup.length === 2 && consumedGroup.every((item) => item?.group?.status === 'CONSUMED') + context.assert(MODULE, '足量完成整组已消费、全部入库完成且不生成余量子单', consumedGroup.response, + consumedGroup.data?.status === 'CONSUMED' && completedInboundStates.every((status) => status === 'LOADED') && fullRemainders.length === 0, - `groupStatus=${consumedGroup[0]?.group?.status || 'missing'}, inboundStates=${completedInboundStates.join('|')}, remainderChildren=${fullRemainders.length}, childRoles=${fullTreesAfter.flatMap((item) => item?.tree?.children || []).map((item) => item?.splitRole).join('|') || 'none'}`, OPTIONS) + `groupStatus=${consumedGroup.data?.status || 'missing'}, inboundStates=${completedInboundStates.join('|')}, remainderChildren=${fullRemainders.length}, childRoles=${fullTreesAfter.flatMap((item) => item?.tree?.children || []).map((item) => item?.splitRole).join('|') || 'none'}`, OPTIONS) const normalTreeBefore = await fulfillmentTrees(context, normalChain.fixtures, '普通单完成前') const normalBatchItems = (normalPda.task?.items || []).map((item) => ({ @@ -949,16 +919,12 @@ export async function run(context) { && splitMembers.some((item) => item.orderId === eighth.orderId) && splitMembers.every((item) => item.fullPackageCount > 0), `members=${JSON.stringify(splitMembers)}`, OPTIONS) - const splitBatchResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query', - { orderIds: [seventh.orderId, id(remainder.orderId), eighth.orderId] }) - const splitBatch = context.apiSuccess(MODULE, '拆分组按订单批查返回完整成员', splitBatchResponse, - evidence(splitBatchResponse), OPTIONS) || [] - context.assert(MODULE, '批查只关联当前余量子单和同组订单且每行含完整members', splitBatchResponse, - splitBatch.length === 2 && splitBatch.every((item) => - [id(remainder.orderId), eighth.orderId].includes(id(item?.orderId)) - && id(item?.group?.groupId) === id(splitGroup?.groupId) - && JSON.stringify(memberFacts(item?.group)) === JSON.stringify(splitMembers)), - `rows=${splitBatch.map((item) => id(item?.orderId)).join('|')}, members=${splitMembers.length}`, OPTIONS) + const splitSummary = await readGroupSummary(context, eighth.inboundCode, + '拆分组按入仓码返回完整成员') + context.assert(MODULE, '拆分组摘要返回全部当前有效成员入仓码且不重复', splitSummary.response, + Number(splitSummary.data?.memberCount) === splitMembers.length + && new Set(summaryInboundCodes(splitSummary.data)).size === splitMembers.length, + `memberCount=${splitSummary.data?.memberCount ?? 'missing'}, members=${summaryInboundCodes(splitSummary.data).join('|')}`, OPTIONS) const splitTreeAfterResponse = await context.request('GET', `/api/platform/v1/orders/${encodeURIComponent(seventh.orderId)}/fulfillment-tree?tenantId=${encodeURIComponent(seventh.tenantId)}`) const splitTreeAfter = context.apiSuccess(MODULE, '拆分余量建组后回读根履约树', splitTreeAfterResponse,