From 8cd822ef8ef04145451f79eab8f52e31be02a043 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 07:39:31 +0800 Subject: [PATCH] feat(numbering): unify daily business document numbers (backend) --- .../core/number/DailyDocumentNumber.java | 44 +++++++ .../core/number/DailyDocumentNumberTest.java | 45 +++++++ deploy/sql/README.md | 10 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 28 ++++- deploy/sql/海外区域统一增量-MySQL8.sql | 36 +++--- finance/AGENTS.md | 2 +- .../mapper/DocumentSequenceMapper.java | 21 ++++ .../DocumentSequenceRepository.java | 24 ++++ .../FinanceDocumentNumberGenerator.java | 49 +++----- .../service/FinanceSettlementService.java | 11 +- .../ReconciliationStatementService.java | 6 +- finance/src/main/resources/db/finance.sql | 6 +- .../FinanceSettlementBehaviorTest.java | 5 +- .../ReconciliationStatementBehaviorTest.java | 46 +++---- .../DocumentSequenceRepositoryTest.java | 76 +++++++++++ .../FinanceDocumentNumberGeneratorTest.java | 85 +++++-------- firstleg/docs/business-index.md | 4 +- .../container/dto/ContainerCustomsDtos.java | 1 - .../service/ContainerCustomsService.java | 26 ++-- .../coload/service/ColoadGroupService.java | 4 +- .../mapper/DocumentSequenceMapper.java | 21 ++++ .../service/PortScheduleService.java | 4 +- .../DocumentSequenceRepository.java | 24 ++++ .../service/SailingCalendarService.java | 10 +- .../FirstlegDocumentNumberGenerator.java | 70 ++++------- .../firstleg/service/SmartLoadingService.java | 39 +++--- .../db/phase3-smart-loading-schema.sql | 4 +- .../main/resources/openapi/customs-v1.yaml | 2 +- .../main/resources/openapi/firstleg-v1.yaml | 15 ++- .../resources/openapi/loading-lists-v1.yaml | 4 +- .../resources/openapi/ocean-bookings-v1.yaml | 2 +- .../openapi/truck-appointments-v1.yaml | 4 +- .../InboundMeasurementReviewContractTest.java | 9 +- .../ContainerCustomsServiceTest.java | 64 +++++++++- .../coload/ColoadGroupServiceTest.java | 11 +- .../service/ArrivalCalendarTest.java | 1 + .../service/PortScheduleServiceTest.java | 2 + .../DocumentSequenceRepositoryTest.java | 76 +++++++++++ .../Phase3FinalUnifiedSqlContractTest.java | 2 +- .../service/SailingCalendarServiceTest.java | 2 + .../FirstlegDocumentNumberGeneratorTest.java | 95 ++++++-------- .../service/SmartLoadingServiceTest.java | 39 ++++-- .../firstleg/service/TestDocumentNumbers.java | 21 ++++ ...astleg-internal-modularization-baseline.md | 8 +- .../service/DeliveryTransferService.java | 13 +- .../service/DispatchDeliveryService.java | 4 +- .../service/CargoDiscardOutboundService.java | 2 +- .../discard/service/CargoDiscardService.java | 2 +- .../service/ExceptionOrderService.java | 3 +- .../service/ReturnCartonReturnService.java | 2 +- .../fleet/business/FleetPoolOrderBridge.java | 6 +- .../FleetPoolOrderNumberGenerator.java | 23 ---- .../fleet/maintenance/VehicleCareService.java | 5 +- .../fleet/trip/FleetBookingService.java | 5 +- .../fleet/trip/FleetCapacityOfferService.java | 4 +- .../lastleg/fleet/trip/FleetTripService.java | 5 +- ...ainerInboundPlanConversionCoordinator.java | 2 +- .../service/InboundFbaAppointmentService.java | 10 +- .../service/InboundPlanService.java | 3 +- .../service/LocalLooseboardService.java | 4 +- .../mapper/DocumentSequenceMapper.java | 21 ++++ .../LastlegOrderNoDailySequenceMapper.java | 19 --- .../entity/OverseasProcurementFee.java | 1 + .../mapper/OverseasProcurementFeeMapper.java | 3 + .../ProcurementFeeNumberRepository.java | 27 ++++ .../service/OverseasProcurementService.java | 5 +- .../service/ProcurementAdjustmentService.java | 8 +- .../service/ProcurementControlService.java | 5 +- .../service/ProcurementFinanceService.java | 18 +-- .../ProcurementOrderNumberGenerator.java | 48 ------- .../service/ProcurementPhysicalService.java | 5 +- .../service/ProcurementTransportService.java | 5 +- .../service/RelabelApplicationService.java | 10 +- .../DocumentSequenceRepository.java | 24 ++++ .../service/ReturnInventoryService.java | 8 +- .../service/ReturnReceiptService.java | 13 +- .../service/CompactBusinessNumberService.java | 59 --------- .../LastlegDocumentNumberGenerator.java | 34 +++++ .../service/LastlegOrderNoGenerator.java | 118 ++++-------------- .../service/WarehouseTransferService.java | 5 +- .../trucking/service/UsTruckOrderService.java | 2 +- .../src/main/resources/db/lastleg.sql | 31 ++--- ...tchDeliveryArrangementPersistenceTest.java | 2 +- .../delivery/DispatchDeliveryServiceTest.java | 2 +- .../service/DeliveryTransferServiceTest.java | 2 +- .../service/ReturnOutboundClosureTest.java | 2 +- .../ReturnOutboundPdaPersistenceTest.java | 3 +- .../CargoDiscardOutboundServiceTest.java | 4 +- .../service/CargoDiscardServiceTest.java | 2 +- .../DispatchAggregatePersistenceTest.java | 1 + .../DispatchDraftResourceLockTest.java | 2 + .../service/DispatchRepairWorkflowTest.java | 2 +- .../ReturnCartonReturnPersistenceTest.java | 4 +- .../business/FleetPoolOrderBridgeTest.java | 9 +- .../FleetPoolOrderNumberGeneratorTest.java | 21 ---- .../VehicleCareAuthorizationTest.java | 4 + .../VehicleCarePersistenceTest.java | 1 + .../OwnedBusinessScopePersistenceTest.java | 2 +- .../trip/FleetCapacityOfferServiceTest.java | 1 + .../trip/FleetTripAuthorizationTest.java | 4 + ...rInboundPlanConversionCoordinatorTest.java | 8 +- .../FbaAppointmentPersistenceTest.java | 1 + .../InboundFbaAppointmentServiceTest.java | 3 +- .../service/InboundPlanServiceTest.java | 2 +- .../service/LocalLooseboardServiceTest.java | 5 +- ...ProcurementLifecycleAuthorizationTest.java | 4 + .../OverseasProcurementServiceTest.java | 4 +- .../ProcurementChargePersistenceTest.java | 3 + .../ProcurementControlPersistenceTest.java | 1 + .../ProcurementFinancePersistenceTest.java | 10 +- .../ProcurementOrderNumberGeneratorTest.java | 17 --- .../ProcurementPhysicalPersistenceTest.java | 1 + .../service/ProcurementQueryServiceTest.java | 2 + .../ProcurementTransportPersistenceTest.java | 2 + .../RelabelApplicationServiceTest.java | 8 +- .../service/ReturnRelabelDeliveryTest.java | 2 +- .../DocumentSequenceRepositoryTest.java | 76 +++++++++++ .../service/ReturnInventoryServiceTest.java | 19 +-- .../service/ReturnReceiptServiceTest.java | 18 +-- .../query/DashboardSqlSemanticTest.java | 56 ++++++++- .../CompactBusinessNumberServiceTest.java | 41 ------ .../LastlegDocumentNumberGeneratorTest.java | 41 ++++++ .../service/LastlegOrderNoGeneratorTest.java | 108 ++++------------ .../service/LoadingCompletedConsumerTest.java | 8 +- .../service/PhysicalCartonNumberTest.java | 17 +++ .../service/ReturnBusinessNumberTest.java | 65 +++------- .../lastleg/service/TestDocumentNumbers.java | 23 ++++ .../service/WarehouseTransferServiceTest.java | 11 +- .../service/UsTruckOrderServiceTest.java | 2 +- .../order/mapper/DocumentSequenceMapper.java | 21 ++++ .../DocumentSequenceRepository.java | 24 ++++ .../service/OrderApplicationService.java | 8 +- .../service/OrderDocumentNumberGenerator.java | 34 +++++ order/src/main/resources/db/order.sql | 8 ++ .../DocumentSequenceRepositoryTest.java | 76 +++++++++++ .../MerchantOrderCommandTransactionTest.java | 1 + .../OrderApplicationServiceStateTest.java | 2 + .../OrderDocumentNumberGeneratorTest.java | 41 ++++++ .../service/OrderIdempotencyServiceTest.java | 2 + .../OrderRemarksSynchronizationTest.java | 1 + .../service/PlatformOrderPageServiceTest.java | 2 + .../service/TenantOrderCountServiceTest.java | 2 + .../TenantOrderStatisticsServiceTest.java | 2 + .../order/service/TestDocumentNumbers.java | 21 ++++ .../phase3-container-customs.mjs | 2 +- .../phase5-root-tax-customs.mjs | 2 +- .../return-boundary-scenarios.mjs | 6 +- .../tailleg-owner-order-number-contract.mjs | 4 +- ...illeg-owner-order-number-contract.test.mjs | 26 ++-- .../service/DomesticContainerService.java | 7 +- .../mapper/DocumentSequenceMapper.java | 21 ++++ .../DocumentSequenceRepository.java | 24 ++++ .../WarehouseDocumentNumberGenerator.java | 34 +++++ warehouse/src/main/resources/db/warehouse.sql | 8 ++ .../service/DomesticContainerServiceTest.java | 10 +- .../DocumentSequenceRepositoryTest.java | 76 +++++++++++ .../service/TestDocumentNumbers.java | 21 ++++ .../WarehouseDocumentNumberGeneratorTest.java | 41 ++++++ 158 files changed, 1801 insertions(+), 972 deletions(-) create mode 100644 common-core/src/main/java/com/baiyue/common/core/number/DailyDocumentNumber.java create mode 100644 common-core/src/test/java/com/baiyue/common/core/number/DailyDocumentNumberTest.java create mode 100644 finance/src/main/java/com/baiyue/finance/mapper/DocumentSequenceMapper.java create mode 100644 finance/src/main/java/com/baiyue/finance/repository/DocumentSequenceRepository.java create mode 100644 finance/src/test/java/com/baiyue/finance/repository/DocumentSequenceRepositoryTest.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/DocumentSequenceMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/repository/DocumentSequenceRepository.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/DocumentSequenceRepositoryTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/TestDocumentNumbers.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGenerator.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/DocumentSequenceMapper.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderNoDailySequenceMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementFeeNumberRepository.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGenerator.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/repository/DocumentSequenceRepository.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/service/CompactBusinessNumberService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegDocumentNumberGenerator.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGeneratorTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGeneratorTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/repository/DocumentSequenceRepositoryTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/CompactBusinessNumberServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegDocumentNumberGeneratorTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/PhysicalCartonNumberTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/TestDocumentNumbers.java create mode 100644 order/src/main/java/com/baiyue/order/mapper/DocumentSequenceMapper.java create mode 100644 order/src/main/java/com/baiyue/order/repository/DocumentSequenceRepository.java create mode 100644 order/src/main/java/com/baiyue/order/service/OrderDocumentNumberGenerator.java create mode 100644 order/src/test/java/com/baiyue/order/repository/DocumentSequenceRepositoryTest.java create mode 100644 order/src/test/java/com/baiyue/order/service/OrderDocumentNumberGeneratorTest.java create mode 100644 order/src/test/java/com/baiyue/order/service/TestDocumentNumbers.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/mapper/DocumentSequenceMapper.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/repository/DocumentSequenceRepository.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseDocumentNumberGenerator.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/repository/DocumentSequenceRepositoryTest.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/service/TestDocumentNumbers.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseDocumentNumberGeneratorTest.java diff --git a/common-core/src/main/java/com/baiyue/common/core/number/DailyDocumentNumber.java b/common-core/src/main/java/com/baiyue/common/core/number/DailyDocumentNumber.java new file mode 100644 index 000000000..4d7936b7d --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/number/DailyDocumentNumber.java @@ -0,0 +1,44 @@ +package com.baiyue.common.core.number; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.function.ToLongFunction; + +/** 统一业务单号格式;序列仍由各服务自己的 Repository/Mapper 分配。 */ +public final class DailyDocumentNumber { + public static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + + /** 工具类不允许实例化。 */ + private DailyDocumentNumber() {} + + /** 先固定北京时间业务日,再使用前缀和日期分配跨客户、跨仓库日流水。 */ + public static String next(String prefix, Clock clock, ToLongFunction sequences) { + requirePrefix(prefix); + LocalDate date = LocalDate.now(clock.withZone(BUSINESS_ZONE)); + String dateText = date.format(DateTimeFormatter.BASIC_ISO_DATE); + long value = sequences.applyAsLong(prefix + ":" + dateText); + return format(prefix, date, value); + } + + /** 严格四位流水,不允许溢出后产生五位号码或退回随机编号。 */ + public static String format(String prefix, LocalDate date, long value) { + requirePrefix(prefix); + if (value < 1 || value > 9999) { + throw new BizException(ResultCode.CONFLICT, "当日" + prefix + "单据四位流水号已用尽或分配失败"); + } + return prefix + date.format(DateTimeFormatter.BASIC_ISO_DATE) + + String.format(Locale.ROOT, "%04d", value); + } + + /** 业务类型只能使用两至四位大写字母。 */ + private static void requirePrefix(String prefix) { + if (prefix == null || !prefix.matches("[A-Z]{2,4}")) { + throw new IllegalArgumentException("业务单据前缀必须为2至4位大写字母"); + } + } +} diff --git a/common-core/src/test/java/com/baiyue/common/core/number/DailyDocumentNumberTest.java b/common-core/src/test/java/com/baiyue/common/core/number/DailyDocumentNumberTest.java new file mode 100644 index 000000000..0baa3e1ef --- /dev/null +++ b/common-core/src/test/java/com/baiyue/common/core/number/DailyDocumentNumberTest.java @@ -0,0 +1,45 @@ +package com.baiyue.common.core.number; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import java.time.*; +import java.util.HashMap; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicReference; +import static org.assertj.core.api.Assertions.*; + +class DailyDocumentNumberTest { + /** 午夜前后取号使用北京时间日期,与主机和 Clock 的时区无关。 */ + @Test void rollsAtShanghaiMidnightAndKeepsPrefixesIndependent() { + var values = new HashMap(); + java.util.function.ToLongFunction allocate = key -> values.merge(key, 1L, Long::sum); + var before = Clock.fixed(Instant.parse("2026-10-19T15:59:59Z"), ZoneOffset.UTC); + var after = Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"), ZoneId.of("America/Los_Angeles")); + assertThat(DailyDocumentNumber.next("FL", before, allocate)).isEqualTo("FL202610190001"); + assertThat(DailyDocumentNumber.next("FL", after, allocate)).isEqualTo("FL202610200001"); + assertThat(DailyDocumentNumber.next("FL", after, allocate)).isEqualTo("FL202610200002"); + assertThat(DailyDocumentNumber.next("ZGRK", after, allocate)).isEqualTo("ZGRK202610200001"); + } + + /** 字符集和流水上限为硬边界,未知输入不能生成近似号码。 */ + @Test void rejectsInvalidPrefixAndSequence() { + for (String prefix : new String[]{"F", "ABCDE", "Fl", "F1", " FL", ""}) { + assertThatThrownBy(() -> DailyDocumentNumber.format(prefix, LocalDate.of(2026, 10, 20), 1)) + .isInstanceOf(IllegalArgumentException.class); + } + for (long value : new long[]{-1, 0, 10000}) { + assertThatThrownBy(() -> DailyDocumentNumber.format("FL", LocalDate.of(2026, 10, 20), value)) + .isInstanceOf(BizException.class); + } + assertThat(DailyDocumentNumber.format("FL", LocalDate.of(2026, 10, 20), 9999)).isEqualTo("FL202610209999"); + } + + /** 全局 Locale 不得让数字变为本地化字符。 */ + @Test void alwaysUsesAsciiDigits() { + Locale old = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("ar")); + assertThat(DailyDocumentNumber.format("FL", LocalDate.of(2026, 10, 20), 1)).isEqualTo("FL202610200001"); + } finally { Locale.setDefault(old); } + } +} diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 1a7498180..3bf607540 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -58,8 +58,8 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| -| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 227 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 228 | +| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 229 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 234 | - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 @@ -82,7 +82,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/228`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=229/234`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 @@ -178,3 +178,7 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test 货物废弃通用库存与散箱字段迁移:`migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql`。新增库存仓位关联及箱唛快照,须先迁移再部署 Lastleg;不回填历史废弃单。 2026-10-16 拉取合并配套结构清单及执行边界见 [数据库变更汇总](20261016-数据库变更汇总.md),已有尾程库使用 `migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql`;空库初始化海外表数同步为 209。该增量可重复执行,尚未执行到任何实际数据库。 + +## 统一业务单号 + +编号规则见 `../../../docs/业务单据编号契约.md`。Order、Warehouse、Lastleg 空库基线新增各自的 `ord_document_sequence`、`wh_document_sequence`、`ll_document_sequence`;Firstleg 与 Finance 复用本库编号表。Lastleg 空库基线移除无生产调用的客户日序列、逻辑秒时钟及旧尾程日序列表,保留排车日序列。采购费用新增可空 `idempotency_key` 及订单/用途/请求键唯一约束,用于退款重放,业务号统一使用 PF。此处只准备基线,不执行 SQL、回填或删除测试存量。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 1ac21825d..6ae9f635a 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -1981,11 +1981,11 @@ CREATE TABLE fl_customs_clearance_result ( ) ENGINE=InnoDB COMMENT='正式申报后的海关结果不可变事实'; CREATE TABLE fl_document_sequence ( - sequence_key varchar(32) NOT NULL COMMENT '单据序列键;日序列如CB:20260818,柜次长期序列固定为CABINET', + sequence_key varchar(32) NOT NULL COMMENT '单据序列键;日序列如LP:20260818,柜次长期序列固定为CABINET', current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号', PRIMARY KEY (sequence_key), CONSTRAINT chk_fl_document_sequence_value CHECK ((current_value > 0)) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程业务编号序列;普通单据按日,柜次全局连续,实物标签独立保留'; CREATE TABLE fl_fee_item ( id bigint unsigned NOT NULL COMMENT '费用项目ID', @@ -4870,7 +4870,7 @@ CREATE TABLE fin_document_sequence ( current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号', PRIMARY KEY (sequence_key), CONSTRAINT chk_fin_document_sequence_value CHECK ((current_value > 0)) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Finance业务单号日序列;生成YYMMDD加四位当日序号'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='财务业务单号日序列;生成前缀加yyyyMMdd加四位当日序号'; CREATE TABLE fin_fee_source ( id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', @@ -4997,7 +4997,7 @@ CREATE TABLE fin_receipt_bill ( CREATE TABLE fin_recharge_request ( id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键', - request_no varchar(32) NOT NULL COMMENT '充值申请业务编号RC-yyMMdd-####', + request_no varchar(32) NOT NULL COMMENT '充值申请业务编号RCyyyyMMdd####', amount decimal(20,2) NOT NULL COMMENT '申请充值金额', voucher_file_id bigint unsigned NOT NULL COMMENT '付款截图File ID', voucher_file_name varchar(255) NOT NULL COMMENT '付款截图文件名快照', @@ -5248,7 +5248,7 @@ CREATE TABLE fin_wallet_hold ( CREATE TABLE fin_wallet_ledger ( id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键', - business_no varchar(32) NOT NULL COMMENT '账户变动业务编号AC-yyMMdd-####', + business_no varchar(32) NOT NULL COMMENT '账户变动业务编号ACyyyyMMdd####', change_type varchar(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整', amount decimal(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负', actual_balance_after decimal(20,2) NOT NULL COMMENT '变动后实际余额', @@ -8397,4 +8397,20 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102213,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:direct-unload:confirm','登记直送目的仓实际卸货',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3)); -SELECT 'PASS' AS initialization_status, 'CN' AS region, 228 AS initialized_tables; +USE baiyue_order; +CREATE TABLE ord_document_sequence ( + sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键', + current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收', + PRIMARY KEY (sequence_key), + CONSTRAINT chk_ord_document_sequence_value CHECK (current_value > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水'; + +USE baiyue_warehouse; +CREATE TABLE wh_document_sequence ( + sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键', + current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收', + PRIMARY KEY (sequence_key), + CONSTRAINT chk_wh_document_sequence_value CHECK (current_value > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水'; + +SELECT 'PASS' AS initialization_status, 'CN' AS region, 230 AS initialized_tables; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 7d8771e8a..f8f46e710 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1865,11 +1865,11 @@ CREATE TABLE ll_order_manual_node_history ( KEY idx_ll_order_manual_node_history (customer_id,order_id,created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单人工节点覆盖不可变历史'; -CREATE TABLE ll_order_no_daily_sequence ( - business_date date NOT NULL COMMENT '业务日期(Asia/Shanghai)', - last_sequence int NOT NULL COMMENT '当日已分配尾程单流水号', - PRIMARY KEY (business_date) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程单号按日原子流水'; +CREATE TABLE ll_dispatch_daily_sequence ( + business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)', + last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999', + PRIMARY KEY(business_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水'; CREATE TABLE ll_order_operation_log ( id bigint NOT NULL COMMENT '操作记录主键', @@ -2077,6 +2077,7 @@ CREATE TABLE ll_overseas_procurement_fee ( settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件', source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID', parent_fee_id BIGINT NULL COMMENT '原费用或预估引用', + idempotency_key VARCHAR(128) NULL COMMENT '退款请求幂等键,独立于业务单号', request_hash CHAR(64) NULL COMMENT '确认请求摘要', id bigint NOT NULL COMMENT '主键ID', order_id bigint NOT NULL COMMENT '采购订单ID', @@ -2098,6 +2099,7 @@ CREATE TABLE ll_overseas_procurement_fee ( updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')), UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version), + UNIQUE KEY uk_ll_procurement_refund_request(order_id,purpose,idempotency_key), PRIMARY KEY (id), UNIQUE KEY uk_ll_procurement_fee_no (fee_no), KEY idx_ll_procurement_fee_order (customer_id,order_id,created_at), @@ -2368,14 +2370,6 @@ CREATE TABLE ll_pda_stocktake_task ( KEY idx_ll_pda_stocktake_list (overseas_warehouse_id,status,created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA库存盘点任务'; -CREATE TABLE ll_procurement_daily_sequence ( - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID', - business_date date NOT NULL COMMENT '北京时间业务日期', - sequence_value int NOT NULL COMMENT '客户当日已分配最大序号', - PRIMARY KEY (customer_id,business_date), - CONSTRAINT chk_ll_procurement_sequence CHECK ((sequence_value between 1 and 9999)) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购客户日序列'; - CREATE TABLE ll_receiving_container_operation ( id bigint NOT NULL COMMENT '柜号收货操作ID', session_id bigint NOT NULL COMMENT '柜号签收会话ID', @@ -4828,12 +4822,6 @@ CREATE TABLE ll_appointment_work_history ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令'; -- 海外业务短单号分配时钟。 -CREATE TABLE ll_compact_number_clock ( - number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写', - last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒', - PRIMARY KEY (number_key) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟'; - -- 仓储转运上门取货表归尾程服务,仅用于空库初始化。 USE baiyue_lastleg; CREATE TABLE ll_fleet_pickup_plan ( @@ -4967,4 +4955,12 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history ( CONSTRAINT ck_owned_business_history_version CHECK (record_version>0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 233 AS initialized_tables; +USE baiyue_lastleg; +CREATE TABLE ll_document_sequence ( + sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键', + current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收', + PRIMARY KEY (sequence_key), + CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水'; + +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 232 AS initialized_tables; diff --git a/finance/AGENTS.md b/finance/AGENTS.md index b39c580e0..5013adedc 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -50,7 +50,7 @@ - 平台写操作必须校验权限、目标客户、幂等键、请求哈希、乐观锁版本和审计;客户接口只能从登录态取得客户范围,不接受客户端指定客户 ID。 - 出账后的业务来源和账单不得原地覆盖或物理删除;调整、作废、收款分配和修订必须保留可追溯记录。金额使用定点小数,当前业务币种固定为 CNY,长整型 ID 跨前端按字符串传输。 - 对账单出账后将客户确认状态置为 `PENDING_CONFIRMATION`;已出账账单调整后必须清除原确认快照并重新待确认,作废或重开时为 `NOT_REQUIRED`。客户只能确认本企业且状态为 `ISSUED`、`PARTIALLY_PAID` 或 `PAID` 的待确认账单,必须校验登录成员、乐观锁版本和幂等键,并记录确认人姓名快照与确认时间。 -- 对账单、应收结算单和应付结算单编号统一按北京时间生成 `REC/AR/AP-yyMMdd-####`,各前缀每日独立递增且单日最多 9999;不得回退为数据库 ID 拼接、随机数或跨时区日期。 +- 财务单据编号遵循 `../../docs/业务单据编号契约.md`,由本服务 Repository/Mapper 按前缀与北京时间日期原子分配;不得回退为数据库 ID 拼接、随机数或跨时区日期。 - 客户授信必须带晚于当天的到期日且不限制最远日期;到期日当天仍有效。有效期内消费先用实际资金,不足部分累计为已用授信;次日失效时已用授信自动并入实际余额并允许为负,充值先归还已用授信或负余额。Order 在客户提交新订单时必须拒绝负数实际余额,已存在的资金冻结仍按原业务单结算或释放。 - 应收款统计只按正式应收结算单的生成月份汇总 CNY 应收、已收和未收,支持可选客户筛选和分页;导出必须通过 Finance Provider + File `FINANCE_RECEIVABLE_STATISTICS` 异步任务,不在 Controller 或 Admin 直接生成文件。 - 费用入池只接受满足上游发布与结算条件的不可变来源版本;来源 ID、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。 diff --git a/finance/src/main/java/com/baiyue/finance/mapper/DocumentSequenceMapper.java b/finance/src/main/java/com/baiyue/finance/mapper/DocumentSequenceMapper.java new file mode 100644 index 000000000..eb3096d29 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/mapper/DocumentSequenceMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.finance.mapper; + +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** 本服务业务编号序列,更新与读取必须处于同一事务连接。 */ +@Mapper +public interface DocumentSequenceMapper { + /** 唯一序列键原子递增,并在连接内保存本次分配值。 */ + @Insert("INSERT INTO fin_document_sequence(sequence_key,current_value) VALUES(#{key},LAST_INSERT_ID(1)) " + + "ON DUPLICATE KEY UPDATE current_value=LAST_INSERT_ID(current_value+1)") + int allocate(@Param("key") String key); + + /** 禁用一级和二级缓存,返回本事务刚刚分配的值。 */ + @Select("SELECT LAST_INSERT_ID()") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + Long allocatedValue(); +} diff --git a/finance/src/main/java/com/baiyue/finance/repository/DocumentSequenceRepository.java b/finance/src/main/java/com/baiyue/finance/repository/DocumentSequenceRepository.java new file mode 100644 index 000000000..de1424ddc --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/repository/DocumentSequenceRepository.java @@ -0,0 +1,24 @@ +package com.baiyue.finance.repository; + +import com.baiyue.finance.mapper.DocumentSequenceMapper; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** 各业务共用本库序列,独立短事务避免持有编号锁进入业务资源锁。 */ +@Repository +public class DocumentSequenceRepository { + private final DocumentSequenceMapper mapper; + + /** 只访问所属服务的编号表。 */ + public DocumentSequenceRepository(DocumentSequenceMapper mapper) { this.mapper = mapper; } + + /** 已分配号不因外层业务回滚而复用,允许失败请求产生断号。 */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public long next(String key) { + mapper.allocate(key); + Long value = mapper.allocatedValue(); + if (value == null || value < 1) throw new IllegalStateException("业务编号分配失败"); + return value; + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java b/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java index d9557200c..81baa3ffd 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java @@ -1,55 +1,34 @@ package com.baiyue.finance.service; +import com.baiyue.common.core.number.DailyDocumentNumber; +import com.baiyue.finance.repository.DocumentSequenceRepository; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; - import java.time.Clock; -import java.time.LocalDate; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.Locale; import java.util.Set; +/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */ @Component public class FinanceDocumentNumberGenerator { - private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); - private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT); - private static final Set SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC", "VC", "VP"); - private static final long MAX_DAILY_SEQUENCE = 9_999L; - - private final JdbcTemplate jdbc; + private static final Set PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC", "VC", "VP", "PAY"); + private final DocumentSequenceRepository sequences; private final Clock clock; + /** 正式运行使用统一业务时区。 */ @Autowired - public FinanceDocumentNumberGenerator(JdbcTemplate jdbc) { - this(jdbc, Clock.system(BUSINESS_ZONE)); + public FinanceDocumentNumberGenerator(DocumentSequenceRepository sequences) { + this(sequences, Clock.system(DailyDocumentNumber.BUSINESS_ZONE)); } - FinanceDocumentNumberGenerator(JdbcTemplate jdbc, Clock clock) { - this.jdbc = jdbc; + /** 固定时钟用于跨日及前缀独立性验证。 */ + FinanceDocumentNumberGenerator(DocumentSequenceRepository sequences, Clock clock) { + this.sequences = sequences; this.clock = clock; } - @Transactional(propagation = Propagation.MANDATORY) + /** 只为本服务已登记的业务类型分配号码。 */ public String next(String prefix) { - if (!SUPPORTED_PREFIXES.contains(prefix)) { - throw new IllegalArgumentException("unsupported finance document prefix: " + prefix); - } - LocalDate sequenceDate = LocalDate.now(clock); - String sequenceKey = prefix + ":" + DateTimeFormatter.BASIC_ISO_DATE.format(sequenceDate); - jdbc.update( - "INSERT INTO fin_document_sequence(sequence_key,current_value) " + - "VALUES(?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE " + - "current_value=LAST_INSERT_ID(current_value+1)", - sequenceKey); - Long sequence = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class); - if (sequence == null || sequence <= 0 || sequence > MAX_DAILY_SEQUENCE) { - throw new FinanceEventException("FIN_409", "当日" + prefix + "单据编号已用尽"); - } - return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" + - String.format(Locale.ROOT, "%04d", sequence); + if (!PREFIXES.contains(prefix)) throw new IllegalArgumentException("未登记的业务编号前缀:" + prefix); + return DailyDocumentNumber.next(prefix, clock, sequences::next); } } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java index 2038ebd45..732b1b98e 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java @@ -66,11 +66,7 @@ public class FinanceSettlementService { private final FinanceSupplierValidator supplierValidator; private final FinanceDocumentNumberGenerator documentNumbers; - public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator, - FinanceSupplierValidator supplierValidator) { - this(jdbc, objectMapper, voucherValidator, supplierValidator, new FinanceDocumentNumberGenerator(jdbc)); - } - + /** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */ @Autowired public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator, FinanceSupplierValidator supplierValidator, @@ -82,6 +78,7 @@ public class FinanceSettlementService { this.documentNumbers = documentNumbers; } + /** 创建应收结算单,幂等请求恢复原单,首次建单分配 AR 日流水。 */ @Transactional public SettlementDetail createReceivableSettlement(long operatorId, String key, CreateReceivableSettlementRequest request) { @@ -521,9 +518,9 @@ public class FinanceSettlementService { "outstandingAmount", decimal(settlement, "outstanding_amount"), "status", status); } + /** 结算收付款使用独立 PAY 日流水,重试由原业务幂等记录恢复。 */ private String transactionNo() { - return "PAY-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" + - UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(Locale.ROOT); + return documentNumbers.next("PAY"); } private String hash(Object value) { diff --git a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java index 7311d5375..0085f28e3 100644 --- a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java @@ -46,10 +46,7 @@ public class ReconciliationStatementService { private final ObjectMapper objectMapper; private final FinanceDocumentNumberGenerator documentNumbers; - public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper) { - this(jdbc, objectMapper, new FinanceDocumentNumberGenerator(jdbc)); - } - + /** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */ @Autowired public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceDocumentNumberGenerator documentNumbers) { @@ -58,6 +55,7 @@ public class ReconciliationStatementService { this.documentNumbers = documentNumbers; } + /** 创建对账单并分配 REC 日流水,重复请求返回已创建记录。 */ @Transactional public ReconciliationDetail create(long operatorId, String key, CreateReconciliationRequest request) { validateCreate(request); diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql index 9c443bc26..de37e73e1 100644 --- a/finance/src/main/resources/db/finance.sql +++ b/finance/src/main/resources/db/finance.sql @@ -398,7 +398,7 @@ CREATE TABLE IF NOT EXISTS fin_document_sequence ( current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号', PRIMARY KEY (sequence_key), CONSTRAINT chk_fin_document_sequence_value CHECK (current_value > 0) -) ENGINE=InnoDB COMMENT='Finance业务单号日序列;生成YYMMDD加四位当日序号'; +) ENGINE=InnoDB COMMENT='财务业务单号日序列;生成前缀加yyyyMMdd加四位当日序号'; CREATE TABLE IF NOT EXISTS fin_audit_log ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', @@ -524,7 +524,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account ( CREATE TABLE IF NOT EXISTS fin_recharge_request ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键', - request_no VARCHAR(32) NOT NULL COMMENT '充值申请业务编号RC-yyMMdd-####', + request_no VARCHAR(32) NOT NULL COMMENT '充值申请业务编号RCyyyyMMdd####', amount DECIMAL(20,2) NOT NULL COMMENT '申请充值金额', voucher_file_id BIGINT UNSIGNED NOT NULL COMMENT '付款截图File ID', voucher_file_name VARCHAR(255) NOT NULL COMMENT '付款截图文件名快照', @@ -549,7 +549,7 @@ CREATE TABLE IF NOT EXISTS fin_recharge_request ( CREATE TABLE IF NOT EXISTS fin_wallet_ledger ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键', - business_no VARCHAR(32) NOT NULL COMMENT '账户变动业务编号AC-yyMMdd-####', + business_no VARCHAR(32) NOT NULL COMMENT '账户变动业务编号ACyyyyMMdd####', change_type VARCHAR(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整', amount DECIMAL(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负', actual_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后实际余额', diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java index 19f41a707..7898c139e 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java @@ -620,8 +620,9 @@ class FinanceSettlementBehaviorTest { org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT JSON_UNQUOTE"), anyLong(), anyLong())) .thenReturn(List.of(new java.util.HashMap<>(Map.of("supplier_category", "BOOKING")))); FinanceDocumentNumberGenerator documentNumbers = org.mockito.Mockito.mock(FinanceDocumentNumberGenerator.class); - org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR-260818-0001"); - org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP-260818-0001"); + org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR202608180001"); + org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP202608180001"); + org.mockito.Mockito.lenient().when(documentNumbers.next("PAY")).thenReturn("PAY202608180001"); return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(), new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers); } diff --git a/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java index 1064e9b0f..f741990ef 100644 --- a/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java @@ -70,7 +70,7 @@ class ReconciliationStatementBehaviorTest { @Test void selectedModeRejectsDuplicateFeeSourcesBeforeWriting() { JdbcTemplate jdbc = mock(JdbcTemplate.class); - ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService target = serviceWithNumber(jdbc); CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L, 88L), LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null); @@ -105,7 +105,7 @@ class ReconciliationStatementBehaviorTest { var result = target.create(71L, "create-key", request); assertThat(result.statementId()).isEqualTo(501L); - assertThat(result.statementNo()).isEqualTo("REC-260818-0001"); + assertThat(result.statementNo()).isEqualTo("REC202608180001"); assertThat(result.currency()).isEqualTo("CNY"); assertThat(result.createdByName()).isEqualTo("platform_admin"); assertThat(result.updatedByName()).isEqualTo("platform_admin"); @@ -132,7 +132,7 @@ class ReconciliationStatementBehaviorTest { }); when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT id,customer_id"), eq(88L))) .thenReturn(List.of(Map.of("id", 88L, "customer_id", 10L))); - ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService target = serviceWithNumber(jdbc); assertThatThrownBy(() -> target.create(71L, "create-key", new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L), LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null))) @@ -144,7 +144,7 @@ class ReconciliationStatementBehaviorTest { @Test void submitUsesExpectedVersionAndPersistsRevisionBeforeStateChange() throws Exception { JdbcTemplate jdbc = actionJdbc("DRAFT", "PENDING_REVIEW", "submit-key"); - ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService target = serviceWithNumber(jdbc); var result = target.action(71L, 501L, "submit-key", new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of())); @@ -164,7 +164,7 @@ class ReconciliationStatementBehaviorTest { @Test void approveFreezesInternalBillAndReturnsContractStatus() throws Exception { JdbcTemplate jdbc = actionJdbc("PENDING_REVIEW", "APPROVED", "approve-key"); - ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService target = serviceWithNumber(jdbc); var result = target.action(71L, 501L, "approve-key", new ReconciliationActionRequest("APPROVE", "审核通过", 1, List.of())); @@ -177,7 +177,7 @@ class ReconciliationStatementBehaviorTest { @Test void issueFreezesSourcesOnlyAfterApproval() throws Exception { JdbcTemplate jdbc = actionJdbc("APPROVED", "ISSUED", "issue-key"); - ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService target = serviceWithNumber(jdbc); var result = target.action(71L, 501L, "issue-key", new ReconciliationActionRequest("ISSUE", null, 2, List.of())); @@ -202,7 +202,7 @@ class ReconciliationStatementBehaviorTest { }); when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("confirm-key"))).thenReturn(List.of()); - ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService target = serviceWithNumber(jdbc); var result = target.confirmForCustomer(9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3)); @@ -236,7 +236,7 @@ class ReconciliationStatementBehaviorTest { JdbcTemplate crossCustomer = mock(JdbcTemplate.class); when(crossCustomer.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L))) .thenReturn(List.of()); - assertThatThrownBy(() -> new ReconciliationStatementService(crossCustomer, new ObjectMapper()).confirmForCustomer( + assertThatThrownBy(() -> serviceWithNumber(crossCustomer).confirmForCustomer( 9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3))) .isInstanceOf(FinanceEventException.class) .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_404"); @@ -251,7 +251,7 @@ class ReconciliationStatementBehaviorTest { }); when(draft.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("draft-key"))).thenReturn(List.of()); - assertThatThrownBy(() -> new ReconciliationStatementService(draft, new ObjectMapper()).confirmForCustomer( + assertThatThrownBy(() -> serviceWithNumber(draft).confirmForCustomer( 9L, 71L, 501L, "draft-key", new CustomerConfirmationRequest(0))) .isInstanceOf(FinanceEventException.class) .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409"); @@ -271,7 +271,7 @@ class ReconciliationStatementBehaviorTest { }); when(stale.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("stale-key"))).thenReturn(List.of()); - assertThatThrownBy(() -> new ReconciliationStatementService(stale, new ObjectMapper()).confirmForCustomer( + assertThatThrownBy(() -> serviceWithNumber(stale).confirmForCustomer( 9L, 71L, 501L, "stale-key", new CustomerConfirmationRequest(2))) .isInstanceOf(FinanceEventException.class) .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409"); @@ -288,7 +288,7 @@ class ReconciliationStatementBehaviorTest { eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("used-key"))).thenReturn(List.of(Map.of( "request_hash", "different-request", "status", "SUCCEEDED", "result_resource_id", 501L, "updated_at", LocalDateTime.now()))); - ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService target = serviceWithNumber(jdbc); assertThatThrownBy(() -> target.action(71L, 501L, "used-key", new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of()))) @@ -300,7 +300,7 @@ class ReconciliationStatementBehaviorTest { @Test void invalidActionAndVersionConflictAreRejectedBeforeMutation() throws Exception { JdbcTemplate jdbc = actionJdbc("DRAFT", "DRAFT", "invalid-key"); - ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService target = serviceWithNumber(jdbc); assertThatThrownBy(() -> target.action(71L, 501L, "invalid-key", new ReconciliationActionRequest("NOT_AN_ACTION", null, 0, List.of()))) @@ -318,7 +318,7 @@ class ReconciliationStatementBehaviorTest { JdbcTemplate jdbc = actionJdbc("ISSUED", "ISSUED", "adjust-key"); when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COALESCE(SUM(amount)"), eq(BigDecimal.class), eq(9L), eq(501L))) .thenReturn(new BigDecimal("280.00")); - ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService target = serviceWithNumber(jdbc); target.action(71L, 501L, "adjust-key", new ReconciliationActionRequest("ADJUST", "运费更正", 3, List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("280.00"), "运费更正")))); @@ -342,7 +342,7 @@ class ReconciliationStatementBehaviorTest { return List.of(mapper.mapRow(rs, 0)); }); - assertThatThrownBy(() -> new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()).action(71L, 501L, + assertThatThrownBy(() -> serviceWithNumber(jdbc).action(71L, 501L, "low-adjust-key", new ReconciliationActionRequest("ADJUST", "金额错误", 3, List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("80.00"), "金额错误"))))) .isInstanceOf(FinanceEventException.class) @@ -355,7 +355,7 @@ class ReconciliationStatementBehaviorTest { JdbcTemplate jdbc = actionJdbc("ISSUED", "VOIDED", "void-key"); when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_receipt_bill"), eq(Long.class), eq(9L), eq(501L))).thenReturn(0L); when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Long.class), eq(9L), eq(501L))).thenReturn(0L); - ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService target = serviceWithNumber(jdbc); var result = target.action(71L, 501L, "void-key", new ReconciliationActionRequest("VOID", "客户重开账期", 3, List.of())); @@ -372,7 +372,7 @@ class ReconciliationStatementBehaviorTest { .thenReturn("receipt".equals(query) ? 1L : 0L); when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Long.class), eq(9L), eq(501L))) .thenReturn("settlement".equals(query) ? 1L : 0L); - assertThatThrownBy(() -> new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()).action(71L, 501L, + assertThatThrownBy(() -> serviceWithNumber(jdbc).action(71L, 501L, "void-" + query, new ReconciliationActionRequest("VOID", "作废原因", 3, List.of()))) .isInstanceOf(FinanceEventException.class) .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409"); @@ -384,7 +384,7 @@ class ReconciliationStatementBehaviorTest { void reopenVoidedStatementReacquiresEveryReleasedSource() throws Exception { JdbcTemplate jdbc = actionJdbc("VOIDED", "DRAFT", "reopen-key"); when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), eq(9L), eq(501L))).thenReturn(1); - ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService target = serviceWithNumber(jdbc); var result = target.action(71L, 501L, "reopen-key", new ReconciliationActionRequest("REOPEN", "重新核对", 4, List.of())); @@ -399,7 +399,7 @@ class ReconciliationStatementBehaviorTest { JdbcTemplate firstJdbc = mock(JdbcTemplate.class, invocation -> "update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation)); when(firstJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("first-key"))).thenReturn(List.of()); - ReconciliationStatementService firstService = new ReconciliationStatementService(firstJdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService firstService = serviceWithNumber(firstJdbc); Long first = ReflectionTestUtils.invokeMethod(firstService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "first-key", "POST", "/statements/501/actions", body); ArgumentCaptor hash = ArgumentCaptor.forClass(String.class); @@ -412,7 +412,7 @@ class ReconciliationStatementBehaviorTest { .thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "SUCCEEDED", "result_resource_id", 501L, "updated_at", LocalDateTime.now()))); assertThat((Long) ReflectionTestUtils.invokeMethod( - new ReconciliationStatementService(replayJdbc, new ObjectMapper().findAndRegisterModules()), + serviceWithNumber(replayJdbc), "reserve", 9L, 71L, "RECONCILIATION_ACTION", "replay-key", "POST", "/statements/501/actions", body)) .isEqualTo(501L); @@ -420,7 +420,7 @@ class ReconciliationStatementBehaviorTest { when(processingJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("processing-key"))) .thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now()))); assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod( - new ReconciliationStatementService(processingJdbc, new ObjectMapper().findAndRegisterModules()), + serviceWithNumber(processingJdbc), "reserve", 9L, 71L, "RECONCILIATION_ACTION", "processing-key", "POST", "/statements/501/actions", body)) .isInstanceOf(FinanceEventException.class) .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409"); @@ -431,7 +431,7 @@ class ReconciliationStatementBehaviorTest { .thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "FAILED", "updated_at", LocalDateTime.now()))); when(recoveryJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("stale-key"))) .thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now().minusMinutes(10)))); - ReconciliationStatementService recoveryService = new ReconciliationStatementService(recoveryJdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService recoveryService = serviceWithNumber(recoveryJdbc); assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "failed-key", "POST", "/statements/501/actions", body)).isNull(); assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "stale-key", "POST", "/statements/501/actions", body)).isNull(); @@ -556,7 +556,7 @@ class ReconciliationStatementBehaviorTest { ResultSet rs = mock(ResultSet.class); when(rs.getLong("id")).thenReturn(501L); when(rs.getLong("tenant_id")).thenReturn(9L); - when(rs.getString("bill_no")).thenReturn("REC-260818-0001"); + when(rs.getString("bill_no")).thenReturn("REC202608180001"); when(rs.getString("customer_name_snapshot")).thenReturn("客户9"); when(rs.getString("status")).thenReturn(status); when(rs.getBigDecimal("total_amount")).thenReturn(new BigDecimal("300.00")); @@ -591,7 +591,7 @@ class ReconciliationStatementBehaviorTest { private static ReconciliationStatementService serviceWithNumber(JdbcTemplate jdbc) { FinanceDocumentNumberGenerator documentNumbers = mock(FinanceDocumentNumberGenerator.class); - org.mockito.Mockito.lenient().when(documentNumbers.next("REC")).thenReturn("REC-260818-0001"); + org.mockito.Mockito.lenient().when(documentNumbers.next("REC")).thenReturn("REC202608180001"); return new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules(), documentNumbers); } } diff --git a/finance/src/test/java/com/baiyue/finance/repository/DocumentSequenceRepositoryTest.java b/finance/src/test/java/com/baiyue/finance/repository/DocumentSequenceRepositoryTest.java new file mode 100644 index 000000000..1c6f5d7d1 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/repository/DocumentSequenceRepositoryTest.java @@ -0,0 +1,76 @@ +package com.baiyue.finance.repository; + +import com.baiyue.finance.mapper.DocumentSequenceMapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.*; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; + +/** 实际 Mapper 和隔离 H2 MySQL 模式验证原子序列,不访问部署环境。 */ +class DocumentSequenceRepositoryTest { + private static class Fixture { + final JdbcTemplate jdbc; + final DataSourceTransactionManager transactions; + final DocumentSequenceMapper mapper; + final DocumentSequenceRepository repository; + /** 仅测试造表允许 JDBC;被测路径全部使用 Mapper。 */ + Fixture() throws Exception { + var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa",""); + jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data); + jdbc.execute("CREATE TABLE fin_document_sequence(sequence_key VARCHAR(32) PRIMARY KEY,current_value BIGINT NOT NULL)"); + var configuration=new MybatisConfiguration();configuration.addMapper(DocumentSequenceMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet(); + mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DocumentSequenceMapper.class); + var proxy=new ProxyFactory(new DocumentSequenceRepository(mapper));proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource())); + repository=(DocumentSequenceRepository)proxy.getProxy(); + } + } + /** 日期和前缀各自独立,重复分配持续递增。 */ + @Test void dateAndPrefixHaveIndependentSequences() throws Exception { + var f=new Fixture(); + assertThat(f.repository.next("AA:20261020")).isEqualTo(1); + assertThat(f.repository.next("AA:20261020")).isEqualTo(2); + assertThat(f.repository.next("BB:20261020")).isEqualTo(1); + assertThat(f.repository.next("AA:20261021")).isEqualTo(1); + } + /** 两个线程使用真实事务连接并发争用,不能产生重号。 */ + @Test void concurrentConnectionsAllocateUniqueNumbers() throws Exception { + var f=new Fixture();var pool=Executors.newFixedThreadPool(2); + try { + var work=new ArrayList>(); + for(int i=0;i<64;i++)work.add(()->f.repository.next("AA:20261020")); + var values=new TreeSet(); + for(var future:pool.invokeAll(work))values.add(future.get()); + assertThat(values).hasSize(64);assertThat(values.first()).isEqualTo(1L);assertThat(values.last()).isEqualTo(64L); + } finally {pool.shutdownNow();} + } + /** 外层建单失败后不能把已经分配的单号再交给其他单据。 */ + @Test void businessRollbackDoesNotReuseAllocatedNumber() throws Exception { + var f=new Fixture(); + assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{ + assertThat(f.repository.next("AA:20261020")).isEqualTo(1); + throw new IllegalStateException("业务回滚"); + })).isInstanceOf(IllegalStateException.class); + assertThat(f.repository.next("AA:20261020")).isEqualTo(2); + } + /** 同一会话中连接值变化后,读取必须绕过 MyBatis 缓存。 */ + @Test void connectionResultIsNeverCached() throws Exception { + var f=new Fixture(); + new TransactionTemplate(f.transactions).executeWithoutResult(status->{ + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(7L); + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(37L); + }); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java index ba0177c12..5bbc279eb 100644 --- a/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java @@ -1,70 +1,41 @@ package com.baiyue.finance.service; +import com.baiyue.finance.repository.DocumentSequenceRepository; +import com.baiyue.common.core.exception.BizException; import org.junit.jupiter.api.Test; import org.springframework.context.annotation.AnnotationConfigApplicationContext; -import org.springframework.jdbc.core.JdbcTemplate; - -import java.time.Clock; -import java.time.Instant; -import java.time.ZoneId; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import java.time.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; class FinanceDocumentNumberGeneratorTest { - private static final Clock CLOCK = Clock.fixed( - Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai")); - - @Test - void startsAsASpringBeanWhenTheTestClockConstructorAlsoExists() { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { - context.registerBean(JdbcTemplate.class, () -> jdbc); - context.register(FinanceDocumentNumberGenerator.class); - context.refresh(); + private static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC); + /** 多构造器情况下正式依赖可以被 Spring 正确选择。 */ + @Test void springCreatesGeneratorWithRepository() { + try(var context=new AnnotationConfigApplicationContext()) { + context.registerBean(DocumentSequenceRepository.class,()->mock(DocumentSequenceRepository.class)); + context.register(FinanceDocumentNumberGenerator.class);context.refresh(); assertThat(context.getBean(FinanceDocumentNumberGenerator.class)).isNotNull(); } } - - @Test - void formatsShortDailySequenceForAllThreeDocuments() { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(1L, 2L, 38L); - FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK); - - assertThat(generator.next("REC")).isEqualTo("REC-260818-0001"); - assertThat(generator.next("AR")).isEqualTo("AR-260818-0002"); - assertThat(generator.next("AP")).isEqualTo("AP-260818-0038"); - - verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), - eq("REC:20260818")); + /** 主机使用 UTC 时仍按北京时间跨午夜,且四位流水不截断。 */ + @Test void usesShanghaiDateAndIndependentSequenceKey() { + var sequences=mock(DocumentSequenceRepository.class); + when(sequences.next("REC:20261020")).thenReturn(1L,2L,9999L); + var numbers=new FinanceDocumentNumberGenerator(sequences,CLOCK); + assertThat(numbers.next("REC")).isEqualTo("REC202610200001"); + assertThat(numbers.next("REC")).isEqualTo("REC202610200002"); + assertThat(numbers.next("REC")).isEqualTo("REC202610209999"); + verify(sequences,times(3)).next("REC:20261020"); } - - @Test - void rejectsUnsupportedTypeAndOverflow() { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK); - assertThatThrownBy(() -> generator.next("PAY")).isInstanceOf(IllegalArgumentException.class); - - when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L); - assertThatThrownBy(() -> generator.next("REC")) - .isInstanceOf(FinanceEventException.class) - .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409"); - } - - @Test - void vehicleCostsAndPaymentsUseIndependentSequenceKeys() { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(1L); - FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK); - assertThat(generator.next("VC")).isEqualTo("VC-260818-0001"); - assertThat(generator.next("VP")).isEqualTo("VP-260818-0001"); - verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), eq("VC:20260818")); - verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), eq("VP:20260818")); + /** 未登记类型不能消耗流水,分配异常或溢出时拒绝出号。 */ + @Test void rejectsUnknownTypeAndOverflow() { + var sequences=mock(DocumentSequenceRepository.class); + var numbers=new FinanceDocumentNumberGenerator(sequences,CLOCK); + assertThatThrownBy(()->numbers.next("ZZZZ")).isInstanceOf(IllegalArgumentException.class); + verifyNoInteractions(sequences); + when(sequences.next("REC:20261020")).thenReturn(10000L); + assertThatThrownBy(()->numbers.next("REC")).isInstanceOf(BizException.class); } } diff --git a/firstleg/docs/business-index.md b/firstleg/docs/business-index.md index f161fd472..9d6532238 100644 --- a/firstleg/docs/business-index.md +++ b/firstleg/docs/business-index.md @@ -57,7 +57,7 @@ | 直送目的仓实际卸货 | `terminaldrayage/controller/PlatformDirectDeliveryController.java`、`service/DirectDeliveryService.java`、`UnloadingEvidenceGuard.java`;按实装入仓来源逐票记录签收人与凭据,必须经 Lastleg 确认 DIRECT 路线且已提柜;不创建海外库存,全柜卸毕才可人工还空;契约 `src/main/resources/openapi/direct-delivery-v1.yaml`,基线 `src/main/resources/db/phase3-direct-delivery-schema.sql` | | 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/OVERSEAS` 海外车队供应商,依次选择车队、预约提柜、确认实际提柜,海外仓卸柜或全部直送目的仓实际卸货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 | | 船节点交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 直投 Lastleg `/internal/lastleg/events`;消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java`,ACK/重试按 `../../docs/统一认证与服务通信契约.md` | -| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | +| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;前缀与生成规则见 `../../../docs/业务单据编号契约.md`,柜次及实物标签规则保持不变 | | 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 | | 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;正式申报批次与海关结果在同一模块独立记录,不改报关四态、PDA 或船期事实;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` | | 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 | @@ -81,7 +81,7 @@ - Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。 - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 -- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 +- 订舱号遵循 `../../../docs/业务单据编号契约.md`;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 - 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 - 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。 - 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java index 7acaa47e8..4e6a3b86e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java @@ -56,7 +56,6 @@ public final class ContainerCustomsDtos { String remark, List results) {} public record DeclarationBatchRequest(@Min(0) int expectedVersion, - @Size(max = 64) String batchNo, @Size(max = 64) String customsEntryNo, @Size(max = 500) String remark) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java index 2425bf67c..257f30a81 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java @@ -29,6 +29,8 @@ import java.util.stream.Collectors; @Service public class ContainerCustomsService { + @jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers; + public static final String WAITING = "WAITING_CUSTOMER_DOCUMENTS"; public static final String READY = "READY_TO_START"; public static final String DECLARING = "DECLARING"; @@ -179,6 +181,7 @@ public class ContainerCustomsService { return platformView(planId); } + /** 首次发送报关资料时生成 BR 批次号,重放沿用已有批次。 */ @Transactional public ContainerView sendInitialToBroker(long planId, long operatorId, String operatorName, String key, ActionRequest request) { @@ -197,7 +200,7 @@ public class ContainerCustomsService { throw conflict("报关资料尚未全部冻结,不能发送给报关行"); } long submissionId = IdWorker.getId(); - String batchNo = brokerBatchNo(planId); + String batchNo = documentNumbers.next("BR"); String snapshot = brokerSubmissionSnapshot(container.id()); if (mapper.insertBrokerSubmission(submissionId, container.id(), planId, batchNo, container.version(), snapshot, operatorId, operatorName) != 1) throw conflict("首次发送报关行批次创建失败"); @@ -356,11 +359,12 @@ public class ContainerCustomsService { return mapper.declarationBatches(container.id()).stream().map(this::declarationBatchView).toList(); } + /** 首次创建分配独立申报批次号,重复命令返回已创建批次。 */ @Transactional public DeclarationBatchView createDeclarationBatch(long planId, long operatorId, String operatorName, String key, DeclarationBatchRequest request) { String action = "CREATE_CUSTOMS_DECLARATION_BATCH_" + planId; - String requestHash = hash(action, request.expectedVersion(), request.batchNo(), request.remark()); + String requestHash = hash(action, request.expectedVersion(), request.remark()); CommandRow replay = claim(operatorId, action, key, requestHash); if (replay != null) return declarationBatchView(mapper.declarationBatch(replay.resultId())); ContainerRow container = mapper.lockContainer(planId); @@ -371,8 +375,8 @@ public class ContainerCustomsService { } if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent(); long batchId = IdWorker.getId(); - String batchNo = trim(request.batchNo()); - if (batchNo == null) batchNo = "DECL-" + batchId; + // 申报批次号由服务端分配,外部报关单号独立保存在 customsEntryNo。 + String batchNo = documentNumbers.next("DECL"); String snapshot = declarationSnapshot(container.id()); if (mapper.insertDeclarationBatch(batchId, container.id(), planId, batchNo, container.version(), snapshot, trim(request.remark())) != 1) throw conflict("正式申报批次创建失败"); @@ -381,20 +385,20 @@ public class ContainerCustomsService { return declarationBatchView(mapper.declarationBatch(batchId)); } + /** 提交只回填外部报关单号,内部批次号保持创建时的值。 */ @Transactional public DeclarationBatchView submitDeclarationBatch(long batchId, long operatorId, String operatorName, String key, DeclarationBatchRequest request) { String action = "SUBMIT_CUSTOMS_DECLARATION_BATCH_" + batchId; String entryNo = trim(request.customsEntryNo()); if (entryNo == null) throw bad("正式申报必须回填报关单号"); - String requestHash = hash(action, request.expectedVersion(), request.batchNo(), entryNo, request.remark()); + String requestHash = hash(action, request.expectedVersion(), entryNo, request.remark()); CommandRow replay = claim(operatorId, action, key, requestHash); if (replay != null) return declarationBatchView(mapper.declarationBatch(batchId)); DeclarationBatchRow batch = mapper.lockDeclarationBatch(batchId); if (batch == null || !"READY".equals(batch.status())) throw conflict("正式申报批次不在可提交状态"); if (!Objects.equals(batch.version(), request.expectedVersion())) throw concurrent(); - String batchNo = trim(request.batchNo()); - if (batchNo == null) batchNo = batch.batchNo(); + String batchNo = batch.batchNo(); if (mapper.submitDeclarationBatch(batchId, batch.version(), batchNo, entryNo, operatorId, operatorName, trim(request.remark())) != 1) throw concurrent(); ContainerRow container = mapper.lockContainerById(batch.customsContainerId()); @@ -501,14 +505,6 @@ public class ContainerCustomsService { catch (JsonProcessingException ex) { throw new IllegalStateException(ex); } } - private String brokerBatchNo(long planId) { - PlanRow plan = requiredPlan(planId); - String planNo = trim(plan.planNo()); - if (planNo != null && planNo.matches("[A-Za-z0-9_-]{1,52}")) return "BR-" + planNo + "-01"; - String suffix = UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase(Locale.ROOT); - return "BR-" + LocalDate.now() + "-" + suffix; - } - private String brokerSubmissionSnapshot(long containerId) { List> snapshot = mapper.documents(containerId, null).stream().map(document -> { List scopes = mapper.frozenScopes(document.id()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java index b5b452245..9db82153a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java @@ -38,6 +38,8 @@ import java.util.Set; /** 同柜不可拆组的唯一状态机与平台编排服务。 */ @Service public class ColoadGroupService implements ColoadGroupAssignmentPort { + @jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers; + private static final Set CURRENT_GROUP_STATUSES = Set.of("ACTIVE", "LOCKED", "CONSUMED"); private static final Set PRE_LOADING_GROUP_STATUSES = Set.of( "PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", @@ -64,7 +66,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort { validateSameRoute(candidates); Long lockedPlanId = validateAllocationState(candidates); long groupId = IdWorker.getId(); - String groupNo = "CLG-" + groupId; + String groupNo = documentNumbers.next("CLG"); InboundCandidateRow first = candidates.get(0); mapper.insertGroup(groupId, groupNo, first.getWarehouseId(), first.getChannelCode().trim(), operatorId, trim(operatorName)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DocumentSequenceMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DocumentSequenceMapper.java new file mode 100644 index 000000000..8e3af23f7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DocumentSequenceMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.mapper; + +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** 本服务业务编号序列,更新与读取必须处于同一事务连接。 */ +@Mapper +public interface DocumentSequenceMapper { + /** 唯一序列键原子递增,并在连接内保存本次分配值。 */ + @Insert("INSERT INTO fl_document_sequence(sequence_key,current_value) VALUES(#{key},LAST_INSERT_ID(1)) " + + "ON DUPLICATE KEY UPDATE current_value=LAST_INSERT_ID(current_value+1)") + int allocate(@Param("key") String key); + + /** 禁用一级和二级缓存,返回本事务刚刚分配的值。 */ + @Select("SELECT LAST_INSERT_ID()") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + Long allocatedValue(); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleService.java index b92d51b5c..353b0bf6f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleService.java @@ -32,6 +32,8 @@ import java.util.Set; @Service public class PortScheduleService { + @jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers; + private static final Set SOURCE_TYPES = Set.of("MANUAL", "CARRIER", "TERMINAL", "API"); @Resource private PortScheduleRepository repository; @@ -72,7 +74,7 @@ public class PortScheduleService { validateLinks(request.data()); PortScheduleEntity value = new PortScheduleEntity(); value.setId(IdWorker.getId()); - value.setScheduleNo("PS" + value.getId()); + value.setScheduleNo(documentNumbers.next("PS")); copy(value, request.data()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/DocumentSequenceRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/DocumentSequenceRepository.java new file mode 100644 index 000000000..cf85ab15d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/DocumentSequenceRepository.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.DocumentSequenceMapper; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** 各业务共用本库序列,独立短事务避免持有编号锁进入业务资源锁。 */ +@Repository +public class DocumentSequenceRepository { + private final DocumentSequenceMapper mapper; + + /** 只访问所属服务的编号表。 */ + public DocumentSequenceRepository(DocumentSequenceMapper mapper) { this.mapper = mapper; } + + /** 已分配号不因外层业务回滚而复用,允许失败请求产生断号。 */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public long next(String key) { + mapper.allocate(key); + Long value = mapper.allocatedValue(); + if (value == null || value < 1) throw new IllegalStateException("业务编号分配失败"); + return value; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java index 425510c4f..e4a78e61c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java @@ -40,6 +40,8 @@ import static com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.*; @Service public class SailingCalendarService { + @jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers; + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); private static final String OPEN = "OPEN"; @Resource private SailingCalendarRepository repository; @@ -77,7 +79,7 @@ public class SailingCalendarService { if (version(request.expectedVersion()) != 0) throw bad("新建模板expectedVersion必须为0"); SailingChannelEntity channel = resolvePriceBookChannel(request.data().channelId(), request.data().warehouseId(), null); validateTemplate(request.data(), channel); if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能创建模板"); - SailingTemplateEntity value = new SailingTemplateEntity(); value.setId(IdWorker.getId()); value.setTemplateNo("ST-" + value.getId()); + SailingTemplateEntity value = new SailingTemplateEntity(); value.setId(IdWorker.getId()); value.setTemplateNo(documentNumbers.next("ST")); copy(value, request.data(), channel); value.setStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value); repository.insert(value); return value; @@ -310,7 +312,7 @@ public class SailingCalendarService { if (existing != null) return isCopyOf(existing, copyReason) ? detail(existing.getId()) : throwCopyConflict(request.etdAt(), source.getVoyageNo()); - SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId()); value.setTemplateId(source.getTemplateId()); value.setChannelId(source.getChannelId()); value.setChannelCodeSnapshot(source.getChannelCodeSnapshot()); value.setChannelNameSnapshot(source.getChannelNameSnapshot()); value.setWarehouseId(source.getWarehouseId()); value.setWarehouseNameSnapshot(source.getWarehouseNameSnapshot()); value.setDeparturePortCode(source.getDeparturePortCode()); value.setDestinationPortCode(source.getDestinationPortCode()); value.setVesselName(source.getVesselName()); value.setVoyageNo(source.getVoyageNo()); value.setEtdAt(request.etdAt()); value.setWarehouseCutoffAt(request.cutoffAt()); value.setEtaAt(copyEta(source, request.etdAt())); value.setStatus("OPEN"); value.setSourceType("COPY"); value.setPlannedCapacityCbm(source.getPlannedCapacityCbm()); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value); + SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo(documentNumbers.next("SL")); value.setTemplateId(source.getTemplateId()); value.setChannelId(source.getChannelId()); value.setChannelCodeSnapshot(source.getChannelCodeSnapshot()); value.setChannelNameSnapshot(source.getChannelNameSnapshot()); value.setWarehouseId(source.getWarehouseId()); value.setWarehouseNameSnapshot(source.getWarehouseNameSnapshot()); value.setDeparturePortCode(source.getDeparturePortCode()); value.setDestinationPortCode(source.getDestinationPortCode()); value.setVesselName(source.getVesselName()); value.setVoyageNo(source.getVoyageNo()); value.setEtdAt(request.etdAt()); value.setWarehouseCutoffAt(request.cutoffAt()); value.setEtaAt(copyEta(source, request.etdAt())); value.setStatus("OPEN"); value.setSourceType("COPY"); value.setPlannedCapacityCbm(source.getPlannedCapacityCbm()); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value); try { repository.insert(value); } catch (DuplicateKeyException ex) { SailingEntity duplicate = repository.sailingByBusinessKey(source.getWarehouseId(), source.getChannelCodeSnapshot(), source.getDeparturePortCode(), source.getDestinationPortCode(), request.etdAt(), source.getVoyageNo()); @@ -362,7 +364,7 @@ public class SailingCalendarService { if (version(request.expectedVersion()) != 0) throw bad("新建船期expectedVersion必须为0"); validateSailing(request.data()); SailingChannelEntity channel = resolveChannel(request.data()); if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能创建船期"); - SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId()); + SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo(documentNumbers.next("SL")); copy(value, request.data(), channel); value.setStatus(OPEN); value.setSourceType(normalizeSource(request.data().sourceType())); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value); try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("船期业务唯一键冲突"); } @@ -398,7 +400,7 @@ public class SailingCalendarService { validateTemplateSnapshot(template); List result = new java.util.ArrayList<>(); for (GenerationPreview preview : generationPreview(templateId, from, to)) if (!preview.conflict()) { - SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId()); value.setTemplateId(templateId); + SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo(documentNumbers.next("SL")); value.setTemplateId(templateId); value.setChannelId(channel.getId()); value.setChannelCodeSnapshot(channel.getChannelCode()); value.setChannelNameSnapshot(channel.getChannelName()); value.setWarehouseId(template.getWarehouseId()); value.setWarehouseNameSnapshot(template.getWarehouseNameSnapshot()); value.setDeparturePortCode(template.getDeparturePortCode()); value.setDestinationPortCode(template.getDestinationPortCode()); value.setVesselName(template.getVesselName()); value.setVoyageNo(preview.voyageNo()); value.setEtdAt(preview.etdAt()); value.setWarehouseCutoffAt(preview.cutoffAt()); value.setStatus(OPEN); value.setSourceType("TEMPLATE"); value.setGeneratedKey(key(template, preview.etdAt(), preview.voyageNo())); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value); repository.insert(value); recordHistory(value.getId(), "GENERATE", null, OPEN, "按固定周模板生成", operatorId, operatorName, value.getCreatedAt()); result.add(value); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGenerator.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGenerator.java index 6fc7b24c8..86dc00a76 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGenerator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGenerator.java @@ -1,71 +1,47 @@ package com.baiyue.firstleg.service; -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.number.DailyDocumentNumber; +import com.baiyue.firstleg.repository.DocumentSequenceRepository; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; - import java.time.Clock; -import java.time.LocalDate; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.Locale; import java.util.Set; +/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */ @Component public class FirstlegDocumentNumberGenerator { - private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); - private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT); - private static final Set SUPPORTED_PREFIXES = Set.of("OB", "TA", "LP", "DP"); - private static final long MAX_DAILY_SEQUENCE = 9_999L; - - private final JdbcTemplate jdbc; + private static final Set PREFIXES = Set.of("OB", "TA", "LP", "DECL", "BR", "PS", "SL", "ST", "CLG"); + private final DocumentSequenceRepository sequences; private final Clock clock; + /** 正式运行使用统一业务时区。 */ @Autowired - public FirstlegDocumentNumberGenerator(JdbcTemplate jdbc) { - this(jdbc, Clock.system(BUSINESS_ZONE)); + public FirstlegDocumentNumberGenerator(DocumentSequenceRepository sequences) { + this(sequences, Clock.system(DailyDocumentNumber.BUSINESS_ZONE)); } - FirstlegDocumentNumberGenerator(JdbcTemplate jdbc, Clock clock) { - this.jdbc = jdbc; + /** 固定时钟用于跨日及前缀独立性验证。 */ + FirstlegDocumentNumberGenerator(DocumentSequenceRepository sequences, Clock clock) { + this.sequences = sequences; this.clock = clock; } - @Transactional(propagation = Propagation.MANDATORY) + /** 只为本服务已登记的业务类型分配号码。 */ public String next(String prefix) { - if (!SUPPORTED_PREFIXES.contains(prefix)) { - throw new IllegalArgumentException("unsupported firstleg document prefix: " + prefix); + // 实物标签未纳入本次改号,DP 保留原六位日期及横杠。 + if ("DP".equals(prefix)) { + java.time.LocalDate date = java.time.LocalDate.now(clock.withZone(DailyDocumentNumber.BUSINESS_ZONE)); + long value = sequences.next("DP:" + date.format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE)); + DailyDocumentNumber.format("DP", date, value); + return "DP-" + date.format(java.time.format.DateTimeFormatter.ofPattern("yyMMdd")) + + "-" + String.format(java.util.Locale.ROOT, "%04d", value); } - LocalDate sequenceDate = LocalDate.now(clock); - String sequenceKey = prefix + ":" + DateTimeFormatter.BASIC_ISO_DATE.format(sequenceDate); - long sequence = nextSequence(sequenceKey); - if (sequence > MAX_DAILY_SEQUENCE) { - throw new BizException(ResultCode.CONFLICT, "当日" + prefix + "单据编号已用尽"); - } - return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" + - String.format(Locale.ROOT, "%04d", sequence); + if (!PREFIXES.contains(prefix)) throw new IllegalArgumentException("未登记的业务编号前缀:" + prefix); + return DailyDocumentNumber.next(prefix, clock, sequences::next); } - /** 平台柜次号是长期业务身份,使用不按日期重置的 D 序号。 */ - @Transactional(propagation = Propagation.MANDATORY) + /** 柜次号保持现有全局连续规则,不按日期重置,也不限制为四位。 */ public String nextCabinetNo() { - return "D" + String.format(Locale.ROOT, "%04d", nextSequence("CABINET")); - } - - private long nextSequence(String sequenceKey) { - jdbc.update( - "INSERT INTO fl_document_sequence(sequence_key,current_value) " + - "VALUES(?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE " + - "current_value=LAST_INSERT_ID(current_value+1)", - sequenceKey); - Long sequence = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class); - if (sequence == null || sequence <= 0) { - throw new BizException(ResultCode.CONFLICT, "业务编号生成失败"); - } - return sequence; + return "D" + String.format(java.util.Locale.ROOT, "%04d", sequences.next("CABINET")); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 5b9879d81..4fbe848b9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -91,12 +91,13 @@ public class SmartLoadingService { } return repository.candidates(warehouseId); } + /** 按柜次渠道查询候选,关联订舱客户快照不限制其他客户的货物。 */ public List candidates(long bookingId, long warehouseId) { ContainerBookingEntity booking = requireBooking(bookingId); if (!Objects.equals(booking.getWarehouseId(), warehouseId)) throw validation("柜次仓库与候选仓库不一致"); if (!StringUtils.hasText(booking.getChannelGroupCode())) throw validation("柜次缺少计划渠道,请重新创建模拟柜"); List values = candidates(warehouseId).stream() - .filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList(); + .filter(candidate -> sameChannel(booking, candidate)).toList(); return enrichColoadCandidates(booking, repository.planByBooking(bookingId), values); } @@ -108,10 +109,11 @@ public class SmartLoadingService { return uniqueCandidateUnits(grouped.values().stream().flatMap(details -> inboundCandidates(details).stream()).toList()); } + /** 推荐保留渠道和显式筛选范围,允许同柜包含多个客户。 */ private List recommendationCandidates(ContainerBookingEntity booking, Long customerId, String inboundCode) { List values = repository.candidates(null).stream() - .filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)) + .filter(candidate -> sameChannel(booking, candidate)) .filter(candidate -> customerId == null || Objects.equals(customerId, candidate.accountId())) .filter(candidate -> !StringUtils.hasText(inboundCode) || inboundCode.trim().equals(candidate.inboundCode())) @@ -513,6 +515,7 @@ public class SmartLoadingService { return view(assigned, requireBooking(assigned.getContainerBookingId())); } + /** 原子新增明细或整托组成,使用货物自身客户归属而非订舱客户快照。 */ private Plan assign(long planId, AssignRequest request, long operatorId, String operatorName, boolean wholeGroupAssignment, Long palletId, String palletNo, Integer actualSkuQuantity, boolean primaryPalletLine, Candidate frozenCandidate, @@ -535,7 +538,6 @@ public class SmartLoadingService { } inboundHoldGuard.requireInboundNotHeld(candidate.accountId(), candidate.inboundId()); if (!wholeGroupAssignment) requireStandalone(candidate.accountId(), candidate.inboundId()); - requireSameCustomerScope(booking, candidate); var task = loadingPickService.mutationSnapshot(planId); if (task.taskId() != null && "LOADING".equals(task.taskStatus())) { splitPreloadService.ensureForRunningLoadingAllocation(candidate.accountId(), candidate.inboundId(), @@ -604,10 +606,7 @@ public class SmartLoadingService { return assignInbound(planId, request, operatorId, operatorName, false, null, true); } - /** - * Reserves declared cartons in a cabinet before order review. Review status is not an allocation - * gate; confirmation/PDA remains blocked until the resulting inbound reaches WAIT_SPLIT. - */ + /** 待审核订单可跨客户占位;权威订单归属和后续审核测量门禁仍必须满足。 */ @Transactional @BusinessAudit(event = "SMART_LOADING_PENDING_REVIEW_ASSIGN", resourceType = "LOADING_PLAN", resourceId = "#planId", description = "待审核订单加入装柜清单") @@ -652,7 +651,6 @@ public class SmartLoadingService { for (InboundAllocation allocation : allocations) { if (allocation.packageCount() == 0) continue; Candidate candidate = allocation.candidate(); - requireSameCustomerScope(booking, candidate); checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true); OrderCargoClient.CargoBinding cargo = declared.stream() .map(DeclaredCargo::cargo) @@ -722,7 +720,7 @@ public class SmartLoadingService { LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); List details = frozenDetails == null - ? manualCandidates(booking).stream() + ? manualCandidates().stream() .filter(candidate -> Objects.equals(candidate.inboundId(), request.inboundId())).toList() : List.copyOf(frozenDetails); if (details.isEmpty()) throw conflict("入仓码不存在、已分配完或尚未完成入库"); @@ -756,6 +754,7 @@ public class SmartLoadingService { return view(assigned, booking); } + /** 按入仓单自己的客户和申报事实建立占位,不以订舱客户限制拼柜。 */ private Plan assignDeclaredInbound(long planId, AssignInboundRequest request, InboundOrderEntity projection, String operation, String requestHash, long operatorId, String operatorName) { @@ -786,7 +785,6 @@ public class SmartLoadingService { for (InboundAllocation allocation : allocations) { if (allocation.packageCount() == 0) continue; Candidate candidate = allocation.candidate(); - requireSameCustomerScope(booking, candidate); checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true); OrderCargoClient.CargoBinding cargo = declared.stream().map(DeclaredCargo::cargo) .filter(value -> Objects.equals(value.cargoId(), candidate.inboundDetailId())) @@ -1007,6 +1005,7 @@ public class SmartLoadingService { @Transactional @BusinessAudit(event = "SMART_LOADING_ITEM_ADJUST", resourceType = "LOADING_PLAN_ITEM", resourceId = "#itemId", description = "调整智能装柜明细") + /** 调整本计划明细箱数,保留原客户归属、可用量、拣货和容量校验。 */ public Plan adjust(long planId, long itemId, AdjustRequest request, long operatorId, String operatorName) { String operation = operation(request.operationId()); String requestHash = sha256("ADJUST|" + planId + '|' + itemId + '|' @@ -1053,7 +1052,6 @@ public class SmartLoadingService { if (request.packageCount() > maxPackageCount) { throw validation("调整箱数超过可用箱数"); } - requireSameCustomerScope(booking, candidate); List others = new ArrayList<>(repository.items(planId)); others.removeIf(x -> Objects.equals(x.getId(), itemId)); checkRouteAndCapacity(booking, others, candidate, request.packageCount(), true); @@ -1360,7 +1358,7 @@ public class SmartLoadingService { if (!"ACTIVE".equals(group.status()) || group.lockedPlanId() != null) { throw conflict("同柜不可拆组已锁定或已消费"); } - List available = manualCandidates(booking); + List available = manualCandidates(); Map> result = new LinkedHashMap<>(); for (Member member : group.members()) { if (!repository.activeItemsByInbound(member.tenantId(), member.inboundId()).isEmpty()) { @@ -1382,14 +1380,15 @@ public class SmartLoadingService { return result; } + /** 整组推荐可跨客户,但仍按柜次渠道选择候选。 */ private List availableCandidates(ContainerBookingEntity booking) { return repository.candidates(null).stream() - .filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList(); + .filter(candidate -> sameChannel(booking, candidate)).toList(); } - private List manualCandidates(ContainerBookingEntity booking) { - return repository.candidates(null).stream() - .filter(candidate -> sameCustomerScope(booking, candidate)).toList(); + /** 平台人工分配可拼装多个客户,明细始终保留各自的真实客户归属。 */ + private List manualCandidates() { + return repository.candidates(null); } private boolean groupFits(ContainerBookingEntity booking, List planItems, @@ -1807,12 +1806,6 @@ public class SmartLoadingService { return StringUtils.hasText(booking.getChannelGroupCode()) && candidateGroup != null && booking.getChannelGroupCode().equals(candidateGroup.code()); } - private static boolean sameCustomerScope(ContainerBookingEntity booking, Candidate candidate) { - return booking.getCustomerId() == null || Objects.equals(booking.getCustomerId(), candidate.accountId()); - } - private static void requireSameCustomerScope(ContainerBookingEntity booking, Candidate candidate) { - if (!sameCustomerScope(booking, candidate)) throw forbidden(); - } /** * A draft may legitimately contain pre-review or pre-measurement members without a fee snapshot. * The pricing port reports that as NOT_READY, while any sent/customer-confirmed member fails and @@ -1911,5 +1904,5 @@ public class SmartLoadingService { } private static String trim(String x){return x==null?null:x.trim();} private static BigDecimal unit(BigDecimal x){return x==null?BigDecimal.ZERO:x.max(BigDecimal.ZERO);} - private static void requireAccount(long x){if(x<=0)throw new BizException(ResultCode.BAD_REQUEST,"accountId必须大于零");} private static BizException conflict(String x){return new BizException(ResultCode.CONFLICT,x);} private static BizException validation(String x){return new BizException(ResultCode.VALIDATION_FAILED,x);} private static BizException forbidden(){return new BizException(ResultCode.FORBIDDEN,"客户归属不匹配");} + private static void requireAccount(long x){if(x<=0)throw new BizException(ResultCode.BAD_REQUEST,"accountId必须大于零");} private static BizException conflict(String x){return new BizException(ResultCode.CONFLICT,x);} private static BizException validation(String x){return new BizException(ResultCode.VALIDATION_FAILED,x);} } diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 8d264ff4a..3593d195d 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -27,11 +27,11 @@ CALL phase3_add_allocation_picked_count(); DROP PROCEDURE phase3_add_allocation_picked_count; CREATE TABLE IF NOT EXISTS fl_document_sequence ( - sequence_key VARCHAR(32) NOT NULL COMMENT '单据序列键;日序列如CB:20260818,柜次长期序列固定为CABINET', + sequence_key VARCHAR(32) NOT NULL COMMENT '单据序列键;日序列如LP:20260818,柜次长期序列固定为CABINET', current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号', PRIMARY KEY (sequence_key), CONSTRAINT chk_fl_document_sequence_value CHECK (current_value > 0) -) ENGINE=InnoDB COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号'; +) ENGINE=InnoDB COMMENT='头程业务编号序列;普通单据按日,柜次全局连续,实物标签独立保留'; CREATE TABLE IF NOT EXISTS fl_container_booking ( id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)', diff --git a/firstleg/src/main/resources/openapi/customs-v1.yaml b/firstleg/src/main/resources/openapi/customs-v1.yaml index e5a7eb370..7852ed3ab 100644 --- a/firstleg/src/main/resources/openapi/customs-v1.yaml +++ b/firstleg/src/main/resources/openapi/customs-v1.yaml @@ -257,7 +257,7 @@ components: ContainerEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {allOf: [{$ref: '#/components/schemas/ContainerView'}], nullable: true, description: 装柜计划尚未完成时返回null,表示报关流程尚未生成}}}]} DocumentEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/DocumentView'}}}]} ActionRequest: {type: object, required: [expectedVersion, confirm], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, confirm: {type: boolean}, remark: {type: string, maxLength: 500, nullable: true}}} - DeclarationBatchRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, batchNo: {type: string, maxLength: 64, nullable: true}, customsEntryNo: {type: string, maxLength: 64, nullable: true}, remark: {type: string, maxLength: 500, nullable: true}}} + DeclarationBatchRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, customsEntryNo: {type: string, maxLength: 64, nullable: true}, remark: {type: string, maxLength: 500, nullable: true}}} ClearanceResultRequest: {type: object, required: [expectedVersion, resultType, occurredAt], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, resultType: {type: string, enum: [INSPECTION, SUPPLEMENT_REQUIRED, RELEASED, DETAINED, OTHER]}, occurredAt: {type: string, format: date-time}, resolution: {type: string, maxLength: 1000, nullable: true}, evidenceFileId: {$ref: '#/components/schemas/LongId', nullable: true}, resolved: {type: boolean}}} ClearanceResolveRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, resolution: {type: string, maxLength: 1000, nullable: true}}} ScopeRequest: {type: object, required: [inboundIds, expectedVersion], additionalProperties: false, properties: {inboundIds: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}, expectedVersion: {type: integer, minimum: 1}}} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 59219443d..db0335fd2 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -700,7 +700,8 @@ paths: 需要 platform:firstleg:smart-loading:list 权限。渠道目录只来自平台当前唯一 ACTIVE 价格版本中 对该国内仓存在 ACTIVE 价格行的渠道;候选入仓码数、可用箱数及可编排柜次数由 Firstleg 当前事实聚合。 零候选或零可用柜次的有效渠道仍返回,Admin 可展示空态。渠道只约束智能推荐;人工加入允许跨渠道, - 仍由服务端重新校验国内仓、客户范围、占用、同柜组、箱数和容量资格。 + 仍由服务端重新校验国内仓、货物原客户归属、占用、同柜组、箱数和容量资格。 + 平台柜次支持跨客户拼柜,关联订舱的客户快照不限制柜内成员客户。 parameters: - {name: warehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} responses: @@ -835,6 +836,7 @@ paths: description: >- 需要 platform:firstleg:smart-loading:assign 权限。customerId 与 inboundCode 均为可选推荐范围; 传入时只推荐同时命中的候选,避免把同渠道历史候选批量加入当前柜。未传时按柜次渠道推荐全部可用候选。 + 关联订舱的客户快照不裁剪候选,未指定客户筛选时可推荐多个客户的货物。 一件代发仍按每个已封托托盘整体加入,不得按箱拆分;同一次推荐完成后按受影响客户统一重算费用。 parameters: - {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -848,7 +850,8 @@ paths: summary: 向预装计划新增入库单明细 description: >- EDITING 或 LOADING 且 PDA task 尚未完成时可用。服务端按 planId 锁定同一 PDA task, - 校验客户范围、可用箱数、容积和限重后原子新增/累加明细;人工加入允许订单国内仓及渠道与柜次不同, + 校验货物原客户归属、可用箱数、容积和限重后原子新增/累加明细;平台允许跨客户拼柜, + 不以关联订舱客户快照限定成员客户。人工加入允许订单国内仓及渠道与柜次不同, 智能推荐仍只选择柜次渠道。LOADING 时同步到原 taskId, 递增 planVersion、taskVersion,原 inventorySnapshotToken 随即失效。与 PDA batch/final complete 并发时, 锁顺序为 PDA task 行锁 -> plan 乐观锁;仅先取得任务锁且持有匹配版本的一方成功,另一方返回 A0409。 @@ -870,6 +873,7 @@ paths: 服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单; 人工分配不限制订单国内仓及渠道与柜次一致;智能推荐允许跨国内仓,但仍只选择柜次渠道; 占用、同柜组和容量门禁不变。 + 支持将不同客户的入仓码加入同一柜次,明细及费用仍保留各自原客户归属。 柜内总容积和总重量分别允许达到柜型基准值的150%,超过即拒绝。 只记录数量计划,具体实装箱号归属为 UNVERIFIED,不得将内部比例分摊宣称为现场箱号事实。 parameters: @@ -886,6 +890,7 @@ paths: 需要 platform:firstleg:smart-loading:assign 权限。订单审核状态不作为清单占位门禁;服务端以 customerId + orderId + inboundCode 重新核验 Order 权威待审核事实,幂等创建或复用 PREPARING 入仓投影,并按订单申报箱数、重量和体积建立柜次占位。人工分配允许订单渠道与柜次渠道不同; + 不同客户可加入同一柜次,关联订舱客户快照不作为客户准入限制。 已被其他有效柜次占用、国内仓不一致、 超容量或订单事实变化返回业务冲突且事务零漂移。清单中存在 PREPARING/WAIT_MEASURE 订单时不得 确认预装或启动 PDA,审核测量完成并进入 WAIT_SPLIT 后才可继续物理装柜。 @@ -2376,7 +2381,7 @@ components: id: {type: string, pattern: '^[1-9][0-9]*$'} currentPortScheduleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, readOnly: true, description: 当前运输读模型由 latestForDispatch 选中的船期记录 ID;无匹配时为空,仅用于定位关联船期,不作为业务编号展示} planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空} - planNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号;未创建计划时为空} + planNo: {type: string, nullable: true, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 预装与确认装柜清单共用的稳定编号;未创建计划时为空} containerNo: {type: string, maxLength: 64, nullable: true} cabinetNo: {type: string, pattern: '^D[0-9]{4,}$', example: D0001, description: 平台全局连续且不按日期重置的稳定柜次号} sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜} @@ -2543,7 +2548,7 @@ components: inboundCode: {type: string, minLength: 1, maxLength: 40, description: 订单当前入仓片段业务号,UI主展示} orderNo: {type: string, nullable: true, description: 订单业务号,UI主展示} loadingPlanId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 装柜计划技术关联ID} - loadingPlanNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', description: 装柜计划业务号,UI主展示} + loadingPlanNo: {type: string, nullable: true, pattern: '^LP[0-9]{12}$', description: 装柜计划业务号,UI主展示} containerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 柜次技术关联ID} cabinetNo: {type: string, nullable: true, description: 稳定柜次业务号,UI主展示} containerNo: {type: string, nullable: true, description: 真实集装箱号,UI主展示} @@ -2676,7 +2681,7 @@ components: required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, coLoadGroups, palletMembers, items] properties: id: {type: string} - planNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号} + planNo: {type: string, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 预装与确认装柜清单共用的稳定编号} status: {type: string, enum: [EDITING, LOADING, COMPLETED]} bookingId: {type: string} containerNo: {type: string, nullable: true} diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index e4cbe949a..5b1354570 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -299,7 +299,7 @@ components: properties: planId: {$ref: '#/components/schemas/LongId'} containerBookingId: {$ref: '#/components/schemas/LongId'} - preloadNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装单号;按北京时间创建日逐日编号,确认后继续作为同一装柜清单号使用} + preloadNo: {type: string, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 预装单号;按北京时间创建日逐日编号,确认后继续作为同一装柜清单号使用} containerNo: {type: string, nullable: true, description: 集装箱号} cabinetNo: {type: string, nullable: true, pattern: '^D[0-9]{4,}$', example: D0001, description: 平台全局连续且不按日期重置的稳定柜次号;不得与真实集装箱号混用} customers: {type: array, description: 计划包含的客户, items: {$ref: '#/components/schemas/Customer'}} @@ -356,7 +356,7 @@ components: properties: planId: {$ref: '#/components/schemas/LongId'} containerBookingId: {$ref: '#/components/schemas/LongId'} - loadingListNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 装柜清单号;沿用预装阶段生成的同一编号} + loadingListNo: {type: string, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 装柜清单号;沿用预装阶段生成的同一编号} warehouseId: {$ref: '#/components/schemas/NullableLongId'} warehouseName: {type: string, nullable: true, description: 仓库名称} containerNo: {type: string, nullable: true, description: 集装箱号} diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 59b73ebf1..4f28595ac 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -313,7 +313,7 @@ components: oceanBooking: type: object properties: - bookingNo: {type: string, pattern: '^OB-[0-9]{6}-[0-9]{4}$', example: OB-260818-0001, description: 平台订舱号;按北京时间创建日逐日编号} + bookingNo: {type: string, pattern: '^OB[0-9]{12}$', example: OB202608180001, description: 平台订舱号;按北京时间创建日逐日编号} parties: {type: array} containerRequirements: {type: array} containers: {type: array, description: Existing containers allocated after port pickup linking} diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index 2aae6cb98..bf9680b35 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -235,9 +235,9 @@ components: required: [appointmentNo, carrierId, carrierName, attachmentCount] description: Stable booking and supplier references returned by appointment detail. Container identities are intentionally absent. properties: - appointmentNo: {type: string, pattern: '^TA-[0-9]{6}-[0-9]{4}$', example: TA-260818-0001, description: 派车单号;按北京时间创建日逐日编号} + appointmentNo: {type: string, pattern: '^TA[0-9]{12}$', example: TA202608180001, description: 派车单号;按北京时间创建日逐日编号} oceanBookingId: {type: integer, format: int64, nullable: true} - oceanBookingNo: {type: string, nullable: true, pattern: '^OB-[0-9]{6}-[0-9]{4}$', example: OB-260818-0001} + oceanBookingNo: {type: string, nullable: true, pattern: '^OB[0-9]{12}$', example: OB202608180001} warehouseId: {type: integer, format: int64, nullable: true} warehouseName: {type: string, nullable: true} shippingCompanyId: {type: integer, format: int64, nullable: true} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java index a67097ac3..ff516dc3b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java @@ -32,7 +32,7 @@ class InboundMeasurementReviewContractTest { } @Test - @DisplayName("OpenAPI不包含待客户确认并声明幂等和字符串长ID") + @DisplayName("测量审核不进入待客户确认,并声明幂等和字符串长ID") void shouldDocumentReviewContract() throws Exception { String openapi = new ClassPathResource("openapi/firstleg-v1.yaml") .getContentAsString(StandardCharsets.UTF_8); @@ -46,8 +46,11 @@ class InboundMeasurementReviewContractTest { .contains("name: inboundId") .contains("schema: {type: string, pattern:") .doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/reject:") - .doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/exception-release:") - .doesNotContain("待客户确认"); + .doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/exception-release:"); + // 约束仅针对测量审核;客户费用确认属于另一业务,允许独立存在。 + int start = openapi.indexOf(" /api/platform/v1/inbounds/{inboundId}/measurements/approve:"); + int end = openapi.indexOf("\n /", start + 1); + assertThat(openapi.substring(start, end < 0 ? openapi.length() : end)).doesNotContain("待客户确认"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java index d017e50fa..9521e272a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java @@ -28,6 +28,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.*; class ContainerCustomsServiceTest { + @org.mockito.Spy private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers = com.baiyue.firstleg.service.TestDocumentNumbers.documents(); + @Mock ContainerCustomsMapper mapper; @Mock CustomsFileClient files; @Mock ObjectMapper objectMapper; @@ -35,6 +37,58 @@ class ContainerCustomsServiceTest { @InjectMocks ContainerCustomsService service; @BeforeEach void setUp(){MockitoAnnotations.openMocks(this);} + /** 服务端分配批次号后,命令重放必须返回原号且不再消耗流水。 */ + @Test void declarationCreationReplayKeepsAllocatedNumber() throws Exception { + String action = "CREATE_CUSTOMS_DECLARATION_BATCH_9"; + var command = new AtomicReference(); + var batch = new AtomicReference(); + when(mapper.claimCommand(anyLong(), eq(5L), eq(action), eq("batch-key"), anyString())).thenAnswer(call -> { + if (command.get() != null) return 0; + command.set(new ContainerCustomsMapper.CommandRow(1L, 5L, action, "batch-key", call.getArgument(4), "PROCESSING", null, null, null)); + return 1; + }); + when(mapper.command(5L, action, "batch-key")).thenAnswer(call -> command.get()); + when(mapper.lockContainer(9L)).thenReturn(container(ContainerCustomsService.DECLARING, 3)); + when(mapper.taxRefundCount(9L)).thenReturn(1); + when(mapper.brokerSubmittedAt(2L)).thenReturn(java.time.LocalDateTime.of(2026, 10, 20, 9, 0)); + when(objectMapper.writeValueAsString(any())).thenReturn("[]"); + when(mapper.insertDeclarationBatch(anyLong(), eq(2L), eq(9L), anyString(), eq(3), eq("[]"), isNull())).thenAnswer(call -> { + batch.set(new ContainerCustomsMapper.DeclarationBatchRow(call.getArgument(0), 2L, 9L, call.getArgument(3), + "READY", 3, null, null, null, null, null, null, 0, null, null)); + return 1; + }); + when(mapper.declarationBatch(anyLong())).thenAnswer(call -> batch.get()); + when(mapper.insertHistory(anyLong(), eq(2L), eq("CREATE_CUSTOMS_DECLARATION_BATCH"), anyString(), anyString(), anyString(), eq(5L), eq("admin"))).thenReturn(1); + when(mapper.completeCommand(eq(5L), eq(action), eq("batch-key"), anyLong())).thenAnswer(call -> { + var old = command.get(); + command.set(new ContainerCustomsMapper.CommandRow(old.id(), 5L, action, "batch-key", old.requestHash(), "SUCCESS", call.getArgument(3), null, null)); + return 1; + }); + var request = new com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.DeclarationBatchRequest(3, null, null); + var created = service.createDeclarationBatch(9L, 5L, "admin", "batch-key", request); + var replay = service.createDeclarationBatch(9L, 5L, "admin", "batch-key", request); + assertThat(created.batchNo()).isEqualTo("DECL202610200001"); + assertThat(replay.id()).isEqualTo(created.id()); + assertThat(replay.batchNo()).isEqualTo(created.batchNo()); + verify(documentNumbers, times(1)).next("DECL"); + } + + /** 外部报关单号与内部批次号独立,正式提交不能修改内部号。 */ + @Test void declarationSubmissionPreservesBatchNumber() { + var batch = new ContainerCustomsMapper.DeclarationBatchRow(41L, 2L, 9L, "DECL202610200001", + "READY", 3, null, null, null, null, null, null, 0, null, null); + when(mapper.claimCommand(anyLong(), eq(5L), eq("SUBMIT_CUSTOMS_DECLARATION_BATCH_41"), eq("submit-key"), anyString())).thenReturn(1); + when(mapper.lockDeclarationBatch(41L)).thenReturn(batch); + when(mapper.submitDeclarationBatch(41L, 0, batch.batchNo(), "CUSTOMS-EXTERNAL-1", 5L, "admin", null)).thenReturn(1); + when(mapper.completeCommand(5L, "SUBMIT_CUSTOMS_DECLARATION_BATCH_41", "submit-key", 41L)).thenReturn(1); + when(mapper.declarationBatch(41L)).thenReturn(batch); + var result = service.submitDeclarationBatch(41L, 5L, "admin", "submit-key", + new com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.DeclarationBatchRequest(0, "CUSTOMS-EXTERNAL-1", null)); + assertThat(result.batchNo()).isEqualTo(batch.batchNo()); + verify(mapper).submitDeclarationBatch(41L, 0, batch.batchNo(), "CUSTOMS-EXTERNAL-1", 5L, "admin", null); + verifyNoInteractions(documentNumbers); + } + @Test void merchantOwnBuyOrderIsNotFound(){ assertMerchantOrderNotFound(7L,31L); } @@ -211,7 +265,7 @@ class ContainerCustomsServiceTest { var declaring=container(ContainerCustomsService.DECLARING,3); var source=new ContainerCustomsMapper.DocumentRow(31L,2L,7L,10L,"source.bin","a".repeat(64), "application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1); - var submission=new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR-LP9-01","SENT",3,"admin", + var submission=new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR202610200001","SENT",3,"admin", java.time.LocalDateTime.now(),1); when(mapper.claimCommand(anyLong(),eq(5L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER_9"),eq("send-key"),anyString())).thenReturn(1); when(mapper.lockContainer(9L)).thenReturn(declaring); @@ -220,7 +274,7 @@ class ContainerCustomsServiceTest { when(mapper.documents(2L,null)).thenReturn(List.of(source)); when(mapper.frozenScopes(31L)).thenReturn(List.of(new ContainerCustomsMapper.ScopeRow(31L,11L,21L,"I"))); when(objectMapper.writeValueAsString(any())).thenReturn("[]"); - when(mapper.insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR-LP9-01"),eq(3),eq("[]"),eq(5L),eq("admin"))).thenReturn(1); + when(mapper.insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR202610200001"),eq(3),eq("[]"),eq(5L),eq("admin"))).thenReturn(1); when(mapper.bumpVersionAfterBrokerSubmission(2L,3)).thenReturn(1); when(mapper.insertHistory(anyLong(),eq(2L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER"),eq(ContainerCustomsService.DECLARING),eq(ContainerCustomsService.DECLARING),isNull(),eq(5L),eq("admin"))).thenReturn(1); when(mapper.completeCommand(eq(5L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER_9"),eq("send-key"),anyLong())).thenReturn(1); @@ -235,9 +289,9 @@ class ContainerCustomsServiceTest { var result=service.sendInitialToBroker(9L,5L,"admin","send-key",new ActionRequest(3,true,null)); assertThat(result.brokerSubmission()).isNotNull(); - assertThat(result.brokerSubmission().batchNo()).isEqualTo("BR-LP9-01"); + assertThat(result.brokerSubmission().batchNo()).isEqualTo("BR202610200001"); assertThat(result.brokerSubmission().sourceVersion()).isEqualTo(3); - verify(mapper).insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR-LP9-01"),eq(3),eq("[]"),eq(5L),eq("admin")); + verify(mapper).insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR202610200001"),eq(3),eq("[]"),eq(5L),eq("admin")); } @Test void initialBrokerSendRejectsStaleAggregateVersion(){ @@ -535,7 +589,7 @@ class ContainerCustomsServiceTest { } private static ContainerCustomsMapper.DocumentRow document(long id){return new ContainerCustomsMapper.DocumentRow(id,2L,7L,10L,"source.bin","a".repeat(64),"application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1);} - private static ContainerCustomsMapper.BrokerSubmissionRow brokerSubmission(){return new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR-LP9-01","SENT",3,"admin",java.time.LocalDateTime.now(),1);} + private static ContainerCustomsMapper.BrokerSubmissionRow brokerSubmission(){return new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR202610200001","SENT",3,"admin",java.time.LocalDateTime.now(),1);} private static ContainerCustomsMapper.ContainerRow container(String status,int version){return new ContainerCustomsMapper.ContainerRow(2L,9L,8L,status,null,null,null,null,null,null,null,null,version);} private void assertMerchantOrderNotFound(long tenantId,long orderId){ assertThatThrownBy(()->service.merchantOrderView(tenantId,orderId)) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java index e2a4fffd8..9a05cf152 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java @@ -37,8 +37,17 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@SpringJUnitConfig(ColoadGroupService.class) +@SpringJUnitConfig({ColoadGroupService.class, ColoadGroupServiceTest.Numbers.class}) class ColoadGroupServiceTest { + @org.springframework.context.annotation.Configuration + static class Numbers { + /** 同柜组业务测试使用真实格式,序列由隔离测试替身提供。 */ + @org.springframework.context.annotation.Bean + com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers() { + return com.baiyue.firstleg.service.TestDocumentNumbers.documents(); + } + } + @MockBean private ColoadGroupMapper mapper; @MockBean private PendingReviewOrderClient pendingReviewOrders; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/ArrivalCalendarTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/ArrivalCalendarTest.java index b636cb9bd..35df1de41 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/ArrivalCalendarTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/ArrivalCalendarTest.java @@ -14,6 +14,7 @@ class ArrivalCalendarTest { void rejectsInvalidRangesAndPassesTheWholeHalfOpenRangeToOneQuery() { var repository = mock(PortScheduleRepository.class); var service = new PortScheduleService(); + org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.firstleg.service.TestDocumentNumbers.documents()); ReflectionTestUtils.setField(service, "repository", repository); var from = LocalDate.of(2026, 9, 28); assertThrows(BizException.class, () -> service.arrivalCalendar(from, from)); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/PortScheduleServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/PortScheduleServiceTest.java index d0a794755..b41cccae1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/PortScheduleServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/PortScheduleServiceTest.java @@ -35,6 +35,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class PortScheduleServiceTest { + @org.mockito.Spy private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers = com.baiyue.firstleg.service.TestDocumentNumbers.documents(); + @Test void platformPublicQueryAllowsZeroButRejectsNegativeCustomerScope() { try (var factory = Validation.buildDefaultValidatorFactory()) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/DocumentSequenceRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/DocumentSequenceRepositoryTest.java new file mode 100644 index 000000000..aa3b4b6db --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/DocumentSequenceRepositoryTest.java @@ -0,0 +1,76 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.DocumentSequenceMapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.*; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; + +/** 实际 Mapper 和隔离 H2 MySQL 模式验证原子序列,不访问部署环境。 */ +class DocumentSequenceRepositoryTest { + private static class Fixture { + final JdbcTemplate jdbc; + final DataSourceTransactionManager transactions; + final DocumentSequenceMapper mapper; + final DocumentSequenceRepository repository; + /** 仅测试造表允许 JDBC;被测路径全部使用 Mapper。 */ + Fixture() throws Exception { + var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa",""); + jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data); + jdbc.execute("CREATE TABLE fl_document_sequence(sequence_key VARCHAR(32) PRIMARY KEY,current_value BIGINT NOT NULL)"); + var configuration=new MybatisConfiguration();configuration.addMapper(DocumentSequenceMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet(); + mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DocumentSequenceMapper.class); + var proxy=new ProxyFactory(new DocumentSequenceRepository(mapper));proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource())); + repository=(DocumentSequenceRepository)proxy.getProxy(); + } + } + /** 日期和前缀各自独立,重复分配持续递增。 */ + @Test void dateAndPrefixHaveIndependentSequences() throws Exception { + var f=new Fixture(); + assertThat(f.repository.next("AA:20261020")).isEqualTo(1); + assertThat(f.repository.next("AA:20261020")).isEqualTo(2); + assertThat(f.repository.next("BB:20261020")).isEqualTo(1); + assertThat(f.repository.next("AA:20261021")).isEqualTo(1); + } + /** 两个线程使用真实事务连接并发争用,不能产生重号。 */ + @Test void concurrentConnectionsAllocateUniqueNumbers() throws Exception { + var f=new Fixture();var pool=Executors.newFixedThreadPool(2); + try { + var work=new ArrayList>(); + for(int i=0;i<64;i++)work.add(()->f.repository.next("AA:20261020")); + var values=new TreeSet(); + for(var future:pool.invokeAll(work))values.add(future.get()); + assertThat(values).hasSize(64);assertThat(values.first()).isEqualTo(1L);assertThat(values.last()).isEqualTo(64L); + } finally {pool.shutdownNow();} + } + /** 外层建单失败后不能把已经分配的单号再交给其他单据。 */ + @Test void businessRollbackDoesNotReuseAllocatedNumber() throws Exception { + var f=new Fixture(); + assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{ + assertThat(f.repository.next("AA:20261020")).isEqualTo(1); + throw new IllegalStateException("业务回滚"); + })).isInstanceOf(IllegalStateException.class); + assertThat(f.repository.next("AA:20261020")).isEqualTo(2); + } + /** 同一会话中连接值变化后,读取必须绕过 MyBatis 缓存。 */ + @Test void connectionResultIsNeverCached() throws Exception { + var f=new Fixture(); + new TransactionTemplate(f.transactions).executeWithoutResult(status->{ + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(7L); + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(37L); + }); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index a37339ac9..718070912 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -80,7 +80,7 @@ class Phase3FinalUnifiedSqlContractTest { @Test void initializationPassIsUniqueAndFinal() throws Exception { String sql = sql(); - int expectedTables = 228; + int expectedTables = 230; long actualTables = java.util.regex.Pattern.compile("(?m)^CREATE TABLE\\s+") .matcher(sql).results().count(); assertThat(actualTables).isEqualTo(expectedTables); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarServiceTest.java index 8381c951c..655008f11 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarServiceTest.java @@ -24,6 +24,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class SailingCalendarServiceTest { + @org.mockito.Spy private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers = com.baiyue.firstleg.service.TestDocumentNumbers.documents(); + @Mock private SailingCalendarRepository repository; @InjectMocks private SailingCalendarService service; private final LocalDateTime copiedDeparture = LocalDateTime.now(ZoneId.of("Asia/Shanghai")).plusDays(14); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGeneratorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGeneratorTest.java index e0cd6f665..7a62e7f24 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGeneratorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGeneratorTest.java @@ -1,73 +1,52 @@ package com.baiyue.firstleg.service; +import com.baiyue.firstleg.repository.DocumentSequenceRepository; import com.baiyue.common.core.exception.BizException; import org.junit.jupiter.api.Test; import org.springframework.context.annotation.AnnotationConfigApplicationContext; -import org.springframework.jdbc.core.JdbcTemplate; - -import java.time.Clock; -import java.time.Instant; -import java.time.ZoneId; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import java.time.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; class FirstlegDocumentNumberGeneratorTest { - private static final Clock CLOCK = Clock.fixed( - Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai")); - - @Test - void startsAsASpringBeanWhenTheTestClockConstructorAlsoExists() { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { - context.registerBean(JdbcTemplate.class, () -> jdbc); - context.register(FirstlegDocumentNumberGenerator.class); - context.refresh(); + private static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC); + /** 多构造器情况下正式依赖可以被 Spring 正确选择。 */ + @Test void springCreatesGeneratorWithRepository() { + try(var context=new AnnotationConfigApplicationContext()) { + context.registerBean(DocumentSequenceRepository.class,()->mock(DocumentSequenceRepository.class)); + context.register(FirstlegDocumentNumberGenerator.class);context.refresh(); assertThat(context.getBean(FirstlegDocumentNumberGenerator.class)).isNotNull(); } } - - @Test - void formatsAllOperationalDocumentPrefixes() { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)) - .thenReturn(1L, 1L, 1L, 1L, 1L); - FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK); - - assertThat(generator.next("OB")).isEqualTo("OB-260818-0001"); - assertThat(generator.next("TA")).isEqualTo("TA-260818-0001"); - assertThatThrownBy(() -> generator.next("CB")).isInstanceOf(IllegalArgumentException.class); - assertThat(generator.next("LP")).isEqualTo("LP-260818-0001"); - assertThat(generator.nextCabinetNo()).isEqualTo("D0001"); - - verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fl_document_sequence"), - eq("OB:20260818")); - verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fl_document_sequence"), - eq("CABINET")); + /** 主机使用 UTC 时仍按北京时间跨午夜,且四位流水不截断。 */ + @Test void usesShanghaiDateAndIndependentSequenceKey() { + var sequences=mock(DocumentSequenceRepository.class); + when(sequences.next("LP:20261020")).thenReturn(1L,2L,9999L); + var numbers=new FirstlegDocumentNumberGenerator(sequences,CLOCK); + assertThat(numbers.next("LP")).isEqualTo("LP202610200001"); + assertThat(numbers.next("LP")).isEqualTo("LP202610200002"); + assertThat(numbers.next("LP")).isEqualTo("LP202610209999"); + verify(sequences,times(3)).next("LP:20261020"); + } + /** 未登记类型不能消耗流水,分配异常或溢出时拒绝出号。 */ + @Test void rejectsUnknownTypeAndOverflow() { + var sequences=mock(DocumentSequenceRepository.class); + var numbers=new FirstlegDocumentNumberGenerator(sequences,CLOCK); + assertThatThrownBy(()->numbers.next("ZZZZ")).isInstanceOf(IllegalArgumentException.class); + verifyNoInteractions(sequences); + when(sequences.next("LP:20261020")).thenReturn(10000L); + assertThatThrownBy(()->numbers.next("LP")).isInstanceOf(BizException.class); } - @Test - void cabinetSequenceDoesNotResetOrStopAtFourDigits() { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(9_999L, 10_000L); - FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK); - - assertThat(generator.nextCabinetNo()).isEqualTo("D9999"); - assertThat(generator.nextCabinetNo()).isEqualTo("D10000"); - } - - @Test - void rejectsUnsupportedTypeAndDailyOverflow() { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK); - assertThatThrownBy(() -> generator.next("MSCU")).isInstanceOf(IllegalArgumentException.class); - - when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L); - assertThatThrownBy(() -> generator.next("LP")).isInstanceOf(BizException.class); + /** 柜次和实物标签明确不改格式,柜次允许超过四位。 */ + @Test void preservesCabinetAndPalletRules() { + var sequences=mock(DocumentSequenceRepository.class); + when(sequences.next("CABINET")).thenReturn(9999L,10000L); + when(sequences.next("DP:20261020")).thenReturn(1L); + var numbers=new FirstlegDocumentNumberGenerator(sequences,CLOCK); + assertThat(numbers.nextCabinetNo()).isEqualTo("D9999"); + assertThat(numbers.nextCabinetNo()).isEqualTo("D10000"); + assertThat(numbers.next("DP")).isEqualTo("DP-261020-0001"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 33232b39c..d822c7242 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -462,8 +462,9 @@ class SmartLoadingServiceTest { verify(repository).occupiedOrderIds(eq(query), anySet()); } + /** 待审核订单来自其他客户时仍可拼柜,客户归属和费用重算维持原值。 */ @Test - void manuallyAssignsCrossChannelPendingReviewOrderAsAProvisionalContainerAllocation() { + void manuallyAssignsCrossCustomerAndChannelPendingReviewOrderAsAProvisionalContainerAllocation() { long customerId = 2092797167699816450L; long orderId = 2095052002489335810L; String inboundCode = "EXXHK2609020361"; @@ -503,6 +504,7 @@ class SmartLoadingServiceTest { assertThat(result.version()).isEqualTo(1); ArgumentCaptor item = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); verify(repository).insert(item.capture()); + assertThat(item.getValue().getAccountId()).isEqualTo(customerId); assertThat(item.getValue().getInboundId()).isEqualTo(10L); assertThat(item.getValue().getInboundDetailId()).isEqualTo(20L); assertThat(item.getValue().getPackageCount()).isEqualTo(5); @@ -1378,20 +1380,31 @@ class SmartLoadingServiceTest { verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any()); } + /** 订舱客户仅为关联资料;平台可将其他客户的货物加入同一柜次。 */ @Test - void rejectsManualAssignmentOutsideLinkedBookingCustomerScope() { - LoadingPlanEntity plan = plan("EDITING", 0); - ContainerBookingEntity linked = booking(); - linked.setCustomerId(88L); - when(repository.plan(1L)).thenReturn(plan); - when(repository.booking(99L)).thenReturn(linked); - when(repository.candidate(1L, 10L, 20L)).thenReturn(candidate(5)); + void allowsManualAssignmentFromAnotherCustomerWithoutRewritingCargoOwnership() { + stubAssignment(); - BizException error = catchThrowableOfType(() -> service.assign(1L, - new AssignRequest(10L, 20L, 1, 0, "assign-customer-mismatch"), 7L, "u"), BizException.class); + Plan result = service.assign(1L, + new AssignRequest(10L, 20L, 1, 0, "assign-cross-customer"), 7L, "u"); - assertThat(error.getResultCode()).isEqualTo(ResultCode.FORBIDDEN); - verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any()); + assertThat(result.version()).isEqualTo(1); + ArgumentCaptor inserted = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); + verify(repository).insert(inserted.capture()); + assertThat(inserted.getValue().getAccountId()).isEqualTo(9L); + assertThat(inserted.getValue().getOrderId()).isEqualTo(30L); + verify(cargoClient).detail(anyString(), eq(9L), eq(30L), eq(20L)); + } + + /** 同渠道候选包含不同客户,不能被订舱客户快照裁剪。 */ + @Test + void listsCandidatesFromBothCustomersInALinkedBooking() { + when(repository.booking(99L)).thenReturn(booking()); + when(repository.candidates(100L)).thenReturn(List.of( + candidate(9L, 10L, 20L, 30L, 5), candidate(88L, 11L, 21L, 31L, 4))); + + assertThat(service.candidates(99L, 100L)).extracting(Candidate::accountId) + .containsExactly(9L, 88L); } @Test @@ -2075,9 +2088,11 @@ class SmartLoadingServiceTest { return group; } + /** 所有流程默认带历史订舱客户,覆盖候选、占位、整托、调整和推荐的跨客户拼柜。 */ private static ContainerBookingEntity booking() { ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(99L); value.setWarehouseId(100L); value.setDestinationWarehouseCode("FBA1"); + value.setCustomerId(88L); value.setChannelGroupCode("MATSON_CLX"); value.setChannelCode(null); value.setContainerNo("MSCU1234567"); value.setCapacityCbm(new BigDecimal("60")); value.setMaxWeightKg(new BigDecimal("20000")); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TestDocumentNumbers.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TestDocumentNumbers.java new file mode 100644 index 000000000..9a98b4129 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TestDocumentNumbers.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.repository.DocumentSequenceRepository; +import java.time.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** 业务单测使用真实格式及隔离计数器;数据库并发由 Repository 集成测试覆盖。 */ +public final class TestDocumentNumbers { + /** 测试工具不实例化。 */ + private TestDocumentNumbers() {} + /** 每个测试生成器独立计数,类型前缀之间互不占号。 */ + public static FirstlegDocumentNumberGenerator documents() { + var sequences=mock(DocumentSequenceRepository.class); + var counters=new ConcurrentHashMap(); + lenient().when(sequences.next(anyString())).thenAnswer(call->counters.computeIfAbsent(call.getArgument(0),key->new AtomicLong()).incrementAndGet()); + return new FirstlegDocumentNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-20T00:00:00Z"),ZoneOffset.UTC)); + } +} diff --git a/lastleg-service/docs/lastleg-internal-modularization-baseline.md b/lastleg-service/docs/lastleg-internal-modularization-baseline.md index 2ac4107d3..13bab30cc 100644 --- a/lastleg-service/docs/lastleg-internal-modularization-baseline.md +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -15,7 +15,7 @@ ### 预约、车队与导出依赖收口(2026-10-12) - 预约规划只声明 `AppointmentSourceLinker` 端口;入仓域负责创建入口与关联明细实现,入仓请求声明自己的来源 DTO,再映射到规划模型。公开 JSON、事务及来源校验保持不变,规划包不反向依赖入仓实现。 -- 车队商业单使用本域的收货证据和签收快照;派送域实现车队声明的证据端口并提供完整签收事实,车队不再导入派送实体、Mapper 或完成策略。商业单编号保持原 LB/TR 规则,由本域生成;`fleet.business` 不反向依赖 `fleet.trip`。既有散板、卡车商业单的同库写入不在此次拆分范围,本次门禁通过不代表完成全域解耦。 +- 车队商业单使用本域的收货证据和签收快照;派送域实现车队声明的证据端口并提供完整签收事实,车队不再导入派送实体、Mapper 或完成策略。商业单编号沿用 LB/TR 前缀,通过共享编号组件按工作区 `docs/业务单据编号契约.md` 分配,各入口共用本库日流水;`fleet.business` 不反向依赖 `fleet.trip`。既有散板、卡车商业单的同库写入不在此次拆分范围,本次门禁通过不代表完成全域解耦。 - 退货客户引用与转仓 FBA 目录各由本域内部客户端读取原权威服务,移除对车队客户端的借用;接口、令牌及 URL 配置不变。退货合箱共用 PDA 仓身份、转仓实收共用仓储资源网关,两条精确类边在基线中登记。 - 平台导出作为组合入口,仅登记 `LastlegExportProviderService` 对 12 个公开查询服务与 DTO/投影的引用,不开放整个切片;车辆仍限应用层。排车规划适配器只使用车队公开规划和收货端口。采购对海外主数据的旧依赖已不存在,移除该旧许可及入向断言。 - 反向检查继续拒绝新消费者、导出直连持久化、车辆应用层外向依赖和任何主源码包循环。分析器继续按既有显式 import、静态 import 及同包类型引用规则检查;本次不改变其识别范围。 @@ -28,6 +28,10 @@ `fleet.ownedbusiness`只为原排车装车后的自有车辆组合运营资料。五条精确引用限`OwnedBusinessModels→DispatchViews`、`OwnedBusinessProjection→DispatchContracts`、`OwnedBusinessQueryService→DispatchContracts/DispatchTypes/DispatchDetailReader`,均为`dispatch.port`公开事实或只读应用端口;不增加`fleet→dispatch-delivery`通用许可,不共享原排车实体、Mapper或具体Service。原详情适配器在dispatch内部委托唯一提供者。`DispatchAggregateService→OwnedBusinessOwnershipCapture`仅在原装车事务捕获归属,不能反向调用运营查询;捕获失败随原确认回滚。反向合同拒绝具体持久化/Service引用、其他消费者借用端口许可及任何包循环。 +### 外配业务原排车详情读取边界(2026-10-21) + +`OutsourcedBusinessModels → DispatchViews` 仅组合既有 `dispatch.port` 公开详情。依赖登记与已有外配反向合同同步,不开放 `fleet→dispatch-delivery` 通用许可;原排车实体、Mapper、具体 Service 及其他消费者仍由门禁拒绝。 + ### 平台自营统计边界 `selfoperated/analytics/` 在依赖分析器中作为独立 `selfoperated-analytics` 切片优先匹配,其 `outbound`、`inventory` 和 `model` 子包不归入客户出库、客户库存或尾程交接切片。该子域通过内部 HTTP 使用 Integration,持久化十张独立 `ll_selfop_*` 表,不引用现有客户履约实体或 Mapper。唯一基础能力引用为 `SelfopEgressController → security.InternalServiceTokenGuard`,复用内部令牌校验;基线仅登记此精确类边,不增加共享技术切片通配许可,包循环仍按同一门禁拒绝。 @@ -36,7 +40,7 @@ 车次关联客户排车单新增 `dispatch-delivery→fleet`:fleet声明 `FleetDispatchGateway`,dispatch内适配器锁定自身排车/出库/派送并同步车和司机快照;子排车修改与发车使用fleet公开的关联门禁。fleet不导入dispatch实体、Mapper或业务服务,因此不新增双向切片依赖。车次与客户单据的归属不变,关联和同步处于同一Lastleg事务;客户单据已经开工时不允许通过改派覆盖执行事实。 -当前分析器把 `wallet.WalletCommandOutboxService` 归入 `shared-technical`,它依赖尾程Outbox实体/Mapper;粗粒度图因而存在 `fleet→shared-technical→tailleg-handoff→dispatch-delivery→fleet` 聚合环。不能把这一聚合环等同于fleet包或Spring组件循环,也不能宣称切片图无环。门禁检查实际包无环,fleet仅允许上节自有运营的五条公开只读引用,dispatch只使用公开端口/门禁;粗粒度切片图保留为耦合报告,不因门禁通过隐去聚合环。 +当前分析器把 `wallet.WalletCommandOutboxService` 归入 `shared-technical`,它依赖尾程Outbox实体/Mapper;粗粒度图因而存在 `fleet→shared-technical→tailleg-handoff→dispatch-delivery→fleet` 聚合环。不能把这一聚合环等同于fleet包或Spring组件循环,也不能宣称切片图无环。门禁检查实际包无环,fleet仅允许上述自有运营的五条公开只读引用及外配详情的一条公开模型引用,dispatch只使用公开端口/门禁;粗粒度切片图保留为耦合报告,不因门禁通过隐去聚合环。 2026-10-02 新增 `fleet` 业务切片,归入当前 Lastleg 部署单元;司机档案与文件适配在 `fleet/driver`,计划与资源占用在 `fleet/trip`,纯资格、分段容量和成本分摊在 `fleet/domain`。fleet依赖技术HTTP配置,以及车辆应用层的 `VehicleRepository` / `VehicleAssignmentGuard` 端口和已有状态服务;新增 `fleet→overseas-master-data` 是同一物理车辆主档和车次占用的明确业务协作,不允许直接依赖车辆持久化适配器。车辆应用层继续只依赖自身端口,注入的占用检查实现归fleet。分析器显式分类并验证这些边界,旧统计及退役安全投影断言不代表当前架构事实。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index fb6f930eb..3963539c5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -132,6 +132,7 @@ public class DeliveryTransferService { @Resource private com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository cancellations; @Resource private com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository outboundQuantities; + /** 创建一件代发出库任务,使用独立的 DFCK 日流水标识。 */ @Transactional public DeliveryResponses.PdaTask createOneClick(String customerId, String operatorId, DeliveryRequests.OneClickOutbound request) { String requestHash = oneClickRequestHash(request); @@ -145,7 +146,7 @@ public class DeliveryTransferService { String selectedWarehouseId = required(firstPosition.getOverseasWarehouseId(), "库存缺少所属海外仓"); OutboundOrder outbound = new OutboundOrder(); outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); - outbound.setOutboundNo(orderNoGenerator.next("ONE_CLICK_DROPSHIP", selectedWarehouseId, outbound.getId())); + outbound.setOutboundNo(orderNoGenerator.next("ONE_CLICK_DROPSHIP")); outbound.setOutboundType("ONE_CLICK_DROPSHIP"); outbound.setStatus("WAIT_PICK"); outbound.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(request.items().stream().mapToInt(i -> i.packages().intValue()).sum()); outbound.setPalletCount(request.items().size()); outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); @@ -227,6 +228,7 @@ public class DeliveryTransferService { value(c.getTotalQuantity()), c.getStatus())).toList(); } + /** 校验退货库存后建立出库和派送单,各自分配业务日流水。 */ @Transactional public DeliveryResponses.PdaTask createReturnOutbound(String customerId, String operatorId, DeliveryRequests.ReturnOutbound request) { @@ -279,7 +281,7 @@ public class DeliveryTransferService { } OutboundOrder outbound = new OutboundOrder(); outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); - outbound.setOutboundNo(orderNoGenerator.next("RETURN_OUTBOUND", warehouseId, outbound.getId())); + outbound.setOutboundNo(orderNoGenerator.next("RETURN_OUTBOUND")); outbound.setOutboundType("RETURN_OUTBOUND"); outbound.setStatus("WAIT_PICK"); outbound.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(locked.size()); outbound.setPalletCount("PALLET".equals(request.unitType()) ? 1 : 0); outbound.setOverseasWarehouseId(warehouseId); outbound.setIdempotencyKey(request.idempotencyKey().trim()); @@ -296,7 +298,7 @@ public class DeliveryTransferService { carton.setStatus("OUTBOUND_PENDING"); carton.setUpdatedAt(now); returnCartons.updateById(carton); } DeliveryOrder delivery = new DeliveryOrder(); delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId); - delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", warehouseId, delivery.getId())); + delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY")); delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); delivery.setOverseasWarehouseId(warehouseId); delivery.setDeliveryGroupKey("RETURN:" + request.returnNo().trim()); delivery.setStatus("WAIT_DELIVERY"); delivery.setDeliveryAddress(request.recipient().trim()); @@ -358,6 +360,7 @@ public class DeliveryTransferService { return result; } + /** 为仓储转运建立普通出库和派送单,保留原库存关联。 */ @Transactional public DeliveryResponses.OutboundDetail createWarehouseTransferOutbound(String customerId, String operatorId, DeliveryRequests.WarehouseTransferOutbound request) { @@ -397,7 +400,7 @@ public class DeliveryTransferService { LocalDateTime now = LocalDateTime.now(); OutboundOrder outbound = new OutboundOrder(); outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); - outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", transfer.getOperatingWarehouseId(), outbound.getId())); + outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND")); outbound.setOverseasWarehouseId(transfer.getOperatingWarehouseId()); outbound.setOutboundType("WAREHOUSE_TRANSFER_OUTBOUND"); outbound.setStatus("WAIT_PICK"); outbound.setRecipientInfo("PRIVATE".equals(destinationType) @@ -434,7 +437,7 @@ public class DeliveryTransferService { LastlegOrder tailOrder = tailOrders.selectById(transfer.getLastlegOrderId()); DeliveryOrder delivery = new DeliveryOrder(); delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId); - delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", transfer.getOperatingWarehouseId(), delivery.getId())); + delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY")); delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); delivery.setOverseasWarehouseId(transfer.getOperatingWarehouseId()); delivery.setCustomerName(tailOrder == null ? null : tailOrder.getCustomerName()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java index 46f2689c0..1ea9fd038 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java @@ -130,7 +130,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort { for (var customer:groups.stream().map(Group::customerId).distinct().sorted().toList()) { var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList(); var order=new OutboundOrder();order.setId(IdWorker.getId());order.setCustomerId(customer); - order.setOutboundNo(numbers.next("OUTBOUND",context.warehouseId(),order.getId())); + order.setOutboundNo(numbers.next("OUTBOUND")); order.setDispatchOrderId(context.dispatchId());order.setDispatchNo(context.dispatchNo());order.setOverseasWarehouseId(context.warehouseId()); order.setOutboundType("DISPATCH_DELIVERY");order.setStatus("WAIT_OUTBOUND");order.setLxCodes(""); order.setPackageCount(Math.toIntExact(cargo.stream().mapToLong(CargoLine::packages).sum())); @@ -276,7 +276,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort { /** 客户、目的地和车辆司机仅来自权威事实,请求不能覆盖本趟资源。 */ private DeliveryOrder delivery(Context context,Group group,ArrangeGroup input,OutboundOrder outbound,String operator,String key,Instant now) { var d=new DeliveryOrder();d.setId(IdWorker.getId());d.setCustomerId(group.customerId());d.setCustomerName(group.customerName()); - d.setDeliveryNo(numbers.next("DELIVERY",context.warehouseId(),d.getId()));d.setOutboundOrderId(outbound.getId());d.setOutboundNo(outbound.getOutboundNo()); + d.setDeliveryNo(numbers.next("DELIVERY"));d.setOutboundOrderId(outbound.getId());d.setOutboundNo(outbound.getOutboundNo()); d.setDispatchOrderId(context.dispatchId());d.setDispatchNo(context.dispatchNo());d.setOverseasWarehouseId(context.warehouseId());d.setOverseasWarehouseName(context.warehouseName()); var destination=group.destination();d.setDeliveryGroupKey(grouping.documentGroupKey(destination));d.setDestinationType(destination.type()); d.setDestinationWarehouseId(destination.warehouseId());d.setDestinationWarehouseCode(destination.warehouseCode());d.setDestinationWarehouseName(destination.warehouseName()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java index d9ef4c468..2dd5503af 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java @@ -48,7 +48,7 @@ public class CargoDiscardOutboundService { LocalDateTime at = LocalDateTime.now(); OutboundOrder outbound = new OutboundOrder(); outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId()); - outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", application.getOverseasWarehouseId(), outbound.getId())); + outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND")); outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE); outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo()); outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java index 4b9e3900b..48107fd17 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java @@ -222,7 +222,7 @@ public class CargoDiscardService { if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId); CargoDiscardApplication application = new CargoDiscardApplication(); application.setId(IdWorker.getId()); application.setCustomerId(customerId); - application.setDiscardNo(orderNoGenerator.next("DISCARD", warehouseId, application.getId())); + application.setDiscardNo(orderNoGenerator.next("DISCARD")); application.setOverseasWarehouseId(warehouseId); application.setOverseasWarehouseName(warehouseName); application.setInboundCodes(java.util.stream.Stream.concat( java.util.stream.Stream.concat(orderCache.values().stream().map(LastlegOrder::getInboundCode), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java index 685804d1a..b83ff9ee7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java @@ -418,6 +418,7 @@ public class ExceptionOrderService { tail == null ? inboundOrder.getPackageCount() : tail.getPackageCount(), List.of()); } + /** 保存异常单及来源关系,异常编号使用 EX 日流水。 */ private ExceptionOrderResponses.Created insert(ExceptionOrderResponses.Relation relation, DeliveryOrder delivery, String type, String reason, Integer packages, String sku, String direction, String logisticsNo, String sourceType, @@ -428,7 +429,7 @@ public class ExceptionOrderService { LocalDateTime now = LocalDateTime.now(); ExceptionOrder value = new ExceptionOrder(); value.setId(IdWorker.getId()); value.setCustomerId(relation.customerId()); - value.setExceptionNo(orderNoGenerator.next("EXCEPTION", relation.overseasWarehouseId(), value.getId())); + value.setExceptionNo(orderNoGenerator.next("EXCEPTION")); value.setExceptionType(type); value.setStatus("PENDING"); value.setReason(trim(reason)); value.setLastMileOrderId(relation.lastMileOrderId()); value.setLastMileOrderNo(relation.lastMileOrderNo()); value.setInboundOrderId(relation.inboundOrderId()); value.setInboundCode(relation.inboundCode()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnService.java index 0d0c5850d..5a282056f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnService.java @@ -57,7 +57,7 @@ public class ReturnCartonReturnService { var pallet=pallets.resolve(input.targetPalletNo()); var location=warehouse.resolveLocation(Long.parseLong(order.getOverseasWarehouseId()),input.targetLocationCode()); if(pallet==null||pallet.palletId()!=input.targetPalletId()||!String.valueOf(pallet.warehouseId()).equals(order.getOverseasWarehouseId())||!Set.of("IDLE","OCCUPIED").contains(pallet.status())||location==null||location.locationId()!=input.targetLocationId()||!Objects.equals(pallet.locationId(),location.locationId())||!Set.of("ACTIVE","IDLE","OCCUPIED").contains(location.status())||"VIRTUAL".equalsIgnoreCase(location.locationType()))throw conflict("目标托盘、库位或海外仓不匹配,不能确认原箱回仓"); - LocalDateTime now=LocalDateTime.now();String receiptNo=numbers.next("RETURN_RECEIPT",order.getOverseasWarehouseId(),IdWorker.getId());boolean first=true; + LocalDateTime now=LocalDateTime.now();String receiptNo=numbers.next("RETURN_RECEIPT");boolean first=true; for(var movement:shipped) { repository.restore(movement); var receipt=new ReturnCartonStockReceipt();receipt.setId(IdWorker.getId());receipt.setReceiptNo(receiptNo);receipt.setCustomerId(order.getCustomerId()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java index 19d19c6cb..927f6aa53 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java @@ -21,13 +21,13 @@ import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*; @Service public class FleetPoolOrderBridge { private final FleetPoolOrderRepository repository; - private final FleetPoolOrderNumberGenerator numbers; + private final com.baiyue.lastleg.service.LastlegDocumentNumberGenerator numbers; private final ObjectMapper json; private final FleetPoolDeliveryEvidence deliveryEvidence; private final com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources; /** 注入同库商业委托仓储及履约协作服务。 */ public FleetPoolOrderBridge(FleetPoolOrderRepository repository, - FleetPoolOrderNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence, + com.baiyue.lastleg.service.LastlegDocumentNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence, com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources) { this.repository=repository; this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence; @@ -50,7 +50,7 @@ public class FleetPoolOrderBridge { var now=LocalDateTime.now();var row=new FleetPoolOrderLink(); row.setId(IdWorker.getId());row.setBookingId(in.bookingId());row.setBookingNo(in.bookingNo()); row.setCustomerId(in.customerId());row.setBusinessType(in.businessType());row.setBusinessOrderId(IdWorker.getId()); - row.setBusinessOrderNo(numbers.next("US_TRUCK".equals(in.businessType())?"TRUCK":"LOOSEBOARD",in.destinationLabel(),row.getBusinessOrderId())); + row.setBusinessOrderNo(numbers.next("US_TRUCK".equals(in.businessType())?"TR":"LB")); row.setWarehouseId(in.warehouseId());row.setDestinationKey(in.destinationKey()); row.setDeliveryMode(in.deliveryMode());row.setDeliveryInstructions(in.deliveryInstructions()); row.setFulfillmentStage("WAITING_INBOUND");row.setStageOccurredAt(now);row.setRecordVersion(0L); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGenerator.java deleted file mode 100644 index 894686ddb..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGenerator.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.baiyue.lastleg.fleet.business; - -import org.springframework.stereotype.Component; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.Locale; -import java.util.regex.Pattern; - -/** Commercial pool orders own LB/TR numbering; no tail-leg order sequence is consumed. */ -@Component -public class FleetPoolOrderNumberGenerator { - private static final Pattern LOCATION=Pattern.compile("LAX|ONT|NYC|JFK|LGB|EWR|SFO|SEA|DFW|ORD|ATL|MIA|PHX|LAS|YYZ|YVR|AMS|LHR|FRA|CDG|HKG|NRT|SYD"); - public String next(String businessType,String destination,long uniqueId){ - String prefix=switch(businessType){case "TRUCK"->"TR";case "LOOSEBOARD"->"LB";default->throw new IllegalArgumentException("Unsupported pool business type");}; - return prefix+"-"+location(destination)+"-"+LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) - +"-"+String.format(Locale.ROOT,"%06d",Math.floorMod(uniqueId,1_000_000)); - } - private static String location(String value){ - String code=value==null?"":value.trim().toUpperCase(Locale.ROOT);var match=LOCATION.matcher(code); - if(Pattern.matches(".*("+LOCATION.pattern()+").*",code)&&match.find())return match.group(); - return code.matches("[A-Z0-9]{2,8}")?code:"WH"; - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareService.java index 93ec5c040..025f756c5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareService.java @@ -22,6 +22,8 @@ import java.util.*; @Service public class VehicleCareService { + @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; + private final VehicleCareMapper records; private final VehicleCareHistoryMapper histories; private final VehicleRepository vehicles; @@ -41,6 +43,7 @@ public class VehicleCareService { return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } public VehicleCareModels.Response detail(long vehicleId, String number) { return response(required(vehicleId, number, false)); } + /** 建立车辆运维记录,首次建单分配 VR 编号。 */ @Transactional public VehicleCareModels.Response create(long vehicleId, String operator, VehicleCareModels.Create request) { // Serialize creation per vehicle; no empty-range locks or duplicate creation after a lost response. @@ -53,7 +56,7 @@ public class VehicleCareService { } validate(request.details()); var row = new VehicleCareEntity(); row.setId(IdWorker.getId()); - row.setRecordNo("VR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT)); + row.setRecordNo(documentNumbers.next("VR")); row.setVehicleId(vehicleId); row.setVehiclePlate(vehicle.plateNumber()); row.setRecordType(request.details().recordType()); row.setOccurredAt(utc(request.details().occurredAt())); row.setDetailsJson(write(request.details())); row.setFilesJson("[]"); row.setIdempotencyKey(request.idempotencyKey()); row.setRequestHash(fingerprint); row.setRecordVersion(0L); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java index fdf8ad43d..6d84bf475 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java @@ -26,6 +26,8 @@ import java.util.Objects; @Service public class FleetBookingService { + @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; + private final FleetTripMapper trips; private final FleetBookingMapper bookings; private final FleetBookingReservations reservations; @@ -56,13 +58,14 @@ public class FleetBookingService { var context=quoteContext.resolve(trip(tripNo,false),query.customerId(),query.destinationKey(),query.currency(),query.priceBasis()); return quotes.candidates(new FleetQuoteCandidateModels.Query(context.customerId(),context.originCity(),context.destinationCode(),context.currency(),context.effectiveOn(),context.priceBasis(),query.page(),query.size())); } + /** 在车次内登记预订,首次请求分配 FB 业务号。 */ @Transactional public FleetBookingModels.Result create(String tripNo,String operator,FleetBookingModels.Create request){ var trip=trip(tripNo,true);String hash=hash(request); var existing=bookings.created(trip.getId(),operator,request.idempotencyKey()); if(existing!=null){if(!hash.equals(existing.getRequestHash()))throw conflict("同一登记请求不能变更客户或货物内容");return result(trip,existing,true);} requireTrip(trip,request.tripRecordVersion());var customer=customers.required(request.customerId()); - var row=new FleetBookingEntity();row.setId(IdWorker.getId());row.setBookingNo("FB-"+row.getId());row.setTripId(trip.getId());row.setTripNo(tripNo); + var row=new FleetBookingEntity();row.setId(IdWorker.getId());row.setBookingNo(documentNumbers.next("FB"));row.setTripId(trip.getId());row.setTripNo(tripNo); row.setCustomerId(customer.id());row.setCustomerCode(customer.code());row.setCustomerName(customer.name());row.setStatus("DRAFT"); apply(trip,row,request.cargo());applyQuote(trip,row,request.quoteRef());row.setIdempotencyKey(request.idempotencyKey());row.setRequestHash(hash);row.setRecordVersion(0L); row.setCreatedBy(operator);row.setCreatedAt(now());row.setUpdatedBy(operator);row.setUpdatedAt(row.getCreatedAt()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java index ca291fe1a..7564a7a5e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java @@ -25,6 +25,7 @@ import java.util.*; @Service public class FleetCapacityOfferService { + @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; public static final String LIST="platform:fleet:capacity-offer:list", MANAGE="platform:fleet:capacity-offer:manage"; private final FleetCapacityOfferMapper offers; private final FleetCapacityOfferCommandMapper commands; @@ -125,6 +126,7 @@ public class FleetCapacityOfferService { var row=offer(number,false);requireVisible(row);customers.required(query.customerId()); return bookingService.quoteCandidates(row.getTripNo(),query); } + /** 发布车队运力并分配 FO 日流水,重放复用原发布记录。 */ @Transactional public FleetCapacityOfferModels.OfferResult create(FleetCapacityOfferModels.Write request){ requireManage();var trip=trip(request.tripNo(),true); @@ -133,7 +135,7 @@ public class FleetCapacityOfferService { tripVersion(trip,request.tripRecordVersion()); if(offers.byTrip(trip.getId())!=null)throw conflict("该车次已有发布,请从既有发布修改"); validateCutoff(trip,request.cutoffAt()); - var row=new FleetCapacityOfferEntity();row.setId(IdWorker.getId());row.setOfferNo("FO-"+row.getId());row.setTripId(trip.getId());row.setTripNo(trip.getTripNo()); + var row=new FleetCapacityOfferEntity();row.setId(IdWorker.getId());row.setOfferNo(documentNumbers.next("FO"));row.setTripId(trip.getId());row.setTripNo(trip.getTripNo()); row.setStatus("DRAFT");row.setRecordVersion(0L);row.setCreatedBy(operator());row.setCreatedAt(now());apply(row,request); if(offers.insert(row)!=1)throw conflict("发布创建失败"); var result=new FleetCapacityOfferModels.OfferResult(project(row,trip),false);saveCommand(trip,"CREATE",trip.getTripNo(),request.idempotencyKey(),request,result);return result; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java index 5f9bcb3e6..6dd698296 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java @@ -27,6 +27,7 @@ import java.util.*; @Service public class FleetTripService { + @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; @jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workChanges; @jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations; private final FleetTripRepository repository; @@ -107,7 +108,7 @@ public class FleetTripService { var warehouse = warehouses.required(request.warehouseId(), "OVERSEAS"); var trip = new FleetTripEntity(); trip.setId(IdWorker.getId()); - trip.setTripNo("TR-" + new java.math.BigInteger(UUID.randomUUID().toString().replace("-", ""), 16).toString(36).toUpperCase(Locale.ROOT)); + trip.setTripNo(documentNumbers.next("CT")); trip.setIdempotencyKey(request.idempotencyKey()); trip.setRequestHash(request.commandHash()); trip.setStatus("PLANNED"); trip.setTripPurpose("DELIVERY"); trip.setWarehouseId(request.warehouseId()); trip.setWarehouseCode(warehouse.code()); trip.setWarehouseName(warehouse.name()); @@ -383,7 +384,7 @@ public class FleetTripService { var warehouse = warehouses.required(request.warehouseId(), "OVERSEAS"); var trip = new FleetTripEntity(); trip.setId(IdWorker.getId()); - trip.setTripNo("TR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT)); + trip.setTripNo(documentNumbers.next("CT")); trip.setIdempotencyKey(request.idempotencyKey()); trip.setRequestHash(hash); trip.setTripPurpose(purpose); trip.setStatus("PLANNED"); trip.setWarehouseId(Long.valueOf(warehouse.id())); trip.setWarehouseCode(warehouse.code()); trip.setWarehouseName(warehouse.name()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java index 13e3782bc..51cc720d8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java @@ -292,7 +292,7 @@ public class ContainerInboundPlanConversionCoordinator { ? plans.existingPlanIdentity(order.customerId(), Long.parseLong(order.orderId())) : null; long planId = existing == null ? IdWorker.getId() : existing.planId(); String planNo = existing == null - ? numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId) : existing.planNo(); + ? numbers.next("INBOUND_PLAN") : existing.planNo(); if (existing != null && existing.palletCount() != targetPalletCount) { throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划托数与柜级预览已不一致"); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java index cec90b860..b58948ce6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java @@ -23,6 +23,8 @@ import java.util.stream.Collectors; @Service public class InboundFbaAppointmentService { + @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; + @Resource private InboundFbaAppointmentMapper appointmentMapper; @Resource private InboundFbaAppointmentItemMapper itemMapper; @Resource private InboundFbaAppointmentQueryMapper queryMapper; @@ -76,6 +78,7 @@ public class InboundFbaAppointmentService { new AppointmentCoverageService.SourceRef(source.customerId(),source.sourceKey(),source.destinationKey())).toList(), request.originWarehouseId(),request.fbaWarehouseCode(),request.details())); } + /** 校验预约覆盖的来源单据,首次创建时分配 YY 预约号。 */ private FbaAppointmentResponse create(String operator,List refs,String expectedOrigin,String expectedDestination,FbaAppointmentDetails details) { var resolved=resolveForWrite(refs); var group=group(resolved); @@ -83,7 +86,7 @@ public class InboundFbaAppointmentService { if(!Objects.equals(group.originWarehouseId(),expectedOrigin) || !Objects.equals(group.destinationKey(),"FBA:"+expectedDestination)) throw conflict("接仓点或目的仓已变化,请重新核对预约货物"); var appointment=new InboundFbaAppointment(); appointment.setId(IdWorker.getId()); - appointment.setAppointmentNo("AP-"+LocalDate.now(ZoneOffset.UTC).toString().replace("-","")+"-"+UUID.randomUUID().toString().replace("-","").substring(0,12).toUpperCase(Locale.ROOT)); + appointment.setAppointmentNo(documentNumbers.next("YY")); appointment.setOriginWarehouseId(group.originWarehouseId()); appointment.setFbaWarehouseCode(resolved.get(0).destinationWarehouseCode()); appointment.setSelectedCount(resolved.size());appointment.setVersion(0); @@ -261,6 +264,7 @@ public class InboundFbaAppointmentService { appointment.setAppointmentStartUtc(null);appointment.setAppointmentEndUtc(null); } + /** 重新分组预约来源,新建预约分配 YY 编号并保留覆盖约束。 */ @Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public RepartitionFbaAppointmentsResponse repartition(String operator,RepartitionFbaAppointmentsRequest request) { validateRepartition(request); @@ -330,7 +334,7 @@ public class InboundFbaAppointmentService { var rows=batch.allocations().stream().map(a->bySource.get(a.sourceKey())).toList(); var quantities=batch.allocations().stream().collect(Collectors.toMap(RepartitionFbaAppointmentsRequest.Allocation::sourceKey,RepartitionFbaAppointmentsRequest.Allocation::packageCount)); var row=new InboundFbaAppointment();row.setId(IdWorker.getId()); - row.setAppointmentNo("AP-"+LocalDate.now(ZoneOffset.UTC).toString().replace("-","")+"-"+UUID.randomUUID().toString().replace("-","").substring(0,12).toUpperCase(Locale.ROOT)); + row.setAppointmentNo(documentNumbers.next("YY")); row.setOriginWarehouseId(grouped.originWarehouseId());row.setFbaWarehouseCode(rows.get(0).destinationWarehouseCode());row.setSelectedCount(rows.size());row.setVersion(0); row.setCreatedBy(operator);row.setCreatedAt(now());row.setUpdatedAt(row.getCreatedAt()); apply(row,new FbaAppointmentDetails(batch.plannedDate(),null,null,null,null,null,"PENDING",batch.remark(),null,null),rows,evidence); @@ -471,7 +475,7 @@ public class InboundFbaAppointmentService { } if(laterAmounts.isEmpty()) throw bad("本次货量没有变化,无需创建变化记录"); var laterRows=resolved.stream().filter(s->laterAmounts.containsKey(s.sourceKey())).toList(); - var later=new InboundFbaAppointment();later.setId(IdWorker.getId());later.setAppointmentNo("AP-"+LocalDate.now(ZoneOffset.UTC).toString().replace("-","")+"-"+UUID.randomUUID().toString().replace("-","").substring(0,12).toUpperCase(Locale.ROOT)); + var later=new InboundFbaAppointment();later.setId(IdWorker.getId());later.setAppointmentNo(documentNumbers.next("YY")); later.setOriginWarehouseId(original.getOriginWarehouseId());later.setFbaWarehouseCode(original.getFbaWarehouseCode());later.setVersion(0);later.setSelectedCount(laterRows.size());later.setCreatedBy(operator);later.setCreatedAt(now());later.setUpdatedAt(now()); apply(later,new FbaAppointmentDetails(intent.laterDate(),null,null,null,null,null,"PENDING",intent.reason(),null,null),laterRows,evidence); appointmentMapper.insert(later);writeItems(later,laterRows,List.of(),evidence,laterAmounts); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 0713ee552..9d2786b62 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -456,6 +456,7 @@ public class InboundPlanService { receivingWarehouse, plannedArrivalDate, null, Map.of()); } + /** 创建入仓计划,复用预分配编号或首次分配 IP 日流水。 */ private InboundPlanResponse createPlan(String customerId, Long selectedOrderId, List orders, List sourcePalletNos, List wholePallets, ManualInboundPlanRequest manual, @@ -468,7 +469,7 @@ public class InboundPlanService { InboundPlan plan = new InboundPlan(); plan.setId(preparedPlan == null ? IdWorker.getId() : preparedPlan.planId()); plan.setPlanNo(preparedPlan == null - ? orderNoGenerator.next("INBOUND_PLAN", orders.get(0).destinationWarehouseCode(), plan.getId()) + ? orderNoGenerator.next("INBOUND_PLAN") : preparedPlan.planNo()); plan.setCustomerId(customerId); plan.setSourceOrderId(selectedOrderId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java index d2d5fa6ee..7e7297f04 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java @@ -63,7 +63,7 @@ public class LocalLooseboardService { LocalLooseboardOrder order = new LocalLooseboardOrder(); order.setId(IdWorker.getId()); order.setCustomerId(customerId); - order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD", request.destination(), order.getId())); + order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD")); apply(order, request.origin(), request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(), request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), @@ -277,7 +277,7 @@ public class LocalLooseboardService { private LastlegOrder createLastlegOrder(LocalLooseboardOrder source, String requestHash, LocalDateTime now) { LastlegOrder order = new LastlegOrder(); order.setId(IdWorker.getId()); - order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD", source.getDestination(), order.getId())); + order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD")); order.setCustomerId(source.getCustomerId()); order.setOrderType("LOCAL_LOOSEBOARD"); order.setInboundCode(null); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/DocumentSequenceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/DocumentSequenceMapper.java new file mode 100644 index 000000000..2c688a046 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/DocumentSequenceMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.mapper; + +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** 本服务业务编号序列,更新与读取必须处于同一事务连接。 */ +@Mapper +public interface DocumentSequenceMapper { + /** 唯一序列键原子递增,并在连接内保存本次分配值。 */ + @Insert("INSERT INTO ll_document_sequence(sequence_key,current_value) VALUES(#{key},LAST_INSERT_ID(1)) " + + "ON DUPLICATE KEY UPDATE current_value=LAST_INSERT_ID(current_value+1)") + int allocate(@Param("key") String key); + + /** 禁用一级和二级缓存,返回本事务刚刚分配的值。 */ + @Select("SELECT LAST_INSERT_ID()") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + Long allocatedValue(); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderNoDailySequenceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderNoDailySequenceMapper.java deleted file mode 100644 index 7f18718cd..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderNoDailySequenceMapper.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.baiyue.lastleg.mapper; - -import org.apache.ibatis.annotations.Insert; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; - -import java.time.LocalDate; - -@Mapper -public interface LastlegOrderNoDailySequenceMapper { - @Insert("INSERT INTO ll_order_no_daily_sequence(business_date,last_sequence) " + - "VALUES(#{businessDate},LAST_INSERT_ID(1)) " + - "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)") - int nextValue(@Param("businessDate") LocalDate businessDate); - - @Select("SELECT LAST_INSERT_ID()") - long currentValue(); -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java index ac50d6b2f..520b2fcff 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java @@ -33,6 +33,7 @@ public class OverseasProcurementFee { private Long sourceFeeId; private Long parentFeeId; private String requestHash; + private String idempotencyKey; private String feeItemCode; private String feeItemName; private BigDecimal amount; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java index bc2e8f7ad..40193e3fa 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java @@ -3,6 +3,9 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; import org.apache.ibatis.annotations.Mapper; @Mapper public interface OverseasProcurementFeeMapper extends BaseMapper { + /** 调用方持有订单锁时,读取当前退款版本上界。 */ + @org.apache.ibatis.annotations.Select("SELECT COALESCE(MAX(fee_version),0) FROM ll_overseas_procurement_fee WHERE order_id=#{orderId} AND purpose='REFUND'") + int maxRefundVersion(@org.apache.ibatis.annotations.Param("orderId") long orderId); @org.apache.ibatis.annotations.Select("SELECT * FROM ll_overseas_procurement_fee WHERE id=#{id} AND order_id=#{orderId} AND customer_id=#{customerId} FOR UPDATE") OverseasProcurementFee lock(@org.apache.ibatis.annotations.Param("id") long id, @org.apache.ibatis.annotations.Param("orderId") long orderId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementFeeNumberRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementFeeNumberRepository.java new file mode 100644 index 000000000..52e24df0d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementFeeNumberRepository.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.procurement.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; +import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper; +import org.springframework.stereotype.Repository; + +/** 采购退款的请求身份独立于展示单号,跨日重试仍定位原费用。 */ +@Repository +public class ProcurementFeeNumberRepository { + private final OverseasProcurementFeeMapper mapper; + + /** 使用采购费用所属服务的 Mapper。 */ + public ProcurementFeeNumberRepository(OverseasProcurementFeeMapper mapper) { this.mapper = mapper; } + + /** 上层已持有订单锁,同客户同订单的退款请求只对应一个费用。 */ + public OverseasProcurementFee findRefund(String customerId, long orderId, String key) { + return mapper.selectOne(new LambdaQueryWrapper() + .eq(OverseasProcurementFee::getCustomerId, customerId) + .eq(OverseasProcurementFee::getOrderId, orderId) + .eq(OverseasProcurementFee::getPurpose, "REFUND") + .eq(OverseasProcurementFee::getIdempotencyKey, key)); + } + + /** 版本沿用同订单退款序列,业务编号不承担费用版本含义。 */ + public int nextRefundVersion(long orderId) { return mapper.maxRefundVersion(orderId) + 1; } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java index 0c9019998..f47a55f0b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -43,7 +43,7 @@ import org.springframework.web.multipart.MultipartFile; @Service public class OverseasProcurementService { - @Resource private ProcurementOrderNumberGenerator orderNoGenerator; + @Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator orderNoGenerator; public static final String REVIEW_PERMISSION = OverseasPermissions.PURCHASE_REVIEW; public static final String MANAGE_PERMISSION = OverseasPermissions.PURCHASE_EDIT; public static final String PENDING_REVIEW = "PENDING_REVIEW"; @@ -61,6 +61,7 @@ public class OverseasProcurementService { @Resource private OverseasProcurementFeeMapper procurementFees; @Resource private ProcurementFileGateway fileGateway; + /** 创建客户采购订单,首次请求分配 CG 日流水。 */ @Transactional public OverseasProcurementResponses.Detail create(String customerId, String operatorId, OverseasProcurementRequests.Create request) { @@ -74,7 +75,7 @@ public class OverseasProcurementService { order.setId(IdWorker.getId()); order.setCustomerId(customerId); order.setCustomerName(trim(request.customerName())); - order.setOrderNo(orderNoGenerator.next(customerId, request.customerName())); + order.setOrderNo(orderNoGenerator.next("CG")); order.setPurchaseCategory(request.purchaseCategory() == null ? "" : request.purchaseCategory().trim()); order.setProductName(request.productName().trim()); order.setProductSpecification(trim(request.productSpecification())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementAdjustmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementAdjustmentService.java index a8a5df697..de6cbda02 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementAdjustmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementAdjustmentService.java @@ -16,6 +16,8 @@ import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService @Service @RequiredArgsConstructor public class ProcurementAdjustmentService { + @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; + private final ProcurementLifecycleService lifecycle; private final ProcurementFinanceService finance; private final JdbcTemplate jdbc; @@ -23,6 +25,7 @@ public class ProcurementAdjustmentService { BigDecimal exchangeRate,String exchangeRateMonth,BigDecimal targetAmount,BigDecimal previousNetAmount, BigDecimal differenceAmount,List feeNos) { } private record Identity(long rootFeeId,Correct request) { } + /** 更正原采购费用并以 PG 日流水标识本次更正记录。 */ @Transactional public Result correct(String customer,String orderNo,String operator,long rootFeeId,Correct request) { var order=lifecycle.lock(customer,orderNo);var identity=new Identity(rootFeeId,request); @@ -37,7 +40,7 @@ public class ProcurementAdjustmentService { .subtract(chain.stream().filter(f->"REFUNDED".equals(f.getWalletStatus())).map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO,BigDecimal::add)); BigDecimal target=request.targetOriginalAmount().multiply(root.getExchangeRate()).setScale(2,RoundingMode.HALF_UP),delta=target.subtract(net); if(target.precision()-target.scale()>18)throw bad("折算人民币金额超出支持范围"); - long id=IdWorker.getId();String no="PC-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase(); + long id=IdWorker.getId();String no=documentNumbers.next("PG"); // The node command carries the exact request hash; this table stores the computed immutable monetary facts. jdbc.update("INSERT INTO ll_procurement_fee_correction(id,order_id,customer_id,correction_no,root_fee_id,idempotency_key,request_hash,target_original_amount,original_currency,exchange_rate,exchange_rate_month,target_amount,previous_net_amount,difference_amount,voucher_file_id,reason,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", id,order.getId(),customer,no,rootFeeId,request.idempotencyKey(),lifecycle.requestHash(identity),request.targetOriginalAmount(),root.getOriginalCurrency(),root.getExchangeRate(),root.getExchangeRateMonth(),target,net,delta,request.voucherFileId(),request.reason(),operator); @@ -47,6 +50,7 @@ public class ProcurementAdjustmentService { lifecycle.complete(order,operator,"CORRECT_FEE_TARGET",request.idempotencyKey(),request.reason(),identity); return result(order.getId(),request.idempotencyKey()); } + /** 替换失败费用,保留原费用链并分配新的 PG 更正记录号。 */ @Transactional public Result replaceFailed(String customer,String orderNo,String operator,long originalFeeId,Correct request) { var order=lifecycle.lock(customer,orderNo);var identity=new Identity(originalFeeId,request); @@ -57,7 +61,7 @@ public class ProcurementAdjustmentService { BigDecimal target=request.targetOriginalAmount().multiply(original.getExchangeRate()).setScale(2,RoundingMode.HALF_UP); if("SAMPLE_PAYMENT".equals(original.getPurpose()))lifecycle.requireReplacementBudget(order,request.targetOriginalAmount(),original.getOriginalCurrency(),original.getBusinessDate(),target); if(target.precision()-target.scale()>18)throw bad("折算人民币金额超出支持范围"); - long id=IdWorker.getId();String no="PC-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase(); + long id=IdWorker.getId();String no=documentNumbers.next("PG"); jdbc.update("INSERT INTO ll_procurement_fee_correction(id,order_id,customer_id,correction_no,root_fee_id,idempotency_key,request_hash,target_original_amount,original_currency,exchange_rate,exchange_rate_month,target_amount,previous_net_amount,difference_amount,voucher_file_id,reason,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", id,order.getId(),customer,no,originalFeeId,request.idempotencyKey(),lifecycle.requestHash(identity),request.targetOriginalAmount(),original.getOriginalCurrency(),original.getExchangeRate(),original.getExchangeRateMonth(),target,BigDecimal.ZERO,target,request.voucherFileId(),request.reason(),operator); var fee=finance.replaceFailed(customer,orderNo,operator,originalFeeId,request.targetOriginalAmount(),request.voucherFileId(),request.reason()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java index c5452b234..678b5e980 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java @@ -13,6 +13,8 @@ import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService @Service @RequiredArgsConstructor public class ProcurementControlService { + @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; + private final ProcurementLifecycleService lifecycle; private final ProcurementFinanceService finance; private final ProcurementWarehouseDirectory warehouses; @@ -141,5 +143,6 @@ public class ProcurementControlService { return result; } private String serialize(Object value){if(value==null)return null;try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}} - private static String number(String prefix){return prefix+'-'+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase();} + /** 说明和关闭记录使用各自独立的日流水。 */ + private String number(String prefix){return documentNumbers.next(prefix);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java index 0aeafce03..9f77bdc76 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java @@ -38,6 +38,9 @@ import java.util.UUID; @Service @RequiredArgsConstructor public class ProcurementFinanceService implements WalletResultHandler { + @jakarta.annotation.Resource private com.baiyue.lastleg.procurement.repository.ProcurementFeeNumberRepository feeNumbers; + @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; + private final OverseasProcurementFeeMapper fees; private final OverseasProcurementFileMapper files; private final OverseasProcurementOrderMapper orders; @@ -118,7 +121,7 @@ public class ProcurementFinanceService implements WalletResultHandler { LocalDateTime now = LocalDateTime.now(); OverseasProcurementFee fee = new OverseasProcurementFee(); fee.setId(IdWorker.getId()); fee.setOrderId(order.getId()); fee.setCustomerId(order.getCustomerId()); - fee.setFeeNo("PF-" + UUID.randomUUID().toString().replace("-", "").substring(0, 24).toUpperCase()); + fee.setFeeNo(documentNumbers.next("PF")); fee.setPurpose(request.purpose().name()); fee.setConfirmationOrigin(origin); fee.setFeeVersion(maxVersion == null ? 1 : maxVersion + 1); fee.setActive(true); fee.setDetermination(request.determination()); fee.setFeeItemCode(code); fee.setFeeItemName(request.purpose() == ProcurementFeePurpose.OTHER ? request.feeItemName().trim() : request.purpose().label()); @@ -205,6 +208,7 @@ public class ProcurementFinanceService implements WalletResultHandler { var order = lockOrder(customerId, orderNo); return refundLocked(order,operatorId,originalFeeId,amount,voucherFileId,reason,idempotencyKey,true); } + /** 订单锁内先恢复同幂等键退款,首次请求校验后才分配独立 PF 业务号。 */ private OverseasProcurementFee refundLocked(OverseasProcurementOrder order,String operatorId,long originalFeeId, BigDecimal amount,long voucherFileId,String reason,String idempotencyKey,boolean checkChain) { String customerId=order.getCustomerId(),orderNo=order.getOrderNo(); @@ -212,11 +216,8 @@ public class ProcurementFinanceService implements WalletResultHandler { var request = new Confirmation(ProcurementFeePurpose.REFUND, "REFUND", "退款", "POSITIVE", amount, "CNY", LocalDate.now(), voucherFileId, reason); validate(request); - // Identity is independent of wall-clock time, so retrying after midnight returns the same refund. - String number = "RF-" + UUID.nameUUIDFromBytes((customerId + ':' + orderNo + ':' + idempotencyKey) - .getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString().replace("-", "").toUpperCase(); String requestHash = hash(List.of(originalFeeId, amount, voucherFileId, reason)); - var existing = fees.selectOne(new LambdaQueryWrapper().eq(OverseasProcurementFee::getFeeNo, number)); + var existing = feeNumbers.findRefund(customerId, order.getId(), idempotencyKey); if (existing != null) { if (!requestHash.equals(existing.getRequestHash())) throw conflict("退款幂等键对应不同原扣款或金额"); return existing; @@ -237,10 +238,11 @@ public class ProcurementFinanceService implements WalletResultHandler { if (already.add(amount).compareTo(original.getAmount()) > 0) throw bad("退款超过原扣款可退余额"); var voucher = voucher(order, request, true); var fee = new OverseasProcurementFee(); + String number = documentNumbers.next("PF"); + fee.setIdempotencyKey(idempotencyKey); fee.setId(IdWorker.getId()); fee.setOrderId(order.getId()); fee.setCustomerId(customerId); fee.setFeeNo(number); fee.setConfirmationOrigin("FINANCIAL");fee.setPurpose("REFUND"); fee.setFeeItemCode("REFUND"); fee.setFeeItemName("关联费用退款"); - Integer last = jdbc.queryForObject("SELECT COALESCE(MAX(fee_version),0) FROM ll_overseas_procurement_fee WHERE order_id=? AND purpose='REFUND'", Integer.class, order.getId()); - fee.setFeeVersion(last == null ? 1 : last + 1); fee.setActive(true); fee.setDetermination("POSITIVE"); + fee.setFeeVersion(feeNumbers.nextRefundVersion(order.getId())); fee.setActive(true); fee.setDetermination("POSITIVE"); fee.setAmount(amount); fee.setCurrency("CNY"); fee.setOriginalAmount(amount); fee.setOriginalCurrency("CNY"); fee.setExchangeRate(BigDecimal.ONE); fee.setExchangeRateMonth(request.businessDate().toString().substring(0, 7)); fee.setBusinessDate(request.businessDate()); fee.setParentFeeId(originalFeeId); fee.setWalletBusinessId(fee.getId()); @@ -272,7 +274,7 @@ public class ProcurementFinanceService implements WalletResultHandler { requireSourceCapacity(delta,delta); var proof=voucher(order,new Confirmation(ProcurementFeePurpose.ADJUSTMENT,"ADJUSTMENT","追加费用","POSITIVE",delta,"CNY",root.getBusinessDate(),voucherId,reason),true); var fee=new OverseasProcurementFee();fee.setId(IdWorker.getId());fee.setOrderId(order.getId());fee.setCustomerId(customer); - fee.setFeeNo("PF-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase());fee.setConfirmationOrigin("FINANCIAL");fee.setPurpose("ADJUSTMENT"); + fee.setFeeNo(documentNumbers.next("PF"));fee.setConfirmationOrigin("FINANCIAL");fee.setPurpose("ADJUSTMENT"); fee.setFeeItemCode("ADJ_"+root.getId());fee.setFeeItemName("原费用正额调整"); fee.setFeeVersion(jdbc.queryForObject("SELECT COALESCE(MAX(fee_version),0)+1 FROM ll_overseas_procurement_fee WHERE order_id=? AND purpose='ADJUSTMENT' AND fee_item_code=?",Integer.class,order.getId(),fee.getFeeItemCode())); fee.setActive(true);fee.setDetermination("POSITIVE");fee.setAmount(delta);fee.setCurrency("CNY");fee.setOriginalCurrency("CNY");fee.setOriginalAmount(delta); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGenerator.java deleted file mode 100644 index da15190a1..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGenerator.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.baiyue.lastleg.procurement.service; - -import net.sourceforge.pinyin4j.PinyinHelper; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; - -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.Locale; - -@Component -public class ProcurementOrderNumberGenerator { - private final JdbcTemplate jdbc; - public ProcurementOrderNumberGenerator(JdbcTemplate jdbc) { this.jdbc = jdbc; } - - @Transactional - public String next(String customerId, String customerName) { - LocalDate date = LocalDate.now(); - jdbc.update("INSERT INTO ll_procurement_daily_sequence(customer_id,business_date,sequence_value) VALUES(?,?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE sequence_value=LAST_INSERT_ID(sequence_value+1)", - customerId, date); - Integer value = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Integer.class); - if (value == null || value < 1 || value > 9999) throw new IllegalStateException("客户采购订单当日序号已耗尽"); - return initials(customerName) + date.format(DateTimeFormatter.BASIC_ISO_DATE) + String.format("%04d", value); - } - - static String initials(String customerName) { - if (!StringUtils.hasText(customerName)) return "KH"; - StringBuilder result = new StringBuilder(); - boolean latinWord = false; - for (char value : customerName.trim().toCharArray()) { - if (result.length() >= 8) break; - if (Character.UnicodeScript.of(value) == Character.UnicodeScript.HAN) { - String[] pinyin = PinyinHelper.toHanyuPinyinStringArray(value); - if (pinyin != null && pinyin.length > 0 && !pinyin[0].isBlank()) result.append(Character.toUpperCase(pinyin[0].charAt(0))); - latinWord = false; - } else if (Character.isLetterOrDigit(value)) { - if (!latinWord) result.append(Character.toUpperCase(value)); - latinWord = true; - } else { - latinWord = false; - } - } - String normalized = result.toString().replaceAll("[^A-Z0-9]", "").toUpperCase(Locale.ROOT); - return normalized.isBlank() ? "KH" : normalized.substring(0, Math.min(normalized.length(), 8)); - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java index 00b377863..23e160e5d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java @@ -19,6 +19,8 @@ import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService @Service @RequiredArgsConstructor public class ProcurementPhysicalService { + @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; + private final ProcurementLifecycleService lifecycle; private final ProcurementWarehouseDirectory warehouses; private final ProcurementFinanceService finance; @@ -327,5 +329,6 @@ public class ProcurementPhysicalService { } private static void measure(BigDecimal value,int scale){if(value==null || value.signum()<=0 || value.scale()>scale)throw bad("箱规须为正数且最多两位小数,毛重须为正数且最多三位小数");} private static long add(long a,long b){try{return Math.addExact(a,b);}catch(ArithmeticException e){throw bad("数量超过可处理范围");}} - private static String number(String prefix){return prefix+'-'+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase();} + /** 实收记录使用独立 PR 日流水。 */ + private String number(String prefix){return documentNumbers.next(prefix);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java index d1b18ee56..eb804e43c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java @@ -19,6 +19,8 @@ import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService /** Every shipment is a single immutable consumption of this order's entire eligible batch. */ @Service @RequiredArgsConstructor public class ProcurementTransportService { + @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; + private final ProcurementLifecycleService lifecycle; private final ProcurementPhysicalService physical; private final ProcurementFinanceService finance; @@ -142,6 +144,7 @@ public class ProcurementTransportService { return snapshot(order); } + /** 确认采购交寄并记录 PJ 日流水,保留原单关联与重放结果。 */ @Transactional public Snapshot ship(String customer,String orderNo,String operator,Ship request) { var order=lifecycle.lock(customer,orderNo); @@ -155,7 +158,7 @@ public class ProcurementTransportService { } else { if(request.shippedAt().isBefore(((LocalDateTime)flow.get("domestic_received_at"))))throw bad("交寄时间不能早于国内收货完成"); } - long id=IdWorker.getId();String number="PS-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase(); + long id=IdWorker.getId();String number=documentNumbers.next("PJ"); jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(?,?,?,?,?,?,?,CAST(? AS JSON),?)", id,order.getId(),customer,number,request.leg().name(),labels.manifestId(),request.shippedAt(),serialize(new Addresses(labels.sender(),labels.receiver())),operator); for(var carton:cargo) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index 65b28a945..f18fd781c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -80,7 +80,7 @@ public class RelabelApplicationService implements WalletResultHandler { READY_FOR_OUTBOUND, "COMPLETED", "REJECTED", "CANCELLED"); private static final Set DELIVERY_METHODS = Set.of("TRUCK", "EXPRESS", "AMAZON_PICKUP", "CUSTOMER_PICKUP", "PICKUP"); - @Resource private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers; + @Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers; @Resource private RelabelApplicationMapper applications; @Resource private com.baiyue.lastleg.relabel.repository.RelabelStatusRepository statusRepository; @Resource private RelabelItemMapper items; @@ -361,6 +361,7 @@ public class RelabelApplicationService implements WalletResultHandler { return create(customerId, operatorId, request, existingTailOrderId); } + /** 创建换标申请,使用 TB 日流水并保留来源货物关联。 */ private RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request, Long existingTailOrderId) { if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required"); @@ -488,7 +489,7 @@ public class RelabelApplicationService implements WalletResultHandler { LocalDateTime now = LocalDateTime.now(); long applicationId = IdWorker.getId(); String operationWarehouseId = request.operationWarehouseId().trim(); - String relabelNo = compactNumbers.next("TB", customerId); + String relabelNo = compactNumbers.next("TB"); BigDecimal chargeAmount = Objects.requireNonNullElse(request.chargeAmount(), BigDecimal.ZERO); boolean requiresWallet = chargeAmount.signum() > 0; int holdIndex = 0; @@ -1065,6 +1066,7 @@ public class RelabelApplicationService implements WalletResultHandler { return detail(customerId, relabelNo); } + /** 建立换标出库与派送单,分别分配 HBCK 和 DL 业务号。 */ private void createDirectOutbound(RelabelApplication application, String operatorId, LocalDateTime now) { String key = "RELABEL:" + application.getId() + ":OUTBOUND"; OutboundOrder existing = outboundOrders.findByIdempotency(application.getCustomerId(), key); @@ -1103,7 +1105,7 @@ public class RelabelApplicationService implements WalletResultHandler { } OutboundOrder outbound = new OutboundOrder(); outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId()); - outbound.setOutboundNo(orderNoGenerator.next("RELABEL_OUTBOUND", application.getOverseasWarehouseId(), outbound.getId())); + outbound.setOutboundNo(orderNoGenerator.next("RELABEL_OUTBOUND")); outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType("RELABEL_OUTBOUND"); outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(relabelItems.stream().mapToInt(item -> item.getReturnCartonId() == null ? intValue(item.getNotifiedPackageCount()) : 1).sum()); outbound.setPalletCount((int) relabelItems.stream().map(this::outboundSourceKey).distinct().count()); @@ -1153,7 +1155,7 @@ public class RelabelApplicationService implements WalletResultHandler { for (var destination : destinations.entrySet()) { var delivery = new com.baiyue.lastleg.delivery.entity.DeliveryOrder(); delivery.setId(IdWorker.getId()); delivery.setCustomerId(application.getCustomerId()); - delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", application.getOverseasWarehouseId(), delivery.getId())); + delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY")); delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); delivery.setOverseasWarehouseId(application.getOverseasWarehouseId()); delivery.setCustomerName(customerName); delivery.setOverseasWarehouseName(warehouseName); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/DocumentSequenceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/DocumentSequenceRepository.java new file mode 100644 index 000000000..f745c1240 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/DocumentSequenceRepository.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.repository; + +import com.baiyue.lastleg.mapper.DocumentSequenceMapper; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** 各业务共用本库序列,独立短事务避免持有编号锁进入业务资源锁。 */ +@Repository +public class DocumentSequenceRepository { + private final DocumentSequenceMapper mapper; + + /** 只访问所属服务的编号表。 */ + public DocumentSequenceRepository(DocumentSequenceMapper mapper) { this.mapper = mapper; } + + /** 已分配号不因外层业务回滚而复用,允许失败请求产生断号。 */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public long next(String key) { + mapper.allocate(key); + Long value = mapper.allocatedValue(); + if (value == null || value < 1) throw new IllegalStateException("业务编号分配失败"); + return value; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java index 0186a7b4c..c1156870e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java @@ -189,7 +189,7 @@ public class ReturnInventoryService { List boxes = List.of(new ReturnInventoryRequests.PlanBox( reserved.entrySet().stream().map(e -> new ReturnInventoryRequests.PlanItem(e.getKey(), e.getValue())).toList(), null, null, null, null)); ReturnPackingPlan plan = new ReturnPackingPlan(); - plan.setId(IdWorker.getId()); plan.setPlanNo(numbers.next("RETURN_PACKING_PLAN", first.getOverseasWarehouseId(), plan.getId())); + plan.setId(IdWorker.getId()); plan.setPlanNo(numbers.next("RETURN_PACKING_PLAN")); plan.setCustomerId(first.getCustomerId()); plan.setOverseasWarehouseId(first.getOverseasWarehouseId()); plan.setReturnNosJson(json(returnNos)); plan.setBoxesJson(json(boxes)); plan.setSourceItemsJson(json(allocation)); plan.setMode(mode); plan.setStatus("PENDING_REVIEW"); plan.setRemark(request.remark()); plan.setIdempotencyKey(request.idempotencyKey()); @@ -540,7 +540,7 @@ public class ReturnInventoryService { long cartonId = IdWorker.getId(); // 包裹签收已生成 PK 标签,后续库存、废弃和扫码沿用同一编码。 if ("PARCEL".equals(receipt.getUnitType())) cartonMark = normalizedUnitCode; - else if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId); + else if (cartonMark == null) cartonMark = LastlegOrderNoGenerator.compactCartonMark(cartonId); if (cartons.findMark(cartonMark) != null) throw conflict("箱号已存在: " + cartonMark); LocalDateTime now = LocalDateTime.now(); long total = lines.stream().mapToLong(ReceiptLine::quantity).sum(); @@ -984,6 +984,7 @@ public class ReturnInventoryService { return mergeScoped(identity, request, limits, targetMark, null); } + /** 在当前仓范围执行合箱,目标实物箱码继续使用原标签算法。 */ private ReturnInventoryResponses.MergeResult mergeScoped(PdaWarehouseIdentity identity, ReturnInventoryRequests.Merge request, Map> limits, String targetMark, String generatedMark) { String requestHash = targetMark == null ? hash(request) : hash(List.of(request, targetMark, limits.keySet())); @@ -1122,7 +1123,7 @@ public class ReturnInventoryService { // A merged carton may remain loose; pallet binding is an explicit warehouse operation. target.setSourceUnitType("BOX"); target.setId(targetId); target.setCustomerId(customerId); target.setOverseasWarehouseId(sources.get(0).getOverseasWarehouseId()); - if (existingTarget == null) target.setCartonMark(generatedMark == null ? numbers.next("RETURN_CARTON", target.getOverseasWarehouseId(), targetId) : generatedMark); + if (existingTarget == null) target.setCartonMark(generatedMark == null ? LastlegOrderNoGenerator.compactCartonMark(targetId) : generatedMark); if (existingTarget != null) { if (text(target.getSourceBatchNo()) != null) consumedBatchNos.addAll(java.util.Arrays.asList(target.getSourceBatchNo().split(","))); if (text(target.getSourceReturnNo()) != null) consumedReturnNos.addAll(java.util.Arrays.asList(target.getSourceReturnNo().split(","))); @@ -1276,6 +1277,7 @@ public class ReturnInventoryService { + ""; } private static String dimension(BigDecimal value) { return value == null ? "—" : value.stripTrailingZeros().toPlainString(); } + /** 按合箱分组生成实物箱唛,保留完整 ID 编码和分箱后缀。 */ private List packingMarks(List boxes) { List> groups = boxes.stream().map(box -> box.items() == null ? List.of() : box.items().stream() .map(ReturnInventoryRequests.PlanItem::skuCode).distinct().sorted().toList()).toList(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java index 655bdefe7..81d9f5a16 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java @@ -62,7 +62,7 @@ public class ReturnReceiptService { public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED"; public static final String COMPLETED = "COMPLETED"; - @Resource private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers; + @Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers; @Resource private ReturnOrderMapper orders; @Resource private ReturnOrderItemMapper orderItems; @Resource private ReturnReceiptMapper receipts; @@ -81,6 +81,7 @@ public class ReturnReceiptService { @Resource private com.baiyue.lastleg.service.FbaWarehouseValidator fbaWarehouseValidator; @Resource private com.baiyue.lastleg.service.ReturnOrderReferenceResolver returnOrderReferences; + /** 创建退货订单并分配 TH 日流水,实物标签继续沿用原规则。 */ @Transactional public ReturnOrderResponses.Detail create(String customerId, String operatorId, ReturnOrderRequests.Create request) { String key = text(request.idempotencyKey()); @@ -100,7 +101,7 @@ public class ReturnReceiptService { order.setOverseasWarehouseId(text(request.overseasWarehouseId())); order.setWarehouseContactName(text(request.warehouseContactName())); order.setWarehouseContactPhone(text(request.warehouseContactPhone())); - order.setReturnNo(compactNumbers.next("TT", customerId)); + order.setReturnNo(compactNumbers.next("TH")); order.setCustomerReferenceNo(text(request.customerReferenceNo())); order.setNeedRelabel(request.needRelabel()); order.setStatus(PENDING_REVIEW); order.setExpectedSkuTypes(skuCodes.size()); order.setExpectedQuantity(expected); order.setReceivedQuantity(0L); order.setRemark(text(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(requestHash); @@ -396,6 +397,7 @@ public class ReturnReceiptService { return result; } + /** 从退货创建换标业务并分配 THHB 编号,重放沿用原记录。 */ @Transactional public ReturnOrderResponses.RelabelCreated createRelabel(String customerId, String operatorId, String returnNo, ReturnOrderRequests.RelabelCreate request) { @@ -430,7 +432,7 @@ public class ReturnReceiptService { totalQuantity = Math.addExact(totalQuantity, requested.quantity()); } long appId = IdWorker.getId(); LocalDateTime now = LocalDateTime.now(); - String relabelNo = compactNumbers.next("TT", customerId); + String relabelNo = compactNumbers.next("THHB"); LastlegOrder tail = order.getLastlegOrderId() == null ? null : lastlegOrders.selectById(order.getLastlegOrderId()); if (tail == null) { tail = new LastlegOrder(); tail.setId(IdWorker.getId()); tail.setOrderNo(relabelNo); @@ -569,6 +571,7 @@ public class ReturnReceiptService { return detail(order.getCustomerId(), order.getReturnNo()); } + /** 确认退货实收,新增实物箱码沿用原编码,不使用单据日流水。 */ @Transactional public PdaReturnReceiptResponses.Confirmed confirm(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Confirm request) { String key = text(request.idempotencyKey()); @@ -643,8 +646,8 @@ public class ReturnReceiptService { long receiptId = IdWorker.getId(); ReturnReceipt receipt = new ReturnReceipt(); receipt.setId(receiptId); receipt.setReturnOrderId(order.getId()); receipt.setCustomerId(order.getCustomerId()); - receipt.setReceiptNo(numbers.next("RETURN_RECEIPT", order.getOverseasWarehouseId(), receiptId)); - receipt.setReceiptBatchNo(numbers.next("RETURN_BATCH", order.getOverseasWarehouseId(), receiptId)); + receipt.setReceiptNo(numbers.next("RETURN_RECEIPT")); + receipt.setReceiptBatchNo(numbers.next("RETURN_BATCH")); receipt.setTrackingNo(trackingNo); receipt.setBoundBy(startedBy); receipt.setStatus("SIGNED"); receipt.setUnitType(unitType); receipt.setUnitCode(unitCode); receipt.setTotalBoxCount(totalBoxCount); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/CompactBusinessNumberService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/CompactBusinessNumberService.java deleted file mode 100644 index ed45087b1..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/CompactBusinessNumberService.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.baiyue.lastleg.service; - -import net.sourceforge.pinyin4j.PinyinHelper; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.time.Instant; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.Locale; - -/** Allocates suffix-free document numbers across all service instances. */ -@Component -public class CompactBusinessNumberService { - private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); - private static final DateTimeFormatter FORMAT = DateTimeFormatter.ofPattern("yyMMddHHmmss", Locale.ROOT).withZone(ZONE); - private final JdbcTemplate jdbc; - private final ReturnOrderReferenceResolver customers; - - public CompactBusinessNumberService(JdbcTemplate jdbc, ReturnOrderReferenceResolver customers) { - this.jdbc = jdbc; - this.customers = customers; - } - - @Transactional - public String next(String prefix, String customerId) { - return allocate(prefix, initials(customers.customerName(customerId)), Instant.now()); - } - - String allocate(String prefix, String initials, Instant now) { - String key = prefix + "-" + initials; - long seconds = now.getEpochSecond(); - // The row lock serializes identical prefixes/initials even across customers and JVMs. - jdbc.update("INSERT INTO ll_compact_number_clock(number_key,last_second) VALUES(?,LAST_INSERT_ID(?)) " - + "ON DUPLICATE KEY UPDATE last_second=LAST_INSERT_ID(GREATEST(last_second+1,?))", key, seconds, seconds); - Long allocated = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class); - if (allocated == null || allocated < seconds) throw new IllegalStateException("业务编号生成失败"); - return key + "-" + FORMAT.format(Instant.ofEpochSecond(allocated)); - } - - static String initials(String name) { - if (name == null || name.isBlank()) return "KH"; - StringBuilder result = new StringBuilder(); - boolean latinWord = false; - for (char c : name.trim().toCharArray()) { - if (result.length() >= 4) break; - if (Character.UnicodeScript.of(c) == Character.UnicodeScript.HAN) { - String[] pinyin = PinyinHelper.toHanyuPinyinStringArray(c); - if (pinyin != null && pinyin.length > 0) result.append(Character.toUpperCase(pinyin[0].charAt(0))); - latinWord = false; - } else if (c < 128 && Character.isLetterOrDigit(c)) { - if (!latinWord) result.append(Character.toUpperCase(c)); - latinWord = true; - } else latinWord = false; - } - return result.isEmpty() ? "KH" : result.toString(); - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegDocumentNumberGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegDocumentNumberGenerator.java new file mode 100644 index 000000000..95a9a594f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegDocumentNumberGenerator.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.common.core.number.DailyDocumentNumber; +import com.baiyue.lastleg.repository.DocumentSequenceRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import java.time.Clock; +import java.util.Set; + +/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */ +@Component +public class LastlegDocumentNumberGenerator { + private static final Set PREFIXES = Set.of("IP", "CK", "DFCK", "HBCK", "THCK", "DL", "EX", "CD", "TB", "ZY", "TH", "THHB", "RR", "RB", "HX", "YY", "TR", "LB", "CG", "PR", "PJ", "PF", "PN", "PX", "PG", "CT", "FB", "FO", "VR"); + private final DocumentSequenceRepository sequences; + private final Clock clock; + + /** 正式运行使用统一业务时区。 */ + @Autowired + public LastlegDocumentNumberGenerator(DocumentSequenceRepository sequences) { + this(sequences, Clock.system(DailyDocumentNumber.BUSINESS_ZONE)); + } + + /** 固定时钟用于跨日及前缀独立性验证。 */ + LastlegDocumentNumberGenerator(DocumentSequenceRepository sequences, Clock clock) { + this.sequences = sequences; + this.clock = clock; + } + + /** 只为本服务已登记的业务类型分配号码。 */ + public String next(String prefix) { + if (!PREFIXES.contains(prefix)) throw new IllegalArgumentException("未登记的业务编号前缀:" + prefix); + return DailyDocumentNumber.next(prefix, clock, sequences::next); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java index f8ebba53f..3ba5d9878 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java @@ -1,122 +1,58 @@ package com.baiyue.lastleg.service; -import com.baiyue.lastleg.mapper.LastlegOrderNoDailySequenceMapper; -import jakarta.annotation.Resource; import org.springframework.stereotype.Component; - import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.LocalDate; -import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.Locale; -import java.util.UUID; -import java.math.BigInteger; -import java.nio.ByteBuffer; +/** 海外单据类型映射;头程来源尾程号与实物箱码保持本次确认的范围边界。 */ @Component public class LastlegOrderNoGenerator { - private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); - private static final DateTimeFormatter BUSINESS_DATE = DateTimeFormatter.ofPattern("yyMMdd"); - @Resource private LastlegOrderNoDailySequenceMapper sequenceMapper; + private final LastlegDocumentNumberGenerator documents; - public String next() { - return next(LocalDate.now(BUSINESS_ZONE)); + /** 所有海外业务入口共用所属服务的日流水。 */ + public LastlegOrderNoGenerator(LastlegDocumentNumberGenerator documents) { + this.documents = documents; } - String next(LocalDate businessDate) { - sequenceMapper.nextValue(businessDate); - long sequence = sequenceMapper.currentValue(); - if (sequence < 1 || sequence > 9999) { - throw new IllegalStateException("尾程单日流水超出 0001-9999 范围: " + businessDate); - } - return "LMO-" + BUSINESS_DATE.format(businessDate) + "-" + String.format("%04d", sequence); - } - - public String next(String businessType, String warehouse, long uniqueId) { - String code = businessType == null ? "" : businessType.trim().toUpperCase(Locale.ROOT); - if ("RETURN_CARTON".equals(code)) return compactCartonMark(uniqueId); - String returnPrefix = switch (code) { - case "RETURN" -> "RT"; + /** 明确业务类型对应的前缀,未知类型不再截断名称或使用内部 ID 回退。 */ + public String next(String businessType) { + String prefix = switch (businessType) { + case "ONE_CLICK_DROPSHIP" -> "DFCK"; + case "OUTBOUND" -> "CK"; + case "RELABEL_OUTBOUND" -> "HBCK"; + case "RETURN_OUTBOUND" -> "THCK"; + case "DELIVERY" -> "DL"; + case "TRUCK" -> "TR"; + case "LOOSEBOARD" -> "LB"; + case "EXCEPTION" -> "EX"; + case "DISCARD" -> "CD"; + case "INBOUND_PLAN" -> "IP"; case "RETURN_RECEIPT" -> "RR"; case "RETURN_BATCH" -> "RB"; - case "RETURN_CARTON" -> "RC"; - case "RETURN_RELABEL" -> "RETU"; - default -> null; + case "RETURN_PACKING_PLAN" -> "HX"; + default -> throw new IllegalArgumentException("未登记的海外单据类型:" + businessType); }; - // Return numbers are independent business identifiers, not truncated database IDs. - // Keep every UUID bit; the longest result (RETU + 25 base-36 digits) fits VARCHAR(32). - if (returnPrefix != null) return returnPrefix + "-" + encodeReturnUuid(UUID.randomUUID()); - // Transfer numbers also identify synthetic inbound events globally. Truncating - // a database ID to six digits can collide between unrelated fresh transfers. - if ("TRANSFER".equals(code) || "WAREHOUSE_TRANSFER".equals(code)) { - return "WT-" + encodeReturnUuid(UUID.randomUUID()); - } - String type = normalizeType(businessType); - return type + "-" + warehouseCode(warehouse) + "-" - + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" - + String.format(Locale.ROOT, "%06d", Math.floorMod(uniqueId, 1_000_000)); + return documents.next(prefix); } - static String encodeReturnUuid(UUID uuid) { - byte[] bytes = ByteBuffer.allocate(16).putLong(uuid.getMostSignificantBits()) - .putLong(uuid.getLeastSignificantBits()).array(); - String encoded = new BigInteger(1, bytes).toString(36).toUpperCase(Locale.ROOT); - return "0".repeat(25 - encoded.length()) + encoded; - } - - /** Preserve every snowflake ID bit; do not truncate or hash physical label identifiers. */ + /** 实物箱码保留完整 ID 的三十六进制编码,本次不修改扫码标签。 */ public static String compactCartonMark(long id) { if (id <= 0) throw new IllegalArgumentException("Carton id must be positive"); return "BX-" + Long.toString(id, 36).toUpperCase(Locale.ROOT); } - private String normalizeType(String value) { - String code = value == null ? "LMO" : value.trim().toUpperCase(Locale.ROOT); - return switch (code) { - case "FIRST_LEG" -> "FL"; - case "ONE_CLICK_DROPSHIP" -> "OD"; - case "RELABEL_APPLICATION", "RELABEL" -> "RL"; - case "WAREHOUSE_TRANSFER", "TRANSFER" -> "WT"; - case "LOCAL_LOOSEBOARD", "LOOSEBOARD" -> "LB"; - case "DISPATCH", "PICKUP_CAR" -> "DS"; - case "OUTBOUND" -> "OB"; - case "DELIVERY" -> "DL"; - case "TRUCK" -> "TR"; - case "EXCEPTION" -> "EX"; - case "DISCARD" -> "CD"; - case "PROCUREMENT" -> "PR"; - case "INBOUND_PLAN" -> "IP"; - default -> { - String normalized = code.replaceAll("[^A-Z0-9]", ""); - yield normalized.isEmpty() ? "LMO" : normalized.substring(0, Math.min(4, normalized.length())); - } - }; - } - - public String warehouseCode(String value) { - String code = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); - if (code.isEmpty()) return "WH"; - if (code.matches(".*(LAX|ONT|NYC|JFK|LGB|EWR|SFO|SEA|DFW|ORD|ATL|MIA|PHX|LAS|YYZ|YVR|AMS|LHR|FRA|CDG|HKG|NRT|SYD).*")) { - var match = java.util.regex.Pattern.compile("(LAX|ONT|NYC|JFK|LGB|EWR|SFO|SEA|DFW|ORD|ATL|MIA|PHX|LAS|YYZ|YVR|AMS|LHR|FRA|CDG|HKG|NRT|SYD)").matcher(code); - if (match.find()) return match.group(1); - } - if (code.matches("[A-Z0-9]{2,8}")) return code; - return "WH"; - } - - /** The physical container number is the tail-leg business order grouping boundary. */ + /** 独立尾程编号尚未确认,头程装柜来源继续使用原有分组编号。 */ public String forContainerNo(String containerNo) { - if (containerNo == null || containerNo.isBlank()) { - throw new IllegalArgumentException("containerNo is required"); - } - String normalized = containerNo.trim(); + if (containerNo == null || containerNo.isBlank()) throw new IllegalArgumentException("containerNo is required"); try { - byte[] digest = MessageDigest.getInstance("SHA-256") - .digest(normalized.getBytes(StandardCharsets.UTF_8)); + byte[] digest = MessageDigest.getInstance("SHA-256").digest(containerNo.trim().getBytes(StandardCharsets.UTF_8)); long suffix = java.nio.ByteBuffer.wrap(digest, 0, 8).getLong(); - return next("FIRST_LEG", null, suffix); + return "FL-WH-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" + + String.format(Locale.ROOT, "%06d", Math.floorMod(suffix, 1_000_000)); } catch (NoSuchAlgorithmException exception) { throw new IllegalStateException("SHA-256 is unavailable", exception); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index 8a790f977..8d2e9859c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -96,7 +96,7 @@ public class WarehouseTransferService { @Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupService pickupService; /** 退货单在转运来源中的展示编码,避免把退货单号误当成普通入仓码查询。 */ public static final String RETURN_ORDER_PREFIX = "RETURN_ORDER:"; - @Resource private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers; + @Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers; @Resource private com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory; @Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool; @Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPoolQueries; @@ -239,7 +239,7 @@ public class WarehouseTransferService { } LocalDateTime now = LocalDateTime.now(); long transferId = IdWorker.getId(); - String transferNo = compactNumbers.next("ZY", customerId); + String transferNo = compactNumbers.next("ZY"); String requestedInboundCode = trim(request.inboundCode()); List selectedIds = request.sourceLastlegOrderIds() == null ? List.of() : request.sourceLastlegOrderIds().stream().distinct().toList(); @@ -2252,6 +2252,7 @@ public class WarehouseTransferService { return item.getId(); } + /** 保存转运箱信息,优先使用预印箱唛,新增箱唛仍按原标签规则生成。 */ private void insertTransferCarton(String customerId, long transferId, long transferItemId, String transferNo, int cartonIndex, int totalCartons, String customerCartonMark, LocalDateTime now, String printedMark) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java index e2537549f..47d4f9d64 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java @@ -70,7 +70,7 @@ public class UsTruckOrderService { UsTruckOrder order = new UsTruckOrder(); order.setId(IdWorker.getId()); order.setCustomerId(customerId); - order.setOrderNo(orderNoGenerator.next("TRUCK", request.origin(), order.getId())); + order.setOrderNo(orderNoGenerator.next("TRUCK")); order.setVehicleType(request.vehicleType()); order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress())); order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress())); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index aa01aafb9..97fce8af3 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -35,12 +35,6 @@ CREATE TABLE IF NOT EXISTS ll_container_shipping_gate ( KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence) ) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照'; -CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence ( - business_date DATE NOT NULL COMMENT '业务日期(Asia/Shanghai)', - last_sequence INT NOT NULL COMMENT '当日已分配尾程单流水号', - PRIMARY KEY(business_date) -) ENGINE=InnoDB COMMENT='尾程单号按日原子流水'; - CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence ( business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)', last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999', @@ -3209,16 +3203,6 @@ CREATE TABLE IF NOT EXISTS ll_wallet_event_inbox ( -CREATE TABLE IF NOT EXISTS ll_procurement_daily_sequence ( - customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', - business_date DATE NOT NULL COMMENT '北京时间业务日期', - sequence_value INT NOT NULL COMMENT '客户当日已分配最大序号', - PRIMARY KEY (customer_id,business_date), - CONSTRAINT chk_ll_procurement_sequence CHECK (sequence_value BETWEEN 1 AND 9999) -) ENGINE=InnoDB COMMENT='海外采购客户日序列'; - - - CREATE TABLE IF NOT EXISTS ll_overseas_procurement_file ( id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', category VARCHAR(16) NOT NULL COMMENT 'RECEIPT实收、VOUCHER费用、LABEL面单、PURCHASE采购、BUDGET预算、EXCEPTION异常凭证', file_id BIGINT NOT NULL COMMENT 'File服务文件ID', @@ -3246,6 +3230,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_fee ( settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件', source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID', parent_fee_id BIGINT NULL COMMENT '原费用或预估引用', + idempotency_key VARCHAR(128) NULL COMMENT '退款请求幂等键,独立于业务单号', request_hash CHAR(64) NULL COMMENT '确认请求摘要', id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', fee_no VARCHAR(64) NOT NULL COMMENT '费用业务号', fee_item_code VARCHAR(32) NOT NULL COMMENT '费用项编码', fee_item_name VARCHAR(128) NOT NULL COMMENT '费用项名称', @@ -3256,6 +3241,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_fee ( created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')), UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version), + UNIQUE KEY uk_ll_procurement_refund_request(order_id,purpose,idempotency_key), PRIMARY KEY(id), UNIQUE KEY uk_ll_procurement_fee_no(fee_no), KEY idx_ll_procurement_fee_order(customer_id,order_id,created_at), CONSTRAINT chk_ll_procurement_fee_amount CHECK(amount>=0), CONSTRAINT chk_ll_procurement_fee_currency CHECK(currency='CNY') ) ENGINE=InnoDB COMMENT='海外采购费用明细及资金生命周期'; @@ -4498,11 +4484,6 @@ CREATE TABLE IF NOT EXISTS ll_appointment_work_history ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令'; -- 新单据短号:相同业务及客户名称缩写共享逻辑秒,避免同秒或多实例重号。 -CREATE TABLE IF NOT EXISTS ll_compact_number_clock ( - number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写', - last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒', - PRIMARY KEY (number_key) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟'; -- 库存转运无需再次预约入仓;目标库:lastleg-service 所属数据库。可重复执行。 SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='estimated_arrival_at' AND is_nullable='NO'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT ''预计入仓时间,库存转运不填写''', 'SELECT 1')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; @@ -4638,3 +4619,11 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history ( CONSTRAINT fk_owned_business_history_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id), CONSTRAINT ck_owned_business_history_version CHECK (record_version>0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史'; + +-- 统一业务单号:前缀 + yyyyMMdd + 四位日流水。 +CREATE TABLE IF NOT EXISTS ll_document_sequence ( + sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键', + current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收', + PRIMARY KEY (sequence_key), + CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水'; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java index 74a79d9f2..09e58ad8b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java @@ -75,7 +75,7 @@ class DispatchDeliveryArrangementPersistenceTest { var repository=new DispatchDeliveryRepository(session.getMapper(DispatchDeliveryArrangementMapper.class),session.getMapper(OutboundOrderMapper.class), session.getMapper(OutboundPalletItemMapper.class),session.getMapper(DeliveryOrderMapper.class),session.getMapper(DeliveryOperationMapper.class), session.getMapper(LastlegOrderMapper.class),session.getMapper(LastlegOrderStatusHistoryMapper.class)); - var numbers=mock(LastlegOrderNoGenerator.class);when(numbers.next(anyString(),anyString(),anyLong())).thenAnswer(a->a.getArgument(0)+"-"+a.getArgument(2)); + var numbers=mock(LastlegOrderNoGenerator.class);when(numbers.next(anyString())).thenAnswer(a->a.getArgument(0)+"-"+com.baomidou.mybatisplus.core.toolkit.IdWorker.getId()); var target=new DispatchDeliveryService(execution,repository,grouping,fees,new NormalDeliveryCompletionPolicy(grouping),numbers,json); var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds),new AnnotationTransactionAttributeSource())); service=(DispatchDeliveryService)proxy.getProxy(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java index f87732f13..836b919f2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java @@ -39,7 +39,7 @@ class DispatchDeliveryServiceTest { @BeforeEach void prepare() { context=context(List.of(cargo(1,"101","ONT8",51,2),cargo(2,"101","LAX9",52,3),cargo(3,"102","ONT8",53,4)),false); var numbers=mock(LastlegOrderNoGenerator.class); - when(numbers.next(anyString(),anyString(),anyLong())).thenAnswer(a->a.getArgument(0)+"-"+a.getArgument(2)); + when(numbers.next(anyString())).thenAnswer(a->a.getArgument(0)+"-"+com.baomidou.mybatisplus.core.toolkit.IdWorker.getId()); service=new DispatchDeliveryService(execution,repository,grouping,fees,new NormalDeliveryCompletionPolicy(grouping),numbers,json); when(execution.lockForArrangement(any(),eq("DP-10"))).thenAnswer(a->context); when(execution.replay(any(),any(),any())).thenReturn(Optional.empty()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index 9377fa9f5..4111fa0b9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -153,7 +153,7 @@ class DeliveryTransferServiceTest { ReflectionTestUtils.setField(service, "planAllocations", planAllocations); ReflectionTestUtils.setField(service, "planLines", planLines); ReflectionTestUtils.setField(service, "inboundCartons", inboundCartons); - ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator()); + ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders()); ReflectionTestUtils.setField(service, "tailOrders", tailOrders); ReflectionTestUtils.setField(service, "histories", histories); ReflectionTestUtils.setField(service, "tailOrderDetails", tailOrderDetails); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java index 58b8ffe06..440a02860 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java @@ -72,7 +72,7 @@ class ReturnOutboundClosureTest { ReflectionTestUtils.setField(service, "pdaPicks", picks); ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications); ReflectionTestUtils.setField(service, "relabelService", relabelService); - ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator()); + ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders()); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); carton = new ReturnInventoryCarton(); carton.setId(10L); carton.setCartonMark("RC#1-1"); carton.setCustomerId("C1"); carton.setOverseasWarehouseId("WH1"); carton.setSourceReturnNo("RT1,RT2"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java index 9626cdc09..48716f73e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java @@ -82,7 +82,8 @@ class ReturnOutboundPdaPersistenceTest { stockProxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource())); ReflectionTestUtils.setField(target, "returnStock", stockProxy.getProxy()); var numbers = mock(LastlegOrderNoGenerator.class); - when(numbers.next(anyString(), eq("WH-1"), anyLong())).thenAnswer(call -> "RETU-" + call.getArgument(2)); + var testNumber = new java.util.concurrent.atomic.AtomicInteger(); + when(numbers.next(anyString())).thenAnswer(call -> "THCK20261020" + String.format("%04d", testNumber.incrementAndGet())); ReflectionTestUtils.setField(target, "orderNoGenerator", numbers); var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource())); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java index 625dfc82d..3d694f07f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java @@ -39,7 +39,7 @@ class CargoDiscardOutboundServiceTest { ReflectionTestUtils.setField(service, "cartons", cartons); ReflectionTestUtils.setField(service, "profiles", profiles); ReflectionTestUtils.setField(service, "returnCartons", returnCartons); - ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator()); + ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders()); application = new CargoDiscardApplication(); application.setId(1L); application.setCustomerId("C1"); application.setDiscardNo("CD-TEST"); application.setOverseasWarehouseId("99"); application.setTotalPackages(5); application.setStatus("APPROVED"); @@ -66,7 +66,7 @@ class CargoDiscardOutboundServiceTest { assertEquals("DISCARD_OUTBOUND", document.getOutboundType()); assertEquals("WAIT_PICK", document.getStatus()); assertNull(document.getLxCodes()); assertEquals(5, document.getPackageCount()); assertEquals(2, document.getPalletCount()); - assertTrue(service.outboundNo(application).startsWith("OB-")); + assertTrue(service.outboundNo(application).matches("CK[0-9]{12}")); assertEquals(List.of(3, 2), snapshots.stream().map(OutboundPalletItem::getPackageCount).toList()); verify(outbounds, times(1)).insert(any(OutboundOrder.class)); verify(pallets, times(2)).insert(any(OutboundPalletItem.class)); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java index 1daa421b3..ed641cf02 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java @@ -134,7 +134,7 @@ class CargoDiscardServiceTest { ReflectionTestUtils.setField(service, "histories", histories); ReflectionTestUtils.setField(service, "inventory", inventory); ReflectionTestUtils.setField(service, "adjustments", adjustments); - ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator()); + ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders()); ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "orderHistories", orderHistories); ReflectionTestUtils.setField(service, "returnCartons", returnCartons); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index add73ae54..90e5e45b1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -517,6 +517,7 @@ public class DispatchAggregatePersistenceTest { mock(com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper.class),mock(com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository.class),mock(DispatchAggregateService.class),json); var vehicles=mock(VehicleRepository.class); var drivers=mock(DriverService.class);var reservations=mock(FleetBookingReservations.class); var fleetService=new FleetTripService(fleetRepo,vehicles,drivers,warehouses,json,gateway,reservations); + org.springframework.test.util.ReflectionTestUtils.setField(fleetService, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); nativeFleetService=fleetService; org.springframework.test.util.ReflectionTestUtils.setField(fleetService,"workChanges",mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class)); org.springframework.test.util.ReflectionTestUtils.setField(fleetService,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftResourceLockTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftResourceLockTest.java index 7d7ce6c3f..f82c79f74 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftResourceLockTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftResourceLockTest.java @@ -22,6 +22,7 @@ class DispatchDraftResourceLockTest { when(repository.lockTrip("TR-7")).thenReturn(first);when(repository.lockTrip("TR-2")).thenReturn(second); when(repository.listBindings(7L)).thenReturn(List.of(a));when(repository.listBindings(2L)).thenReturn(List.of(b)); var service=new FleetTripService(repository,vehicles,mock(DriverService.class),mock(FleetWarehouseDirectory.class),new ObjectMapper(),mock(FleetDispatchGateway.class),mock(FleetBookingReservations.class)); + org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); service.lockExistingDispatchShells(List.of("DS-A","DS-B")); var order=inOrder(repository,vehicles); order.verify(repository).lockTrip("TR-2");order.verify(repository).lockTrip("TR-7"); @@ -37,6 +38,7 @@ class DispatchDraftResourceLockTest { when(vehicles.lockById(anyLong())).thenReturn(Optional.of(mock(VehicleRepository.VehicleSnapshot.class))); when(repository.lockDriverIdentity(anyLong())).thenAnswer(call->call.getArgument(0)); var service=new FleetTripService(repository,vehicles,mock(DriverService.class),mock(FleetWarehouseDirectory.class),new ObjectMapper(),mock(FleetDispatchGateway.class),mock(FleetBookingReservations.class)); + org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); var v9=new FleetExecutionModels.DispatchVehicle("MASTER",9L,null,null,null,null,null,List.of()); var v3=new FleetExecutionModels.DispatchVehicle("MASTER",3L,null,null,null,null,null,List.of()); var d8=new FleetExecutionModels.DispatchDriver("MASTER",8L,null,null,null,null,List.of(),null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java index 2d4e9bc52..87b5f2e07 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java @@ -111,7 +111,7 @@ class DispatchRepairWorkflowTest { db.execute("CREATE TABLE IF NOT EXISTS ll_physical_pallet_guard(warehouse_pallet_id BIGINT PRIMARY KEY,created_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3))"); for(var column:List.of("order_id BIGINT","warehouse_pallet_id BIGINT","inventory_position_id BIGINT","received_packages BIGINT"))db.execute("ALTER TABLE ll_inbound_stock_receipt ADD COLUMN IF NOT EXISTS "+column); candidates=spy(session.getMapper(DispatchCandidateMapper.class));doReturn(List.of()).when(candidates).nativeReturnRelabelAvailable(any(),any(),any());dependencies.put(DispatchCandidateMapper.class,candidates); - dependencies.put(ObjectMapper.class,json);dependencies.put(LastlegOrderNoGenerator.class,new LastlegOrderNoGenerator()); + dependencies.put(ObjectMapper.class,json);dependencies.put(LastlegOrderNoGenerator.class,com.baiyue.lastleg.service.TestDocumentNumbers.orders()); var sourceRepository=new com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository(candidates,session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)); dependencies.put(com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository.class,sourceRepository);dependencies.put(DispatchCommandHasher.class,new DispatchCommandHasher(json)); var relabelSources=new RelabelDispatchSourceService(new RelabelDispatchSourceRepository(mapper(RelabelDispatchSourceMapper.class)),json); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java index a7181eb07..1057f33a6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java @@ -58,7 +58,7 @@ class ReturnCartonReturnPersistenceTest { var pallets=mock(PalletPhysicalGateway.class);var warehouse=mock(WarehouseResourceGateway.class); when(pallets.resolve("P-NEW")).thenReturn(new PalletPhysicalGateway.PalletReference(501,"P-NEW",92,301L,"OCCUPIED","ONT8",1)); when(warehouse.resolveLocation(92,"LOC-301")).thenReturn(new WarehouseResourceGateway.WarehouseLocationReference(301,"LOC-301","RACK",BigDecimal.TEN,"ACTIVE")); - service=new ReturnCartonReturnService(repo,pallets,warehouse,new ObjectMapper(),new com.baiyue.lastleg.service.LastlegOrderNoGenerator()); + service=new ReturnCartonReturnService(repo,pallets,warehouse,new ObjectMapper(),com.baiyue.lastleg.service.TestDocumentNumbers.orders()); db.update("INSERT INTO ll_exception_order(id,customer_id,exception_no,delivery_order_id,last_mile_order_id,outbound_no,status,return_direction,overseas_warehouse_id) VALUES(1,'C','EX-1',3,100,'OUT-1','PROCESSING','OVERSEAS_WAREHOUSE','92')"); db.update("INSERT INTO ll_delivery_order(id,customer_id,outbound_order_id) VALUES(3,'C',2)"); db.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,status) VALUES(2,'C','OUT-1',10,'OUTBOUNDED')"); @@ -183,7 +183,7 @@ class ReturnCartonReturnPersistenceTest { org.springframework.test.util.ReflectionTestUtils.setField(business,"objectMapper",new ObjectMapper().findAndRegisterModules()); org.springframework.test.util.ReflectionTestUtils.setField(business,"evidenceFiles",mock(com.baiyue.lastleg.exceptionorder.gateway.ExceptionEvidenceFileGateway.class)); org.springframework.test.util.ReflectionTestUtils.setField(business,"nativeReturns",service); - org.springframework.test.util.ReflectionTestUtils.setField(business,"orderNoGenerator",new com.baiyue.lastleg.service.LastlegOrderNoGenerator()); + org.springframework.test.util.ReflectionTestUtils.setField(business,"orderNoGenerator",com.baiyue.lastleg.service.TestDocumentNumbers.orders()); org.springframework.test.util.ReflectionTestUtils.setField(business,"inboundOrders",map(com.baiyue.lastleg.mapper.LastlegInboundOrderMapper.class)); org.springframework.test.util.ReflectionTestUtils.setField(business,"outboundOrders",map(OutboundOrderMapper.class)); org.springframework.test.util.ReflectionTestUtils.setField(business,"outboundPalletItems",map(OutboundPalletItemMapper.class)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java index f1cd3e630..ad460ebe3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java @@ -22,7 +22,7 @@ class FleetPoolOrderBridgeTest { LocalLooseboardHistoryMapper looseHistory=mock(LocalLooseboardHistoryMapper.class); UsTruckOrderMapper trucks=mock(UsTruckOrderMapper.class); UsTruckOrderStatusHistoryMapper truckHistory=mock(UsTruckOrderStatusHistoryMapper.class); - FleetPoolOrderNumberGenerator numbers=mock(FleetPoolOrderNumberGenerator.class); + com.baiyue.lastleg.service.LastlegDocumentNumberGenerator numbers=mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class); FleetPoolDeliveryEvidence evidence=mock(FleetPoolDeliveryEvidence.class); com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker.class); FleetPoolOrderBridge service; @@ -30,7 +30,7 @@ class FleetPoolOrderBridgeTest { @BeforeEach void setup(){ service=new FleetPoolOrderBridge(new FleetPoolOrderRepository(links,loose,looseHistory,trucks,truckHistory),numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources); - when(numbers.next(anyString(),any(),anyLong())).thenReturn("LOCAL-001"); + when(numbers.next(anyString())).thenReturn("LOCAL-001"); when(links.lockBooking(7L)).thenAnswer(a->row);when(links.lockBookingNo("FB-7")).thenAnswer(a->row); when(links.lockIncoming("9","WT-7")).thenAnswer(a->row); when(links.lockDispatch("9","DISPATCH-7")).thenAnswer(a->row==null?List.of():List.of(row)); @@ -171,7 +171,10 @@ class FleetPoolOrderBridgeTest { assertThat(method.getAnnotation(Transactional.class).propagation()).isEqualTo(Propagation.MANDATORY); assertThat(Arrays.stream(FleetPoolOrderBridge.class.getDeclaredFields()).map(f->f.getType().getSimpleName())) .noneMatch(name->name.contains("FleetTrip")||name.contains("FleetBooking")||name.contains("Fee")||name.contains("Inventory")); - assertThat(Arrays.stream(FleetPoolOrderBridge.class.getDeclaredFields()).map(f->f.getType().getPackageName())) + assertThat(Arrays.stream(FleetPoolOrderBridge.class.getDeclaredFields()) + // 只允许共享的编号基础能力;业务编排服务仍禁止反向依赖。 + .filter(f -> f.getType() != com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class) + .map(f->f.getType().getPackageName())) .noneMatch(name->name.startsWith("com.baiyue.lastleg.delivery")||name.equals("com.baiyue.lastleg.fleet.trip") ||name.equals("com.baiyue.lastleg.service")); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGeneratorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGeneratorTest.java deleted file mode 100644 index 76c6a83aa..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGeneratorTest.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.baiyue.lastleg.fleet.business; - -import org.junit.jupiter.api.Test; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import static org.assertj.core.api.Assertions.*; - -class FleetPoolOrderNumberGeneratorTest { - private final FleetPoolOrderNumberGenerator generator=new FleetPoolOrderNumberGenerator(); - @Test void existingLooseboardAndTruckFormatLocationAndSuffixArePreserved(){ - String day=LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); - assertThat(generator.next("LOOSEBOARD","FBA:ONT8",1234567)).isEqualTo("LB-ONT-"+day+"-234567"); - assertThat(generator.next("TRUCK","nyc",1000001)).isEqualTo("TR-NYC-"+day+"-000001"); - } - @Test void existingPrivateDestinationAndShortCodeRulesArePreserved(){ - String day=LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); - assertThat(generator.next("TRUCK","private address",9)).isEqualTo("TR-WH-"+day+"-000009"); - assertThat(generator.next("LOOSEBOARD","ab12",9)).isEqualTo("LB-AB12-"+day+"-000009"); - assertThat(generator.next("TRUCK",null,-1)).isEqualTo("TR-WH-"+day+"-999999"); - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareAuthorizationTest.java index 8fdde5eab..ca70d8559 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareAuthorizationTest.java @@ -30,6 +30,10 @@ class VehicleCareAuthorizationTest { } private void authenticate(String code) { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("user", "unused", List.of(new SimpleGrantedAuthority(code)))); } @Configuration @EnableMethodSecurity static class Config { + /** 为控制器权限测试中的服务替身补齐编号依赖。 */ + @Bean com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers() { + return com.baiyue.lastleg.service.TestDocumentNumbers.documents(); + } @Bean VehicleCareService service() { return mock(VehicleCareService.class); } @Bean PlatformVehicleCareController controller(VehicleCareService service) { return new PlatformVehicleCareController(service); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCarePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCarePersistenceTest.java index 6efa92b94..c35e7d950 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCarePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCarePersistenceTest.java @@ -51,6 +51,7 @@ class VehicleCarePersistenceTest { when(files.upload(anyLong(), anyString(), any())).thenReturn(new VehicleCareModels.File("9001", "photo.png", "image/png", 8)); var target = new VehicleCareService(session.getMapper(VehicleCareMapper.class), session.getMapper(VehicleCareHistoryMapper.class), vehicles, files, new ObjectMapper().registerModule(new JavaTimeModule()), mock(VehicleCareFinanceService.class)); + org.springframework.test.util.ReflectionTestUtils.setField(target, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource())); service = (VehicleCareService) proxy.getProxy(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java index b2e75e9ec..7101dd39f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java @@ -39,7 +39,7 @@ class OwnedBusinessScopePersistenceTest { baseline.setup();var reader=mock(DispatchDetailReader.class); when(reader.detail(any(),anyString())).thenAnswer(call->{ var s=baseline.repository.source(call.getArgument(1));var r=baseline.codec.read(s.getResourcesJson(),DispatchContracts.Resources.class); - return new DispatchViews.Detail(s.getDispatchNo(),DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(), + return new DispatchViews.Detail(s.getDispatchNo(),"DELIVERY",DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(), DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver()),null,null,List.of()); }); queries=tx(new OwnedBusinessQueryService(baseline.repository,baseline.projection,baseline.codec,reader)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferServiceTest.java index 25a81baf9..f0465d1a7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferServiceTest.java @@ -32,6 +32,7 @@ class FleetCapacityOfferServiceTest { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(FleetCapacityOfferService.LIST)))); service=new FleetCapacityOfferService(offers,mock(FleetCapacityOfferCommandMapper.class),trips,mock(FleetBookingMapper.class),mock(FleetBookingService.class),reservations, execution,mock(FleetCustomerDirectory.class),mock(FleetPoolOrderBridge.class),new ObjectMapper().registerModule(new JavaTimeModule()),Clock.fixed(now,ZoneOffset.UTC)); + org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); } @AfterEach void clear(){SecurityContextHolder.clearContext();} @Test void ordinaryPageOnlyCalculatesRequestedDatabasePageAndPreservesAccurateTotal(){ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java index e43e900e1..dd284b5bc 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java @@ -100,6 +100,10 @@ class FleetTripAuthorizationTest { } @Configuration @EnableMethodSecurity static class Config { + /** 为控制器权限测试中的服务替身补齐编号依赖。 */ + @Bean com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers() { + return com.baiyue.lastleg.service.TestDocumentNumbers.documents(); + } /** 既有取货能力依赖原仓储引用;权限测试使用只读替身,不连接数据库。 */ @Bean com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickups() { return mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); } @Bean com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workTripChanges() { return mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java index 49195f710..29ce24699 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java @@ -359,7 +359,7 @@ class ContainerInboundPlanConversionCoordinatorTest { new ContainerInboundPlanPreviewResponse.Group("ONE_CLICK_DROPSHIP", "31", "US-LAX-01", "Los Angeles", 1, 1, 1, 1, 0, 0, 1, 0, List.of(overseas))), null); when(plans.previewContainer(BOOKING_ID)).thenReturn(preview); - when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))) + when(numbers.next(eq("INBOUND_PLAN"))) .thenReturn("IP-FBA", "IP-OVERSEAS"); when(plans.sourcePalletNos("100", 11L, "ONE_CLICK_DROPSHIP")).thenReturn(List.of("MSCU6639870-ABE3-001")); when(plans.sourcePalletNos("200", 12L, "ONE_CLICK_DROPSHIP")).thenReturn(List.of("MSCU6639870-US-LAX-01-001")); @@ -392,7 +392,7 @@ class ContainerInboundPlanConversionCoordinatorTest { List.of(new ContainerInboundPlanPreviewResponse.Group("FIRST_LEG", "31", "US-LAX-01", "Los Angeles", 1, 1, 2, 0, 2, 0, 2, 0, List.of(overseas))), null); when(plans.previewContainer(BOOKING_ID)).thenReturn(preview); - when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))).thenReturn("IP-OVERSEAS"); + when(numbers.next(eq("INBOUND_PLAN"))).thenReturn("IP-OVERSEAS"); when(plans.sourcePalletNos("200", 12L, "FIRST_LEG")).thenReturn(List.of()); ContainerPlanConversionCommand command = ReflectionTestUtils.invokeMethod(coordinator, "prepareCommand", @@ -424,7 +424,7 @@ class ContainerInboundPlanConversionCoordinatorTest { List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2", 1, 1, 3, 2, 1, 0, 3, 0, List.of(order))), null); when(plans.previewContainer(BOOKING_ID)).thenReturn(preview); - when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))).thenReturn("IP-ACY2"); + when(numbers.next(eq("INBOUND_PLAN"))).thenReturn("IP-ACY2"); when(plans.sourcePalletNos("100", 11L, "FIRST_LEG")).thenReturn(List.of( "D0004-101-001", "D0004-101-002")); @@ -475,7 +475,7 @@ class ContainerInboundPlanConversionCoordinatorTest { assertEquals(21L, preparation.items().get(0).planId()); assertEquals("IP-MANUAL", preparation.items().get(0).planNo()); - verify(numbers, never()).next(any(String.class), any(String.class), any(Long.class)); + verify(numbers, never()).next(any(String.class)); } private ContainerInboundPlanPreviewResponse.Order previewOrder(String customerId, String orderId, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java index bac6bc6e1..7bbb3f4ae 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java @@ -262,6 +262,7 @@ class FbaAppointmentPersistenceTest { @Bean com.baiyue.lastleg.mapper.LastlegOrderMapper orders(){var mock=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);var row=new com.baiyue.lastleg.entity.LastlegOrder();row.setId(100L);row.setCustomerId("C");row.setInboundCode("WT-REAL");when(mock.selectById(100L)).thenReturn(row);return mock;} @Bean AppointmentExternalSourceGateway bookings(){var mock=mock(AppointmentExternalSourceGateway.class);when(mock.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("C","B-1","TR-1","RECEIVED","100","FBA:ONT8",100L,10,2L)));return mock;} @Bean AppointmentSourceLinker linker(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings){return new InboundAppointmentSourceLinker(orders,items,bookings);} + @Bean com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers() { return com.baiyue.lastleg.service.TestDocumentNumbers.documents(); } @Bean InboundFbaAppointmentService service(){return new InboundFbaAppointmentService();} @Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper workMapper(SqlSessionTemplate session){return session.getMapper(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.class);} /** 使用生产持久层,继续验证需求失效与预约写入的同事务行为。 */ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java index 12871cd94..3be3975fd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java @@ -33,6 +33,7 @@ class InboundFbaAppointmentServiceTest { headers=mock(InboundFbaAppointmentMapper.class);items=mock(InboundFbaAppointmentItemMapper.class);query=mock(InboundFbaAppointmentQueryMapper.class); commands=mock(FbaAppointmentCommandMapper.class);sources=mock(AppointmentSourceService.class);flags=mock(AppointmentBatchPreviewMapper.class); service=new InboundFbaAppointmentService(); + org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); ReflectionTestUtils.setField(service,"receiving",mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class)); ReflectionTestUtils.setField(service,"receivingWork",mock(com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.class)); ReflectionTestUtils.setField(service,"workChanges",mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges.class)); @@ -95,7 +96,7 @@ class InboundFbaAppointmentServiceTest { } @Test void savesCrossCustomerArrangementBeforeAnyInboundPlanOrExternalNumberExists() { - var result=create("create");assertTrue(result.appointmentNo().startsWith("AP-"));assertNull(result.confirmationNo());assertNull(result.appointmentAt()); + var result=create("create");assertTrue(result.appointmentNo().matches("YY[0-9]{12}"));assertNull(result.confirmationNo());assertNull(result.appointmentAt()); assertEquals(LocalDate.of(2026,10,15),result.plannedDate());assertEquals(2,result.selectedCount());assertEquals("PENDING",result.appointmentStatus()); assertEquals(List.of("A","B"),result.items().stream().map(FbaAppointmentResponse.Item::customerId).toList());verify(query,never()).selectedLines(anyList()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index b810f9981..9df6a6190 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -75,7 +75,7 @@ class InboundPlanServiceTest { ReflectionTestUtils.setField(service, "commandMapper", commandMapper); ReflectionTestUtils.setField(service, "sourcePalletMapper", sourcePalletMapper); ReflectionTestUtils.setField(service, "objectMapper", JsonMapper.builder().findAndAddModules().build()); - ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator()); + ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders()); org.mockito.Mockito.lenient().when(lineMapper.selectList(any())).thenReturn(List.of()); org.mockito.Mockito.lenient().when(palletMapper.selectList(any())).thenReturn(List.of()); org.mockito.Mockito.lenient().when(allocationMapper.selectList(any())).thenReturn(List.of()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java index c89e42be6..ca428ac24 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java @@ -47,7 +47,7 @@ class LocalLooseboardServiceTest { @Mock LocalLooseboardOperationMapper operations; @Mock LastlegOrderMapper lastlegOrders; @Mock LastlegOrderStatusHistoryMapper lastlegHistories; - @Spy LastlegOrderNoGenerator lastlegOrderNoGenerator = new LastlegOrderNoGenerator(); + @Spy LastlegOrderNoGenerator lastlegOrderNoGenerator = com.baiyue.lastleg.service.TestDocumentNumbers.orders(); private LocalLooseboardService service; private final Map operationRows = new HashMap<>(); @@ -64,8 +64,7 @@ class LocalLooseboardServiceTest { ReflectionTestUtils.setField(repository, "lastlegHistories", lastlegHistories); ReflectionTestUtils.setField(service, "lastlegOrderNoGenerator", lastlegOrderNoGenerator); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); - org.mockito.Mockito.lenient().when(lastlegOrderNoGenerator.next(anyString(), any(), anyLong())) - .thenReturn("LB-WH-20260901-000001"); + org.mockito.Mockito.lenient().doReturn("LB202610200001").when(lastlegOrderNoGenerator).next(anyString()); org.mockito.Mockito.lenient().when(operations.findKey(anyString(), any(), anyString())) .thenAnswer(call -> operationRows.get(call.getArgument(2))); org.mockito.Mockito.lenient().when(operations.insert(any(LocalLooseboardOperation.class))).thenAnswer(call -> { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleAuthorizationTest.java index 92e19711a..ef06232a8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleAuthorizationTest.java @@ -27,6 +27,10 @@ class ProcurementLifecycleAuthorizationTest { private final ProcurementLifecycleService service=context.getBean(ProcurementLifecycleService.class); @Configuration @EnableMethodSecurity(proxyTargetClass=true) static class Config { + /** 为控制器权限测试中的服务替身补齐编号依赖。 */ + @Bean com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers() { + return com.baiyue.lastleg.service.TestDocumentNumbers.documents(); + } @Bean ProcurementLifecycleService service(){return mock(ProcurementLifecycleService.class);} @Bean ProcurementLifecycleController controller(ProcurementLifecycleService service){return new ProcurementLifecycleController(service);} @Bean ProcurementPhysicalService physicalService(){return mock(ProcurementPhysicalService.class);} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java index 7f11a6952..a4b2578d3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java @@ -43,7 +43,7 @@ class OverseasProcurementServiceTest { @Mock OverseasProcurementOrderMapper orders; @Mock OverseasProcurementStatusHistoryMapper histories; @Mock ProcurementWarehouseDirectory warehouseDirectory; - @Mock ProcurementOrderNumberGenerator orderNoGenerator; + @Mock com.baiyue.lastleg.service.LastlegDocumentNumberGenerator orderNoGenerator; @Mock OverseasProcurementFileMapper procurementFiles; @Mock OverseasProcurementFeeMapper procurementFees; @Mock ProcurementFileGateway fileGateway; @@ -62,7 +62,7 @@ class OverseasProcurementServiceTest { ReflectionTestUtils.setField(service, "procurementFiles", procurementFiles); ReflectionTestUtils.setField(service, "procurementFees", procurementFees); ReflectionTestUtils.setField(service, "fileGateway", fileGateway); - lenient().when(orderNoGenerator.next(anyString(), any())).thenReturn("ACME202609050001"); + lenient().when(orderNoGenerator.next(anyString())).thenReturn("ACME202609050001"); lenient().when(procurementFiles.selectList(any())).thenReturn(List.of()); lenient().when(procurementFees.selectList(any())).thenReturn(List.of()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementChargePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementChargePersistenceTest.java index f032b12ea..14927f83a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementChargePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementChargePersistenceTest.java @@ -58,8 +58,11 @@ class ProcurementChargePersistenceTest { when(commands.publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong())).thenAnswer(i->"cmd-"+events.incrementAndGet()); when(commands.publishRefund(anyLong(),anyString(),anyString(),any(),anyString(),anyString(),anyLong())).thenAnswer(i->"cmd-"+events.incrementAndGet()); var json=new ObjectMapper().findAndRegisterModules();finance=new ProcurementFinanceService(fees,files,orders,converter,sources,commands,jdbc,json); + org.springframework.test.util.ReflectionTestUtils.setField(finance, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); + org.springframework.test.util.ReflectionTestUtils.setField(finance, "feeNumbers", new com.baiyue.lastleg.procurement.repository.ProcurementFeeNumberRepository(fees)); lifecycle=new ProcurementLifecycleService(orders,files,mock(ProcurementWarehouseDirectory.class),finance,converter,jdbc,json); charges=new ProcurementChargeService(lifecycle,finance,jdbc);adjustments=new ProcurementAdjustmentService(lifecycle,finance,jdbc); + org.springframework.test.util.ReflectionTestUtils.setField(adjustments, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); tx.execute(s->finance.confirm("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT","货款","ZERO",BigDecimal.ZERO,"CNY",LocalDate.of(2026,9,1),null,"free"))); estimateId=tx.execute(s->finance.confirm("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE","预估","ZERO",BigDecimal.ZERO,"CNY",LocalDate.of(2026,9,1),null,"zero estimate"))).getId(); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementControlPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementControlPersistenceTest.java index 6a7fce90f..5cee6c842 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementControlPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementControlPersistenceTest.java @@ -51,6 +51,7 @@ class ProcurementControlPersistenceTest { when(warehouses.required(92)).thenReturn(new ReceivingWarehouse(92L,"NEW","Correct receiving warehouse","US","ACTIVE")); var json=new ObjectMapper().findAndRegisterModules();lifecycle=new ProcurementLifecycleService(orders,files,warehouses,finance,mock(OverseasFeeCurrencyConverter.class),jdbc,json); service=new ProcurementControlService(lifecycle,finance,warehouses,jdbc,json); + org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); } @Test void purchasedButUnreceivedGoodsCanCloseOnlyWithAllSupplierDispositionAndCustomerEvidence() { assertThat(service.read("9","PO-1").remaining()).containsExactly(new ProcurementControlService.Remaining("PURCHASE_REMAINDER",null,"PO-1",10)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java index 1c1b269cc..5eda4b1ca 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java @@ -68,6 +68,8 @@ class ProcurementFinancePersistenceTest { when(commands.publishRefund(anyLong(),anyString(),anyString(),any(),anyString(),anyString(),anyLong())) .thenAnswer(i -> "command-"+sequence.incrementAndGet()); service=new ProcurementFinanceService(fees,files,orders,converter,sources,commands,jdbc,new ObjectMapper().findAndRegisterModules()); + org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); + org.springframework.test.util.ReflectionTestUtils.setField(service, "feeNumbers", new com.baiyue.lastleg.procurement.repository.ProcurementFeeNumberRepository(fees)); } @Test void actualFeePublishesOnlyAfterSuccessfulConsumption() { @@ -235,7 +237,13 @@ class ProcurementFinancePersistenceTest { handle(result(fees.selectById(fee.getId()),"SETTLE","SUCCEEDED","SETTLED","100")); jdbc.update("INSERT INTO ll_event_outbox VALUES('OVERSEAS_PROCUREMENT_FEE','901','PUBLISHED')"); var refund=tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","refund-key")); - assertThat(tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","refund-key")).getId()).isEqualTo(refund.getId()); + assertThat(refund.getFeeNo()).matches("PF[0-9]{12}"); + var replay = tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","refund-key")); + assertThat(replay.getId()).isEqualTo(refund.getId()); + assertThat(replay.getFeeNo()).isEqualTo(refund.getFeeNo()); + assertThatThrownBy(() -> tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("21"),1,"correction","refund-key"))) + .hasMessageContaining("退款幂等键对应不同"); + verify(commands, times(1)).publishRefund(anyLong(),anyString(),anyString(),any(),anyString(),anyString(),anyLong()); assertThatThrownBy(() -> tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","another-key"))) .hasMessageContaining("未完成的调整或退款"); handle(new WalletCommandCompletedV1(refund.getWalletCommandEventId(),"REFUND",9,"PROCUREMENT_FEE",refund.getFeeNo(), diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGeneratorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGeneratorTest.java deleted file mode 100644 index bd8ce150b..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGeneratorTest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.baiyue.lastleg.procurement.service; - -import org.junit.jupiter.api.Test; -import static org.assertj.core.api.Assertions.assertThat; - -class ProcurementOrderNumberGeneratorTest { - @Test void convertsChineseCompanyNameToInitials() { - assertThat(ProcurementOrderNumberGenerator.initials("杭州积乐贸易有限公司")).isEqualTo("HZJLMYYX"); - } - @Test void keepsOneInitialPerLatinWordAndUsesFallback() { - assertThat(ProcurementOrderNumberGenerator.initials("Great Ocean Trading LLC")).isEqualTo("GOTL"); - assertThat(ProcurementOrderNumberGenerator.initials(" ")).isEqualTo("KH"); - } - @Test void limitsInitialsToEightCharacters() { - assertThat(ProcurementOrderNumberGenerator.initials("一二三四五六七八九十")).hasSize(8); - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java index 638a83214..8caa1277d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java @@ -55,6 +55,7 @@ class ProcurementPhysicalPersistenceTest { when(finance.current(11,ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT")).thenReturn(payment);when(finance.sourceLinked(payment)).thenReturn(true); var lifecycle=new ProcurementLifecycleService(orders,files,warehouses,finance,mock(OverseasFeeCurrencyConverter.class),jdbc,new ObjectMapper().findAndRegisterModules()); service=new ProcurementPhysicalService(lifecycle,warehouses,finance,jdbc); + org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); } @Test void splitReceiptsAndCartonSourcesRemainConservedWithoutWms() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java index df56e9c73..0bf8057c8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java @@ -24,7 +24,9 @@ class ProcurementQueryServiceTest { var m=Pattern.compile("CREATE TABLE IF NOT EXISTS "+name+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema);assertThat(m.find()).isTrue();jdbc.execute(m.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");")); } var physical=new ProcurementPhysicalService(fixture.lifecycle,fixture.warehouses,fixture.finance,jdbc); + org.springframework.test.util.ReflectionTestUtils.setField(physical, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); transport=new ProcurementTransportService(fixture.lifecycle,physical,fixture.finance,fixture.warehouses,jdbc,json); + org.springframework.test.util.ReflectionTestUtils.setField(transport, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); base=mock(OverseasProcurementService.class); when(base.detail("9","PO-1")).thenReturn(new OverseasProcurementResponses.Detail(null,null,null,null,null,null,"internal remark","operator","reviewer","internal review",null,List.of(),List.of(new OverseasProcurementResponses.FileInfo(1L,"EXCEPTION",1001L,"private.pdf","application/pdf",10,null)),List.of())); query=new ProcurementQueryService(fixture.lifecycle,physical,transport,fixture.finance,mock(ProcurementAdjustmentService.class),fixture.service,base,jdbc); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java index ac1debecc..50f1bbacf 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java @@ -68,7 +68,9 @@ class ProcurementTransportPersistenceTest { var json=new ObjectMapper().findAndRegisterModules(); var lifecycle=new ProcurementLifecycleService(orders,files,warehouses,finance,mock(OverseasFeeCurrencyConverter.class),jdbc,json); var physical=new ProcurementPhysicalService(lifecycle,warehouses,finance,jdbc); + org.springframework.test.util.ReflectionTestUtils.setField(physical, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); service=new ProcurementTransportService(lifecycle,physical,finance,warehouses,jdbc,json); + org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); } @Test void entireBatchShipsOnceWithFrozenAddressLabelsAndSources() { prepare();var request=shipRequest(Leg.OVERSEAS,"ship"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index 1e1740d23..72f93a84f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -208,17 +208,17 @@ class RelabelApplicationServiceTest { @Mock OverseasFeeSourceService feeSources; @Mock ReturnOrderMapper returnOrders; @Mock com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper pdaTasks; - @Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator(); + @Spy LastlegOrderNoGenerator orderNoGenerator = com.baiyue.lastleg.service.TestDocumentNumbers.orders(); private RelabelApplicationService service; - private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers; + private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers; @BeforeEach void setUp() { - compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.CompactBusinessNumberService.class); + compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class); service = new RelabelApplicationService(); ReflectionTestUtils.setField(service, "compactNumbers", compactNumbers); - org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030"); + org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030"); ReflectionTestUtils.setField(service, "bindingGuard", bindingGuard); ReflectionTestUtils.setField(service, "returnPackingTraces", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.class)); ReflectionTestUtils.setField(service, "skuConversions", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.ReturnSkuConversionService.class)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelDeliveryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelDeliveryTest.java index a19d418ee..ae22320ec 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelDeliveryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelDeliveryTest.java @@ -108,7 +108,7 @@ class ReturnRelabelDeliveryTest { when(customers.find("C1")).thenReturn(new FleetCustomerDirectory.Customer("C1", "C", "测试客户", null, null)); when(warehouses.required(100L, "OVERSEAS")).thenReturn(new DriverModels.WarehouseOption("100", "WH", "洛杉矶测试仓", "OVERSEAS")); ReflectionTestUtils.setField(service, "outboundTasks", mock(OutboundPdaTaskMapper.class)); - ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator()); + ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders()); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); var application = new RelabelApplication(); application.setId(1L); application.setCustomerId("C1"); application.setRelabelNo("REL1"); application.setOverseasWarehouseId("100"); application.setDeliveryMethod(deliveryMethod); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/DocumentSequenceRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/DocumentSequenceRepositoryTest.java new file mode 100644 index 000000000..136356f8f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/DocumentSequenceRepositoryTest.java @@ -0,0 +1,76 @@ +package com.baiyue.lastleg.repository; + +import com.baiyue.lastleg.mapper.DocumentSequenceMapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.*; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; + +/** 实际 Mapper 和隔离 H2 MySQL 模式验证原子序列,不访问部署环境。 */ +class DocumentSequenceRepositoryTest { + private static class Fixture { + final JdbcTemplate jdbc; + final DataSourceTransactionManager transactions; + final DocumentSequenceMapper mapper; + final DocumentSequenceRepository repository; + /** 仅测试造表允许 JDBC;被测路径全部使用 Mapper。 */ + Fixture() throws Exception { + var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa",""); + jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data); + jdbc.execute("CREATE TABLE ll_document_sequence(sequence_key VARCHAR(32) PRIMARY KEY,current_value BIGINT NOT NULL)"); + var configuration=new MybatisConfiguration();configuration.addMapper(DocumentSequenceMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet(); + mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DocumentSequenceMapper.class); + var proxy=new ProxyFactory(new DocumentSequenceRepository(mapper));proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource())); + repository=(DocumentSequenceRepository)proxy.getProxy(); + } + } + /** 日期和前缀各自独立,重复分配持续递增。 */ + @Test void dateAndPrefixHaveIndependentSequences() throws Exception { + var f=new Fixture(); + assertThat(f.repository.next("AA:20261020")).isEqualTo(1); + assertThat(f.repository.next("AA:20261020")).isEqualTo(2); + assertThat(f.repository.next("BB:20261020")).isEqualTo(1); + assertThat(f.repository.next("AA:20261021")).isEqualTo(1); + } + /** 两个线程使用真实事务连接并发争用,不能产生重号。 */ + @Test void concurrentConnectionsAllocateUniqueNumbers() throws Exception { + var f=new Fixture();var pool=Executors.newFixedThreadPool(2); + try { + var work=new ArrayList>(); + for(int i=0;i<64;i++)work.add(()->f.repository.next("AA:20261020")); + var values=new TreeSet(); + for(var future:pool.invokeAll(work))values.add(future.get()); + assertThat(values).hasSize(64);assertThat(values.first()).isEqualTo(1L);assertThat(values.last()).isEqualTo(64L); + } finally {pool.shutdownNow();} + } + /** 外层建单失败后不能把已经分配的单号再交给其他单据。 */ + @Test void businessRollbackDoesNotReuseAllocatedNumber() throws Exception { + var f=new Fixture(); + assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{ + assertThat(f.repository.next("AA:20261020")).isEqualTo(1); + throw new IllegalStateException("业务回滚"); + })).isInstanceOf(IllegalStateException.class); + assertThat(f.repository.next("AA:20261020")).isEqualTo(2); + } + /** 同一会话中连接值变化后,读取必须绕过 MyBatis 缓存。 */ + @Test void connectionResultIsNeverCached() throws Exception { + var f=new Fixture(); + new TransactionTemplate(f.transactions).executeWithoutResult(status->{ + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(7L); + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(37L); + }); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java index 12e253f98..39963ddad 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java @@ -281,7 +281,7 @@ class ReturnInventoryServiceTest { receipt.setPalletCode("PAL-RETURN-1"); receipt.setUnitType("PALLET"); receipt.setUnitCode("PAL-RETURN-1"); - when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-1"); + service.createReceiptCarton(order, receipt, null, List.of(new ReturnInventoryService.ReceiptLine("SKU-A", "商品A", 2))); @@ -339,7 +339,7 @@ class ReturnInventoryServiceTest { when(cartons.listReceipt(21L)).thenReturn(List.of(source)); when(cartons.lockMark("RC-SOURCE")).thenReturn(source); when(cartonItems.lockCarton(31L)).thenReturn(List.of(skuA, skuB)); - when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-MERGED"); + when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1); service.merge(new PdaWarehouseIdentity("operator-1", "操作员", 42L), @@ -382,13 +382,16 @@ class ReturnInventoryServiceTest { when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 6L))); when(packingTrace.ownership("10001", "RC-SOURCE", "SKU-A")).thenReturn(List.of( new com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership("RT-1", 3L))); - when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET"); + when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1); service.merge(new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(quantity)); + var target = ArgumentCaptor.forClass(ReturnInventoryCarton.class); + verify(cartons).insert(target.capture()); + assertThat(target.getValue().getCartonMark()).isEqualTo(LastlegOrderNoGenerator.compactCartonMark(target.getValue().getId())); verify(packingTrace).insert(anyLong(), eq("10001"), anyLong(), eq("RC-SOURCE"), - eq("RC-TARGET"), eq("RT-1"), eq("SKU-A"), eq(quantity), any()); + eq(target.getValue().getCartonMark()), eq("RT-1"), eq("SKU-A"), eq(quantity), any()); verify(packingTrace, org.mockito.Mockito.times(1)).insert(anyLong(), anyString(), anyLong(), anyString(), anyString(), anyString(), anyString(), anyLong(), any()); assertThat(source.getTotalQuantity()).isEqualTo(6L - quantity); @@ -432,7 +435,7 @@ class ReturnInventoryServiceTest { void fullyConsumedParcelSourceIsMergedAndCreatesOnePhysicalCarton() { ReturnInventoryCarton locked = prepareConcurrentSource(); when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L))); - when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET"); + when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1); service.merge(new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(2)); assertThat(locked.getTotalQuantity()).isZero(); @@ -457,14 +460,14 @@ class ReturnInventoryServiceTest { var source = prepareConcurrentSource(); when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L))); when(cartons.completedSkuQuantity(31L, "SKU-A")).thenReturn(2L); - when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET"); + when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1); var relabel = org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.mapper.RelabelItemMapper.class); ReflectionTestUtils.setField(service, "relabelItems", relabel); service.merge(new PdaWarehouseIdentity("operator-2", "name", 42L), mergeRequest(2)); var target = ArgumentCaptor.forClass(ReturnInventoryCarton.class); verify(cartons).insert(target.capture()); - verify(relabel).moveCompletedReturnCarton("10001", 31L, target.getValue().getId(), "RC-TARGET"); + verify(relabel).moveCompletedReturnCarton("10001", 31L, target.getValue().getId(), target.getValue().getCartonMark()); assertThat(source.getTotalQuantity()).isZero(); } @@ -579,7 +582,7 @@ class ReturnInventoryServiceTest { when(cartonItems.lockSku(31L, "SKU-A")).thenReturn(line); when(balances.reserveAvailable("10001", "42", "SKU-A", 2)).thenReturn(1); when(balances.releaseAvailable("10001", "42", "SKU-A", 2)).thenReturn(1); - when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("PLAN-1"); + when(numbers.next(anyString())).thenReturn("PLAN-1"); var request = new ReturnInventoryRequests.PackingPlanCreate(List.of("RT-1"), "SKU", List.of(new ReturnInventoryRequests.PlanBox(List.of(new ReturnInventoryRequests.PlanItem("SKU-A", 2)), null, null, null, null)), null, "plan-key"); var created = service.createPackingPlan("platform", request); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java index 99f173a76..20fc7b23e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java @@ -131,13 +131,13 @@ class ReturnReceiptServiceTest { ReturnReceiptService service; PdaWarehouseIdentity identity; - private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers; + private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers; @BeforeEach void setUp() { - compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.CompactBusinessNumberService.class); + compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class); service = new ReturnReceiptService(); ReflectionTestUtils.setField(service, "compactNumbers", compactNumbers); - org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030"); + org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "202610200001"); ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "orderItems", orderItems); ReflectionTestUtils.setField(service, "receipts", receipts); @@ -257,7 +257,7 @@ class ReturnReceiptServiceTest { when(orderItems.lockOrder(1L)).thenReturn(List.of(item(11L, "SKU-A", 50, 50), item(12L, "SKU-B", 40, 40))); when(fbaWarehouseValidator.required("200", "XLX6", null)).thenReturn( new FbaWarehouseValidator.VerifiedFbaWarehouse(200L, "XLX6", "FBA XLX6", "US", "CA")); - when(compactNumbers.next(eq("TT"), eq("customer-1"))).thenReturn("RL-MULTI"); + when(compactNumbers.next(eq("THHB"))).thenReturn("RL-MULTI"); when(lastlegOrders.selectById(99L)).thenReturn(tail); when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(first, second)); when(inventory.matchesReturnSource(any(), eq("RT-MULTI"))).thenCallRealMethod(); @@ -310,7 +310,7 @@ class ReturnReceiptServiceTest { when(orderItems.lockOrder(1L)).thenReturn(List.of(item(11L, "SKU-A", 100, 100))); when(fbaWarehouseValidator.required("200", "XLX6", null)).thenReturn( new FbaWarehouseValidator.VerifiedFbaWarehouse(200L, "XLX6", "FBA XLX6", "US", "CA")); - when(compactNumbers.next(eq("TT"), eq("customer-1"))).thenReturn("RL-SECOND"); + when(compactNumbers.next(eq("THHB"))).thenReturn("RL-SECOND"); when(lastlegOrders.selectById(99L)).thenReturn(tail); when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(carton)); when(inventory.matchesReturnSource(any(), eq("RT-MULTI"))).thenCallRealMethod(); @@ -365,7 +365,7 @@ class ReturnReceiptServiceTest { void createForecastPersistsCompleteTailOrderSourceAndReturnsItsBusinessNumber() { AtomicReference saved = new AtomicReference<>(); when(orders.findKey("customer-1", "create-key")).thenReturn(null); - when(compactNumbers.next(eq("TT"), eq("customer-1"))).thenReturn("RT-1"); + when(compactNumbers.next(eq("TH"))).thenReturn("RT-1"); when(orders.insert(any(ReturnOrder.class))).thenAnswer(invocation -> { saved.set(invocation.getArgument(0)); return 1; }); when(orders.findNo("customer-1", "RT-1")).thenAnswer(invocation -> saved.get()); when(orderItems.listOrder(anyLong())).thenReturn(List.of()); @@ -389,7 +389,7 @@ class ReturnReceiptServiceTest { assertEquals("商品A", cargo.getValue().getProductNameZh()); assertEquals(2, cargo.getValue().getProductQuantity()); assertEquals(0, cargo.getValue().getPackageCount()); - verify(numbers, never()).next(eq("RETURN_RELABEL"), anyString(), anyLong()); + verify(numbers, never()).next(eq("RETURN_RELABEL")); } @Test @@ -559,8 +559,8 @@ class ReturnReceiptServiceTest { when(orders.lockNo("RT-1")).thenReturn(order); when(orderItems.lockOrder(1L)).thenReturn(items); when(orderItems.listOrder(1L)).thenReturn(items); - when(numbers.next(eq("RETURN_RECEIPT"), eq("10"), anyLong())).thenReturn("RR-1"); - when(numbers.next(eq("RETURN_BATCH"), eq("10"), anyLong())).thenReturn("RB-1"); + when(numbers.next(eq("RETURN_RECEIPT"))).thenReturn("RR-1"); + when(numbers.next(eq("RETURN_BATCH"))).thenReturn("RB-1"); ReturnInventoryCarton carton = new ReturnInventoryCarton(); carton.setCartonMark(cartonMark); when(inventory.createReceiptCarton(eq(order), any(ReturnReceipt.class), any(), any())).thenReturn(carton); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java index 588b2fcbb..2b16ca6e6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java @@ -344,6 +344,60 @@ class DashboardSqlSemanticTest { assertTrue(rendered.endsWith(finalSelect));rendered=rendered.substring(0,rendered.length()-finalSelect.length()) +"SELECT customer_code,sku_code,bucket_code FROM classified WHERE bucket_code IS NOT NULL"; } - String id="dashboard.literal."+(++sequence);config.addMappedStatement(new MappedStatement.Builder(config,id,new org.apache.ibatis.builder.StaticSqlSource(config,rendered),SqlCommandType.SELECT).resultMaps(original.getResultMaps()).build());return session.selectList(id); + // H2 会指数展开重复引用的 CTE;沿用周转测试的逐关系物化方式, + // 保留生产查询表达式和结果映射,不据此推定 MySQL 的执行计划。 + var relations = new ArrayList(); + try { + String finalSql = materializeRelations(rendered, relations); + String id="dashboard.literal."+(++sequence); + config.addMappedStatement(new MappedStatement.Builder(config,id, + new org.apache.ibatis.builder.StaticSqlSource(config,finalSql),SqlCommandType.SELECT) + .resultMaps(original.getResultMaps()).build()); + return session.selectList(id); + } catch (SQLException error) { + throw new AssertionError("本地统计 SQL 测试关系物化失败", error); + } finally { + Collections.reverse(relations); + for (String name : relations) { + try (var statement = session.getConnection().createStatement()) { + statement.execute("DROP TABLE " + name); + } catch (SQLException error) { + throw new AssertionError("本地统计 SQL 测试关系释放失败:" + name, error); + } + } + } + } + + /** 只物化实际生产 CTE 的查询内容,临时表局限在当前隔离 H2 会话。 */ + private String materializeRelations(String sql, List relations) throws SQLException { + if (!sql.startsWith("WITH ")) return sql; + int cursor = 5; + while (true) { + int as = sql.indexOf(" AS (", cursor); + assertTrue(as >= cursor, "生产 CTE 应使用具名 AS 查询"); + String name = sql.substring(cursor, as).trim(); + assertTrue(name.matches("[A-Za-z_][A-Za-z0-9_]*"), "测试临时关系必须是固定 SQL 标识"); + int start = as + 5, end = start, depth = 1; + boolean quoted = false; + while (depth > 0) { + char ch = sql.charAt(end++); + if (ch == '\'') { + if (quoted && end < sql.length() && sql.charAt(end) == '\'') { end++; continue; } + quoted = !quoted; + } else if (!quoted) { + if (ch == '(') depth++; + if (ch == ')') depth--; + } + } + try (var statement = session.getConnection().createStatement()) { + statement.execute("CREATE LOCAL TEMPORARY TABLE " + name + " AS " + sql.substring(start, end - 1)); + } + relations.add(name); + cursor = end; + while (cursor < sql.length() && Character.isWhitespace(sql.charAt(cursor))) cursor++; + if (sql.charAt(cursor) != ',') return sql.substring(cursor); + cursor++; + while (cursor < sql.length() && Character.isWhitespace(sql.charAt(cursor))) cursor++; + } } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/CompactBusinessNumberServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/CompactBusinessNumberServiceTest.java deleted file mode 100644 index 07ff1c047..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/CompactBusinessNumberServiceTest.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.baiyue.lastleg.service; - -import org.junit.jupiter.api.Test; -import org.springframework.jdbc.core.JdbcTemplate; -import java.time.Instant; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class CompactBusinessNumberServiceTest { - @Test void usesCustomerInitialsAndShanghaiSecondsWithoutSequenceSuffix() { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - var customers = mock(ReturnOrderReferenceResolver.class); - when(customers.customerName("42")).thenReturn("柏悦物流"); - when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)) - .thenAnswer(call -> Instant.now().getEpochSecond()); - String number = new CompactBusinessNumberService(jdbc, customers).next("TB", "42"); - assertTrue(number.matches("TB-BYWL-[0-9]{12}"), number); - assertEquals("AB", CompactBusinessNumberService.initials("Alpha Beta")); - } - - @Test void usesDatabaseLogicalSecondAcrossInstancesAndClockRollback() { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - var first = new CompactBusinessNumberService(jdbc, null); - var second = new CompactBusinessNumberService(jdbc, null); - Instant now = Instant.parse("2026-10-16T02:30:00Z"); - when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)) - .thenReturn(now.getEpochSecond(), now.getEpochSecond() + 1, now.getEpochSecond() + 2); - assertEquals("ZY-ABC-261016103000", first.allocate("ZY", "ABC", now)); - assertEquals("ZY-ABC-261016103001", second.allocate("ZY", "ABC", now)); - assertEquals("ZY-ABC-261016103002", first.allocate("ZY", "ABC", now.minusSeconds(60))); - } - - @Test void physicalLabelKeepsEveryIdBitAndIsCompact() { - long id = 2087379674555461633L; - String mark = LastlegOrderNoGenerator.compactCartonMark(id); - assertTrue(mark.length() <= 16); - assertEquals(id, Long.parseLong(mark.substring(3), 36)); - assertNotEquals(mark, LastlegOrderNoGenerator.compactCartonMark(id + 1_000_000)); - assertEquals(mark, new LastlegOrderNoGenerator().next("RETURN_CARTON", "92", id)); - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegDocumentNumberGeneratorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegDocumentNumberGeneratorTest.java new file mode 100644 index 000000000..96fb3af0a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegDocumentNumberGeneratorTest.java @@ -0,0 +1,41 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.repository.DocumentSequenceRepository; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import java.time.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class LastlegDocumentNumberGeneratorTest { + private static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC); + + /** 多构造器情况下正式依赖可以被 Spring 正确选择。 */ + @Test void springCreatesGeneratorWithRepository() { + try(var context=new AnnotationConfigApplicationContext()) { + context.registerBean(DocumentSequenceRepository.class,()->mock(DocumentSequenceRepository.class)); + context.register(LastlegDocumentNumberGenerator.class);context.refresh(); + assertThat(context.getBean(LastlegDocumentNumberGenerator.class)).isNotNull(); + } + } + /** 主机使用 UTC 时仍按北京时间跨午夜,且四位流水不截断。 */ + @Test void usesShanghaiDateAndIndependentSequenceKey() { + var sequences=mock(DocumentSequenceRepository.class); + when(sequences.next("YY:20261020")).thenReturn(1L,2L,9999L); + var numbers=new LastlegDocumentNumberGenerator(sequences,CLOCK); + assertThat(numbers.next("YY")).isEqualTo("YY202610200001"); + assertThat(numbers.next("YY")).isEqualTo("YY202610200002"); + assertThat(numbers.next("YY")).isEqualTo("YY202610209999"); + verify(sequences,times(3)).next("YY:20261020"); + } + /** 未登记类型不能消耗流水,分配异常或溢出时拒绝出号。 */ + @Test void rejectsUnknownTypeAndOverflow() { + var sequences=mock(DocumentSequenceRepository.class); + var numbers=new LastlegDocumentNumberGenerator(sequences,CLOCK); + assertThatThrownBy(()->numbers.next("ZZZZ")).isInstanceOf(IllegalArgumentException.class); + verifyNoInteractions(sequences); + when(sequences.next("YY:20261020")).thenReturn(10000L); + assertThatThrownBy(()->numbers.next("YY")).isInstanceOf(BizException.class); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java index 8bef1431f..e6f0e86a7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java @@ -1,94 +1,30 @@ package com.baiyue.lastleg.service; -import com.baiyue.lastleg.mapper.LastlegOrderNoDailySequenceMapper; -import org.apache.ibatis.annotations.Insert; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; -import java.time.LocalDate; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) class LastlegOrderNoGeneratorTest { - @Mock LastlegOrderNoDailySequenceMapper sequenceMapper; - private LastlegOrderNoGenerator generator; - - @BeforeEach - void setUp() { - generator = new LastlegOrderNoGenerator(); - ReflectionTestUtils.setField(generator, "sequenceMapper", sequenceMapper); + /** 各作业类型使用不同前缀,同类型第二次调用必须递增。 */ + @Test void routesEveryOperationalTypeToItsDailySequence() { + var generator=TestDocumentNumbers.orders(); + Map.ofEntries(Map.entry("OUTBOUND","CK"),Map.entry("ONE_CLICK_DROPSHIP","DFCK"), + Map.entry("RELABEL_OUTBOUND","HBCK"),Map.entry("RETURN_OUTBOUND","THCK"), + Map.entry("DELIVERY","DL"),Map.entry("TRUCK","TR"),Map.entry("LOOSEBOARD","LB"), + Map.entry("EXCEPTION","EX"),Map.entry("DISCARD","CD"),Map.entry("INBOUND_PLAN","IP"), + Map.entry("RETURN_RECEIPT","RR"),Map.entry("RETURN_BATCH","RB"),Map.entry("RETURN_PACKING_PLAN","HX")) + .forEach((type,prefix)->{ + assertEquals(prefix+"202610200001",generator.next(type)); + assertEquals(prefix+"202610200002",generator.next(type)); + }); + assertThrows(IllegalArgumentException.class,()->generator.next("UNKNOWN")); } - - @Test - void usesShanghaiBusinessDateAndDatabaseAllocatedSequence() { - LocalDate date = LocalDate.of(2026, 8, 27); - when(sequenceMapper.currentValue()).thenReturn(1L, 2L); - - assertEquals("LMO-260827-0001", generator.next(date)); - assertEquals("LMO-260827-0002", generator.next(date)); - verify(sequenceMapper, times(2)).nextValue(date); - } - - @Test - void mapperUsesOneAtomicDatabaseOperationForConcurrentInstances() throws Exception { - Insert insert = LastlegOrderNoDailySequenceMapper.class - .getMethod("nextValue", LocalDate.class).getAnnotation(Insert.class); - - assertTrue(insert.value()[0].contains("ON DUPLICATE KEY UPDATE")); - assertTrue(insert.value()[0].contains("LAST_INSERT_ID(last_sequence+1)")); - } - - @Test - void crossDayAllocationUsesTheNewShanghaiBusinessDateSequence() { - LocalDate firstDay = LocalDate.of(2026, 8, 27); - LocalDate nextDay = firstDay.plusDays(1); - when(sequenceMapper.currentValue()).thenReturn(42L, 1L); - - assertEquals("LMO-260827-0042", generator.next(firstDay)); - assertEquals("LMO-260828-0001", generator.next(nextDay)); - verify(sequenceMapper).nextValue(firstDay); - verify(sequenceMapper).nextValue(nextDay); - } - - @Test - void rejectsDailySequenceBeyondFourDigits() { - when(sequenceMapper.currentValue()).thenReturn(10_000L); - assertThrows(IllegalStateException.class, () -> generator.next(LocalDate.of(2026, 8, 27))); - } - - @Test void actualContainerNumberIsStableAndShared() { - LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator(); - assertTrue(generator.forContainerNo("2087379674555461633").matches("FL-WH-\\d{8}-\\d{6}")); - assertEquals(generator.forContainerNo("MSCU1"), new LastlegOrderNoGenerator().forContainerNo("MSCU1")); - assertNotEquals(generator.forContainerNo("MSCU1"), generator.forContainerNo("CAB1")); - assertTrue(generator.forContainerNo("MSCU1234567890").length() <= 32); - assertThrows(IllegalArgumentException.class, () -> generator.forContainerNo(" ")); - } - - @Test void formatsBusinessWarehouseAndDate() { - String value = new LastlegOrderNoGenerator().next("OUTBOUND", "warehouse-LAX-01", 123L); - assertTrue(value.matches("OB-LAX-\\d{8}-000123"), value); - assertTrue(value.length() <= 24); - } - - @Test void transferNumbersDoNotCollideWhenDatabaseIdSuffixesRepeat() { - String first = generator.next("TRANSFER", "warehouse-LAX-01", 123L); - String second = generator.next("TRANSFER", "warehouse-LAX-01", 1_000_123L); - String repeatedId = generator.next("WAREHOUSE_TRANSFER", "warehouse-LAX-01", 123L); - assertTrue(first.matches("WT-[0-9A-Z]{25}"), first); - assertTrue(first.length() <= 32); - assertNotEquals(first, second); - assertNotEquals(first, repeatedId); + /** 头程来源尾程号未纳入独立改号,保持同柜分组关系。 */ + @Test void actualContainerNumberRemainsStableAndShared() { + var generator=TestDocumentNumbers.orders(); + assertTrue(generator.forContainerNo("MSCU6639870").matches("FL-WH-\\d{8}-\\d{6}")); + assertEquals(generator.forContainerNo("MSCU6639870"),TestDocumentNumbers.orders().forContainerNo("MSCU6639870")); + assertNotEquals(generator.forContainerNo("MSCU6639870"),generator.forContainerNo("CSQU3054383")); + assertThrows(IllegalArgumentException.class,()->generator.forContainerNo(" ")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 6b714df95..5f43e3f66 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -3,10 +3,10 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) class LoadingCompletedConsumerTest { private final Map frozenContainerNos=new HashMap<>(); - @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper loadingEvidence; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; - @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"loadingEvidence",loadingEvidence); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); } + @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper loadingEvidence; @Mock LastlegDocumentNumberGenerator sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; + @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"loadingEvidence",loadingEvidence); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); } @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); } - @Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); } + @Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verifyNoInteractions(sequences); } @Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); } @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","","MSCU6639870",0L,1L); } @@ -15,7 +15,7 @@ class LoadingCompletedConsumerTest { @Test void rejectsDifferentContainerNumberForSameSourceBookingBeforeAnyBusinessWrite(){ EventEnvelope source=event(); frozenContainerNos.put("book-1","CSQU3054383"); IllegalStateException error=assertThrows(IllegalStateException.class,()->service.consume(source)); assertTrue(error.getMessage().contains("sourceContainerBookingId")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void rejectsExistingOrderContainerDriftForSameSourceBooking(){ when(orders.lockContainerNumbers("book-1")).thenReturn(List.of("MSCU6639870","CSQU3054383")); assertThrows(IllegalStateException.class,()->service.consume(event())); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void acceptsSeparateCustomerEventsForTheSameFrozenContainerIdentity(){ EventEnvelope first=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(first).outcome()); com.fasterxml.jackson.databind.node.ObjectNode node=first.payload().deepCopy(); int index=0; for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("customerId","200").put("customerName","Customer 200").put("sourceInboundId","customer-200-in-"+(++index)); } String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174200",first.eventType(),first.eventVersion(),first.occurredAt(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(second).outcome()); verify(shippingGates,times(2)).initialize(eq("book-1"),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); } - @Test void replayPreservesExistingLmoNumberAndAddsNothing(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); row.setStatus("PROCESSED"); when(inbox.selectById(event.eventId())).thenReturn(row); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.DUPLICATE, service.consume(event).outcome()); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); verify(histories,never()).insert(any(LastlegOrderStatusHistory.class)); } + @Test void replayPreservesExistingLmoNumberAndAddsNothing(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); row.setStatus("PROCESSED"); when(inbox.selectById(event.eventId())).thenReturn(row); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.DUPLICATE, service.consume(event).outcome()); verifyNoInteractions(sequences); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); verify(histories,never()).insert(any(LastlegOrderStatusHistory.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } @Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); } @Test void concurrentDuplicateKeyWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox raced=new LastlegEventInbox(); raced.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(null,raced); doThrow(new org.springframework.dao.DuplicateKeyException("race")).when(inbox).insert(any(LastlegEventInbox.class)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); assertTrue(error.getMessage().contains("摘要冲突")); verify(orders,never()).countContainer(anyString(),anyString()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/PhysicalCartonNumberTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/PhysicalCartonNumberTest.java new file mode 100644 index 000000000..07f41c186 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/PhysicalCartonNumberTest.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.service; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class PhysicalCartonNumberTest { + /** 箱码不属于本次改号,完整保留内部 ID 的每一位以保证扫码唯一性。 */ + @Test void preservesEveryIdBit() { + long id = 2087379674555461633L; + String mark = LastlegOrderNoGenerator.compactCartonMark(id); + assertTrue(mark.length() <= 16); + assertEquals(id, Long.parseLong(mark.substring(3), 36)); + assertNotEquals(mark, LastlegOrderNoGenerator.compactCartonMark(id + 1_000_000)); + assertEquals(mark, LastlegOrderNoGenerator.compactCartonMark(id)); + assertThrows(IllegalArgumentException.class, () -> LastlegOrderNoGenerator.compactCartonMark(0)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/ReturnBusinessNumberTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ReturnBusinessNumberTest.java index 06066a3aa..92e76366a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/ReturnBusinessNumberTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ReturnBusinessNumberTest.java @@ -1,60 +1,27 @@ package com.baiyue.lastleg.service; import org.junit.jupiter.api.Test; -import java.math.BigInteger; +import java.util.List; import java.util.HashSet; -import java.util.Map; -import java.util.UUID; -import java.util.stream.IntStream; import static org.junit.jupiter.api.Assertions.*; class ReturnBusinessNumberTest { - // 实物箱唛采用稳定BX编码,由CompactBusinessNumberServiceTest校验;此处仅验证独立业务编号。 - private static final Map TYPES = Map.of("RETURN", "RT", "RETURN_RECEIPT", "RR", - "RETURN_BATCH", "RB", "RETURN_RELABEL", "RETU"); - - @Test - void formerlyCollidingInternalIdsAndRepeatedIdsNoLongerDetermineBusinessNumbers() { - LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator(); - long first = 2099800000000781889L, second = first + 1_000_000L; - assertEquals(Math.floorMod(first, 1_000_000), Math.floorMod(second, 1_000_000)); - TYPES.forEach((type, prefix) -> { - String a = generator.next(type, "warehouse-LAX-01", first); - String b = generator.next(type, "warehouse-LAX-01", second); - String c = generator.next(type, "warehouse-LAX-01", first); - assertEquals(3, new HashSet<>(java.util.List.of(a, b, c)).size()); - for (String value : java.util.List.of(a, b, c)) { - assertTrue(value.matches(prefix + "-[0-9A-Z]{25}"), value); - assertTrue(value.length() <= 32); - assertFalse(value.contains(Long.toString(first))); - } - }); - } - - @Test - void encodingPreservesAll128BitsIncludingLeadingZerosAndSignBits() { - for (UUID uuid : java.util.List.of(new UUID(0, 0), new UUID(-1, -1), new UUID(Long.MIN_VALUE, 1), - UUID.fromString("00000000-0000-4000-8000-000000000001"), - UUID.fromString("ffffffff-ffff-4fff-bfff-000000000001"))) { - String encoded = LastlegOrderNoGenerator.encodeReturnUuid(uuid); - assertEquals(25, encoded.length()); - assertEquals(new BigInteger(uuid.toString().replace("-", ""), 16), new BigInteger(encoded, 36)); + /** 独立单据不再依赖客户简称和内部 ID,各前缀从一开始。 */ + @Test void businessPrefixesHaveIndependentFourDigitSequences() { + var generator=TestDocumentNumbers.documents(); + for(String prefix:List.of("TH","THHB","TB","ZY","RR","RB","HX","CG","PR","PJ","PF","PN","PX","PG","CT","FB","FO","VR")) { + var values=List.of(generator.next(prefix),generator.next(prefix),generator.next(prefix)); + assertEquals(3,new HashSet<>(values).size()); + assertEquals(prefix+"202610200001",values.get(0)); + assertEquals(prefix+"202610200003",values.get(2)); } } - - @Test - void independentInstancesGenerateDistinctNumbersDuringConcurrentBurst() { - var values = IntStream.range(0, 20_000).parallel() - .mapToObj(i -> new LastlegOrderNoGenerator().next("RETURN_RECEIPT", "42", 123L)).toList(); - assertEquals(values.size(), new HashSet<>(values).size()); - } - - @Test - void nonReturnRulesRemainUnchangedAndReturnNormalizationIsLocaleIndependent() { - LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator(); - for (String type : java.util.List.of("OUTBOUND", "RELABEL", "PROCUREMENT", "DISPATCH")) { - assertEquals(generator.next(type, "LAX", 123), generator.next(type, "LAX", 1_000_123)); - } - assertTrue(generator.next(" return_receipt ", "42", 1).matches("RR-[0-9A-Z]{25}")); + /** 商业池和普通作业入口必须共享卡车及散板同类型序列。 */ + @Test void commercialAndOperationalEntriesShareTheSameCounters() { + var documents=TestDocumentNumbers.documents();var operations=new LastlegOrderNoGenerator(documents); + assertEquals("TR202610200001",documents.next("TR")); + assertEquals("TR202610200002",operations.next("TRUCK")); + assertEquals("LB202610200001",operations.next("LOOSEBOARD")); + assertEquals("LB202610200002",documents.next("LB")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TestDocumentNumbers.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TestDocumentNumbers.java new file mode 100644 index 000000000..4756d4fd5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TestDocumentNumbers.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.repository.DocumentSequenceRepository; +import java.time.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** 业务单测使用真实格式及隔离计数器;数据库并发由 Repository 集成测试覆盖。 */ +public final class TestDocumentNumbers { + /** 测试工具不实例化。 */ + private TestDocumentNumbers() {} + /** 每个测试生成器独立计数,类型前缀之间互不占号。 */ + public static LastlegDocumentNumberGenerator documents() { + var sequences=mock(DocumentSequenceRepository.class); + var counters=new ConcurrentHashMap(); + lenient().when(sequences.next(anyString())).thenAnswer(call->counters.computeIfAbsent(call.getArgument(0),key->new AtomicLong()).incrementAndGet()); + return new LastlegDocumentNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-20T00:00:00Z"),ZoneOffset.UTC)); + } + /** 海外类型映射同样使用生产生成器。 */ + public static LastlegOrderNoGenerator orders() { return new LastlegOrderNoGenerator(documents()); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index 860ffb446..ae4a41420 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -196,7 +196,7 @@ class WarehouseTransferServiceTest { @Mock LastlegOrderDetailMapper orderDetails; @Mock LastlegOrderStatusHistoryMapper orderHistories; @Mock RelabelApplicationMapper relabelApplications; - @Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator(); + @Spy LastlegOrderNoGenerator orderNoGenerator = com.baiyue.lastleg.service.TestDocumentNumbers.orders(); @Mock InventoryCommandService inventory; @Mock InventoryCommandMapper inventoryCommands; @Mock com.baiyue.lastleg.relabel.service.RelabelApplicationService relabelService; @@ -204,16 +204,16 @@ class WarehouseTransferServiceTest { @Mock WarehouseTransferReceiptCoordinator receiptCoordinator; @Mock com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory; - private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers; + private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers; @BeforeEach void setUp() { - compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.CompactBusinessNumberService.class); + compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class); service = new WarehouseTransferService(); var measurementRepository = new com.baiyue.lastleg.transfer.repository.TransferMeasurementRepository(); ReflectionTestUtils.setField(measurementRepository, "cartons", transferCartons); ReflectionTestUtils.setField(service, "transferMeasurementRepository", measurementRepository); ReflectionTestUtils.setField(service, "compactNumbers", compactNumbers); - org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030"); + org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030"); ReflectionTestUtils.setField(service, "customerDirectory", customerDirectory); lenient().when(customerDirectory.find("TENANT-A")).thenReturn( new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer("TENANT-A", "C-A", "Customer A", null, null)); @@ -267,8 +267,7 @@ class WarehouseTransferServiceTest { ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands); ReflectionTestUtils.setField(service, "relabelService", relabelService); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); - lenient().when(orderNoGenerator.next(anyString(), any(), anyLong())) - .thenReturn("WT-0000000000000000000000001"); + lenient().doReturn("ZY202610200001").when(orderNoGenerator).next(anyString()); lenient().when(inventoryAllocations.selectList(any())).thenReturn(List.of()); lenient().when(transferReceipts.selectList(any())).thenReturn(List.of()); lenient().when(transferCartons.listOrder(anyString(), anyLong())).thenReturn(List.of()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java index d5e6a928f..b69f00f26 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java @@ -80,7 +80,7 @@ class UsTruckOrderServiceTest { @Test void createStoresBothContactsAndMinuteTimesWithoutInventingPallets() { - when(orderNoGenerator.next(anyString(), anyString(), anyLong())).thenReturn("TK1"); + when(orderNoGenerator.next(anyString())).thenReturn("TK1"); when(histories.listOrder(anyString(), anyLong())).thenReturn(historyRows); when(orders.insert(any(UsTruckOrder.class))).thenAnswer(call -> { order = call.getArgument(0); return 1; }); var request = create("PALLET_15", "new-truck"); diff --git a/order/src/main/java/com/baiyue/order/mapper/DocumentSequenceMapper.java b/order/src/main/java/com/baiyue/order/mapper/DocumentSequenceMapper.java new file mode 100644 index 000000000..cfcbe8714 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/mapper/DocumentSequenceMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.order.mapper; + +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** 本服务业务编号序列,更新与读取必须处于同一事务连接。 */ +@Mapper +public interface DocumentSequenceMapper { + /** 唯一序列键原子递增,并在连接内保存本次分配值。 */ + @Insert("INSERT INTO ord_document_sequence(sequence_key,current_value) VALUES(#{key},LAST_INSERT_ID(1)) " + + "ON DUPLICATE KEY UPDATE current_value=LAST_INSERT_ID(current_value+1)") + int allocate(@Param("key") String key); + + /** 禁用一级和二级缓存,返回本事务刚刚分配的值。 */ + @Select("SELECT LAST_INSERT_ID()") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + Long allocatedValue(); +} diff --git a/order/src/main/java/com/baiyue/order/repository/DocumentSequenceRepository.java b/order/src/main/java/com/baiyue/order/repository/DocumentSequenceRepository.java new file mode 100644 index 000000000..fab12d8b3 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/repository/DocumentSequenceRepository.java @@ -0,0 +1,24 @@ +package com.baiyue.order.repository; + +import com.baiyue.order.mapper.DocumentSequenceMapper; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** 各业务共用本库序列,独立短事务避免持有编号锁进入业务资源锁。 */ +@Repository +public class DocumentSequenceRepository { + private final DocumentSequenceMapper mapper; + + /** 只访问所属服务的编号表。 */ + public DocumentSequenceRepository(DocumentSequenceMapper mapper) { this.mapper = mapper; } + + /** 已分配号不因外层业务回滚而复用,允许失败请求产生断号。 */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public long next(String key) { + mapper.allocate(key); + Long value = mapper.allocatedValue(); + if (value == null || value < 1) throw new IllegalStateException("业务编号分配失败"); + return value; + } +} 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 39ccc4a2e..845d253d4 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -44,6 +44,8 @@ import java.time.format.DateTimeFormatter; @Service public class OrderApplicationService { + @jakarta.annotation.Resource private OrderDocumentNumberGenerator documentNumbers; + private static final ZoneId INBOUND_CODE_ZONE = ZoneId.of("Asia/Shanghai"); private static final DateTimeFormatter INBOUND_CODE_DATE = DateTimeFormatter.ofPattern("yyMMdd"); @Resource private OrderRepository repository; @@ -83,6 +85,7 @@ public class OrderApplicationService { return orderId; } + /** 创建主订单时分配独立日流水,入仓码与后续拆单规则保持不变。 */ private Long createDraftInternal(long tenantId, long userId, String username, OrderRequests.SaveRequest request, boolean allowLegacyInboundCode) { AddressSnapshots addressSnapshots = resolveCustomerAddresses(tenantId, request); @@ -94,7 +97,7 @@ public class OrderApplicationService { request.destinationType()); OrderEntity order = new OrderEntity(); long orderId = IdWorker.getId(); - order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(nextNo(orderId)); + order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(documentNumbers.next("FL")); order.setStatus(OrderStatus.DRAFT); apply(order, tenantId, request, snapshots, addressSnapshots, allowLegacyInboundCode); order.setCreatedBy(userId); order.setUpdatedBy(userId); @@ -984,7 +987,4 @@ public class OrderApplicationService { private static String normalize(String value) { return StringUtils.hasText(value) ? value.trim() : null; } - private String nextNo(long orderId) { - return "FL" + orderId; - } } diff --git a/order/src/main/java/com/baiyue/order/service/OrderDocumentNumberGenerator.java b/order/src/main/java/com/baiyue/order/service/OrderDocumentNumberGenerator.java new file mode 100644 index 000000000..27a71a2b3 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/OrderDocumentNumberGenerator.java @@ -0,0 +1,34 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.number.DailyDocumentNumber; +import com.baiyue.order.repository.DocumentSequenceRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import java.time.Clock; +import java.util.Set; + +/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */ +@Component +public class OrderDocumentNumberGenerator { + private static final Set PREFIXES = Set.of("FL"); + private final DocumentSequenceRepository sequences; + private final Clock clock; + + /** 正式运行使用统一业务时区。 */ + @Autowired + public OrderDocumentNumberGenerator(DocumentSequenceRepository sequences) { + this(sequences, Clock.system(DailyDocumentNumber.BUSINESS_ZONE)); + } + + /** 固定时钟用于跨日及前缀独立性验证。 */ + OrderDocumentNumberGenerator(DocumentSequenceRepository sequences, Clock clock) { + this.sequences = sequences; + this.clock = clock; + } + + /** 只为本服务已登记的业务类型分配号码。 */ + public String next(String prefix) { + if (!PREFIXES.contains(prefix)) throw new IllegalArgumentException("未登记的业务编号前缀:" + prefix); + return DailyDocumentNumber.next(prefix, clock, sequences::next); + } +} diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index 47fd3a3bf..3dfac6a2e 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -423,3 +423,11 @@ CREATE TABLE IF NOT EXISTS ord_idempotency_record ( UNIQUE KEY uk_order_idempotency_scope (tenant_id, operator_id, operation, idempotency_key), KEY idx_order_idempotency_created (created_at) ) ENGINE=InnoDB COMMENT='订单写接口持久化幂等记录:唯一键保证同租户同操作者并发请求只执行一次'; + +-- 统一业务单号:前缀 + yyyyMMdd + 四位日流水。 +CREATE TABLE IF NOT EXISTS ord_document_sequence ( + sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键', + current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收', + PRIMARY KEY (sequence_key), + CONSTRAINT chk_ord_document_sequence_value CHECK (current_value > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水'; diff --git a/order/src/test/java/com/baiyue/order/repository/DocumentSequenceRepositoryTest.java b/order/src/test/java/com/baiyue/order/repository/DocumentSequenceRepositoryTest.java new file mode 100644 index 000000000..e5ab7c8ef --- /dev/null +++ b/order/src/test/java/com/baiyue/order/repository/DocumentSequenceRepositoryTest.java @@ -0,0 +1,76 @@ +package com.baiyue.order.repository; + +import com.baiyue.order.mapper.DocumentSequenceMapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.*; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; + +/** 实际 Mapper 和隔离 H2 MySQL 模式验证原子序列,不访问部署环境。 */ +class DocumentSequenceRepositoryTest { + private static class Fixture { + final JdbcTemplate jdbc; + final DataSourceTransactionManager transactions; + final DocumentSequenceMapper mapper; + final DocumentSequenceRepository repository; + /** 仅测试造表允许 JDBC;被测路径全部使用 Mapper。 */ + Fixture() throws Exception { + var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa",""); + jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data); + jdbc.execute("CREATE TABLE ord_document_sequence(sequence_key VARCHAR(32) PRIMARY KEY,current_value BIGINT NOT NULL)"); + var configuration=new MybatisConfiguration();configuration.addMapper(DocumentSequenceMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet(); + mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DocumentSequenceMapper.class); + var proxy=new ProxyFactory(new DocumentSequenceRepository(mapper));proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource())); + repository=(DocumentSequenceRepository)proxy.getProxy(); + } + } + /** 日期和前缀各自独立,重复分配持续递增。 */ + @Test void dateAndPrefixHaveIndependentSequences() throws Exception { + var f=new Fixture(); + assertThat(f.repository.next("AA:20261020")).isEqualTo(1); + assertThat(f.repository.next("AA:20261020")).isEqualTo(2); + assertThat(f.repository.next("BB:20261020")).isEqualTo(1); + assertThat(f.repository.next("AA:20261021")).isEqualTo(1); + } + /** 两个线程使用真实事务连接并发争用,不能产生重号。 */ + @Test void concurrentConnectionsAllocateUniqueNumbers() throws Exception { + var f=new Fixture();var pool=Executors.newFixedThreadPool(2); + try { + var work=new ArrayList>(); + for(int i=0;i<64;i++)work.add(()->f.repository.next("AA:20261020")); + var values=new TreeSet(); + for(var future:pool.invokeAll(work))values.add(future.get()); + assertThat(values).hasSize(64);assertThat(values.first()).isEqualTo(1L);assertThat(values.last()).isEqualTo(64L); + } finally {pool.shutdownNow();} + } + /** 外层建单失败后不能把已经分配的单号再交给其他单据。 */ + @Test void businessRollbackDoesNotReuseAllocatedNumber() throws Exception { + var f=new Fixture(); + assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{ + assertThat(f.repository.next("AA:20261020")).isEqualTo(1); + throw new IllegalStateException("业务回滚"); + })).isInstanceOf(IllegalStateException.class); + assertThat(f.repository.next("AA:20261020")).isEqualTo(2); + } + /** 同一会话中连接值变化后,读取必须绕过 MyBatis 缓存。 */ + @Test void connectionResultIsNeverCached() throws Exception { + var f=new Fixture(); + new TransactionTemplate(f.transactions).executeWithoutResult(status->{ + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(7L); + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(37L); + }); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/MerchantOrderCommandTransactionTest.java b/order/src/test/java/com/baiyue/order/service/MerchantOrderCommandTransactionTest.java index fe71ec2e1..108f095c8 100644 --- a/order/src/test/java/com/baiyue/order/service/MerchantOrderCommandTransactionTest.java +++ b/order/src/test/java/com/baiyue/order/service/MerchantOrderCommandTransactionTest.java @@ -99,6 +99,7 @@ class MerchantOrderCommandTransactionTest { ReflectionTestUtils.setField(completion, "outbox", session.getMapper(OutboxEventMapper.class)); ReflectionTestUtils.setField(completion, "json", json); OrderApplicationService target = new OrderApplicationService(); + org.springframework.test.util.ReflectionTestUtils.setField(target, "documentNumbers", com.baiyue.order.service.TestDocumentNumbers.documents()); ReflectionTestUtils.setField(target, "repository", repository); ReflectionTestUtils.setField(target, "fulfillmentCompletion", proxy(completion, manager)); ReflectionTestUtils.setField(target, "json", json); 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 d926c26fb..1f81d1917 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -47,6 +47,8 @@ class OrderApplicationServiceStateTest { @Configuration(proxyBeanMethods = false) static class TestConfig { + @org.springframework.context.annotation.Bean com.baiyue.order.service.OrderDocumentNumberGenerator documentNumbers() { return com.baiyue.order.service.TestDocumentNumbers.documents(); } + @Bean ObjectMapper objectMapper() { return new ObjectMapper().findAndRegisterModules(); } } @Test diff --git a/order/src/test/java/com/baiyue/order/service/OrderDocumentNumberGeneratorTest.java b/order/src/test/java/com/baiyue/order/service/OrderDocumentNumberGeneratorTest.java new file mode 100644 index 000000000..1c4995399 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/OrderDocumentNumberGeneratorTest.java @@ -0,0 +1,41 @@ +package com.baiyue.order.service; + +import com.baiyue.order.repository.DocumentSequenceRepository; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import java.time.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class OrderDocumentNumberGeneratorTest { + private static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC); + + /** 多构造器情况下正式依赖可以被 Spring 正确选择。 */ + @Test void springCreatesGeneratorWithRepository() { + try(var context=new AnnotationConfigApplicationContext()) { + context.registerBean(DocumentSequenceRepository.class,()->mock(DocumentSequenceRepository.class)); + context.register(OrderDocumentNumberGenerator.class);context.refresh(); + assertThat(context.getBean(OrderDocumentNumberGenerator.class)).isNotNull(); + } + } + /** 主机使用 UTC 时仍按北京时间跨午夜,且四位流水不截断。 */ + @Test void usesShanghaiDateAndIndependentSequenceKey() { + var sequences=mock(DocumentSequenceRepository.class); + when(sequences.next("FL:20261020")).thenReturn(1L,2L,9999L); + var numbers=new OrderDocumentNumberGenerator(sequences,CLOCK); + assertThat(numbers.next("FL")).isEqualTo("FL202610200001"); + assertThat(numbers.next("FL")).isEqualTo("FL202610200002"); + assertThat(numbers.next("FL")).isEqualTo("FL202610209999"); + verify(sequences,times(3)).next("FL:20261020"); + } + /** 未登记类型不能消耗流水,分配异常或溢出时拒绝出号。 */ + @Test void rejectsUnknownTypeAndOverflow() { + var sequences=mock(DocumentSequenceRepository.class); + var numbers=new OrderDocumentNumberGenerator(sequences,CLOCK); + assertThatThrownBy(()->numbers.next("ZZZZ")).isInstanceOf(IllegalArgumentException.class); + verifyNoInteractions(sequences); + when(sequences.next("FL:20261020")).thenReturn(10000L); + assertThatThrownBy(()->numbers.next("FL")).isInstanceOf(BizException.class); + } +} 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 8914aa3aa..30e721604 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java @@ -49,6 +49,8 @@ class OrderIdempotencyServiceTest { @Configuration(proxyBeanMethods = false) static class TestConfig { + @org.springframework.context.annotation.Bean com.baiyue.order.service.OrderDocumentNumberGenerator documentNumbers() { return com.baiyue.order.service.TestDocumentNumbers.documents(); } + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } } @Test diff --git a/order/src/test/java/com/baiyue/order/service/OrderRemarksSynchronizationTest.java b/order/src/test/java/com/baiyue/order/service/OrderRemarksSynchronizationTest.java index 7c1eb5513..67c58fc15 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderRemarksSynchronizationTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderRemarksSynchronizationTest.java @@ -23,6 +23,7 @@ class OrderRemarksSynchronizationTest { @SuppressWarnings("unchecked") ObjectProvider provider = mock(ObjectProvider.class); OrderApplicationService service = new OrderApplicationService(); + org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.order.service.TestDocumentNumbers.documents()); ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "firstlegProvider", provider); ReflectionTestUtils.setField(service, "internalToken", "internal-token"); 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 64f0e0cf4..edaaff21a 100644 --- a/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java @@ -27,6 +27,8 @@ class PlatformOrderPageServiceTest { @Resource private OrderApplicationService service; @Configuration(proxyBeanMethods = false) static class TestConfig { + @org.springframework.context.annotation.Bean com.baiyue.order.service.OrderDocumentNumberGenerator documentNumbers() { return com.baiyue.order.service.TestDocumentNumbers.documents(); } + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } } private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest( 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 7e64a6269..21db75a20 100644 --- a/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java @@ -30,6 +30,8 @@ class TenantOrderCountServiceTest { @Resource private OrderApplicationService service; @Configuration(proxyBeanMethods = false) static class TestConfig { + @org.springframework.context.annotation.Bean com.baiyue.order.service.OrderDocumentNumberGenerator documentNumbers() { return com.baiyue.order.service.TestDocumentNumbers.documents(); } + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } } @Test 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 9bee3e435..66e132c9d 100644 --- a/order/src/test/java/com/baiyue/order/service/TenantOrderStatisticsServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/TenantOrderStatisticsServiceTest.java @@ -35,6 +35,8 @@ class TenantOrderStatisticsServiceTest { @Configuration(proxyBeanMethods = false) static class TestConfig { + @org.springframework.context.annotation.Bean com.baiyue.order.service.OrderDocumentNumberGenerator documentNumbers() { return com.baiyue.order.service.TestDocumentNumbers.documents(); } + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } } diff --git a/order/src/test/java/com/baiyue/order/service/TestDocumentNumbers.java b/order/src/test/java/com/baiyue/order/service/TestDocumentNumbers.java new file mode 100644 index 000000000..8b3142c0c --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/TestDocumentNumbers.java @@ -0,0 +1,21 @@ +package com.baiyue.order.service; + +import com.baiyue.order.repository.DocumentSequenceRepository; +import java.time.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** 业务单测使用真实格式及隔离计数器;数据库并发由 Repository 集成测试覆盖。 */ +public final class TestDocumentNumbers { + /** 测试工具不实例化。 */ + private TestDocumentNumbers() {} + /** 每个测试生成器独立计数,类型前缀之间互不占号。 */ + public static OrderDocumentNumberGenerator documents() { + var sequences=mock(DocumentSequenceRepository.class); + var counters=new ConcurrentHashMap(); + lenient().when(sequences.next(anyString())).thenAnswer(call->counters.computeIfAbsent(call.getArgument(0),key->new AtomicLong()).incrementAndGet()); + return new OrderDocumentNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-20T00:00:00Z"),ZoneOffset.UTC)); + } +} diff --git a/scripts/admin-test-env-modules/phase3-container-customs.mjs b/scripts/admin-test-env-modules/phase3-container-customs.mjs index c51dabad5..851c6f0ee 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs.mjs @@ -352,7 +352,7 @@ export async function verifyFormalDeclarationAndExceptions(context, routes, fixt const before = await platformDetail(context, routes, fixture) const baseline = customsDocumentFacts(before.body?.data) const created = await routes.platformCreateDeclaration(context, fixture, { - expectedVersion: Number(before.body?.data?.version), batchNo: `${context.marker}-DECL`.slice(-64), remark: context.marker, + expectedVersion: Number(before.body?.data?.version), remark: context.marker, }, key(context, 'formal-create')) context.assert(MODULE, '平台人工创建正式申报批次', created, success(created) && created.body?.data?.status === 'READY', evidence(created), OPTIONS) diff --git a/scripts/admin-test-env-modules/phase5-root-tax-customs.mjs b/scripts/admin-test-env-modules/phase5-root-tax-customs.mjs index 59e069b02..abfbb548a 100644 --- a/scripts/admin-test-env-modules/phase5-root-tax-customs.mjs +++ b/scripts/admin-test-env-modules/phase5-root-tax-customs.mjs @@ -61,7 +61,7 @@ export async function prepareTaxCustoms(context,loading){ {expectedVersion:Number(current.version),confirm:true,remark:context.marker},context.token,key('execute')) current=await detail() const batch=await api(context,'POST','F40 人工创建真实正式申报批次',`${base}/declaration-batches`, - {expectedVersion:Number(current.version),batchNo:`${context.marker}-DECL`.slice(-64),remark:context.marker},context.token,key('declaration')) + {expectedVersion:Number(current.version),remark:context.marker},context.token,key('declaration')) proof.declarationBatchId=id(batch.id) const submitted=await api(context,'POST','F40 人工提交正式申报',`${PLATFORM}/declaration-batches/${batch.id}/submit`, {expectedVersion:Number(batch.version),customsEntryNo:`F40TAX-${proof.loadingPlanId}`,remark:context.marker},context.token,key('submit-declaration')) diff --git a/scripts/admin-test-env-modules/return-boundary-scenarios.mjs b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs index b7a938f18..c6f1e66d7 100644 --- a/scripts/admin-test-env-modules/return-boundary-scenarios.mjs +++ b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs @@ -91,8 +91,8 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p items, }) resources.returnNos.push(data.order.returnNo) - check('新退货单及退货主单采用独立完整编号', null, - /^RT-[0-9A-Z]{25}$/.test(data.order.returnNo) && /^RETU-[0-9A-Z]{25}$/.test(data.order.lastlegOrderNo)) + check('新退货单使用日流水且尾程主单沿用来源业务号', null, + /^TH[0-9]{12}$/.test(data.order.returnNo) && data.order.lastlegOrderNo === data.order.returnNo) const customerId = customerByToken.get(token) if (!customerId || data.order.status !== 'PENDING_REVIEW') throw new Error('边界退货客户或审核初态不符') const reviewed = await context.request('POST', `/api/platform/v1/last-leg/return-orders/${data.order.returnNo}/review`, { decision: 'APPROVE', remark: context.marker }, undefined, { 'X-Customer-Id': customerId }) @@ -113,7 +113,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p async function receive(order, operator = picker) { const data = await good(operator.token, 'POST', '准备边界场景实收库存', `${RECEIPTS}/confirm`, receiptBody(order)) check('签收单批次采用独立完整编号且绑定本轮包裹', null, - /^RR-[0-9A-Z]{25}$/.test(data.receiptNo) && /^RB-[0-9A-Z]{25}$/.test(data.receiptBatchNo) + /^RR[0-9]{12}$/.test(data.receiptNo) && /^RB[0-9]{12}$/.test(data.receiptBatchNo) && data.unitType === 'PARCEL' && data.unitCode === order.unitCode && !data.palletCode) order.cartonMark = data.cartonMark order.batch = data.receiptBatchNo diff --git a/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs b/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs index d750c8115..ea946baa8 100644 --- a/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs +++ b/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs @@ -6,8 +6,8 @@ export const IMMUTABLE_TAILLEG_EVENT_FIXTURE = Object.freeze({ }) export const TAILLEG_ORDER_NUMBER = Object.freeze({ - pattern: /^LMO-\d{6}-\d{4}$/, - timeZone: 'Asia/Shanghai', + pattern: /^FL-WH-\d{8}-\d{6}$/, + timeZone: 'server-local', sharedBy: 'containerNo', }) diff --git a/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs b/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs index 0757cdc17..7c1b3dfa7 100644 --- a/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs +++ b/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs @@ -62,34 +62,30 @@ test('Lastleg query DTO uses the persisted frozen owner name', () => { assert.match(orderService, /o\.getCustomerName\(\)/) }) -test('new tailleg number is Shanghai daily LMO-yyMMdd-#### with concurrency and cross-day tests', () => { - assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('LMO-260827-0001'), true) +test('head-leg sourced tail orders preserve the existing container grouping number', () => { + assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('FL-WH-20261020-000042'), true) assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('LMO2087379674555461633'), false) - assert.equal(TAILLEG_ORDER_NUMBER.timeZone, 'Asia/Shanghai') - assert.match(generator, /Asia\/Shanghai/) - assert.match(generator, /yyMMdd/) - assert.match(generator, /LMO-/) - assert.match(generator, /%04d|padStart\(4|String\.format/) - assert.match(generatorTests, /(?:concurrent|并发)/i) - assert.match(generatorTests, /(?:cross.?day|next.?day|跨天)/i) - assert.match(generatorTests, /LMO-260827-0001/) - assert.match(generatorTests, /LMO-260828-0001/) + assert.equal(TAILLEG_ORDER_NUMBER.sharedBy, 'containerNo') + assert.match(generator, /FL-WH-/) + assert.match(generator, /BASIC_ISO_DATE/) + assert.match(generator, /SHA-256/) + assert.match(generatorTests, /actualContainerNumberRemainsStableAndShared/) }) test('one container with 15 inbound rows shares one stable container order number', () => { const rows = Array.from({ length: 15 }, (_, index) => ({ - containerNo: 'CONT-15', orderNo: 'LMO-260827-0042', sourceInboundId: `inbound-${index + 1}`, + containerNo: 'CONT-15', orderNo: 'FL-WH-20261020-000042', sourceInboundId: `inbound-${index + 1}`, })) assert.equal(validContainerOrderGroup(rows), true) assert.equal(validContainerOrderGroup(rows.map((row, index) => index === 14 - ? { ...row, orderNo: 'LMO-260827-0043' } : row)), false) + ? { ...row, orderNo: 'FL-WH-20261020-000043' } : row)), false) assert.match(consumer, /String orderNo\s*=\s*orderNoGenerator\.forContainerNo\(payload\.containerNo\(\)\);[\s\S]*for \(int index/) assert.match(consumer, /createOrder\(envelope, payload, source, orderNo, rawSource, containerNo/) assert.match(consumerTests, /oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber/) }) test('same event replay adds nothing and preserves the allocated number', () => { - const before = { orderCount: 15, orderNo: 'LMO-260827-0042', detailCount: 30, historyCount: 15 } + const before = { orderCount: 15, orderNo: 'FL-WH-20261020-000042', detailCount: 30, historyCount: 15 } assert.equal(validEventReplay(before, { ...before }), true) assert.equal(validEventReplay(before, { ...before, orderCount: 30 }), false) const duplicateBranch = consumer.slice(consumer.indexOf('if(existing!=null)'), consumer.indexOf('LastlegEventInbox inbox=')) @@ -103,5 +99,5 @@ test('same event replay adds nothing and preserves the allocated number', () => assert.match(racedBranch, /DeliveryAck\.Outcome\.DUPLICATE/) assert.match(consumerTests, /replayPreservesExistingLmoNumberAndAddsNothing/) - assert.match(consumerTests, /verify\(sequences,never\(\)\)\.nextValue/) + assert.match(consumerTests, /verifyNoInteractions\(sequences\)/) }) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerService.java b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerService.java index 2f692993d..6244a6eb0 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerService.java @@ -24,6 +24,8 @@ import java.util.List; @Service public class DomesticContainerService { + @jakarta.annotation.Resource private com.baiyue.warehouse.service.WarehouseDocumentNumberGenerator documentNumbers; + private static final long PLATFORM_TENANT = 0L; @Resource private DomesticContainerRepository repository; @Resource private WarehouseRepository warehouseRepository; @@ -57,7 +59,7 @@ public class DomesticContainerService { String container = normalize(request.containerNo()); DomesticContainerOrderEntity order = new DomesticContainerOrderEntity(); order.setId(IdWorker.getId()); order.setTenantId(PLATFORM_TENANT); order.setBusinessType("INBOUND"); - order.setOrderNo(nextNo("ZGRK")); order.setWarehouseId(warehouse.getId()); order.setWarehouseName(warehouse.getWarehouseName()); + order.setOrderNo(documentNumbers.next("ZGRK")); order.setWarehouseId(warehouse.getId()); order.setWarehouseName(warehouse.getWarehouseName()); order.setContainerNo(container); order.setCargoName(request.cargoName().trim()); order.setBoxCount(request.boxCount()); order.setVolumeCbm(request.volumeCbm()); order.setOperationTime(request.inboundTime()); order.setStatus(request.status()); order.setRemark(request.remark()); if ("COMPLETED".equals(request.status())) { @@ -86,7 +88,7 @@ public class DomesticContainerService { if (request.boxCount() > inventory.getAvailableBoxes()) throw new BizException(ResultCode.CONFLICT, "出库箱数超过可用库存"); } DomesticContainerOrderEntity order = new DomesticContainerOrderEntity(); order.setId(IdWorker.getId()); order.setTenantId(PLATFORM_TENANT); order.setBusinessType("OUTBOUND"); - order.setOrderNo(nextNo("ZGCK")); order.setWarehouseId(warehouse.getId()); order.setWarehouseName(warehouse.getWarehouseName()); order.setContainerNo(inventory.getContainerNo()); + order.setOrderNo(documentNumbers.next("ZGCK")); order.setWarehouseId(warehouse.getId()); order.setWarehouseName(warehouse.getWarehouseName()); order.setContainerNo(inventory.getContainerNo()); order.setCargoName(inventory.getCargoName()); order.setBoxCount(request.boxCount()); order.setVolumeCbm(BigDecimal.ZERO); order.setOperationTime(request.outboundTime()); order.setStatus(request.status()); order.setRemark(request.remark()); repository.insertOrder(order); if ("COMPLETED".equals(request.status())) { inventory.setOutboundBoxes(inventory.getOutboundBoxes()+order.getBoxCount()); inventory.setAvailableBoxes(inventory.getAvailableBoxes()-order.getBoxCount()); inventory.setInventoryStatus(status(inventory)); requireUpdated(repository.updateInventory(inventory)); transaction(inventory, order, "OUTBOUND"); } @@ -107,7 +109,6 @@ public class DomesticContainerService { private void requireUpdated(int count) { if (count != 1) throw new BizException(ResultCode.CONFLICT, "库存已被并发修改,请刷新后重试"); } private void requireClaimCompleted(int count) { if (count != 1) throw new BizException(ResultCode.CONFLICT, "幂等操作状态已变化,请使用原键重试"); } private DomesticContainerDtos.CreateResult claim(String type, String key, String hash) { try { if (repository.claim(IdWorker.getId(), type, key, hash) == 1) return null; } catch (DuplicateKeyException ignored) {} DomesticContainerIdempotencyEntity e = repository.findClaim(type, key); if (e == null || !hash.equals(e.getRequestHash())) throw new BizException(ResultCode.CONFLICT, "幂等键已用于其他请求"); if ("COMPLETED".equals(e.getOperationStatus())) return new DomesticContainerDtos.CreateResult(e.getResourceId(), e.getResourceNo(), true); throw new BizException(ResultCode.CONFLICT, "幂等请求正在处理中"); } - private String nextNo(String prefix) { return prefix + java.time.format.DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS").format(LocalDateTime.now()) + IdWorker.getIdStr().substring(10); } private static String normalize(String s) { return s.trim().toUpperCase(java.util.Locale.ROOT); } private static String hash(String value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new IllegalStateException(e); } } private PageResult mapOrders(PageResult p) { return new PageResult<>(p.records().stream().map(o -> new DomesticContainerDtos.OrderView(o.getId(),o.getBusinessType(),o.getOrderNo(),o.getWarehouseId(),o.getWarehouseName(),o.getContainerNo(),o.getCargoName(),o.getBoxCount(),o.getVolumeCbm(),o.getOperationTime(),o.getStatus(),o.getRemark(),o.getVersion(),o.getCreatedAt())).toList(),p.page(),p.size(),p.total(),p.pages()); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/DocumentSequenceMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/DocumentSequenceMapper.java new file mode 100644 index 000000000..8f19686f3 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/DocumentSequenceMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.warehouse.mapper; + +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** 本服务业务编号序列,更新与读取必须处于同一事务连接。 */ +@Mapper +public interface DocumentSequenceMapper { + /** 唯一序列键原子递增,并在连接内保存本次分配值。 */ + @Insert("INSERT INTO wh_document_sequence(sequence_key,current_value) VALUES(#{key},LAST_INSERT_ID(1)) " + + "ON DUPLICATE KEY UPDATE current_value=LAST_INSERT_ID(current_value+1)") + int allocate(@Param("key") String key); + + /** 禁用一级和二级缓存,返回本事务刚刚分配的值。 */ + @Select("SELECT LAST_INSERT_ID()") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + Long allocatedValue(); +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/DocumentSequenceRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/DocumentSequenceRepository.java new file mode 100644 index 000000000..16eb5d000 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/DocumentSequenceRepository.java @@ -0,0 +1,24 @@ +package com.baiyue.warehouse.repository; + +import com.baiyue.warehouse.mapper.DocumentSequenceMapper; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** 各业务共用本库序列,独立短事务避免持有编号锁进入业务资源锁。 */ +@Repository +public class DocumentSequenceRepository { + private final DocumentSequenceMapper mapper; + + /** 只访问所属服务的编号表。 */ + public DocumentSequenceRepository(DocumentSequenceMapper mapper) { this.mapper = mapper; } + + /** 已分配号不因外层业务回滚而复用,允许失败请求产生断号。 */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public long next(String key) { + mapper.allocate(key); + Long value = mapper.allocatedValue(); + if (value == null || value < 1) throw new IllegalStateException("业务编号分配失败"); + return value; + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseDocumentNumberGenerator.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseDocumentNumberGenerator.java new file mode 100644 index 000000000..95c0d2559 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseDocumentNumberGenerator.java @@ -0,0 +1,34 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.number.DailyDocumentNumber; +import com.baiyue.warehouse.repository.DocumentSequenceRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import java.time.Clock; +import java.util.Set; + +/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */ +@Component +public class WarehouseDocumentNumberGenerator { + private static final Set PREFIXES = Set.of("ZGRK", "ZGCK"); + private final DocumentSequenceRepository sequences; + private final Clock clock; + + /** 正式运行使用统一业务时区。 */ + @Autowired + public WarehouseDocumentNumberGenerator(DocumentSequenceRepository sequences) { + this(sequences, Clock.system(DailyDocumentNumber.BUSINESS_ZONE)); + } + + /** 固定时钟用于跨日及前缀独立性验证。 */ + WarehouseDocumentNumberGenerator(DocumentSequenceRepository sequences, Clock clock) { + this.sequences = sequences; + this.clock = clock; + } + + /** 只为本服务已登记的业务类型分配号码。 */ + public String next(String prefix) { + if (!PREFIXES.contains(prefix)) throw new IllegalArgumentException("未登记的业务编号前缀:" + prefix); + return DailyDocumentNumber.next(prefix, clock, sequences::next); + } +} diff --git a/warehouse/src/main/resources/db/warehouse.sql b/warehouse/src/main/resources/db/warehouse.sql index 3ce0a8f8a..22fb93114 100644 --- a/warehouse/src/main/resources/db/warehouse.sql +++ b/warehouse/src/main/resources/db/warehouse.sql @@ -114,3 +114,11 @@ CREATE TABLE IF NOT EXISTS wh_overseas_storage_fee_tier ( CONSTRAINT chk_overseas_fee_tier_days CHECK (start_day>=1 AND (end_day IS NULL OR end_day>=start_day)), CONSTRAINT chk_overseas_fee_tier_price CHECK (unit_price>=0) ) ENGINE=InnoDB COMMENT='客户海外仓费用阶梯'; + +-- 统一业务单号:前缀 + yyyyMMdd + 四位日流水。 +CREATE TABLE IF NOT EXISTS wh_document_sequence ( + sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键', + current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收', + PRIMARY KEY (sequence_key), + CONSTRAINT chk_wh_document_sequence_value CHECK (current_value > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水'; diff --git a/warehouse/src/test/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerServiceTest.java index bbaba8358..202e1438b 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerServiceTest.java @@ -20,8 +20,16 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -@SpringJUnitConfig(DomesticContainerService.class) +@SpringJUnitConfig({DomesticContainerService.class, DomesticContainerServiceTest.Numbers.class}) class DomesticContainerServiceTest { + @org.springframework.context.annotation.Configuration + static class Numbers { + /** 业务测试复用真实格式和独立流水,避免依赖运行环境数据库。 */ + @org.springframework.context.annotation.Bean + com.baiyue.warehouse.service.WarehouseDocumentNumberGenerator documentNumbers() { + return com.baiyue.warehouse.service.TestDocumentNumbers.documents(); + } + } @MockBean DomesticContainerRepository repository; @MockBean WarehouseRepository warehouses; @Resource DomesticContainerService service; diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/DocumentSequenceRepositoryTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/DocumentSequenceRepositoryTest.java new file mode 100644 index 000000000..0f4ff9832 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/DocumentSequenceRepositoryTest.java @@ -0,0 +1,76 @@ +package com.baiyue.warehouse.repository; + +import com.baiyue.warehouse.mapper.DocumentSequenceMapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.*; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; + +/** 实际 Mapper 和隔离 H2 MySQL 模式验证原子序列,不访问部署环境。 */ +class DocumentSequenceRepositoryTest { + private static class Fixture { + final JdbcTemplate jdbc; + final DataSourceTransactionManager transactions; + final DocumentSequenceMapper mapper; + final DocumentSequenceRepository repository; + /** 仅测试造表允许 JDBC;被测路径全部使用 Mapper。 */ + Fixture() throws Exception { + var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa",""); + jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data); + jdbc.execute("CREATE TABLE wh_document_sequence(sequence_key VARCHAR(32) PRIMARY KEY,current_value BIGINT NOT NULL)"); + var configuration=new MybatisConfiguration();configuration.addMapper(DocumentSequenceMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet(); + mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DocumentSequenceMapper.class); + var proxy=new ProxyFactory(new DocumentSequenceRepository(mapper));proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource())); + repository=(DocumentSequenceRepository)proxy.getProxy(); + } + } + /** 日期和前缀各自独立,重复分配持续递增。 */ + @Test void dateAndPrefixHaveIndependentSequences() throws Exception { + var f=new Fixture(); + assertThat(f.repository.next("AA:20261020")).isEqualTo(1); + assertThat(f.repository.next("AA:20261020")).isEqualTo(2); + assertThat(f.repository.next("BB:20261020")).isEqualTo(1); + assertThat(f.repository.next("AA:20261021")).isEqualTo(1); + } + /** 两个线程使用真实事务连接并发争用,不能产生重号。 */ + @Test void concurrentConnectionsAllocateUniqueNumbers() throws Exception { + var f=new Fixture();var pool=Executors.newFixedThreadPool(2); + try { + var work=new ArrayList>(); + for(int i=0;i<64;i++)work.add(()->f.repository.next("AA:20261020")); + var values=new TreeSet(); + for(var future:pool.invokeAll(work))values.add(future.get()); + assertThat(values).hasSize(64);assertThat(values.first()).isEqualTo(1L);assertThat(values.last()).isEqualTo(64L); + } finally {pool.shutdownNow();} + } + /** 外层建单失败后不能把已经分配的单号再交给其他单据。 */ + @Test void businessRollbackDoesNotReuseAllocatedNumber() throws Exception { + var f=new Fixture(); + assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{ + assertThat(f.repository.next("AA:20261020")).isEqualTo(1); + throw new IllegalStateException("业务回滚"); + })).isInstanceOf(IllegalStateException.class); + assertThat(f.repository.next("AA:20261020")).isEqualTo(2); + } + /** 同一会话中连接值变化后,读取必须绕过 MyBatis 缓存。 */ + @Test void connectionResultIsNeverCached() throws Exception { + var f=new Fixture(); + new TransactionTemplate(f.transactions).executeWithoutResult(status->{ + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(7L); + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(37L); + }); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/TestDocumentNumbers.java b/warehouse/src/test/java/com/baiyue/warehouse/service/TestDocumentNumbers.java new file mode 100644 index 000000000..f2c17f9be --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/TestDocumentNumbers.java @@ -0,0 +1,21 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.warehouse.repository.DocumentSequenceRepository; +import java.time.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** 业务单测使用真实格式及隔离计数器;数据库并发由 Repository 集成测试覆盖。 */ +public final class TestDocumentNumbers { + /** 测试工具不实例化。 */ + private TestDocumentNumbers() {} + /** 每个测试生成器独立计数,类型前缀之间互不占号。 */ + public static WarehouseDocumentNumberGenerator documents() { + var sequences=mock(DocumentSequenceRepository.class); + var counters=new ConcurrentHashMap(); + lenient().when(sequences.next(anyString())).thenAnswer(call->counters.computeIfAbsent(call.getArgument(0),key->new AtomicLong()).incrementAndGet()); + return new WarehouseDocumentNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-20T00:00:00Z"),ZoneOffset.UTC)); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseDocumentNumberGeneratorTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseDocumentNumberGeneratorTest.java new file mode 100644 index 000000000..bf3a9810a --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseDocumentNumberGeneratorTest.java @@ -0,0 +1,41 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.warehouse.repository.DocumentSequenceRepository; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import java.time.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class WarehouseDocumentNumberGeneratorTest { + private static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC); + + /** 多构造器情况下正式依赖可以被 Spring 正确选择。 */ + @Test void springCreatesGeneratorWithRepository() { + try(var context=new AnnotationConfigApplicationContext()) { + context.registerBean(DocumentSequenceRepository.class,()->mock(DocumentSequenceRepository.class)); + context.register(WarehouseDocumentNumberGenerator.class);context.refresh(); + assertThat(context.getBean(WarehouseDocumentNumberGenerator.class)).isNotNull(); + } + } + /** 主机使用 UTC 时仍按北京时间跨午夜,且四位流水不截断。 */ + @Test void usesShanghaiDateAndIndependentSequenceKey() { + var sequences=mock(DocumentSequenceRepository.class); + when(sequences.next("ZGRK:20261020")).thenReturn(1L,2L,9999L); + var numbers=new WarehouseDocumentNumberGenerator(sequences,CLOCK); + assertThat(numbers.next("ZGRK")).isEqualTo("ZGRK202610200001"); + assertThat(numbers.next("ZGRK")).isEqualTo("ZGRK202610200002"); + assertThat(numbers.next("ZGRK")).isEqualTo("ZGRK202610209999"); + verify(sequences,times(3)).next("ZGRK:20261020"); + } + /** 未登记类型不能消耗流水,分配异常或溢出时拒绝出号。 */ + @Test void rejectsUnknownTypeAndOverflow() { + var sequences=mock(DocumentSequenceRepository.class); + var numbers=new WarehouseDocumentNumberGenerator(sequences,CLOCK); + assertThatThrownBy(()->numbers.next("ZZZZ")).isInstanceOf(IllegalArgumentException.class); + verifyNoInteractions(sequences); + when(sequences.next("ZGRK:20261020")).thenReturn(10000L); + assertThatThrownBy(()->numbers.next("ZGRK")).isInstanceOf(BizException.class); + } +}