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
Vendored
+5
View File
@@ -56,6 +56,8 @@ def commonPathServices = [
] ]
def databaseMigrationPaths = ['deploy/sql/'] def databaseMigrationPaths = ['deploy/sql/']
// 暂停数据库结构只读门禁;保留实现,完成 Credentials 配置后改为 true。
def databaseSchemaContractEnabled = false
def downstreamResults = [:] def downstreamResults = [:]
def downstreamBuildUrls = [:] def downstreamBuildUrls = [:]
@@ -370,6 +372,9 @@ pipeline {
} }
stage('数据库结构只读门禁') { stage('数据库结构只读门禁') {
when {
expression { databaseSchemaContractEnabled }
}
agent { label 'java-builder' } agent { label 'java-builder' }
steps { steps {
script { script {
+1
View File
@@ -163,6 +163,7 @@ services:
FILE_DB_USERNAME: ${FILE_DB_USERNAME} FILE_DB_USERNAME: ${FILE_DB_USERNAME}
FILE_DB_PASSWORD: ${FILE_DB_PASSWORD} FILE_DB_PASSWORD: ${FILE_DB_PASSWORD}
FILE_STORAGE_ROOT: /data/files FILE_STORAGE_ROOT: /data/files
PDA_UPDATE_PUBLIC_BASE_URL: ${PDA_UPDATE_PUBLIC_BASE_URL:-}
JAVA_TOOL_OPTIONS: "-Xms1g -Xmx2g -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8" JAVA_TOOL_OPTIONS: "-Xms1g -Xmx2g -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
# Compose 会插值整份文件,即使只部署 auth 等其他服务;File 的必填校验由 deploy-services.sh 按服务执行。 # Compose 会插值整份文件,即使只部署 auth 等其他服务;File 的必填校验由 deploy-services.sh 按服务执行。
ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER-} ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER-}
@@ -4,6 +4,8 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.PageResult;
import org.springframework.cloud.openfeign.FeignClient; import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping; 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.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
@@ -29,6 +31,13 @@ public interface PendingReviewOrderClient {
@RequestParam long page, @RequestParam long page,
@RequestParam long size); @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, record Row(Long orderId, String orderNo, String inboundCode, Long customerId,
String customerName, Long warehouseId, String channelCode, String channelName, String customerName, Long warehouseId, String channelCode, String channelName,
int totalPackageCount, LocalDateTime createdAt, LocalDateTime expectedArrivalAt, int totalPackageCount, LocalDateTime createdAt, LocalDateTime expectedArrivalAt,
@@ -13,6 +13,11 @@ import static com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRo
/** 同柜不可拆组在 Firstleg 单库内的唯一持久化入口。 */ /** 同柜不可拆组在 Firstleg 单库内的唯一持久化入口。 */
@Mapper @Mapper
public interface ColoadGroupMapper { 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({"<script>", """
SELECT o.tenant_id tenantId,o.order_id orderId,o.order_no orderNo, SELECT o.tenant_id tenantId,o.order_id orderId,o.order_no orderNo,
o.id inboundId,o.inbound_code inboundCode,o.process_status processStatus, 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.GroupRow;
import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.InboundCandidateRow; import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.InboundCandidateRow;
import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort; 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.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -41,6 +45,10 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
@Resource @Resource
private ColoadGroupMapper mapper; 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 @Transactional
public GroupSnapshot create(List<Long> requestedOrderIds, String idempotencyKey, public GroupSnapshot create(List<Long> requestedOrderIds, String idempotencyKey,
@@ -51,6 +59,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
CommandClaim claim = claim(operatorId, "CREATE", key, requestHash); CommandClaim claim = claim(operatorId, "CREATE", key, requestHash);
if (claim.replayGroupId() != null) return requiredSnapshot(claim.replayGroupId()); if (claim.replayGroupId() != null) return requiredSnapshot(claim.replayGroupId());
preparePendingReviewInbounds(orderIds);
List<InboundCandidateRow> candidates = authoritativeCandidates(orderIds, null); List<InboundCandidateRow> candidates = authoritativeCandidates(orderIds, null);
validateSameRoute(candidates); validateSameRoute(candidates);
Long lockedPlanId = validateAllocationState(candidates); Long lockedPlanId = validateAllocationState(candidates);
@@ -265,6 +274,37 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
return resolved; 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) { private String unavailableReason(InboundCandidateRow row, Long allowedGroupId) {
if (row.getTenantId() == null || row.getTenantId() <= 0 if (row.getTenantId() == null || row.getTenantId() <= 0
|| row.getInboundId() == null || row.getInboundId() <= 0 || row.getInboundId() == null || row.getInboundId() <= 0
@@ -470,6 +510,10 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
return new BizException(ResultCode.CONFLICT, message); return new BizException(ResultCode.CONFLICT, message);
} }
private static BizException unavailable() {
return new BizException(ResultCode.INTERNAL_ERROR, "待审核订单权威服务暂不可用");
}
private record CommandClaim(long commandId, Long replayGroupId) { private record CommandClaim(long commandId, Long replayGroupId) {
} }
} }
@@ -9,7 +9,8 @@ paths:
summary: 创建同柜不可拆组 summary: 创建同柜不可拆组
description: >- description: >-
需要 platform:firstleg:smart-loading:assign。orderIds 至少2项;服务端解析每个订单处于待审核、 需要 platform:firstleg:smart-loading:assign。orderIds 至少2项;服务端解析每个订单处于待审核、
待测量/测量中、待分柜或装柜中的全部有效入仓片段,要求国内仓和渠道一致,并冻结全部片段及其满箱数。 待测量/测量中、待分柜或装柜中的全部有效入仓片段;待审核订单先经 Order 批量权威核验并幂等准备
PREPARING 入库投影。全部成员要求国内仓和渠道一致,并冻结全部片段及其满箱数。
全部成员未进入装柜计划时创建 ACTIVE 组;全部成员已经按满箱数完整进入同一个装柜计划时直接创建并 全部成员未进入装柜计划时创建 ACTIVE 组;全部成员已经按满箱数完整进入同一个装柜计划时直接创建并
锁定为 LOCKED 组。仅部分成员进柜、分散在不同装柜计划、箱数不完整、已属于其他活动组或权威仓库/ 锁定为 LOCKED 组。仅部分成员进柜、分散在不同装柜计划、箱数不完整、已属于其他活动组或权威仓库/
渠道快照缺失时拒绝。跨成员仓库或渠道不一致时返回入仓码及仓库名/渠道名;平台错误不得展示 渠道快照缺失时拒绝。跨成员仓库或渠道不一致时返回入仓码及仓库名/渠道名;平台错误不得展示
@@ -1,6 +1,11 @@
package com.baiyue.firstleg.loading.coload; package com.baiyue.firstleg.loading.coload;
import com.baiyue.common.core.exception.BizException; 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.ColoadGroupMapper;
import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.GroupRow; import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.GroupRow;
import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.CommandRow; 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 com.baiyue.firstleg.loading.coload.service.ColoadGroupService;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@@ -35,9 +41,19 @@ import static org.mockito.Mockito.when;
class ColoadGroupServiceTest { class ColoadGroupServiceTest {
@MockBean @MockBean
private ColoadGroupMapper mapper; private ColoadGroupMapper mapper;
@MockBean private PendingReviewOrderClient pendingReviewOrders;
@MockBean private OrderCargoClient orderCargoClient;
@MockBean private InboundOrderCreationService inboundOrderCreationService;
@Resource @Resource
private ColoadGroupService service; 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 @Test
void readsCrossCustomerTwoMemberGroupByEitherInboundCodeWithoutInternalIdentifiers() { void readsCrossCustomerTwoMemberGroupByEitherInboundCodeWithoutInternalIdentifiers() {
when(mapper.groupsByInboundCode("IN-A")).thenReturn(List.of(lookup(500L, "ACTIVE"))); 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)); 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.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = { @org.junit.jupiter.params.provider.ValueSource(strings = {
"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
@@ -49,6 +49,9 @@ class ColoadGroupSqlContractTest {
.contains("FROM fl_inbound_order o") .contains("FROM fl_inbound_order o")
.contains("o.order_no orderNo", "o.inbound_code inboundCode") .contains("o.order_no orderNo", "o.inbound_code inboundCode")
.contains("o.warehouse_name warehouseName", "o.channel_name channelName") .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("FROM fl_loading_plan_item item")
.contains("item.deleted=0") .contains("item.deleted=0")
.contains("member.active_flag=1") .contains("member.active_flag=1")
@@ -63,6 +66,15 @@ class ColoadGroupSqlContractTest {
.doesNotContain("destination_code"); .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 @Test
void inboundCodeReadKeepsConsumedGroupsAndAllFrozenEffectiveFragments() throws Exception { void inboundCodeReadKeepsConsumedGroupsAndAllFrozenEffectiveFragments() throws Exception {
Method lookup = ColoadGroupMapper.class.getDeclaredMethod("groupsByInboundCode", String.class); Method lookup = ColoadGroupMapper.class.getDeclaredMethod("groupsByInboundCode", String.class);
+1 -1
View File
@@ -14,7 +14,7 @@
| 平台订单 API | `controller/PlatformFirstLegOrderController.java` | | 平台订单 API | `controller/PlatformFirstLegOrderController.java` |
| 平台批量审核预览 | `controller/PlatformFirstLegOrderController.java` 的 `/batch-review-preview`、`service/BatchOrderReviewPreviewService.java`、`dto/BatchOrderReviewPreviewResponse.java`;按目标客户和请求顺序一次返回订单、中文货物摘要、渠道、目的仓及逐项 `canApprove/blockReason`,实现固定两次批量查询,禁止前端逐票读取详情 | | 平台批量审核预览 | `controller/PlatformFirstLegOrderController.java` 的 `/batch-review-preview`、`service/BatchOrderReviewPreviewService.java`、`dto/BatchOrderReviewPreviewResponse.java`;按目标客户和请求顺序一次返回订单、中文货物摘要、渠道、目的仓及逐项 `canApprove/blockReason`,实现固定两次批量查询,禁止前端逐票读取详情 |
| 统一订单查询、审核优先状态与稳定渠道编码 | `service/UnifiedOrderQueryService.java`、`dto/UnifiedOrderResponse.java`、`src/main/resources/openapi/order-v1.yaml`;`channelCode` 直接来自 Order/Firstleg 权威快照,只用于业务校验,不得由 `channelName` 推导。Order 为 `SUBMITTED` 时即使已有 Firstleg 入库投影,统一主状态/下一动作仍固定为 `PENDING_REVIEW/APPROVE_ORDER` | | 统一订单查询、审核优先状态与稳定渠道编码 | `service/UnifiedOrderQueryService.java`、`dto/UnifiedOrderResponse.java`、`src/main/resources/openapi/order-v1.yaml`;`channelCode` 直接来自 Order/Firstleg 权威快照,只用于业务校验,不得由 `channelName` 推导。Order 为 `SUBMITTED` 时即使已有 Firstleg 入库投影,统一主状态/下一动作仍固定为 `PENDING_REVIEW/APPROVE_ORDER` |
| Firstleg 智能装柜待审核候选 | `controller/InternalPendingReviewOrderController.java`、`service/PendingReviewOrderQueryService.java`、`dto/PendingReviewOrderDtos.java`、`repository/OrderRepository.java`;内部入口 `/internal/order/v1/pending-review-orders` 只接受服务令牌,只分页返回未删除的 `SUBMITTED` 头程订单并固定投影为 `PENDING_REVIEW`,按 `Asia/Shanghai` 自然日过滤、`created_at DESC, id DESC` 稳定排序,不读取或修复 Firstleg 投影 | | Firstleg 智能装柜待审核候选 | `controller/InternalPendingReviewOrderController.java`、`service/PendingReviewOrderQueryService.java`、`dto/PendingReviewOrderDtos.java`、`repository/OrderRepository.java`;内部入口 `/internal/order/v1/pending-review-orders` 只接受服务令牌,只分页返回未删除的 `SUBMITTED` 头程订单并固定投影为 `PENDING_REVIEW`,按 `Asia/Shanghai` 自然日过滤、`created_at DESC, id DESC` 稳定排序;`POST /batch` 按最多 100 个订单 ID 批量核验待审核状态,供同柜建组幂等准备 Firstleg `PREPARING` 投影 |
| 平台版本化修改客户原始申报 | `controller/PlatformUnifiedOrderController.java` 的 `PUT /api/platform/v1/orders/{orderId}/declaration`、`service/OrderDeclarationUpdateService.java`、`dto/OrderDeclarationDtos.java`;权限 `platform:order:declaration:edit`,货物以订单内 `itemNo` 定位并保持行集合完整,契约 `src/main/resources/openapi/order-v1.yaml` | | 平台版本化修改客户原始申报 | `controller/PlatformUnifiedOrderController.java` 的 `PUT /api/platform/v1/orders/{orderId}/declaration`、`service/OrderDeclarationUpdateService.java`、`dto/OrderDeclarationDtos.java`;权限 `platform:order:declaration:edit`,货物以订单内 `itemNo` 定位并保持行集合完整,契约 `src/main/resources/openapi/order-v1.yaml` |
| 申报同步、渠道履约/计价门禁 | `service/OrderFulfillmentChannelChangeGuard.java`、`OrderPricingChannelGuard.java`、`OrderInboundOutboxPublisher.java`,内部客户端 `FirstlegInboundClient.java`、`FirstlegPricingClient.java`;审核后通过版本化 `ORDER_DECLARATION_UPDATED_V1` 同步 Firstleg | | 申报同步、渠道履约/计价门禁 | `service/OrderFulfillmentChannelChangeGuard.java`、`OrderPricingChannelGuard.java`、`OrderInboundOutboxPublisher.java`,内部客户端 `FirstlegInboundClient.java`、`FirstlegPricingClient.java`;审核后通过版本化 `ORDER_DECLARATION_UPDATED_V1` 同步 Firstleg |
| 入仓码业务关系摘要 | `service/InboundCodeAssociationService.java`、`dto/InboundCodeAssociationSummary.java`、平台/客户 `FirstLegOrderController` 的 `/inbound-code-associations/{inboundCode}`;客户入口必须按认证客户隔离 | | 入仓码业务关系摘要 | `service/InboundCodeAssociationService.java`、`dto/InboundCodeAssociationSummary.java`、平台/客户 `FirstLegOrderController` 的 `/inbound-code-associations/{inboundCode}`;客户入口必须按认证客户隔离 |
@@ -12,6 +12,8 @@ import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.ModelAttribute;
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.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -40,6 +42,14 @@ public class InternalPendingReviewOrderController {
return ApiResponse.success(service.page(query)); return ApiResponse.success(service.page(query));
} }
@PostMapping("/batch")
public ApiResponse<java.util.List<PendingReviewOrderDtos.Row>> batch(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody PendingReviewOrderDtos.BatchRequest request) {
requireToken(token);
return ApiResponse.success(service.batch(request.orderIds()));
}
private void requireToken(String token) { private void requireToken(String token) {
byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8); byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8);
if (!MessageDigest.isEqual(expectedToken, supplied)) { if (!MessageDigest.isEqual(expectedToken, supplied)) {
@@ -4,6 +4,8 @@ import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat;
@@ -41,6 +43,11 @@ public final class PendingReviewOrderDtos {
} }
} }
public record BatchRequest(
@NotEmpty @Size(min = 1, max = 100) List<@NotNull @Min(1) Long> orderIds
) {
}
public record Row( public record Row(
@Schema(type = "string", pattern = "^[1-9][0-9]*$") Long orderId, @Schema(type = "string", pattern = "^[1-9][0-9]*$") Long orderId,
String orderNo, String orderNo,
@@ -5,6 +5,8 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.PendingReviewOrderDtos; import com.baiyue.order.dto.PendingReviewOrderDtos;
import com.baiyue.order.entity.OrderCargoEntity; import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.repository.OrderRepository; import com.baiyue.order.repository.OrderRepository;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -40,16 +42,33 @@ public class PendingReviewOrderQueryService {
var source = orders.pendingReviewPage(channelCodes, query.warehouseId(), query.customerId(), var source = orders.pendingReviewPage(channelCodes, query.warehouseId(), query.customerId(),
normalize(query.inboundCode()), createdFrom, createdBefore, expectedArrivalFrom, normalize(query.inboundCode()), createdFrom, createdBefore, expectedArrivalFrom,
expectedArrivalBefore, query.excludedOrderIds(), query.page(), query.size()); expectedArrivalBefore, query.excludedOrderIds(), query.page(), query.size());
List<Long> orderIds = source.records().stream().map(order -> order.getId()).toList(); return new PageResult<>(rows(source.records()), source.page(), source.size(), source.total(), source.pages());
}
public List<PendingReviewOrderDtos.Row> batch(List<Long> requestedOrderIds) {
if (requestedOrderIds == null || requestedOrderIds.isEmpty() || requestedOrderIds.size() > 100
|| requestedOrderIds.stream().anyMatch(java.util.Objects::isNull)
|| requestedOrderIds.stream().anyMatch(id -> id <= 0)
|| requestedOrderIds.stream().distinct().count() != requestedOrderIds.size()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "待审核订单ID必须为1至100个不同的正整数");
}
Map<Long, OrderEntity> byId = orders.findByIds(null, requestedOrderIds).stream()
.filter(order -> !Boolean.TRUE.equals(order.getDeleted()))
.filter(order -> order.getStatus() == OrderStatus.SUBMITTED)
.collect(Collectors.toMap(OrderEntity::getId, order -> order));
return rows(requestedOrderIds.stream().map(byId::get).filter(java.util.Objects::nonNull).toList());
}
private List<PendingReviewOrderDtos.Row> rows(List<OrderEntity> source) {
List<Long> orderIds = source.stream().map(OrderEntity::getId).toList();
Map<Long, Integer> packageCounts = orders.findCargoByOrderIds(null, orderIds).stream() Map<Long, Integer> packageCounts = orders.findCargoByOrderIds(null, orderIds).stream()
.collect(Collectors.groupingBy(OrderCargoEntity::getOrderId, .collect(Collectors.groupingBy(OrderCargoEntity::getOrderId,
Collectors.summingInt(cargo -> cargo.getPackageCount() == null ? 0 : cargo.getPackageCount()))); Collectors.summingInt(cargo -> cargo.getPackageCount() == null ? 0 : cargo.getPackageCount())));
var rows = source.records().stream().map(order -> new PendingReviewOrderDtos.Row( return source.stream().map(order -> new PendingReviewOrderDtos.Row(
order.getId(), order.getOrderNo(), order.getInboundCode(), order.getCustomerId(), order.getId(), order.getOrderNo(), order.getInboundCode(), order.getCustomerId(),
order.getCustomerName(), order.getReceivingWarehouseId(), order.getChannelCode(), order.getCustomerName(), order.getReceivingWarehouseId(), order.getChannelCode(),
order.getChannelName(), packageCounts.getOrDefault(order.getId(), 0), order.getCreatedAt(), order.getChannelName(), packageCounts.getOrDefault(order.getId(), 0), order.getCreatedAt(),
order.getExpectedArrivalAt(), "PENDING_REVIEW")).toList(); order.getExpectedArrivalAt(), "PENDING_REVIEW")).toList();
return new PageResult<>(rows, source.page(), source.size(), source.total(), source.pages());
} }
private static List<String> normalizeChannelCodes(List<String> values) { private static List<String> normalizeChannelCodes(List<String> values) {
@@ -137,6 +137,30 @@ paths:
application/json: application/json:
schema: {$ref: '#/components/schemas/PendingReviewOrderPageEnvelope'} schema: {$ref: '#/components/schemas/PendingReviewOrderPageEnvelope'}
'403': {description: 内部服务令牌无效} '403': {description: 内部服务令牌无效}
/internal/order/v1/pending-review-orders/batch:
post:
security: []
summary: Firstleg 按订单 ID 批量核验 Order 权威待审核订单
description: >-
仅接受内部服务令牌。只返回请求集合中未删除且 status=SUBMITTED 的订单,按请求顺序返回;
供订单列表创建同柜组前幂等准备 Firstleg PREPARING 入库投影,不返回已审核或失效订单。
parameters:
- name: X-Internal-Service-Token
in: header
required: true
schema: {type: string}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/PendingReviewOrderBatchRequest'}
responses:
'200':
description: Order 权威待审核订单集合
content:
application/json:
schema: {$ref: '#/components/schemas/PendingReviewOrderListEnvelope'}
'403': {description: 内部服务令牌无效}
/internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches: /internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches:
post: post:
security: [] security: []
@@ -1293,6 +1317,23 @@ components:
size: {type: integer, format: int64, minimum: 1} size: {type: integer, format: int64, minimum: 1}
total: {type: integer, format: int64, minimum: 0} total: {type: integer, format: int64, minimum: 0}
pages: {type: integer, format: int64, minimum: 0} pages: {type: integer, format: int64, minimum: 0}
PendingReviewOrderBatchRequest:
type: object
additionalProperties: false
required: [orderIds]
properties:
orderIds:
type: array
minItems: 1
maxItems: 100
uniqueItems: true
items: {type: string, pattern: '^[1-9][0-9]*$'}
PendingReviewOrderListEnvelope:
type: object
properties:
code: {type: string, example: '200'}
message: {type: string}
data: {type: array, items: {$ref: '#/components/schemas/PendingReviewOrderRow'}}
PendingReviewOrderRow: PendingReviewOrderRow:
type: object type: object
required: [orderId, orderNo, inboundCode, customerId, customerName, warehouseId, channelCode, channelName, totalPackageCount, createdAt, expectedArrivalAt, mainStatus] required: [orderId, orderNo, inboundCode, customerId, customerName, warehouseId, channelCode, channelName, totalPackageCount, createdAt, expectedArrivalAt, mainStatus]
@@ -35,4 +35,14 @@ class InternalPendingReviewOrderControllerTest {
assertThat(controller.page("0123456789abcdef0123456789abcdef", query).data()).isSameAs(page); assertThat(controller.page("0123456789abcdef0123456789abcdef", query).data()).isSameAs(page);
verify(service).page(query); verify(service).page(query);
} }
@Test
void batchUsesTheSameInternalAuthenticationBoundary() {
var request = new PendingReviewOrderDtos.BatchRequest(List.of(101L, 102L));
when(service.batch(request.orderIds())).thenReturn(List.of());
assertThatThrownBy(() -> controller.batch("wrong", request)).isInstanceOf(BizException.class);
assertThat(controller.batch("0123456789abcdef0123456789abcdef", request).data()).isEmpty();
verify(service).batch(request.orderIds());
}
} }
@@ -13,12 +13,15 @@ class PendingReviewOrderOpenApiContractTest {
String yaml = Files.readString(Path.of("src/main/resources/openapi/order-v1.yaml")); String yaml = Files.readString(Path.of("src/main/resources/openapi/order-v1.yaml"));
assertThat(yaml) assertThat(yaml)
.contains("/internal/order/v1/pending-review-orders:") .contains("/internal/order/v1/pending-review-orders:")
.contains("/internal/order/v1/pending-review-orders/batch:")
.contains("channelCodes") .contains("channelCodes")
.contains("createdStart") .contains("createdStart")
.contains("createdEnd") .contains("createdEnd")
.contains("expectedArrivalStart") .contains("expectedArrivalStart")
.contains("expectedArrivalEnd") .contains("expectedArrivalEnd")
.contains("excludedOrderIds") .contains("excludedOrderIds")
.contains("PendingReviewOrderBatchRequest:")
.contains("PendingReviewOrderListEnvelope:")
.contains("PendingReviewOrderRow:") .contains("PendingReviewOrderRow:")
.contains("expectedArrivalAt: {type: string, format: date-time, nullable: true}") .contains("expectedArrivalAt: {type: string, format: date-time, nullable: true}")
.contains("mainStatus: {type: string, enum: [PENDING_REVIEW]}") .contains("mainStatus: {type: string, enum: [PENDING_REVIEW]}")
@@ -5,6 +5,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.PendingReviewOrderDtos; import com.baiyue.order.dto.PendingReviewOrderDtos;
import com.baiyue.order.entity.OrderCargoEntity; import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.repository.OrderRepository; import com.baiyue.order.repository.OrderRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -90,6 +91,38 @@ class PendingReviewOrderQueryServiceTest {
null, null, null, null, null, List.of(), 1, 20))).isInstanceOf(BizException.class); null, null, null, null, null, List.of(), 1, 20))).isInstanceOf(BizException.class);
} }
@Test
void batchReturnsOnlyRequestedSubmittedOrdersInRequestOrder() {
OrderEntity submitted = order(101L, OrderStatus.SUBMITTED);
OrderEntity approved = order(102L, OrderStatus.APPROVED);
OrderEntity deleted = order(103L, OrderStatus.SUBMITTED); deleted.setDeleted(true);
when(repository.findByIds(null, List.of(103L, 101L, 102L))).thenReturn(List.of(approved, submitted, deleted));
when(repository.findCargoByOrderIds(null, List.of(101L))).thenReturn(List.of(cargo(101L, 4)));
var result = service.batch(List.of(103L, 101L, 102L));
assertThat(result).singleElement().satisfies(row -> {
assertThat(row.orderId()).isEqualTo(101L);
assertThat(row.totalPackageCount()).isEqualTo(4);
assertThat(row.mainStatus()).isEqualTo("PENDING_REVIEW");
});
}
@Test
void batchRejectsDuplicateOrInvalidOrderIds() {
assertThatThrownBy(() -> service.batch(List.of(101L, 101L))).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.batch(List.of(0L))).isInstanceOf(BizException.class);
}
private static OrderEntity order(long id, OrderStatus status) {
OrderEntity order = new OrderEntity();
order.setId(id); order.setStatus(status); order.setDeleted(false);
order.setOrderNo("FL" + id); order.setInboundCode("RK" + id);
order.setCustomerId(22L); order.setCustomerName("测试客户");
order.setReceivingWarehouseId(33L); order.setChannelCode("SEA-US"); order.setChannelName("美线");
return order;
}
private static OrderCargoEntity cargo(long orderId, int packageCount) { private static OrderCargoEntity cargo(long orderId, int packageCount) {
OrderCargoEntity cargo = new OrderCargoEntity(); OrderCargoEntity cargo = new OrderCargoEntity();
cargo.setOrderId(orderId); cargo.setPackageCount(packageCount); cargo.setOrderId(orderId); cargo.setPackageCount(packageCount);