优化
This commit is contained in:
+47
@@ -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<List<InternalOrderGroupInfo>> 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, "内部服务令牌无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -108,6 +108,16 @@ public interface ColoadGroupMapper {
|
||||
""")
|
||||
List<MemberCodeRow> effectiveMemberCodes(@Param("groupId") long groupId);
|
||||
|
||||
@Select({"<script>", """
|
||||
SELECT member.order_id orderId,member.group_id groupId
|
||||
FROM fl_coload_group_member member
|
||||
JOIN fl_coload_group group_row ON group_row.id=member.group_id
|
||||
WHERE member.order_id IN
|
||||
""", "<foreach collection='orderIds' item='orderId' open='(' separator=',' close=')'>",
|
||||
"#{orderId}", "</foreach>",
|
||||
"AND group_row.group_status<>'CANCELLED' ORDER BY member.order_id,group_row.created_at DESC", "</script>"})
|
||||
List<OrderGroupLinkRow> linksByOrderIds(@Param("orderIds") List<Long> orderIds);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_coload_group
|
||||
SET group_status='CANCELLED',cancelled_by=#{operatorId},
|
||||
|
||||
+7
@@ -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 {
|
||||
|
||||
+15
@@ -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<InternalOrderGroupInfo> queryInternalByOrderIds(List<Long> requestedOrderIds) {
|
||||
List<Long> orderIds = normalizedOrderIds(requestedOrderIds, 1, 200);
|
||||
Map<Long, InternalOrderGroupInfo> latest = new LinkedHashMap<>();
|
||||
Map<Long, GroupSnapshot> 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) {
|
||||
|
||||
+12
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+10
@@ -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);
|
||||
|
||||
@@ -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<List<OrderGroupInfo>> batchQuery(@RequestHeader("Authorization") String authorization,
|
||||
ApiResponse<List<OrderGroupInfo>> batchQuery(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody BatchQueryRequest request);
|
||||
|
||||
record BatchQueryRequest(List<Long> orderIds) {}
|
||||
record OrderGroupInfo(Long orderId, GroupSnapshot group) {}
|
||||
record GroupSnapshot(Long groupId, String groupNo, String status, List<Member> members) {}
|
||||
record Member(Long tenantId, Long orderId, Long inboundId, Integer fullPackageCount) {}
|
||||
record GroupSnapshot(String status, List<Member> members) {}
|
||||
record Member(Long orderId) {}
|
||||
}
|
||||
|
||||
@@ -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<PageResult<UnifiedOrderResponse.Row>> 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<UnifiedOrderResponse.Detail> 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")
|
||||
|
||||
@@ -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<CoLoadGroupMemberSummary> 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<String> memberInboundCodes) {}
|
||||
|
||||
public record Detail(Row summary, OrderDetailResponse order,
|
||||
List<TimelineEvent> timeline) {}
|
||||
|
||||
@@ -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<UnifiedOrderResponse.Row> page(Long tenantId, UnifiedOrderQuery query) {
|
||||
return page(tenantId, query, null);
|
||||
return page(tenantId, query, false);
|
||||
}
|
||||
|
||||
public PageResult<UnifiedOrderResponse.Row> platformPage(Long tenantId, UnifiedOrderQuery query,
|
||||
String authorization) {
|
||||
return page(tenantId, query, authorization);
|
||||
public PageResult<UnifiedOrderResponse.Row> platformPage(Long tenantId, UnifiedOrderQuery query) {
|
||||
return page(tenantId, query, true);
|
||||
}
|
||||
|
||||
private PageResult<UnifiedOrderResponse.Row> 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<OrderStatus> 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<Long, Integer> childCounts = childCounts(source.records());
|
||||
Map<Long, UnifiedOrderResponse.CoLoadGroupSummary> coLoadByOrder = coLoadGroups(
|
||||
source.records().stream().map(OrderEntity::getId).toList(), platformAuthorization);
|
||||
source.records().stream().map(OrderEntity::getId).toList(), includeCoLoadGroups);
|
||||
List<UnifiedOrderResponse.Row> 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<UnifiedOrderResponse.TimelineEvent> 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<UnifiedOrderResponse.Row> pageFromFirstleg(Long tenantId, UnifiedOrderQuery query,
|
||||
String platformAuthorization) {
|
||||
boolean includeCoLoadGroups) {
|
||||
List<Long> candidateOrderIds = StringUtils.hasText(query.fbaCode())
|
||||
? orders.findOrderIdsBySkuNo(tenantId, query.fbaCode()) : null;
|
||||
if (candidateOrderIds != null && candidateOrderIds.isEmpty()) {
|
||||
@@ -151,8 +151,8 @@ public class UnifiedOrderQueryService {
|
||||
Map<Long, String> latestNotes = latestNotes(tenantId, orderIds);
|
||||
Map<Long, CargoSummary> cargoSummaries = cargoSummaries(tenantId, orderIds);
|
||||
Map<Long, Integer> childCounts = childCounts(List.copyOf(orderById.values()));
|
||||
Map<Long, UnifiedOrderResponse.CoLoadGroupSummary> coLoadByOrder =
|
||||
coLoadGroups(orderIds, platformAuthorization);
|
||||
Map<Long, UnifiedOrderResponse.CoLoadGroupSummary> coLoadByOrder = coLoadGroups(
|
||||
orderIds, includeCoLoadGroups);
|
||||
List<UnifiedOrderResponse.Row> 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<Long, UnifiedOrderResponse.CoLoadGroupSummary> coLoadGroups(
|
||||
List<Long> orderIds, String platformAuthorization) {
|
||||
if (!StringUtils.hasText(platformAuthorization) || orderIds.isEmpty()) return Map.of();
|
||||
List<Long> orderIds, boolean includeCoLoadGroups) {
|
||||
if (!includeCoLoadGroups || orderIds.isEmpty()) return Map.of();
|
||||
List<FirstlegColoadGroupClient.OrderGroupInfo> data = require(
|
||||
call(() -> coloadGroups.batchQuery(platformAuthorization,
|
||||
call(() -> coloadGroups.batchQuery(internalToken,
|
||||
new FirstlegColoadGroupClient.BatchQueryRequest(orderIds.stream().distinct().toList()))),
|
||||
"同柜组摘要响应无效");
|
||||
List<Long> memberOrderIds = data.stream()
|
||||
.filter(Objects::nonNull).map(FirstlegColoadGroupClient.OrderGroupInfo::group)
|
||||
.filter(Objects::nonNull).flatMap(group -> safeMembers(group).stream())
|
||||
List<Long> 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<Long, String> inboundCodeByOrder = orders.findByIds(null, memberOrderIds).stream()
|
||||
.collect(Collectors.toMap(OrderEntity::getId, OrderEntity::getInboundCode, (left, right) -> left));
|
||||
Map<Long, String> 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<FirstlegColoadGroupClient.Member> safeMembers(FirstlegColoadGroupClient.GroupSnapshot group) {
|
||||
return group.members() == null ? List.of() : group.members();
|
||||
}
|
||||
|
||||
private UnifiedOrderResponse.CoLoadGroupSummary coLoadGroup(
|
||||
FirstlegColoadGroupClient.GroupSnapshot group, Map<Long, String> inboundCodeByOrder) {
|
||||
List<UnifiedOrderResponse.CoLoadGroupMemberSummary> 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<FirstlegColoadGroupClient.Member> members = safeMembers(group);
|
||||
List<String> inboundCodes = members.stream().map(FirstlegColoadGroupClient.Member::orderId)
|
||||
.map(inboundCodeByOrder::get).toList();
|
||||
return new UnifiedOrderResponse.CoLoadGroupSummary(group.status(), members.size(), inboundCodes);
|
||||
}
|
||||
|
||||
private List<FirstlegColoadGroupClient.Member> safeMembers(
|
||||
FirstlegColoadGroupClient.GroupSnapshot group) {
|
||||
return group.members() == null ? List.of() : group.members();
|
||||
}
|
||||
|
||||
private UnifiedOrderResponse.OrderRelationSummary relation(OrderEntity order,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String, Object> inboundCode = stringMap(stringMap(row.get("properties")).get("inboundCode"));
|
||||
assertThat(inboundCode).containsEntry("nullable", true);
|
||||
assertThat(inboundCode.get("description").toString()).contains("不得回退");
|
||||
Map<String, Object> 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<String, Object> stringMap(Object value) {
|
||||
return (Map<String, Object>) value;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
'组成员不能单独调整箱数',
|
||||
'组成员不能单独删除',
|
||||
'整组原子移除',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user