发布:将已验 TEST 功能更新至既有生产灰度(2026-10-23) #30

Merged
admin merged 122 commits from test into main 2026-10-23 10:51:23 +08:00
147 changed files with 1696 additions and 872 deletions
Showing only changes of commit f00d0e16cf - Show all commits
@@ -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<String, Long>();
java.util.function.ToLongFunction<String> 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); }
}
}
+7 -3
View File
@@ -60,8 +60,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` 拼写。
@@ -84,7 +84,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 不算成功。
## 平台管理员密码
@@ -180,3 +180,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、回填或删除测试存量。
@@ -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',
@@ -4856,7 +4856,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',
@@ -4983,7 +4983,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 '付款截图文件名快照',
@@ -5234,7 +5234,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 '变动后实际余额',
@@ -8383,4 +8383,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, 227 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, 229 AS initialized_tables;
+10 -34
View File
@@ -1860,12 +1860,6 @@ 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',
@@ -2078,6 +2072,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',
@@ -2099,6 +2094,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),
@@ -2369,14 +2365,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',
@@ -4831,12 +4819,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 (
@@ -4995,18 +4977,12 @@ CREATE TABLE ll_dispatch_transport_command (
PRIMARY KEY(dispatch_no,request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本';
-- Region: OVERSEAS
-- Schema: baiyue_lastleg
-- Purpose: 客户散板原单接入拼车需求池,未确认登记不占位。
-- Preconditions: MySQL 8;已选择 baiyue_lastleg;预订表已存在。
-- Postconditions: 预订原单来源列及客户来源索引存在,不回填历史来源。
SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND column_name='source_looseboard_order_id'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD COLUMN source_looseboard_order_id BIGINT NULL COMMENT ''客户原始散板商业单ID,取消后保留来源''');
PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl;
EXECUTE looseboard_demand_stmt;
DEALLOCATE PREPARE looseboard_demand_stmt;
SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND index_name='idx_booking_looseboard_source'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status)');
PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl;
EXECUTE looseboard_demand_stmt;
DEALLOCATE PREPARE looseboard_demand_stmt;
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, 236 AS initialized_tables;
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 234 AS initialized_tables;
+1 -1
View File
@@ -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、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。
@@ -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) {
@@ -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);
+3 -3
View File
@@ -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 '变动后实际余额',
@@ -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);
}
@@ -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<String> 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);
}
}
@@ -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<Callable<Long>>();
for(int i=0;i<64;i++)work.add(()->f.repository.next("AA:20261020"));
var values=new TreeSet<Long>();
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);
});
}
}
@@ -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);
}
}
+2 -2
View File
@@ -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,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。
@@ -56,7 +56,6 @@ public final class ContainerCustomsDtos {
String remark, List<ClearanceResultView> results) {}
public record DeclarationBatchRequest(@Min(0) int expectedVersion,
@Size(max = 64) String batchNo,
@Size(max = 64) String customsEntryNo,
@Size(max = 500) String remark) {}
@@ -181,6 +181,7 @@ public class ContainerCustomsService {
return platformView(planId);
}
/** 首次发送报关资料时生成 BR 批次号,重放沿用已有批次。 */
@Transactional
public ContainerView sendInitialToBroker(long planId, long operatorId, String operatorName, String key,
ActionRequest request) {
@@ -358,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);
@@ -373,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 = documentNumbers.next("DECL");
// 申报批次号由服务端分配,外部报关单号独立保存在 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("正式申报批次创建失败");
@@ -383,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());
@@ -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)',
@@ -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}}}
@@ -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}
@@ -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: 集装箱号}
@@ -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}
@@ -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}
@@ -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
@@ -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<ContainerCustomsMapper.CommandRow>();
var batch = new AtomicReference<ContainerCustomsMapper.DeclarationBatchRow>();
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))
@@ -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;
@@ -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));
@@ -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()) {
@@ -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<Callable<Long>>();
for(int i=0;i<64;i++)work.add(()->f.repository.next("AA:20261020"));
var values=new TreeSet<Long>();
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);
});
}
}
@@ -80,7 +80,7 @@ class Phase3FinalUnifiedSqlContractTest {
@Test
void initializationPassIsUniqueAndFinal() throws Exception {
String sql = sql();
int expectedTables = 228;
int expectedTables = 229;
long actualTables = java.util.regex.Pattern.compile("(?m)^CREATE TABLE\\s+")
.matcher(sql).results().count();
assertThat(actualTables).isEqualTo(expectedTables);
@@ -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);
@@ -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");
}
}
@@ -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<String,AtomicLong>();
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));
}
}
@@ -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。分析器显式分类并验证这些边界,旧统计及退役安全投影断言不代表当前架构事实。
@@ -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());
@@ -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());
@@ -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));
@@ -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),
@@ -301,8 +301,9 @@ public interface DispatchCandidateMapper {
"c.pallet_code AS palletNo,COUNT(DISTINCT c.id) AS packageCount,'RELABEL' AS sourceStage,1 AS returnCartonSource," +
"GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') AS relabelCartonMarks," +
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS fbaWarehouseCode," +
"COALESCE((SELECT CASE WHEN COUNT(*)=COUNT(x.weight) THEN SUM(x.weight) END FROM (SELECT DISTINCT c2.id,c2.weight FROM ll_return_inventory_carton c2 JOIN ll_relabel_item ri2 ON ri2.return_carton_id=c2.id AND ri2.customer_id=c2.customer_id JOIN ll_relabel_application a2 ON a2.id=ri2.application_id AND a2.customer_id=ri2.customer_id WHERE c2.customer_id=a.customer_id AND c2.pallet_code=c.pallet_code AND c2.status='RELABEL_PENDING' AND a2.id=a.id) x),(SELECT rr.weight FROM ll_return_receipt rr WHERE rr.customer_id=a.customer_id AND rr.unit_type='PALLET' AND rr.unit_code=c.pallet_code ORDER BY rr.signed_at DESC LIMIT 1)) AS grossWeightKg," +
"COALESCE((SELECT CASE WHEN COUNT(*)=COUNT(x.volume) THEN SUM(x.volume) END FROM (SELECT DISTINCT c2.id,(c2.length*c2.width*c2.height)/1000000 AS volume FROM ll_return_inventory_carton c2 JOIN ll_relabel_item ri2 ON ri2.return_carton_id=c2.id AND ri2.customer_id=c2.customer_id JOIN ll_relabel_application a2 ON a2.id=ri2.application_id AND a2.customer_id=ri2.customer_id WHERE c2.customer_id=a.customer_id AND c2.pallet_code=c.pallet_code AND c2.status='RELABEL_PENDING' AND a2.id=a.id) x),(SELECT rr.length*rr.width*rr.height/1000000 FROM ll_return_receipt rr WHERE rr.customer_id=a.customer_id AND rr.unit_type='PALLET' AND rr.unit_code=c.pallet_code ORDER BY rr.signed_at DESC LIMIT 1)) AS volumeCbm," +
// 从唯一实箱汇总并用EXISTS核对申请,避免一箱多明细重复计量和跨层相关派生表。
"COALESCE((SELECT CASE WHEN COUNT(*)=COUNT(c2.weight) THEN SUM(c2.weight) END FROM ll_return_inventory_carton c2 WHERE c2.customer_id=a.customer_id AND c2.pallet_code=c.pallet_code AND c2.status='RELABEL_PENDING' AND EXISTS (SELECT 1 FROM ll_relabel_item ri2 WHERE ri2.return_carton_id=c2.id AND ri2.customer_id=c2.customer_id AND ri2.application_id=a.id)),(SELECT rr.weight FROM ll_return_receipt rr WHERE rr.customer_id=a.customer_id AND rr.unit_type='PALLET' AND rr.unit_code=c.pallet_code ORDER BY rr.signed_at DESC LIMIT 1)) AS grossWeightKg," +
"COALESCE((SELECT CASE WHEN COUNT(*)=COUNT(c2.length*c2.width*c2.height) THEN SUM(c2.length*c2.width*c2.height/1000000) END FROM ll_return_inventory_carton c2 WHERE c2.customer_id=a.customer_id AND c2.pallet_code=c.pallet_code AND c2.status='RELABEL_PENDING' AND EXISTS (SELECT 1 FROM ll_relabel_item ri2 WHERE ri2.return_carton_id=c2.id AND ri2.customer_id=c2.customer_id AND ri2.application_id=a.id)),(SELECT rr.length*rr.width*rr.height/1000000 FROM ll_return_receipt rr WHERE rr.customer_id=a.customer_id AND rr.unit_type='PALLET' AND rr.unit_code=c.pallet_code ORDER BY rr.signed_at DESC LIMIT 1)) AS volumeCbm," +
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS destinationWarehouseCode " +
"FROM ll_relabel_application a JOIN ll_relabel_item ri ON ri.application_id=a.id AND ri.customer_id=a.customer_id " +
"JOIN ll_return_inventory_carton c ON c.id=ri.return_carton_id AND c.customer_id=a.customer_id " +
@@ -19,6 +19,7 @@ public interface DispatchCandidatePageMapper {
/** 复用既有资格SQL,只统一各来源投影的列序,不复制资格或客户隔离规则。 */
final class Sql {
private static final String FIELDS="planId,planPalletId,warehousePalletId,orderId,customerId,customerName,planNo,orderType,volumeWeightKg,grossWeightKg,containerNo,appointmentAt,expectedLoadingAt,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName,orderNo,orderRefs,inboundCode,palletNo,destinationWarehouseId,destinationWarehouseCode,destinationWarehouseName,fbaWarehouseCode,packageCount,volumeCbm,sourceStage,cartonMarks,relabelCartonMarks,destinationType,deliveryAddress,postalCode,contactName,contactPhone,returnReceiptId,returnReceiptNo,returnCartonSource";
private static final Set<String> TEXT_FIELDS=Set.of(("customerId,customerName,planNo,orderType,containerNo,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName,orderNo,orderRefs,inboundCode,palletNo,destinationWarehouseId,destinationWarehouseCode,destinationWarehouseName,fbaWarehouseCode,sourceStage,cartonMarks,relabelCartonMarks,destinationType,deliveryAddress,postalCode,contactName,contactPhone,returnReceiptNo").split(","));
private static final Set<String> PLAN_MISSING=Set.of("returnReceiptId","returnReceiptNo","returnCartonSource");
private static final Set<String> RETURN_FIELDS=Set.of(("returnReceiptId,returnReceiptNo,orderId,customerId,customerName,orderType,orderNo,orderRefs,inboundCode,warehousePalletId,palletNo,overseasWarehouseId,overseasWarehouseName,overseasWarehouseCode,destinationType,destinationWarehouseCode,fbaWarehouseCode,destinationWarehouseName,deliveryAddress,postalCode,contactName,contactPhone,sourceStage,packageCount").split(","));
private static final Set<String> NATIVE_FIELDS=Set.of(("customerId,orderId,overseasWarehouseId,overseasWarehouseName,orderType,orderNo,orderRefs,inboundCode,palletNo,packageCount,sourceStage,returnCartonSource,relabelCartonMarks,fbaWarehouseCode,grossWeightKg,volumeCbm,destinationWarehouseCode").split(","));
@@ -27,32 +28,37 @@ public interface DispatchCandidatePageMapper {
+"<bind name='fbaWarehouseCode' value='query.destinationWarehouseCode'/><bind name='fba' value='query.destinationWarehouseCode'/>"
+"<bind name='sourceStage' value='query.sourceStage'/><bind name='draftToken' value='query.draftToken'/>";
/** 来源字段缺省仅表示该来源没有此字段,不伪造业务编号或实托身份。 */
private static String project(String body,Set<String> fields) {
return "SELECT "+Arrays.stream(FIELDS.split(",")).map(field->fields.contains(field)?"s."+field:
("returnCartonSource".equals(field)?"0":"NULL")+" AS "+field).collect(Collectors.joining(","))
private static String project(String body,Set<String> fields,boolean count) {
return "SELECT "+Arrays.stream((count?"overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName,fbaWarehouseCode":FIELDS).split(",")).map(field->column(field,fields.contains(field)?"s."+field:
("returnCartonSource".equals(field)?"0":"NULL"))).collect(Collectors.joining(","))
+" FROM ("+body+") s";
}
/** 在UNION前统一文本列,避免表字段、CAST及聚合结果采用不同collation而报1271;数值与时间保持原类型。 */
private static String column(String field,String expression) {
return (TEXT_FIELDS.contains(field)?"CONVERT("+expression+" USING utf8mb4) COLLATE utf8mb4_unicode_ci":expression)+" AS "+field;
}
/** 不同来源用同一组筛选和稳定行标识组合,排序前排除未选来源类型。 */
private static String rows() {
private static String rows(boolean count) {
var planFields=new HashSet<>(Arrays.asList(FIELDS.split(",")));planFields.removeAll(PLAN_MISSING);
return "<choose><when test=\"sourceStage != 'RELABEL'\">"+project(DispatchCandidateMapper.AVAILABLE_PLAN_BODY,planFields)
+"</when><otherwise>SELECT "+Arrays.stream(FIELDS.split(",")).map(f->"NULL AS "+f).collect(Collectors.joining(","))+" WHERE 1=0</otherwise></choose>"
var fields=count?"overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName,fbaWarehouseCode":FIELDS;
return "<choose><when test=\"sourceStage != 'RELABEL'\">"+project(DispatchCandidateMapper.AVAILABLE_PLAN_BODY,planFields,count)
+"</when><otherwise>SELECT "+Arrays.stream(fields.split(",")).map(f->column(f,"NULL")).collect(Collectors.joining(","))+" WHERE 1=0</otherwise></choose>"
+"<if test='planIds == null or planIds.isEmpty()'>"
+"<if test=\"sourceStage == null or sourceStage == 'RECEIVED'\"> UNION ALL "+project(ReturnDispatchCandidateMapper.AVAILABLE_BODY,RETURN_FIELDS)+"</if>"
+"<if test=\"sourceStage == null or sourceStage == 'RECEIVED'\"> UNION ALL "+project(ReturnDispatchCandidateMapper.AVAILABLE_BODY,RETURN_FIELDS,count)+"</if>"
+"<if test=\"sourceStage == null or sourceStage == 'RECEIVED' or sourceStage == 'RELABEL'\"> UNION ALL "
+project(DispatchCandidateMapper.RELABEL_BODY,planFields)+" UNION ALL "+project(DispatchCandidateMapper.NATIVE_RELABEL_BODY,NATIVE_FIELDS)+"</if></if>";
+project(DispatchCandidateMapper.RELABEL_BODY,planFields,count)+" UNION ALL "+project(DispatchCandidateMapper.NATIVE_RELABEL_BODY,NATIVE_FIELDS,count)+"</if></if>";
}
/** 仓库页签也是服务端筛选,分页后不能再用本地筛选伪装完整结果。 */
private static String filtered() {
return " FROM ("+rows()+") candidates WHERE overseasWarehouseId=#{query.warehouseId}"
private static String filtered(boolean count) {
return " FROM ("+rows(count)+") candidates WHERE overseasWarehouseId=#{query.warehouseId}"
+"<if test='query.destinationWarehouseCode != null'> AND fbaWarehouseCode=#{query.destinationWarehouseCode}</if>"
+"<if test=\"query.warehouseTab == 'LAX'\"> AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'lax|洛杉矶|los angeles'</if>"
+"<if test=\"query.warehouseTab == 'NYC'\"> AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'nyc|jfk|(^|[^a-z])ny([^a-z]|$)|纽约|new[ -]?york'</if>";
}
/** 同一事务内统计分页全集。 */
public static String count(){return "<script>"+BINDINGS+"SELECT COUNT(*)"+filtered()+"</script>";}
public static String count(){return "<script>"+BINDINGS+"SELECT COUNT(*)"+filtered(true)+"</script>";}
/** LIMIT发生在任何逐组Java装配之前,最后补来源身份列以保证同名来源不乱序。 */
public static String page(){return "<script>"+BINDINGS+"SELECT *"+filtered()
public static String page(){return "<script>"+BINDINGS+"SELECT *"+filtered(false)
+" ORDER BY customerId,orderNo,palletNo,sourceStage,planPalletId,returnReceiptId,orderRefs,returnCartonSource"
+" LIMIT #{query.size} OFFSET #{offset}</script>";}
}
@@ -524,7 +524,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var identity=identity(actor,number,Action.LOADING_COMPLETE,request.recordVersion(),request.idempotencyKey(),request);
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
if(row.getRecordVersion()!=request.recordVersion().longValue() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()) || !ReviewState.APPROVED.name().equals(row.getReviewState()))throw conflict("当前版本或审核阶段不能确认装车");
lockExecutionSources(row,repository.cargo(row.getId()));
var items=repository.cargo(row.getId());
// 未实收/未首拣的PLAN尚未物化实托,先按未核平拒绝,不能将空实托集合传给执行锁。
if(items.isEmpty() || items.stream().anyMatch(i->i.getWarehousePalletId()==null))throw conflict("逐项真实拣货尚未核平");
lockExecutionSources(row,items);
var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平");
if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对");
if(request.measurements()==null || request.measurements().size()!=preview.pallets().size())throw bad("请确认本车全部实托测量");
@@ -30,7 +30,7 @@ public class DispatchPdaQueryService {
require(actor);if(number==null || number.isBlank())throw new BizException(ResultCode.BAD_REQUEST,"请输入排车单号");
var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return assemble(actor,rows).get(0);
}
/** 固定批量查询数量,不因车数、客户数或托盘数执行逐行详情查询。 */
/** 已绑定实托批量读取;尚未绑定的原PLAN仅在本次只读快照内核对自然实收。 */
private List<DispatchViews.PdaTask> assemble(Actor actor,List<DispatchAggregate> roots) {
if(roots.isEmpty())return List.of();if(roots.size()>200)throw new BizException(ResultCode.BAD_REQUEST,"作业超过完整读取上限,请按阶段筛选或扫描具体排车单号");
var ids=roots.stream().map(DispatchAggregate::getId).toList();var cargo=repository.items(ids).stream().collect(Collectors.groupingBy(DispatchItem::getDispatchOrderId));
@@ -100,6 +100,7 @@ public class DispatchPdaQueryService {
if(rows.size()!=1)return List.of();
var actual=rows.get(0);
if(!"RECEIVED".equals(actual.sourceStage()) || actual.warehousePalletId()==null
|| !Objects.equals(actual.planPalletId(),item.getPlanPalletId())
|| !Objects.equals(actual.customerId(),item.getCustomerId())
|| !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId())
|| !Objects.equals(actual.packageCount(),item.getPackageCount())
@@ -112,7 +113,7 @@ public class DispatchPdaQueryService {
// 同托跨客户、换标及整申请来源必须全部已在原车;不能把新合托货物暴露为原计划可拣库存。
var group=sourceRules.selectionGroup(actor.boundWarehouseId(),new DispatchCommands.CargoSelection(
SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null));
if(!group.complete() || group.sources().isEmpty())return List.of();
if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return List.of();
for(var member:group.sources()) {
var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null);
if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId())
@@ -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());
@@ -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());
@@ -22,13 +22,13 @@ import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*;
public class FleetPoolOrderBridge {
@jakarta.annotation.Resource private FleetLooseboardDemandRepository demands;
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;
@@ -53,7 +53,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(source==null?IdWorker.getId():source.getId());
row.setBusinessOrderNo(source==null?numbers.next("US_TRUCK".equals(in.businessType())?"TRUCK":"LOOSEBOARD",in.destinationLabel(),row.getBusinessOrderId()):source.getOrderNo());
row.setBusinessOrderNo(source==null?numbers.next("US_TRUCK".equals(in.businessType())?"TR":"LB"):source.getOrderNo());
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);
@@ -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";
}
}
@@ -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);
@@ -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());
@@ -25,6 +25,8 @@ import java.util.*;
@Service
public class FleetCapacityOfferService {
@jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers;
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService demands;
public static final String LIST="platform:fleet:capacity-offer:list", MANAGE="platform:fleet:capacity-offer:manage";
private final FleetCapacityOfferMapper offers;
@@ -131,6 +133,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);
@@ -139,7 +142,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;
@@ -27,6 +27,8 @@ import java.util.*;
@Service
public class FleetTripService {
@jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers;
@org.springframework.beans.factory.annotation.Autowired(required=false) private org.springframework.context.ApplicationEventPublisher transportEvents;
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workChanges;
@jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations;
@@ -114,7 +116,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());
@@ -390,7 +392,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());
@@ -179,15 +179,14 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw palletLookupFailure(result.getStatusCode()); })
.body(WarehousePalletResponse.class);
if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Warehouse pallet service returned no usable response");
requirePalletResponse(response);
return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().warehouseId(),
response.data().locationId(), response.data().status(), response.data().fbaWarehouseCode(),
response.data().unitPrice(), response.data().version());
}
private ResponseStatusException palletLookupFailure(HttpStatusCode status) {
return new ResponseStatusException(status.value() == 404 ? HttpStatus.NOT_FOUND
return new ResponseStatusException(status.value() == 409 ? HttpStatus.CONFLICT : status.value() == 404 ? HttpStatus.NOT_FOUND
: (status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE),
status.value() == 404 ? "Warehouse pallet was not found" : "Warehouse pallet lookup failed");
}
@@ -199,14 +198,23 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
throw palletLookupFailure(result.getStatusCode());
}).body(WarehousePalletResponse.class);
requirePalletResponse(response);
return response;
}
/** 托盘状态冲突是业务拒绝,不能把HTTP 200内的A0409误报为仓储服务不可用。 */
private void requirePalletResponse(WarehousePalletResponse response) {
if (response != null && "A0409".equals(response.code())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
response.message() == null || response.message().isBlank() ? "Warehouse pallet state conflicts with this operation" : response.message());
}
if (response != null && "A0404".equals(response.code())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse pallet was not found");
}
if (response == null || response.data() == null) {
if (response == null || !"200".equals(response.code()) || response.data() == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Warehouse pallet service returned no usable response");
}
return response;
}
private WarehousePalletReference reference(WarehousePalletData pallet) {
@@ -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, "人工计划托数与柜级预览已不一致");
}
@@ -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<AppointmentCoverageService.SourceRef> 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);
@@ -456,6 +456,7 @@ public class InboundPlanService {
receivingWarehouse, plannedArrivalDate, null, Map.of());
}
/** 创建入仓计划,复用预分配编号或首次分配 IP 日流水。 */
private InboundPlanResponse createPlan(String customerId, Long selectedOrderId,
List<SourceOrderSnapshot> orders, List<String> sourcePalletNos,
List<WholePalletInput> 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);
@@ -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(),
@@ -276,7 +276,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);
@@ -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();
}
@@ -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;
@@ -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<OverseasProcurementFee> {
/** 调用方持有订单锁时,读取当前退款版本上界。 */
@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,
@@ -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<OverseasProcurementFee>()
.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; }
}
@@ -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()));
@@ -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<String> 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());
@@ -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);}
}
@@ -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<OverseasProcurementFee>().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);
@@ -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));
}
}
@@ -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);}
}
@@ -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) {
@@ -80,7 +80,7 @@ public class RelabelApplicationService implements WalletResultHandler {
READY_FOR_OUTBOUND, "COMPLETED", "REJECTED", "CANCELLED");
private static final Set<String> 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);
@@ -189,7 +189,7 @@ public class ReturnInventoryService {
List<ReturnInventoryRequests.PlanBox> 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<String, Map<String, Long>> 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 {
+ "</div></body></html>";
}
private static String dimension(BigDecimal value) { return value == null ? "—" : value.stripTrailingZeros().toPlainString(); }
/** 按合箱分组生成实物箱唛,保留完整 ID 编码和分箱后缀。 */
private List<String> packingMarks(List<ReturnInventoryRequests.PlanBox> boxes) {
List<List<String>> groups = boxes.stream().map(box -> box.items() == null ? List.<String>of() : box.items().stream()
.map(ReturnInventoryRequests.PlanItem::skuCode).distinct().sorted().toList()).toList();
@@ -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);
@@ -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();
}
}
@@ -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);
}
@@ -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<Long> 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) {
@@ -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()));
@@ -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',
@@ -3212,16 +3206,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',
@@ -3249,6 +3233,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 '费用项名称',
@@ -3259,6 +3244,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='海外采购费用明细及资金生命周期';
@@ -4501,11 +4487,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;
@@ -4667,3 +4648,11 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_transport_command (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
PRIMARY KEY(dispatch_no,request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci 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='业务单据每日独立原子流水';
@@ -888,8 +888,8 @@ paths:
'422': { description: 排车单没有可打印的 BOL 明细 }
/api/platform/v1/last-leg/dispatches/candidates:
get:
summary: 读取真实候选及来源版本
description: 权限 platform:overseas-warehouse:dispatch:list。
summary: 分页读取真实候选及来源版本
description: 权限 platform:overseas-warehouse:dispatch:list。数据库按客户、订单、托盘和来源身份稳定排序分页;总数与当前页来自同一只读快照。selectionGroup仍包含完整同托及整申请来源,可包含页外成员;选取时服务端再次权威解析,不以当前页充当完整组。
security:
- bearerAuth: []
responses:
@@ -947,6 +947,26 @@ paths:
in: query
schema:
type: string
- name: page
in: query
schema:
type: integer
minimum: 1
default: 1
- name: size
in: query
schema:
type: integer
minimum: 1
maximum: 50
default: 20
- name: warehouseTab
in: query
description: 对起运仓名称和编码的服务端页签筛选,在统计和分页前生效。
schema:
type: string
enum: [ALL, LAX, NYC]
default: ALL
/api/platform/v1/last-leg/dispatches/drafts/pre-arrange:
post:
summary: 将所有非空容器原子转换为整车
@@ -6972,6 +6992,19 @@ components:
nullable: true
complete:
type: boolean
description: 仅当前响应包含筛选后的全部候选时为true;分页中的false不影响每个selectionGroup的完整性。
total:
type: string
pattern: ^[0-9]+$
description: 同一筛选下的来源总行数。
page:
type: integer
size:
type: integer
pages:
type: string
pattern: ^[0-9]+$
required: [candidates, snapshotAt, complete, total, page, size, pages]
DispatchDraftContainer:
type: object
x-java-record: DispatchPlanningModels.DraftContainer
@@ -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();
@@ -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());
@@ -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);
@@ -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");
@@ -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()));
@@ -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));
}
@@ -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);
@@ -38,7 +38,9 @@ class SmartDispatchOpenApiContractTest {
assertThat(map(paths.get(base+"/drafts"))).containsKey("get");
var candidate=map(map(paths.get(base+"/candidates")).get("get"));
var parameterNames=((List<?>)candidate.get("parameters")).stream().map(SmartDispatchOpenApiContractTest::map).map(p->p.get("name")).toList();
assertThat(parameterNames).contains("warehouseId","destinationWarehouseCode","draftToken").doesNotContain("customerId","fbaWarehouseCode");
assertThat(parameterNames).contains("warehouseId","destinationWarehouseCode","draftToken","page","size","warehouseTab").doesNotContain("customerId","fbaWarehouseCode");
assertThat(map(map(schemas.get("DispatchCandidateList")).get("properties")).keySet())
.containsExactlyInAnyOrder("candidates","snapshotAt","complete","total","page","size","pages");
var draft=map(map(schemas.get("DispatchDraft")).get("properties"));
assertThat(draft.keySet()).containsExactlyInAnyOrder("draftToken","recordVersion","status","warehouseId","containers","sources","expiresAt");
var vehicle=map(map(schemas.get("DispatchDraftVehicle")).get("properties"));assertThat(vehicle.keySet()).containsExactlyInAnyOrder("draftContainerId","dispatch");
@@ -0,0 +1,127 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery;
import org.apache.ibatis.scripting.defaults.DefaultParameterHandler;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.*;
import java.util.*;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.*;
/** 显式指定TEST配置才运行;只读真实MySQL,不建表、不造数、不输出客户记录或连接凭据。 */
@EnabledIfSystemProperty(named="dispatch.mysql.profile",matches=".+")
class DispatchCandidateMySqlReadOnlyTest {
private Connection connect() throws Exception {
var profile=new HashMap<String,String>();
for(String line:Files.readAllLines(Path.of(System.getProperty("dispatch.mysql.profile")))) {
var match=java.util.regex.Pattern.compile("^\\s*(?:export\\s+)?([A-Z][A-Z0-9_]*)\\s*=\\s*(.*)$").matcher(line);
if(!match.matches())continue;
String value=match.group(2).trim();
if(value.length()>1&&(value.startsWith("\"")&&value.endsWith("\"")||value.startsWith("'")&&value.endsWith("'")))
value=value.substring(1,value.length()-1);
profile.put(match.group(1),value);
}
assertEquals("test",profile.get("CHONGCHONG_ENV"),"只允许显式TEST只读检查");
String url="jdbc:mysql://"+profile.get("CHONGCHONG_DB_HOST")+":"+profile.get("CHONGCHONG_DB_PORT")
+"/baiyue_lastleg?characterEncoding=UTF-8&connectTimeout=5000&socketTimeout=15000";
try {
var connection=DriverManager.getConnection(url,profile.get("CHONGCHONG_DB_USERNAME"),profile.get("CHONGCHONG_DB_PASSWORD"));
connection.setReadOnly(true);
return connection;
} catch(SQLException error) {
throw new AssertionError("MySQL connection failed; SQLState="+error.getSQLState()+", code="+error.getErrorCode());
}
}
/** 用纯SELECT构造冲突,确认原始UNION拒绝混合collation且实际生产投影保留中文/字符身份,无需修改测试库。 */
@Test void mixedCollationsAreNormalizedBeforeUnionForCountAndPage() throws Exception {
var fields=Arrays.stream(DispatchPalletCandidate.class.getDeclaredFields()).filter(f->f.getType()==String.class)
.map(java.lang.reflect.Field::getName).collect(Collectors.toSet());
var project=DispatchCandidatePageMapper.Sql.class.getDeclaredMethod("project",String.class,Set.class,boolean.class);
project.setAccessible(true);
String general="SELECT "+fields.stream().sorted().map(f->"_utf8mb4'洛杉矶-001' COLLATE utf8mb4_general_ci AS "+f).collect(Collectors.joining(","));
String unicode=general.replace("utf8mb4_general_ci","utf8mb4_unicode_ci");
try(var connection=connect();var statement=connection.createStatement()) {
statement.setQueryTimeout(10);
var failure=assertThrows(SQLException.class,()->statement.executeQuery(general+" UNION ALL "+unicode));
assertTrue(Set.of(1267,1271).contains(failure.getErrorCode()),"MySQL应报告collation冲突");
for(boolean count:List.of(true,false)) {
String sql=project.invoke(null,general,fields,count)+" UNION ALL "+project.invoke(null,unicode,fields,count);
try(var rows=statement.executeQuery(sql)) {
int total=0;
while(rows.next()) {
total++;
assertEquals("洛杉矶-001",rows.getString("overseasWarehouseId"));
assertEquals("洛杉矶-001",rows.getString("fbaWarehouseCode"));
if(!count) {
for(String field:fields)assertEquals("洛杉矶-001",rows.getString(field),field);
assertNull(rows.getObject("planPalletId"));
assertNull(rows.getObject("appointmentAt"));
assertNull(rows.getObject("grossWeightKg"));
}
}
assertEquals(2,total);
}
}
}
}
/** 执行MyBatis正式SQL和绑定参数,分别检查真实四来源count/page以及筛选分支;仅输出行数和SQL耗时。 */
@Test void actualCandidateQueriesExecuteOnMySql() throws Exception {
String warehouse=System.getProperty("dispatch.mysql.warehouse");
assertNotNull(warehouse,"指定只读验收仓");
var configuration=new Configuration();
configuration.addMapper(DispatchCandidatePageMapper.class);
List<CandidateQuery> queries=List.of(
new CandidateQuery(warehouse,null,null,null,null,null,1,20,"ALL"),
new CandidateQuery(warehouse,null,null,null,null,null,2,20,"ALL"),
new CandidateQuery(warehouse,null,null,null,"RELABEL",null,1,20,"ALL"),
new CandidateQuery(warehouse,null,null,null,"RECEIVED",null,1,20,"LAX"),
new CandidateQuery(warehouse,null,null,null,"PLAN",null,1,20,"NYC"),
new CandidateQuery(warehouse,null,null,"ONT8",null,null,1,20,"ALL"),
new CandidateQuery(warehouse,List.of(-1L),null,null,null,null,1,20,"ALL"));
try(var connection=connect()) {
try(var statement=connection.createStatement();var rows=statement.executeQuery("SELECT @@collation_connection,COLLATION(CAST(1 AS CHAR(128)))")) {
assertTrue(rows.next());
System.out.printf("MYSQL_COLLATION connection=%s castAsChar=%s%n",rows.getString(1),rows.getString(2));
}
try(var statement=connection.createStatement();var rows=statement.executeQuery(
"SELECT TABLE_NAME,COLUMN_NAME,COLLATION_NAME FROM information_schema.COLUMNS "
+"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('ll_order','ll_inbound_plan','ll_return_receipt','ll_relabel_application') "
+"AND COLUMN_NAME IN ('customer_id','overseas_warehouse_id','overseas_warehouse_name','overseas_warehouse_code') ORDER BY TABLE_NAME,COLUMN_NAME")) {
while(rows.next())System.out.printf("MYSQL_COLLATION %s.%s=%s%n",rows.getString(1),rows.getString(2),rows.getString(3));
}
for(var query:queries) {
var parameters=new HashMap<String,Object>();
parameters.put("query",query);parameters.put("offset",(long)(query.page()-1)*query.size());
long total=0;int size=0;long started=System.nanoTime();
for(String method:List.of("count","page")) {
var mapped=configuration.getMappedStatement(DispatchCandidatePageMapper.class.getName()+"."+method);
var bound=mapped.getBoundSql(parameters);
assertTrue(bound.getSql().stripLeading().startsWith("SELECT"));
try(var statement=connection.prepareStatement(bound.getSql())) {
statement.setQueryTimeout(10);
new DefaultParameterHandler(mapped,parameters,bound).setParameters(statement);
try(var rows=statement.executeQuery()) {
if(method.equals("count")) { assertTrue(rows.next());total=rows.getLong(1); }
else while(rows.next()) {
size++;assertEquals(warehouse,rows.getString("overseasWarehouseId"));
if(query.destinationWarehouseCode()!=null)assertEquals(query.destinationWarehouseCode(),rows.getString("fbaWarehouseCode"));
}
}
}
}
assertTrue(size<=query.size());assertTrue(size<=total);
System.out.printf(Locale.ROOT,"MYSQL_READ_ONLY page=%d stage=%s tab=%s total=%d rows=%d countAndPageMs=%.3f%n",
query.page(),query.sourceStage(),query.warehouseTab(),total,size,(System.nanoTime()-started)/1_000_000d);
}
}
}
}
@@ -536,6 +536,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));
@@ -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);
@@ -0,0 +1,70 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode;
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
import com.baiyue.lastleg.relabel.repository.RelabelDispatchSourceRepository;
import com.baiyue.lastleg.relabel.service.RelabelDispatchSourceService;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 真实排车、车次、命令和来源入口回归;未物化PLAN不得进入仅接受实托的执行锁。 */
class DispatchLoadingEligibilityPersistenceTest {
/** 全车在途和部分实托已拣的混合车均应业务拒绝,原单/命令/占用/扫码事实完全不变。 */
@ParameterizedTest
@ValueSource(booleans={false,true})
void unmaterializedPlanRejectsLoadingBeforePhysicalSourceLock(boolean mixed) throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var plan=f.source(1,"101",501,"ONT8",3);
var selections=mixed?List.of(plan,f.source(2,"202",502,"LAX9",2)):List.of(plan);
var created=f.tx.execute(t->f.service.create(f.actor,f.configured("unreceived-loading",selections)));
var approved=f.approve(created);
f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL,source_stage='PLAN' WHERE plan_pallet_id=1");
if(mixed) {
f.picked(2,"202",502,2);
f.db.update("UPDATE ll_dispatch_order SET picked_package_count=2");
}
var providerRows=mock(RelabelDispatchSourceRepository.class);
var provider=spy(new RelabelDispatchSourceService(providerRows,f.json));
var sources=mock(DispatchSourceRepository.class);
var rules=spy(new DispatchService());
ReflectionTestUtils.setField(rules,"relabelSources",provider);
ReflectionTestUtils.setField(rules,"wholeSources",sources);
ReflectionTestUtils.setField(f.service,"sourceRules",rules);
var actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
var preview=f.tx.execute(t->f.service.loading(actor,created.dispatchNo()));
assertThat(preview.pickingReconciled()).isFalse();
assertThat(preview.loadingComplete().allowed()).isFalse();
assertThat(preview.loadingComplete().blockCode()).isEqualTo("PICKING_INCOMPLETE");
assertThat(preview.pallets()).hasSize(mixed?1:0);
var tables=List.of("ll_dispatch_order","ll_dispatch_item","ll_dispatch_command","ll_dispatch_history",
"ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_dispatch_pallet_lock","ll_dispatch_physical_pallet_lock",
"ll_fleet_trip","ll_fleet_trip_history");
var before=snapshot(f,tables);
var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"unreceived-negative",preview.manifestHash(),List.of(),"未实收负例");
assertThatThrownBy(()->f.tx.execute(t->f.service.loadingComplete(actor,created.dispatchNo(),request)))
.isInstanceOfSatisfying(BizException.class,error->assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT))
.hasMessage("逐项真实拣货尚未核平");
assertThat(snapshot(f,tables)).isEqualTo(before);
verify(rules,never()).lockExecutionSources(anyString(),anyList());
verifyNoInteractions(provider,providerRows,sources,f.ownedBusinessOwnership);
}
/** 按实际列值读取CLOB及时间,避免比较JDBC临时对象身份。 */
private Object snapshot(DispatchAggregatePersistenceTest.Fixture f,List<String> tables) {
return tables.stream().map(table->f.db.query("SELECT * FROM "+table+" ORDER BY id",(row,index)->{
var values=new java.util.ArrayList<String>();
for(int column=1;column<=row.getMetaData().getColumnCount();column++)values.add(row.getString(column));
return values;
})).toList();
}
}
@@ -28,6 +28,45 @@ import static org.mockito.Mockito.*;
/** 真实提供者→完整组选货→主单/占用→实际库存命令/归属→PDA/装车的本机事务链。 */
class DispatchMergedRelabelPersistenceTest {
/** 原生退货分页按实箱去重计量,不能因申请多明细重复计箱或重量。 */
@Test void nativeReturnPageMeasuresOneCartonOnce() throws Exception {
var f=new Merged(false);var at=LocalDateTime.of(2026,1,1,0,0);
f.insert("ll_return_inventory_carton","id",901,"customer_id","101","overseas_warehouse_id","9",
"carton_mark","NATIVE-CARTON","pallet_code","NATIVE-PALLET","relabel_application_id",201,
"relabel_no","RL-A","status","RELABEL_PENDING","sku_types",1,"total_quantity",2,
"weight",12,"length",100,"width",100,"height",100,"created_at",at,"updated_at",at);
f.f.db.update("UPDATE ll_relabel_item SET source_inventory_position_id=NULL,return_carton_id=901 WHERE id=301");
f.insert("ll_relabel_item","id",303,"application_id",201,"customer_id","101","product_name_zh","同箱另一明细",
"total_package_count",1,"notified_package_count",1,"carton_mark","NATIVE-CARTON","operation_type","CARTON_LABEL",
"return_carton_id",901,"destination_fba_warehouse_code","ONT8");
var query=new DispatchPlanningModels.CandidateQuery("9",null,"RL-A",null,"RELABEL",null,1,20,"ALL");
var mapper=f.f.session.getMapper(DispatchCandidatePageMapper.class);
assertThat(mapper.count(query)).isOne();
var row=mapper.page(query,0).get(0);
assertThat(row.returnCartonSource()).isTrue();assertThat(row.packageCount()).isOne();
assertThat(row.grossWeightKg()).isEqualByComparingTo("12");assertThat(row.volumeCbm()).isEqualByComparingTo("1");
f.f.db.update("UPDATE ll_return_inventory_carton SET weight=NULL,height=NULL WHERE id=901");
row=mapper.page(query,0).get(0);
assertThat(row.grossWeightKg()).isNull();assertThat(row.volumeCbm()).isNull();
}
/** 数据库限页不能截断同托来源组;总数、翻页及服务端搜索必须一致。 */
@Test void candidatePagesRetainCompleteCrossPageSelectionGroup() throws Exception {
var f=new Merged(false);
var all=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,null,null,1,1,"ALL"));
assertThat(all.total()).isEqualTo(2);assertThat(all.rows()).hasSize(1);
var first=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null,1,1,"ALL"));
var second=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null,2,1,"ALL"));
assertThat(first.total()).isEqualTo(2);assertThat(second.total()).isEqualTo(2);
assertThat(first.rows()).hasSize(1);assertThat(second.rows()).hasSize(1);
assertThat(first.rows().get(0).orderRefs()).isNotEqualTo(second.rows().get(0).orderRefs());
var group=f.rules.selectionGroups(first.rows()).values().iterator().next();
assertThat(group.complete()).as(group.blockReasons().toString()).isTrue();
assertThat(group.sources()).extracting(DispatchPlanningModels.SelectionGroupSource::sourceBusinessNo).containsExactlyInAnyOrder("RL-A","RL-B");
var filtered=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,"RL-B","ONT8","RELABEL",null,1,1,"ALL"));
assertThat(filtered.total()).isOne();assertThat(filtered.rows()).extracting(r->r.orderRefs()).containsExactly("RL-B");
var empty=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("99",null,null,null,"RELABEL",null,1,1,"ALL"));
assertThat(empty.total()).isZero();assertThat(empty.rows()).isEmpty();
}
/** 同客户共享position与跨客户共享physical都只占一托,来源及SHIP严格独立。 */
@ParameterizedTest @ValueSource(booleans={false,true})
void mergedApplicationsUseOnePhysicalPalletAndKeepExactSources(boolean crossCustomer) throws Exception {
@@ -190,17 +229,23 @@ class DispatchMergedRelabelPersistenceTest {
for(var table:List.of("ll_order","ll_inventory_position","ll_inventory_balance","ll_inventory_ledger","ll_inventory_operation","ll_physical_pallet_guard",
"ll_relabel_application","ll_relabel_item","ll_relabel_pda_task","ll_relabel_pda_history","ll_relabel_pda_unit_fact","ll_inbound_plan","ll_inbound_plan_pallet",
"ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_warehouse_transfer_pallet",
"ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_order_manual_node","ll_container_shipping_gate")) {
"ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_order_manual_node","ll_container_shipping_gate",
"ll_return_inventory_carton","ll_return_receipt","ll_return_stock_receipt","ll_exception_order","ll_delivery_order")) {
f.db.execute("DROP TABLE IF EXISTS "+table);int start=schema.indexOf("CREATE TABLE "+table+" (");int end=schema.indexOf(") ENGINE=",start);
if(start<0 || end<0)throw new IllegalStateException("正式初始化表缺失:"+table);
// H2的JSON、collation及约束命名空间与MySQL不同;保留实际列和等价四来源排他约束。
f.db.execute(schema.substring(start,end+1).replaceAll("(?i)\\bJSON\\b","CLOB").replaceAll("(?i)\\s+COLLATE\\s+\\w+","")
.replace("_utf8mb4'","'")
.replace("_utf8mb4", "")
.replaceAll("(?i)\\b(UNIQUE KEY|KEY|CONSTRAINT)\\s+(\\w+)","$1 "+table+"_$2")
.replaceAll("\\((receipt_id|return_receipt_id|return_carton_id|dispatch_item_source_id) IS NOT NULL\\)","(CASE WHEN $1 IS NOT NULL THEN 1 ELSE 0 END)"));
}
var provider=new RelabelDispatchSourceService(new RelabelDispatchSourceRepository(f.session.getMapper(RelabelDispatchSourceMapper.class)),f.json);
var origins=new DispatchItemSourceRepository(f.session.getMapper(DispatchItemSourceMapper.class));var snapshots=new DispatchItemSourceService(origins,provider,f.json);
f.session.getConfiguration().addMapper(DispatchCandidatePageMapper.class);
// H2不实现MySQL的字符集转换;这里只验证分页/完整组,collation由真实MySQL只读测试覆盖。
configuration.setDefaultScriptingLanguage(H2CandidateLanguageDriver.class);
configuration.addMapper(DispatchCandidatePageMapper.class);
configuration.setDefaultScriptingLanguage(org.apache.ibatis.scripting.xmltags.XMLLanguageDriver.class);
var sourceRepository=new DispatchSourceRepository(f.session.getMapper(DispatchCandidateMapper.class),f.session.getMapper(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class));
ReflectionTestUtils.setField(sourceRepository,"pages",f.session.getMapper(DispatchCandidatePageMapper.class));
ReflectionTestUtils.setField(rules,"wholeSources",sourceRepository);ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json));
@@ -247,4 +292,11 @@ class DispatchMergedRelabelPersistenceTest {
/** 历史由真实提供者解析,固定发生次序不依赖当前系统时钟。 */
void history(long id,long app,String customer,String action,Object detail,int day) throws Exception {insert("ll_relabel_pda_history","id",id,"task_id",1000+app,"application_id",app,"customer_id",customer,"action",action,"to_status","WAIT_REVIEW","operator_key","worker","operator_name","测试","detail_json",f.json.writeValueAsString(detail),"created_at",LocalDateTime.of(2026,1,day,0,0));}
}
public static class H2CandidateLanguageDriver extends org.apache.ibatis.scripting.xmltags.XMLLanguageDriver {
@Override public org.apache.ibatis.mapping.SqlSource createSqlSource(org.apache.ibatis.session.Configuration configuration,String script,Class<?> parameterType) {
return super.createSqlSource(configuration,script.replaceAll(
"CONVERT\\((s\\.\\w+|NULL) USING utf8mb4\\) COLLATE utf8mb4_unicode_ci","$1"),parameterType);
}
}
}
@@ -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);
@@ -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));
@@ -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);
FleetLooseboardDemandRepository demands=mock(FleetLooseboardDemandRepository.class);
@@ -32,7 +32,7 @@ class FleetPoolOrderBridgeTest {
service=new FleetPoolOrderBridge(new FleetPoolOrderRepository(links,loose,looseHistory,trucks,truckHistory),numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources);
org.springframework.test.util.ReflectionTestUtils.setField(service,"demands",demands);
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));
@@ -190,7 +190,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"));
}
@@ -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");
}
}
@@ -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); }
}
@@ -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();
@@ -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));
@@ -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(){
@@ -112,6 +112,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.business.FleetLooseboardDemandService looseboardDemands() { return mock(com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService.class); }
/** 桥接器的原单查询使用仓储替身。 */
@@ -164,6 +164,46 @@ class HttpWarehouseResourceGatewayTest {
server.verify();
}
/** 实际SHIP后报废的原托扫码仍拒绝;保留提供者业务码/原因,不伪装为服务故障或按ID重试。 */
@Test
void inspectPreservesScrappedPalletBusinessConflictWithoutFallback() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/D1747-1-001"))
.andRespond(withSuccess("""
{"code":"A0409","message":"托盘已报废","data":null}
""", MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.inspectPallet("D1747-1-001"))
.isInstanceOfSatisfying(ResponseStatusException.class, error -> {
assertThat(error.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
assertThat(error.getReason()).isEqualTo("托盘已报废");
});
server.verify();
}
/** 按ID读取使用同一响应边界,错误码即使带data也不能被当作合法实托。 */
@Test
void idLookupRejectsBusinessConflictEvenWithPayload() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/id/31"))
.andRespond(withSuccess("""
{"code":"A0409","message":"托盘状态冲突","data":{"palletId":31,"palletCode":"PAL-1",
"warehouseId":88,"locationId":40,"status":"IDLE","version":1}}
""", MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.resolvePallet(31L))
.isInstanceOfSatisfying(ResponseStatusException.class, error ->
assertThat(error.getStatusCode()).isEqualTo(HttpStatus.CONFLICT));
server.verify();
}
/** 同一个状态冲突无论通过HTTP状态还是业务信封传输,都不能降为400或503。 */
@Test
void inspectPreservesHttpConflict() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-CONFLICT"))
.andRespond(withStatus(HttpStatus.CONFLICT));
assertThatThrownBy(() -> gateway.inspectPallet("PAL-CONFLICT"))
.isInstanceOfSatisfying(ResponseStatusException.class, error ->
assertThat(error.getStatusCode()).isEqualTo(HttpStatus.CONFLICT));
server.verify();
}
@Test
void resolveReservedFallsBackToConfirmedPhysicalBindingForEarlierPlanDisplayCode() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/IP-ACY2-20260909-166786-P001"))
@@ -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,
@@ -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);}
/** 使用生产持久层,继续验证需求失效与预约写入的同事务行为。 */
@@ -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());
}
@@ -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());

Some files were not shown because too many files have changed in this diff Show More