From ec4b1e0fe5a71be940e3e14f7adda9f17e406124 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 18 Aug 2026 11:41:10 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 56 +++++++++++++++---- .../InternalPriceBookController.java | 2 +- .../dto/PriceBookChannelValidation.java | 3 +- .../service/MerchantPriceBookService.java | 5 +- .../resources/db/phase2-inbound-schema.sql | 31 +++++++++- .../main/resources/openapi/firstleg-v1.yaml | 9 +-- .../controller/PriceBookContractTest.java | 4 +- ...boundCodeGlobalUniquenessContractTest.java | 20 +++++++ .../service/MerchantPriceBookServiceTest.java | 18 +++++- .../order/client/FirstlegPricingClient.java | 2 +- .../PlatformFirstLegOrderController.java | 7 +++ .../order/repository/OrderRepository.java | 3 +- .../MerchantBatchDraftItemService.java | 13 +++++ .../service/OrderApplicationService.java | 20 ++++++- .../service/OrderPricingChannelGuard.java | 16 +++++- order/src/main/resources/db/order.sql | 33 ++++++++++- .../src/main/resources/openapi/order-v1.yaml | 15 +++++ .../config/InboundCodeModeProviderTest.java | 25 +++++++++ .../InboundCodePolicyContractTest.java | 7 +++ .../dto/OrderRequestsValidationTest.java | 26 +++++++++ ...boundCodeGlobalUniquenessContractTest.java | 20 +++++++ .../MerchantBatchDraftItemServiceTest.java | 45 +++++++++++++++ .../OrderApplicationServiceStateTest.java | 46 ++++++++++++++- .../service/OrderIdempotencyServiceTest.java | 2 + .../service/OrderPricingChannelGuardTest.java | 20 +++++-- .../service/PlatformOrderPageServiceTest.java | 2 + .../service/TenantOrderCountServiceTest.java | 2 + .../TenantOrderStatisticsServiceTest.java | 2 + 28 files changed, 412 insertions(+), 42 deletions(-) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCodeGlobalUniquenessContractTest.java create mode 100644 order/src/test/java/com/baiyue/order/config/InboundCodeModeProviderTest.java create mode 100644 order/src/test/java/com/baiyue/order/repository/InboundCodeGlobalUniquenessContractTest.java create mode 100644 order/src/test/java/com/baiyue/order/service/MerchantBatchDraftItemServiceTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index fa3648501..2e3db3594 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -515,7 +515,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', order_no VARCHAR(32) NOT NULL COMMENT '客户范围内唯一订单号', - inbound_code VARCHAR(40) NULL COMMENT '提交后生成并固定的入仓码;未提交草稿为空', + inbound_code VARCHAR(40) NULL COMMENT '全平台唯一入仓码;来源由全局模式决定,提交后固定', customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID', customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照', status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态:DRAFT草稿、SUBMITTED已提交、APPROVED审核通过、ACCEPTED已接单、REJECTED审核驳回、PROCESSING履约中、COMPLETED已完成、CANCELLED已取消', @@ -556,7 +556,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_order_tenant_no (tenant_id, order_no), - UNIQUE KEY uk_order_tenant_inbound_code (tenant_id, inbound_code), + UNIQUE KEY uk_order_inbound_code (inbound_code), KEY idx_order_tenant_status_time (tenant_id, status, created_at), KEY idx_order_tenant_customer (tenant_id, customer_id), KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) @@ -569,7 +569,7 @@ SET @order_inbound_code_nullable_sql = IF( WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order' AND column_name = 'inbound_code' AND is_nullable = 'NO' ), - 'ALTER TABLE baiyue_order.ord_order MODIFY COLUMN inbound_code VARCHAR(40) NULL COMMENT ''提交后生成并固定的入仓码;未提交草稿为空''', + 'ALTER TABLE baiyue_order.ord_order MODIFY COLUMN inbound_code VARCHAR(40) NULL COMMENT ''全平台唯一入仓码;来源由全局模式决定,提交后固定''', 'SELECT 1' ); PREPARE order_inbound_code_nullable_stmt FROM @order_inbound_code_nullable_sql; @@ -2427,7 +2427,7 @@ SET @inbound_code_exists := ( WHERE table_schema = DATABASE() AND table_name = 'ord_order' AND column_name = 'inbound_code' ); SET @inbound_code_sql := IF(@inbound_code_exists = 0, - 'ALTER TABLE ord_order ADD COLUMN inbound_code VARCHAR(40) NULL COMMENT ''客户提交后生成的入仓码,草稿为空'' AFTER order_no', + 'ALTER TABLE ord_order ADD COLUMN inbound_code VARCHAR(40) NULL COMMENT ''全平台唯一入仓码;来源由全局模式决定,提交后固定'' AFTER order_no', 'SELECT 1'); PREPARE inbound_code_statement FROM @inbound_code_sql; EXECUTE inbound_code_statement; @@ -2436,15 +2436,27 @@ DEALLOCATE PREPARE inbound_code_statement; SET @inbound_code_index_exists := ( SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'ord_order' - AND index_name = 'uk_order_tenant_inbound_code' + AND index_name = 'uk_order_inbound_code' ); SET @inbound_code_index_sql := IF(@inbound_code_index_exists = 0, - 'ALTER TABLE ord_order ADD UNIQUE KEY uk_order_tenant_inbound_code (tenant_id, inbound_code)', + 'ALTER TABLE ord_order ADD UNIQUE KEY uk_order_inbound_code (inbound_code)', 'SELECT 1'); PREPARE inbound_code_index_statement FROM @inbound_code_index_sql; EXECUTE inbound_code_index_statement; DEALLOCATE PREPARE inbound_code_index_statement; +SET @legacy_inbound_code_index_exists := ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'ord_order' + AND index_name = 'uk_order_tenant_inbound_code' +); +SET @legacy_inbound_code_index_sql := IF(@legacy_inbound_code_index_exists > 0, + 'ALTER TABLE ord_order DROP INDEX uk_order_tenant_inbound_code', + 'SELECT 1'); +PREPARE legacy_inbound_code_index_statement FROM @legacy_inbound_code_index_sql; +EXECUTE legacy_inbound_code_index_statement; +DEALLOCATE PREPARE legacy_inbound_code_index_statement; + -- ============================================================================ -- 来源:firstleg\src\main\resources\db\phase2-inbound-schema.sql -- ============================================================================ @@ -2460,7 +2472,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( task_id BIGINT UNSIGNED NULL COMMENT '头程履约任务ID', order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID,不建立跨库外键', order_no VARCHAR(32) NOT NULL COMMENT '订单号快照', - inbound_code VARCHAR(40) NOT NULL COMMENT '客户内唯一入仓码', + inbound_code VARCHAR(40) NOT NULL COMMENT '全平台唯一入仓码', process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态:WAIT_MEASURE待测量、PREPARING草稿预入库、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、WAIT_SPLIT待分柜', order_status_snapshot VARCHAR(24) NOT NULL COMMENT '订单状态快照', measure_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回', @@ -2513,7 +2525,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), - UNIQUE KEY uk_inbound_tenant_code (tenant_id, inbound_code), + UNIQUE KEY uk_inbound_code (inbound_code), UNIQUE KEY uk_inbound_tenant_order (tenant_id, order_id), KEY idx_inbound_process_time (tenant_id, process_status, created_at, id), KEY idx_inbound_measure_time (tenant_id, measure_review_status, created_at, id), @@ -2527,6 +2539,30 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id) ) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照'; +SET @inbound_global_code_index_exists := ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_code' +); +SET @inbound_global_code_index_sql := IF(@inbound_global_code_index_exists = 0, + 'ALTER TABLE fl_inbound_order ADD UNIQUE KEY uk_inbound_code (inbound_code)', + 'SELECT 1'); +PREPARE inbound_global_code_index_statement FROM @inbound_global_code_index_sql; +EXECUTE inbound_global_code_index_statement; +DEALLOCATE PREPARE inbound_global_code_index_statement; + +SET @inbound_legacy_code_index_exists := ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_tenant_code' +); +SET @inbound_legacy_code_index_sql := IF(@inbound_legacy_code_index_exists > 0, + 'ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_tenant_code', + 'SELECT 1'); +PREPARE inbound_legacy_code_index_statement FROM @inbound_legacy_code_index_sql; +EXECUTE inbound_legacy_code_index_statement; +DEALLOCATE PREPARE inbound_legacy_code_index_statement; + UPDATE baiyue_order.ord_order o JOIN baiyue_firstleg.fl_inbound_order i ON i.tenant_id = o.tenant_id AND i.order_id = o.id AND i.deleted = 0 @@ -12797,7 +12833,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'fee_review_sta CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'fee_status', '费用状态:UNPRICED未计费、CALCULATED已计算、CONFIRMED已确认、SENT已发送、VOIDED已作废'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'hold_flag', '当前是否扣货'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'id', '入库单ID(雪花ID)'); -CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'inbound_code', '客户内唯一入仓码'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'inbound_code', '全平台唯一入仓码'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'inbound_role', '入仓单层级:MASTER主单、CHILD子单'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'inbounded_at', '入库完成时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'master_order_id', '仅用于约束有效主单订单唯一性'); @@ -13769,7 +13805,7 @@ CALL queue_column_comment('baiyue_order', 'ord_order', 'expected_ship_date', ' CALL queue_column_comment('baiyue_order', 'ord_order', 'express_company', '履约快递公司名称快照'); CALL queue_column_comment('baiyue_order', 'ord_order', 'express_tracking_no', '履约快递单号'); CALL queue_column_comment('baiyue_order', 'ord_order', 'id', '订单ID(雪花ID)'); -CALL queue_column_comment('baiyue_order', 'ord_order', 'inbound_code', '客户提交后生成的入仓码,草稿为空'); +CALL queue_column_comment('baiyue_order', 'ord_order', 'inbound_code', '全平台唯一入仓码;来源由全局模式决定,提交后固定'); CALL queue_column_comment('baiyue_order', 'ord_order', 'order_no', '客户范围内唯一订单号'); CALL queue_column_comment('baiyue_order', 'ord_order', 'order_role', '订单层级:MASTER主订单、CHILD子订单'); CALL queue_column_comment('baiyue_order', 'ord_order', 'parent_order_id', '直属父订单ID;主订单为空;禁止子订单嵌套'); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java index 362df4a5a..54fb3d8a9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java @@ -36,7 +36,7 @@ public class InternalPriceBookController { @Valid @RequestBody PriceBookChannelValidation.Request request) { requireToken(token); return ApiResponse.success(service.requireAvailableChannel(request.tenantId(), request.originWarehouseId(), - request.channelCode(), request.destinationType())); + request.channelCode(), request.destinationType(), request.requireInboundCodePrefix())); } private void requireToken(String token) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookChannelValidation.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookChannelValidation.java index 8fc24341b..dccef333b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookChannelValidation.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookChannelValidation.java @@ -12,7 +12,8 @@ public final class PriceBookChannelValidation { @Min(1) long tenantId, @Min(1) long originWarehouseId, @NotBlank @Size(max = 64) String channelCode, - @NotBlank @Pattern(regexp = "FBA|OVERSEAS|PRIVATE") String destinationType + @NotBlank @Pattern(regexp = "FBA|OVERSEAS|PRIVATE") String destinationType, + boolean requireInboundCodePrefix ) {} public record Response(String channelCode, String inboundCodePrefix) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java index 7ec7922d4..87d608bca 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java @@ -49,7 +49,7 @@ public class MerchantPriceBookService { } public Response requireAvailableChannel(long merchantTenantId, long originWarehouseId, String channelCode, - String destinationType) { + String destinationType, boolean requireInboundCodePrefix) { requireMerchant(merchantTenantId); if (originWarehouseId <= 0) throw validation("请选择有效起运仓"); Long versionId = repository.activeVersionId(); @@ -57,6 +57,9 @@ public class MerchantPriceBookService { if (versionId == null || !repository.channelAvailable(versionId, originWarehouseId, normalized)) { throw new BizException(ResultCode.CONFLICT, "所选渠道已下线,请重新选择"); } + if (!requireInboundCodePrefix) { + return new Response(normalized, null); + } var prefix = repository.channelPrefix(normalized); if (prefix == null || !StringUtils.hasText(prefix.inboundCodePrefix())) { throw new BizException(ResultCode.CONFLICT, "所选渠道未配置进仓编码"); diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 8225c2372..b4a3e9e58 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( task_id BIGINT UNSIGNED NULL COMMENT '头程履约任务ID', order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID,不建立跨库外键', order_no VARCHAR(32) NOT NULL COMMENT '订单号快照', - inbound_code VARCHAR(40) NOT NULL COMMENT '客户内唯一入仓码', + inbound_code VARCHAR(40) NOT NULL COMMENT '全平台唯一入仓码', process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态:WAIT_MEASURE待测量、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、WAIT_SPLIT待分柜', order_status_snapshot VARCHAR(24) NOT NULL COMMENT '订单状态快照', measure_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回', @@ -63,7 +63,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), - UNIQUE KEY uk_inbound_tenant_code (tenant_id, inbound_code), + UNIQUE KEY uk_inbound_code (inbound_code), UNIQUE KEY uk_inbound_tenant_order (tenant_id, order_id), KEY idx_inbound_process_time (tenant_id, process_status, created_at, id), KEY idx_inbound_measure_time (tenant_id, measure_review_status, created_at, id), @@ -77,6 +77,33 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id) ) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照'; +-- 既有开发库若存在跨客户重复码则严格失败,禁止自动覆盖或改写入仓码。 +SET @inbound_global_code_index_sql = IF( + NOT EXISTS( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_code' + ), + 'ALTER TABLE baiyue_firstleg.fl_inbound_order ADD UNIQUE KEY uk_inbound_code (inbound_code)', + 'SELECT 1' +); +PREPARE inbound_global_code_index_stmt FROM @inbound_global_code_index_sql; +EXECUTE inbound_global_code_index_stmt; +DEALLOCATE PREPARE inbound_global_code_index_stmt; + +SET @inbound_tenant_code_index_drop_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_tenant_code' + ), + 'ALTER TABLE baiyue_firstleg.fl_inbound_order DROP INDEX uk_inbound_tenant_code', + 'SELECT 1' +); +PREPARE inbound_tenant_code_index_drop_stmt FROM @inbound_tenant_code_index_drop_sql; +EXECUTE inbound_tenant_code_index_drop_stmt; +DEALLOCATE PREPARE inbound_tenant_code_index_drop_stmt; + CREATE TABLE IF NOT EXISTS fl_inbound_pickup_snapshot ( id BIGINT UNSIGNED NOT NULL COMMENT '提货信息快照ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 7685acb03..b1c7d71ba 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -51,8 +51,8 @@ paths: '200': {description: 幂等补偿完成} /internal/firstleg/v1/pricing/channels/validate: post: - summary: 校验订单渠道并返回进仓编码前缀 - description: 仅供订单服务调用;前缀按渠道和目的仓类型选择,同一渠道的包税与不包税订单共用编码前缀。 + summary: 校验订单渠道并按需返回进仓编码前缀 + description: 仅供订单服务调用;AUTO 模式要求并返回编码前缀,LEGACY_MANUAL 模式仅校验渠道可用性。 security: - internalServiceToken: [] requestBody: @@ -61,14 +61,15 @@ paths: application/json: schema: type: object - required: [tenantId, originWarehouseId, channelCode, destinationType] + required: [tenantId, originWarehouseId, channelCode, destinationType, requireInboundCodePrefix] properties: tenantId: {type: string, pattern: '^[1-9][0-9]*$'} originWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'} channelCode: {type: string, minLength: 1, maxLength: 64} destinationType: {type: string, enum: [FBA, OVERSEAS, PRIVATE]} + requireInboundCodePrefix: {type: boolean} responses: - '200': {description: 成功时 data 返回 channelCode 和 inboundCodePrefix;未配置对应进仓编码时返回业务冲突} + '200': {description: 成功时 data 返回 channelCode;requireInboundCodePrefix=true 时同时返回 inboundCodePrefix} /api/platform/v1/first-leg/customer-dimension-config: get: summary: 查询客户固定四箱规尺寸配置 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java index d23d7d14b..cccb0a153 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java @@ -120,7 +120,7 @@ class PriceBookContractTest { .getContentAsString(StandardCharsets.UTF_8); assertThat(firstlegOpenapi) .contains("/internal/firstleg/v1/pricing/channels/validate:") - .contains("required: [tenantId, originWarehouseId, channelCode, destinationType]") - .contains("同一渠道的包税与不包税订单共用编码前缀"); + .contains("required: [tenantId, originWarehouseId, channelCode, destinationType, requireInboundCodePrefix]") + .contains("LEGACY_MANUAL 模式仅校验渠道可用性"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCodeGlobalUniquenessContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCodeGlobalUniquenessContractTest.java new file mode 100644 index 000000000..f5b01c360 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCodeGlobalUniquenessContractTest.java @@ -0,0 +1,20 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundCodeGlobalUniquenessContractTest { + @Test + void inboundSchemaUsesTheSamePlatformWideInboundCodeIndex() throws Exception { + String sql = new ClassPathResource("db/phase2-inbound-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql) + .contains("UNIQUE KEY uk_inbound_code (inbound_code)") + .doesNotContain("UNIQUE KEY uk_inbound_tenant_code (tenant_id, inbound_code)"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java index 16f671151..9412f0928 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java @@ -51,9 +51,9 @@ class MerchantPriceBookServiceTest { when(repository.channelPrefix("MATSON_CLX_TRUCK")) .thenReturn(new ChannelPrefixRow("MATSON_CLX_TRUCK", "ZBYHK", "ZBYHWK")); - assertThat(service.requireAvailableChannel(10001L, 10L, "matson_clx_truck", "FBA") + assertThat(service.requireAvailableChannel(10001L, 10L, "matson_clx_truck", "FBA", true) .inboundCodePrefix()).isEqualTo("ZBYHK"); - assertThat(service.requireAvailableChannel(10001L, 10L, "MATSON_CLX_TRUCK", "OVERSEAS") + assertThat(service.requireAvailableChannel(10001L, 10L, "MATSON_CLX_TRUCK", "OVERSEAS", true) .inboundCodePrefix()).isEqualTo("ZBYHWK"); } @@ -66,10 +66,22 @@ class MerchantPriceBookServiceTest { when(repository.channelPrefix("OA_HOUSTON_TRUCK")) .thenReturn(new ChannelPrefixRow("OA_HOUSTON_TRUCK", "HPHK", null)); - assertThat(service.requireAvailableChannel(10001L, 10L, "OA_HOUSTON_TRUCK", "OVERSEAS") + assertThat(service.requireAvailableChannel(10001L, 10L, "OA_HOUSTON_TRUCK", "OVERSEAS", true) .inboundCodePrefix()).isEqualTo("HPHK"); } + @Test + void shouldValidateLegacyChannelWithoutRequiringAnInboundCodePrefix() { + when(repository.activeVersionId()).thenReturn(7L); + when(repository.channelAvailable(7L, 10L, "LEGACY_CHANNEL")).thenReturn(true); + var response = service.requireAvailableChannel( + 10001L, 10L, "LEGACY_CHANNEL", "FBA", false); + + assertThat(response.channelCode()).isEqualTo("LEGACY_CHANNEL"); + assertThat(response.inboundCodePrefix()).isNull(); + verify(repository, never()).channelPrefix("LEGACY_CHANNEL"); + } + @Test void shouldReturnEmptyChannelsWithoutActiveVersion() { when(repository.activeVersionId()).thenReturn(null); diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java index 81320e0db..3a14fbe13 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java @@ -14,6 +14,6 @@ public interface FirstlegPricingClient { @RequestBody ChannelValidationRequest request); record ChannelValidationRequest(long tenantId, long originWarehouseId, String channelCode, - String destinationType) {} + String destinationType, boolean requireInboundCodePrefix) {} record ChannelValidationResponse(String channelCode, String inboundCodePrefix) {} } diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index 3c71451d7..bb2594cfc 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -8,6 +8,7 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.order.dto.BatchOrderReviewResponse; import com.baiyue.order.dto.OrderDetailResponse; import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.dto.InboundCodePolicyResponse; import com.baiyue.order.dto.TenantOrderCountResponse; import com.baiyue.order.dto.TenantOrderStatisticsResponse; import com.baiyue.order.entity.OrderEntity; @@ -47,6 +48,12 @@ public class PlatformFirstLegOrderController { @Resource private OrderApplicationService service; + @GetMapping("/inbound-code-policy") + @PreAuthorize("hasAnyAuthority('platform:order:list', 'platform:order:create', 'platform:order:edit', 'platform:order:submit')") + public ApiResponse inboundCodePolicy() { + return ApiResponse.success(service.inboundCodePolicy()); + } + @BusinessAudit(event = "ORDER_CREATE", resourceType = "FIRST_LEG_ORDER", description = "平台代客创建草稿") @Operation(summary = "平台代客创建草稿", description = "在明确指定的目标租户内创建头程订单草稿,必须携带幂等键。") @ApiResponses({ diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index dc95a32da..9734d6c5d 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -121,10 +121,9 @@ public class OrderRepository { .eq(OrderEntity::getId, orderId)); } - public boolean inboundCodeExists(long tenantId, String inboundCode, Long excludedOrderId) { + public boolean inboundCodeExists(String inboundCode, Long excludedOrderId) { if (!StringUtils.hasText(inboundCode)) return false; return orderMapper.selectCount(new LambdaQueryWrapper() - .eq(OrderEntity::getTenantId, tenantId) .eq(OrderEntity::getInboundCode, inboundCode) .ne(excludedOrderId != null, OrderEntity::getId, excludedOrderId)) > 0; } diff --git a/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftItemService.java b/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftItemService.java index cfdd87a5a..29ac8acd3 100644 --- a/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftItemService.java +++ b/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftItemService.java @@ -3,7 +3,10 @@ package com.baiyue.order.service; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.repository.OrderRepository; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; import jakarta.annotation.Resource; +import jakarta.validation.Validator; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -13,9 +16,19 @@ import org.springframework.transaction.annotation.Transactional; public class MerchantBatchDraftItemService { @Resource private OrderApplicationService orders; @Resource private OrderRepository repository; + @Resource private Validator validator; @Transactional(propagation = Propagation.REQUIRES_NEW) public OrderEntity create(long tenantId, long userId, OrderRequests.SaveRequest draft, String idempotencyKey) { + var violations = validator.validate(draft); + if (!violations.isEmpty()) { + String message = violations.stream() + .sorted(java.util.Comparator.comparing(violation -> violation.getPropertyPath().toString())) + .findFirst() + .map(violation -> violation.getPropertyPath() + ": " + violation.getMessage()) + .orElse("草稿参数不合法"); + throw new BizException(ResultCode.VALIDATION_FAILED, message); + } Long id = orders.createDraft(tenantId, userId, draft, idempotencyKey); return repository.findOwned(tenantId, id); } diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 613eefd5a..832ad9e86 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -80,7 +80,11 @@ public class OrderApplicationService { order.setStatus(OrderStatus.DRAFT); apply(order, tenantId, request, snapshots); order.setCreatedBy(userId); order.setUpdatedBy(userId); - repository.insertOrder(order); + try { + repository.insertOrder(order); + } catch (DuplicateKeyException duplicate) { + throw duplicateInboundCode(order, duplicate); + } saveChildren(tenantId, userId, order.getId(), request); record(tenantId, order.getId(), null, OrderStatus.DRAFT, "CREATE", null, userId, null); return order.getId(); @@ -118,7 +122,11 @@ public class OrderApplicationService { if (before == OrderStatus.SUBMITTED) { order.setStatus(OrderStatus.SUBMITTED); } - if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update"); + try { + if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update"); + } catch (DuplicateKeyException duplicate) { + throw duplicateInboundCode(order, duplicate); + } repository.replaceDraftChildren(tenantId, id); saveChildren(tenantId, userId, id, request); record(tenantId, id, before, order.getStatus(), "UPDATE", null, userId, null); @@ -473,7 +481,7 @@ public class OrderApplicationService { } return; } - if (normalized != null && repository.inboundCodeExists(order.getTenantId(), normalized, order.getId())) { + if (normalized != null && repository.inboundCodeExists(normalized, order.getId())) { throw new BizException(ResultCode.CONFLICT, "入仓码已存在,请核对旧系统数据"); } order.setInboundCode(normalized); @@ -489,6 +497,12 @@ public class OrderApplicationService { } return pricingChannelGuard.requireAvailable(tenantId, receivingWarehouseId, channelCode, destinationType); } + private RuntimeException duplicateInboundCode(OrderEntity order, DuplicateKeyException duplicate) { + if (StringUtils.hasText(order.getInboundCode())) { + return new BizException(ResultCode.CONFLICT, "入仓码已存在,请核对旧系统数据"); + } + return duplicate; + } private void requirePrintableInboundCode(OrderEntity order) { String inboundCode = order.getInboundCode(); if (!StringUtils.hasText(inboundCode) || !inboundCode.matches("[A-Za-z0-9._:-]+")) { diff --git a/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java b/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java index 2f8c40568..8a901f1b3 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java +++ b/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java @@ -16,10 +16,20 @@ public class OrderPricingChannelGuard { public String requireAvailable(long tenantId, long originWarehouseId, String channelCode, String destinationType) { + return validate(tenantId, originWarehouseId, channelCode, destinationType, true); + } + + public void requireActive(long tenantId, long originWarehouseId, String channelCode, + String destinationType) { + validate(tenantId, originWarehouseId, channelCode, destinationType, false); + } + + private String validate(long tenantId, long originWarehouseId, String channelCode, + String destinationType, boolean requireInboundCodePrefix) { try { ApiResponse response = client.validateChannel(internalToken, new FirstlegPricingClient.ChannelValidationRequest( - tenantId, originWarehouseId, channelCode, destinationType)); + tenantId, originWarehouseId, channelCode, destinationType, requireInboundCodePrefix)); if (response == null) { throw unavailable(); } @@ -29,8 +39,8 @@ public class OrderPricingChannelGuard { ? code.message() : response.message(); throw new BizException(code, message); } - if (response.data() == null || response.data().inboundCodePrefix() == null - || response.data().inboundCodePrefix().isBlank()) { + if (response.data() == null || requireInboundCodePrefix + && (response.data().inboundCodePrefix() == null || response.data().inboundCodePrefix().isBlank())) { throw new BizException(ResultCode.CONFLICT, "所选渠道未配置入仓码前缀"); } return response.data().inboundCodePrefix(); diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index 463935f52..d61276e08 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', order_no VARCHAR(32) NOT NULL COMMENT '客户范围内唯一订单号', - inbound_code VARCHAR(40) NULL COMMENT '提交后生成并固定的入仓码;未提交草稿为空', + inbound_code VARCHAR(40) NULL COMMENT '全平台唯一入仓码;来源由全局模式决定,提交后固定', customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID', customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照', status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态:DRAFT草稿、SUBMITTED已提交、APPROVED审核通过、ACCEPTED已接单、REJECTED审核驳回、PROCESSING履约中、COMPLETED已完成、CANCELLED已取消', @@ -54,13 +54,40 @@ CREATE TABLE IF NOT EXISTS ord_order ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_order_tenant_no (tenant_id, order_no), - UNIQUE KEY uk_order_tenant_inbound_code (tenant_id, inbound_code), + UNIQUE KEY uk_order_inbound_code (inbound_code), KEY idx_order_tenant_status_time (tenant_id, status, created_at), KEY idx_order_tenant_role_submitted (tenant_id, order_role, submitted_at), KEY idx_order_tenant_customer (tenant_id, customer_id), KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) ) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态'; +-- 既有开发库必须先确认不存在跨客户重复码;若存在则在此处失败,不自动改写业务数据。 +SET @order_global_inbound_index_sql = IF( + NOT EXISTS( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order' + AND index_name = 'uk_order_inbound_code' + ), + 'ALTER TABLE baiyue_order.ord_order ADD UNIQUE KEY uk_order_inbound_code (inbound_code)', + 'SELECT 1' +); +PREPARE order_global_inbound_index_stmt FROM @order_global_inbound_index_sql; +EXECUTE order_global_inbound_index_stmt; +DEALLOCATE PREPARE order_global_inbound_index_stmt; + +SET @order_tenant_inbound_index_drop_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order' + AND index_name = 'uk_order_tenant_inbound_code' + ), + 'ALTER TABLE baiyue_order.ord_order DROP INDEX uk_order_tenant_inbound_code', + 'SELECT 1' +); +PREPARE order_tenant_inbound_index_drop_stmt FROM @order_tenant_inbound_index_drop_sql; +EXECUTE order_tenant_inbound_index_drop_stmt; +DEALLOCATE PREPARE order_tenant_inbound_index_drop_stmt; + -- Existing development databases may still have the former NOT NULL contract. SET @order_inbound_code_nullable_sql = IF( EXISTS( @@ -68,7 +95,7 @@ SET @order_inbound_code_nullable_sql = IF( WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order' AND column_name = 'inbound_code' AND is_nullable = 'NO' ), - 'ALTER TABLE baiyue_order.ord_order MODIFY COLUMN inbound_code VARCHAR(40) NULL COMMENT ''提交后生成并固定的入仓码;未提交草稿为空''', + 'ALTER TABLE baiyue_order.ord_order MODIFY COLUMN inbound_code VARCHAR(40) NULL COMMENT ''全平台唯一入仓码;来源由全局模式决定,提交后固定''', 'SELECT 1' ); PREPARE order_inbound_code_nullable_stmt FROM @order_inbound_code_nullable_sql; diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index cf5f10217..9891be3ce 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -6,6 +6,21 @@ info: security: - bearerAuth: [] paths: + /api/platform/v1/orders/first-leg/inbound-code-policy: + get: + summary: 平台端查询全系统入仓码模式 + description: 与客户下单端返回同一份全局策略,不按客户区分。 + responses: + '200': + description: 查询成功 + content: + application/json: + schema: + type: object + properties: + code: {type: string, example: '200'} + data: {$ref: '#/components/schemas/InboundCodePolicy'} + '403': {description: 需要平台订单列表、创建、编辑或提交权限之一} /api/platform/v1/orders: get: summary: 平台统一订单列表 diff --git a/order/src/test/java/com/baiyue/order/config/InboundCodeModeProviderTest.java b/order/src/test/java/com/baiyue/order/config/InboundCodeModeProviderTest.java new file mode 100644 index 000000000..856ed80c6 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/config/InboundCodeModeProviderTest.java @@ -0,0 +1,25 @@ +package com.baiyue.order.config; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class InboundCodeModeProviderTest { + @Test + void parsesBothSupportedModesCaseInsensitively() { + assertThat(new InboundCodeModeProvider("auto").mode()) + .isEqualTo(InboundCodeModeProvider.Mode.AUTO); + assertThat(new InboundCodeModeProvider(" legacy_manual ").legacyManual()).isTrue(); + } + + @Test + void rejectsMissingAndUnknownModesInsteadOfFallingBackToAutomaticGeneration() { + assertThatThrownBy(() -> new InboundCodeModeProvider(" ")) + .isInstanceOf(IllegalStateException.class) + .hasMessage("baiyue.order.inbound-code-mode 必须显式配置"); + assertThatThrownBy(() -> new InboundCodeModeProvider("LEGACY-MANUAL")) + .isInstanceOf(IllegalStateException.class) + .hasMessage("baiyue.order.inbound-code-mode 仅支持 AUTO 或 LEGACY_MANUAL"); + } +} diff --git a/order/src/test/java/com/baiyue/order/controller/InboundCodePolicyContractTest.java b/order/src/test/java/com/baiyue/order/controller/InboundCodePolicyContractTest.java index 37e46f845..a805097ef 100644 --- a/order/src/test/java/com/baiyue/order/controller/InboundCodePolicyContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/InboundCodePolicyContractTest.java @@ -20,6 +20,12 @@ class InboundCodePolicyContractTest { .isEqualTo("hasAnyAuthority('merchant:order:list', 'merchant:order:create', 'merchant:order:edit')"); assertThat(method.getGenericReturnType().getTypeName()) .contains(InboundCodePolicyResponse.class.getName()); + + var platformMethod = PlatformFirstLegOrderController.class.getDeclaredMethod("inboundCodePolicy"); + assertThat(platformMethod.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAnyAuthority('platform:order:list', 'platform:order:create', 'platform:order:edit', 'platform:order:submit')"); + assertThat(platformMethod.getGenericReturnType().getTypeName()) + .contains(InboundCodePolicyResponse.class.getName()); } @Test @@ -29,6 +35,7 @@ class InboundCodePolicyContractTest { assertThat(openapi) .contains("/api/merchant/v1/orders/first-leg/inbound-code-policy:") + .contains("/api/platform/v1/orders/first-leg/inbound-code-policy:") .contains("enum: [AUTO, LEGACY_MANUAL]") .contains("manualEntryRequired: {type: boolean}") .contains("该策略不按客户区分") diff --git a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java index 1cbe646e3..e269eacce 100644 --- a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java +++ b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java @@ -54,6 +54,32 @@ class OrderRequestsValidationTest { assertThat(validator.validate(draft)).isEmpty(); } + @Test + void validatesTheManuallyEnteredInboundCodeForSingleAndBatchCallers() { + var source = request(null, null, "TAX_INCLUDED"); + var invalid = withInboundCode(source, "INVALID#CODE"); + var tooLong = withInboundCode(source, "A".repeat(41)); + var valid = withInboundCode(source, "NYEXX2608140001"); + + assertThat(validator.validate(invalid)) + .anyMatch(violation -> violation.getPropertyPath().toString().equals("inboundCode")); + assertThat(validator.validate(tooLong)) + .anyMatch(violation -> violation.getPropertyPath().toString().equals("inboundCode")); + assertThat(validator.validate(valid)).isEmpty(); + } + + private OrderRequests.SaveRequest withInboundCode(OrderRequests.SaveRequest source, String inboundCode) { + return new OrderRequests.SaveRequest( + source.customerId(), source.customerName(), source.transportMode(), source.tradeTerm(), + source.destinationCountryCode(), source.destinationWarehouseId(), source.destinationWarehouseName(), + source.expectedShipDate(), source.customerRemark(), source.sender(), source.receiver(), source.cargoes(), + source.pickupMethod(), source.requestedPickupAt(), source.receivingWarehouseId(), + source.receivingWarehouseName(), source.channelCode(), source.channelName(), source.carrierId(), + source.carrierName(), source.taxMode(), source.pricingTaxMode(), source.containerMode(), + source.customsInspection(), source.destinationType(), source.amazonWarehouseCode(), source.shipmentId(), + source.referenceId(), inboundCode); + } + private OrderRequests.SaveRequest request(Long carrierId, String carrierName, String pricingTaxMode) { var cargo = new OrderRequests.Cargo( "货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE, diff --git a/order/src/test/java/com/baiyue/order/repository/InboundCodeGlobalUniquenessContractTest.java b/order/src/test/java/com/baiyue/order/repository/InboundCodeGlobalUniquenessContractTest.java new file mode 100644 index 000000000..fd63e5e59 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/repository/InboundCodeGlobalUniquenessContractTest.java @@ -0,0 +1,20 @@ +package com.baiyue.order.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundCodeGlobalUniquenessContractTest { + @Test + void orderSchemaUsesOnePlatformWideInboundCodeIndex() throws Exception { + String sql = new ClassPathResource("db/order.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql) + .contains("UNIQUE KEY uk_order_inbound_code (inbound_code)") + .doesNotContain("UNIQUE KEY uk_order_tenant_inbound_code (tenant_id, inbound_code)"); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/MerchantBatchDraftItemServiceTest.java b/order/src/test/java/com/baiyue/order/service/MerchantBatchDraftItemServiceTest.java new file mode 100644 index 000000000..4192791b0 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/MerchantBatchDraftItemServiceTest.java @@ -0,0 +1,45 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.repository.OrderRepository; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Path; +import jakarta.validation.Validator; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class MerchantBatchDraftItemServiceTest { + @Test + void rejectsAnInvalidBatchRowBeforeCreatingTheDraft() { + OrderApplicationService orders = mock(OrderApplicationService.class); + OrderRepository repository = mock(OrderRepository.class); + Validator validator = mock(Validator.class); + @SuppressWarnings("unchecked") + ConstraintViolation violation = mock(ConstraintViolation.class); + Path path = mock(Path.class); + OrderRequests.SaveRequest draft = mock(OrderRequests.SaveRequest.class); + when(path.toString()).thenReturn("inboundCode"); + when(violation.getPropertyPath()).thenReturn(path); + when(violation.getMessage()).thenReturn("格式不正确"); + when(validator.validate(draft)).thenReturn(Set.of(violation)); + MerchantBatchDraftItemService service = new MerchantBatchDraftItemService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "validator", validator); + + assertThatThrownBy(() -> service.create(1L, 2L, draft, "batch-key")) + .isInstanceOf(BizException.class) + .hasMessage("inboundCode: 格式不正确"); + + verify(orders, never()).createDraft(1L, 2L, draft, "batch-key"); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 9fabb539f..636f7649a 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -3,6 +3,7 @@ package com.baiyue.order.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.order.client.WarehouseClient; +import com.baiyue.order.config.InboundCodeModeProvider; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.entity.OrderCargoEntity; @@ -37,6 +38,7 @@ class OrderApplicationServiceStateTest { @MockBean private OrderMasterDataGuard contextMasterData; @MockBean private OrderPricingChannelGuard contextPricingChannelGuard; @MockBean private ProductImageGuard contextProductImageGuard; + @MockBean private InboundCodeModeProvider contextInboundCodeModeProvider; @Resource private OrderApplicationService springService; @Configuration(proxyBeanMethods = false) @@ -260,7 +262,7 @@ class OrderApplicationServiceStateTest { when(masterData.validate(10001L, request)).thenReturn(snapshots()); when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1); OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData); - ReflectionTestUtils.setField(service, "inboundCodeMode", "LEGACY_MANUAL"); + useInboundCodeMode(service, InboundCodeModeProvider.Mode.LEGACY_MANUAL); service.createDraft(10001L, 2L, request, "legacy-code-draft"); @@ -268,6 +270,38 @@ class OrderApplicationServiceStateTest { verify(repository, never()).nextInboundCodeSequence(any()); } + @Test + void legacyManualModeRejectsAnInboundCodeAlreadyUsedByAnotherCustomer() { + OrderRepository repository = mock(OrderRepository.class); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + OrderRequests.SaveRequest request = withInboundCode(request(), "NYEXX2608140001"); + when(masterData.validate(10001L, request)).thenReturn(snapshots()); + when(repository.inboundCodeExists(eq("NYEXX2608140001"), anyLong())).thenReturn(true); + OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData); + useInboundCodeMode(service, InboundCodeModeProvider.Mode.LEGACY_MANUAL); + + assertThatThrownBy(() -> service.createDraft(10001L, 2L, request, "duplicate-global-code")) + .isInstanceOf(BizException.class) + .hasMessage("入仓码已存在,请核对旧系统数据"); + + verify(repository, never()).insertOrder(any()); + } + + @Test + void automaticModeRejectsClientSuppliedInboundCodes() { + OrderRepository repository = mock(OrderRepository.class); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + OrderRequests.SaveRequest request = withInboundCode(request(), "NYEXX2608140001"); + when(masterData.validate(10001L, request)).thenReturn(snapshots()); + OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData); + + assertThatThrownBy(() -> service.createDraft(10001L, 2L, request, "auto-manual-code")) + .isInstanceOf(BizException.class) + .hasMessage("当前系统由新系统自动生成入仓码,请勿人工填写"); + + verify(repository, never()).insertOrder(any()); + } + @Test void legacyManualModeRejectsSubmissionWithoutAnExternalInboundCode() { OrderRepository repository = mock(OrderRepository.class); @@ -277,7 +311,7 @@ class OrderApplicationServiceStateTest { when(repository.findParties(20002L, 1L)).thenReturn(List.of()); OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)); - ReflectionTestUtils.setField(service, "inboundCodeMode", "LEGACY_MANUAL"); + useInboundCodeMode(service, InboundCodeModeProvider.Mode.LEGACY_MANUAL); assertThatThrownBy(() -> service.transit( 20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null)) @@ -548,10 +582,18 @@ class OrderApplicationServiceStateTest { when(repository.nextInboundCodeSequence(any(LocalDate.class))).thenReturn(1L); ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing); ReflectionTestUtils.setField(service, "productImageGuard", mock(ProductImageGuard.class)); + useInboundCodeMode(service, InboundCodeModeProvider.Mode.AUTO); ReflectionTestUtils.setField(service, "json", new ObjectMapper().findAndRegisterModules()); return service; } + private void useInboundCodeMode(OrderApplicationService service, InboundCodeModeProvider.Mode mode) { + InboundCodeModeProvider provider = mock(InboundCodeModeProvider.class); + when(provider.mode()).thenReturn(mode); + when(provider.legacyManual()).thenReturn(mode == InboundCodeModeProvider.Mode.LEGACY_MANUAL); + ReflectionTestUtils.setField(service, "inboundCodeModeProvider", provider); + } + private OrderRequests.SaveRequest request() { var party = new OrderRequests.Party( "公司", "联系人", "13800138000", null, "CN", null, "深圳", null, "地址", null); diff --git a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java index f56372c80..6efebce49 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java @@ -3,6 +3,7 @@ package com.baiyue.order.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.config.InboundCodeModeProvider; import com.baiyue.order.entity.OrderIdempotencyRecordEntity; import com.baiyue.order.repository.OrderRepository; import com.fasterxml.jackson.databind.ObjectMapper; @@ -41,6 +42,7 @@ class OrderIdempotencyServiceTest { @MockBean private OrderMasterDataGuard contextMasterData; @MockBean private OrderPricingChannelGuard contextPricingChannelGuard; @MockBean private ProductImageGuard contextProductImageGuard; + @MockBean private InboundCodeModeProvider contextInboundCodeModeProvider; @Resource private OrderApplicationService springService; @Configuration(proxyBeanMethods = false) diff --git a/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java index a00240828..d26d9b3e6 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java @@ -31,7 +31,7 @@ class OrderPricingChannelGuardTest { @Test void forwardsCustomerOriginAndChannelToFirstleg() { - var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK", "FBA"); + var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK", "FBA", true); when(client.validateChannel("token", request)).thenReturn(ApiResponse.success( new FirstlegPricingClient.ChannelValidationResponse("MATSON_TRUCK", "ZBYHK"))); @@ -40,9 +40,21 @@ class OrderPricingChannelGuardTest { verify(client).validateChannel("token", request); } + @Test + void legacyManualModeOnlyRequiresTheChannelToRemainActive() { + var request = new FirstlegPricingClient.ChannelValidationRequest( + 20002L, 8L, "LEGACY_CHANNEL", "FBA", false); + when(client.validateChannel("token", request)).thenReturn(ApiResponse.success( + new FirstlegPricingClient.ChannelValidationResponse("LEGACY_CHANNEL", null))); + + guard.requireActive(20002L, 8L, "LEGACY_CHANNEL", "FBA"); + + verify(client).validateChannel("token", request); + } + @Test void removedOrForgedChannelKeepsFirstlegBusinessError() { - var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "FAKE", "FBA"); + var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "FAKE", "FBA", true); when(client.validateChannel("token", request)).thenReturn( ApiResponse.failure(ResultCode.CONFLICT, "所选渠道已下线,请重新选择", null, null)); @@ -55,7 +67,7 @@ class OrderPricingChannelGuardTest { @Test void nullResponseMapsToServiceUnavailable() { - var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK", "FBA"); + var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK", "FBA", true); when(client.validateChannel("token", request)).thenReturn(null); assertUnavailable(() -> guard.requireAvailable(20002L, 8L, "MATSON_TRUCK", "FBA")); @@ -63,7 +75,7 @@ class OrderPricingChannelGuardTest { @Test void downstreamHttpFailureMapsToServiceUnavailable() { - var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK", "FBA"); + var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK", "FBA", true); when(client.validateChannel("token", request)).thenThrow(feign(403)); assertUnavailable(() -> guard.requireAvailable(20002L, 8L, "MATSON_TRUCK", "FBA")); diff --git a/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java b/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java index 10e802c31..49d6131eb 100644 --- a/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java @@ -1,6 +1,7 @@ package com.baiyue.order.service; import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.config.InboundCodeModeProvider; import com.baiyue.order.repository.OrderRepository; import org.junit.jupiter.api.Test; import jakarta.annotation.Resource; @@ -20,6 +21,7 @@ class PlatformOrderPageServiceTest { @MockBean private OrderMasterDataGuard masterDataGuard; @MockBean private OrderPricingChannelGuard pricingChannelGuard; @MockBean private ProductImageGuard productImageGuard; + @MockBean private InboundCodeModeProvider inboundCodeModeProvider; @Resource private OrderApplicationService service; @Configuration(proxyBeanMethods = false) static class TestConfig { diff --git a/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java b/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java index e8684f703..6ce84eb99 100644 --- a/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java @@ -1,6 +1,7 @@ package com.baiyue.order.service; import com.baiyue.order.repository.OrderRepository; +import com.baiyue.order.config.InboundCodeModeProvider; import org.junit.jupiter.api.Test; import jakarta.annotation.Resource; import org.springframework.boot.test.mock.mockito.MockBean; @@ -23,6 +24,7 @@ class TenantOrderCountServiceTest { @MockBean private OrderMasterDataGuard masterDataGuard; @MockBean private OrderPricingChannelGuard pricingChannelGuard; @MockBean private ProductImageGuard productImageGuard; + @MockBean private InboundCodeModeProvider inboundCodeModeProvider; @Resource private OrderApplicationService service; @Configuration(proxyBeanMethods = false) static class TestConfig { diff --git a/order/src/test/java/com/baiyue/order/service/TenantOrderStatisticsServiceTest.java b/order/src/test/java/com/baiyue/order/service/TenantOrderStatisticsServiceTest.java index 31779bf61..a9186caca 100644 --- a/order/src/test/java/com/baiyue/order/service/TenantOrderStatisticsServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/TenantOrderStatisticsServiceTest.java @@ -1,6 +1,7 @@ package com.baiyue.order.service; import com.baiyue.order.dto.TenantOrderStatisticsResponse; +import com.baiyue.order.config.InboundCodeModeProvider; import com.baiyue.order.repository.OrderRepository; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -27,6 +28,7 @@ class TenantOrderStatisticsServiceTest { @MockBean private OrderMasterDataGuard masterDataGuard; @MockBean private OrderPricingChannelGuard pricingChannelGuard; @MockBean private ProductImageGuard productImageGuard; + @MockBean private InboundCodeModeProvider inboundCodeModeProvider; @Resource private OrderApplicationService service; @Configuration(proxyBeanMethods = false)