This commit is contained in:
2026-09-20 15:03:24 +08:00
parent a7d458cddd
commit 0e52f4e11e
16 changed files with 246 additions and 5 deletions
@@ -4,6 +4,8 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
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.RequestParam;
@@ -29,6 +31,13 @@ public interface PendingReviewOrderClient {
@RequestParam long page,
@RequestParam long size);
@PostMapping("/batch")
ApiResponse<List<Row>> batch(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody BatchRequest request);
record BatchRequest(List<Long> orderIds) {}
record Row(Long orderId, String orderNo, String inboundCode, Long customerId,
String customerName, Long warehouseId, String channelCode, String channelName,
int totalPackageCount, LocalDateTime createdAt, LocalDateTime expectedArrivalAt,
@@ -13,6 +13,11 @@ import static com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRo
/** 同柜不可拆组在 Firstleg 单库内的唯一持久化入口。 */
@Mapper
public interface ColoadGroupMapper {
@Select({"<script>", "SELECT DISTINCT order_id FROM fl_inbound_order WHERE deleted=0 AND order_id IN",
"<foreach collection='orderIds' item='orderId' open='(' separator=',' close=')'>",
"#{orderId}", "</foreach>", "</script>"})
List<Long> existingOrderIds(@Param("orderIds") List<Long> orderIds);
@Select({"<script>", """
SELECT o.tenant_id tenantId,o.order_id orderId,o.order_no orderNo,
o.id inboundId,o.inbound_code inboundCode,o.process_status processStatus,
@@ -13,6 +13,10 @@ import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.Comm
import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.GroupRow;
import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.InboundCandidateRow;
import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.client.PendingReviewOrderClient;
import com.baiyue.firstleg.service.InboundOrderCreationService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -41,6 +45,10 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
@Resource
private ColoadGroupMapper mapper;
@Resource private PendingReviewOrderClient pendingReviewOrders;
@Resource private OrderCargoClient orderCargoClient;
@Resource private InboundOrderCreationService inboundOrderCreationService;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Transactional
public GroupSnapshot create(List<Long> requestedOrderIds, String idempotencyKey,
@@ -51,6 +59,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
CommandClaim claim = claim(operatorId, "CREATE", key, requestHash);
if (claim.replayGroupId() != null) return requiredSnapshot(claim.replayGroupId());
preparePendingReviewInbounds(orderIds);
List<InboundCandidateRow> candidates = authoritativeCandidates(orderIds, null);
validateSameRoute(candidates);
Long lockedPlanId = validateAllocationState(candidates);
@@ -265,6 +274,37 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
return resolved;
}
private void preparePendingReviewInbounds(List<Long> orderIds) {
Set<Long> existing = Set.copyOf(mapper.existingOrderIds(orderIds));
List<Long> missing = orderIds.stream().filter(orderId -> !existing.contains(orderId)).toList();
if (missing.isEmpty()) return;
try {
var response = pendingReviewOrders.batch(internalToken,
new PendingReviewOrderClient.BatchRequest(missing));
if (response == null || !response.successful() || response.data() == null) throw unavailable();
for (PendingReviewOrderClient.Row row : response.data()) {
if (row == null || row.orderId() == null || !missing.contains(row.orderId())
|| row.customerId() == null || !"PENDING_REVIEW".equals(row.mainStatus())) {
throw conflict("待审核订单权威快照已变化,请刷新后重试");
}
var snapshotResponse = orderCargoClient.inboundSnapshot(
internalToken, row.customerId(), row.orderId());
if (snapshotResponse == null || !snapshotResponse.successful()
|| snapshotResponse.data() == null
|| snapshotResponse.data().tenantId() != row.customerId()
|| snapshotResponse.data().orderId() != row.orderId()
|| !Objects.equals(snapshotResponse.data().inboundCode(), row.inboundCode())) {
throw conflict("待审核订单履约快照已变化,请刷新后重试");
}
inboundOrderCreationService.prepareFromDraft(snapshotResponse.data());
}
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw unavailable();
}
}
private String unavailableReason(InboundCandidateRow row, Long allowedGroupId) {
if (row.getTenantId() == null || row.getTenantId() <= 0
|| row.getInboundId() == null || row.getInboundId() <= 0
@@ -470,6 +510,10 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
return new BizException(ResultCode.CONFLICT, message);
}
private static BizException unavailable() {
return new BizException(ResultCode.INTERNAL_ERROR, "待审核订单权威服务暂不可用");
}
private record CommandClaim(long commandId, Long replayGroupId) {
}
}
@@ -9,7 +9,8 @@ paths:
summary: 创建同柜不可拆组
description: >-
需要 platform:firstleg:smart-loading:assign。orderIds 至少2项;服务端解析每个订单处于待审核、
待测量/测量中、待分柜或装柜中的全部有效入仓片段,要求国内仓和渠道一致,并冻结全部片段及其满箱数。
待测量/测量中、待分柜或装柜中的全部有效入仓片段;待审核订单先经 Order 批量权威核验并幂等准备
PREPARING 入库投影。全部成员要求国内仓和渠道一致,并冻结全部片段及其满箱数。
全部成员未进入装柜计划时创建 ACTIVE 组;全部成员已经按满箱数完整进入同一个装柜计划时直接创建并
锁定为 LOCKED 组。仅部分成员进柜、分散在不同装柜计划、箱数不完整、已属于其他活动组或权威仓库/
渠道快照缺失时拒绝。跨成员仓库或渠道不一致时返回入仓码及仓库名/渠道名;平台错误不得展示
@@ -1,6 +1,11 @@
package com.baiyue.firstleg.loading.coload;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.client.PendingReviewOrderClient;
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
import com.baiyue.firstleg.service.InboundOrderCreationService;
import com.baiyue.firstleg.loading.coload.persistence.ColoadGroupMapper;
import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.GroupRow;
import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.CommandRow;
@@ -12,6 +17,7 @@ import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.Orde
import com.baiyue.firstleg.loading.coload.service.ColoadGroupService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@@ -35,9 +41,19 @@ import static org.mockito.Mockito.when;
class ColoadGroupServiceTest {
@MockBean
private ColoadGroupMapper mapper;
@MockBean private PendingReviewOrderClient pendingReviewOrders;
@MockBean private OrderCargoClient orderCargoClient;
@MockBean private InboundOrderCreationService inboundOrderCreationService;
@Resource
private ColoadGroupService service;
@BeforeEach
void allowNoPendingReviewProjectionByDefault() {
when(mapper.existingOrderIds(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(pendingReviewOrders.batch(anyString(), any(PendingReviewOrderClient.BatchRequest.class)))
.thenReturn(ApiResponse.success(List.of()));
}
@Test
void readsCrossCustomerTwoMemberGroupByEitherInboundCodeWithoutInternalIdentifiers() {
when(mapper.groupsByInboundCode("IN-A")).thenReturn(List.of(lookup(500L, "ACTIVE")));
@@ -154,6 +170,31 @@ class ColoadGroupServiceTest {
verify(mapper).insertMember(anyLong(), eq(groupId.getValue()), eq(202L), eq(12L), eq(1002L), eq(50));
}
@Test
void provisionsSelectedPendingReviewOrderBeforeResolvingColoadCandidates() {
PendingReviewOrderClient.Row pending = new PendingReviewOrderClient.Row(
11L, "FO-11", "IN-1001", 101L, "客户", 9L, "CH", "美森海卡",
30, LocalDateTime.now(), null, "PENDING_REVIEW");
OrderApprovedInboundCommand snapshot = new OrderApprovedInboundCommand(
101L, 11L, "FO-11", "IN-1001", 101L, "客户", 9L, "义乌仓",
null, null, null, "FIRST_LEG", "CH", "美森海卡", null, null,
null, null, null, false, "FBA", null, null, null, null, null, null, 30);
when(pendingReviewOrders.batch(anyString(), any(PendingReviewOrderClient.BatchRequest.class)))
.thenReturn(ApiResponse.success(List.of(pending)));
when(mapper.existingOrderIds(List.of(11L, 12L))).thenReturn(List.of(12L));
when(orderCargoClient.inboundSnapshot(anyString(), eq(101L), eq(11L)))
.thenReturn(ApiResponse.success(snapshot));
when(inboundOrderCreationService.prepareFromDraft(snapshot)).thenReturn(1001L);
stubSuccessfulCreate("pending-review", List.of(
candidate(101L, 11L, 1001L, 30), candidate(202L, 12L, 1002L, 50)), "ACTIVE", null);
var result = service.create(List.of(11L, 12L), "pending-review", 7L, "operator");
assertThat(result.status()).isEqualTo("ACTIVE");
verify(inboundOrderCreationService).prepareFromDraft(snapshot);
verify(pendingReviewOrders).batch(anyString(), eq(new PendingReviewOrderClient.BatchRequest(List.of(11L))));
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {
"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
@@ -49,6 +49,9 @@ class ColoadGroupSqlContractTest {
.contains("FROM fl_inbound_order o")
.contains("o.order_no orderNo", "o.inbound_code inboundCode")
.contains("o.warehouse_name warehouseName", "o.channel_name channelName")
.contains("activeAllocationPlanCount", "activeAllocationPlanId", "activeAllocatedPackageCount")
.contains("COUNT(DISTINCT item.loading_plan_id)", "MIN(item.loading_plan_id)")
.contains("COALESCE(SUM(item.package_count),0)")
.contains("FROM fl_loading_plan_item item")
.contains("item.deleted=0")
.contains("member.active_flag=1")
@@ -63,6 +66,15 @@ class ColoadGroupSqlContractTest {
.doesNotContain("destination_code");
}
@Test
void mapperDetectsExistingFirstlegProjectionsBeforeCallingThePendingReviewOwner() throws Exception {
Method method = ColoadGroupMapper.class.getDeclaredMethod("existingOrderIds", java.util.List.class);
String sql = String.join(" ", method.getAnnotation(Select.class).value());
assertThat(sql).contains("SELECT DISTINCT order_id FROM fl_inbound_order")
.contains("deleted=0", "order_id IN")
.doesNotContain("FOR UPDATE");
}
@Test
void inboundCodeReadKeepsConsumedGroupsAndAllFrozenEffectiveFragments() throws Exception {
Method lookup = ColoadGroupMapper.class.getDeclaredMethod("groupsByInboundCode", String.class);