Compare commits

..
186 changed files with 2913 additions and 1070 deletions
@@ -0,0 +1,44 @@
package com.baiyue.common.core.number;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.time.Clock;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.function.ToLongFunction;
/** 统一业务单号格式;序列仍由各服务自己的 Repository/Mapper 分配。 */
public final class DailyDocumentNumber {
public static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
/** 工具类不允许实例化。 */
private DailyDocumentNumber() {}
/** 先固定北京时间业务日,再使用前缀和日期分配跨客户、跨仓库日流水。 */
public static String next(String prefix, Clock clock, ToLongFunction<String> sequences) {
requirePrefix(prefix);
LocalDate date = LocalDate.now(clock.withZone(BUSINESS_ZONE));
String dateText = date.format(DateTimeFormatter.BASIC_ISO_DATE);
long value = sequences.applyAsLong(prefix + ":" + dateText);
return format(prefix, date, value);
}
/** 严格四位流水,不允许溢出后产生五位号码或退回随机编号。 */
public static String format(String prefix, LocalDate date, long value) {
requirePrefix(prefix);
if (value < 1 || value > 9999) {
throw new BizException(ResultCode.CONFLICT, "当日" + prefix + "单据四位流水号已用尽或分配失败");
}
return prefix + date.format(DateTimeFormatter.BASIC_ISO_DATE)
+ String.format(Locale.ROOT, "%04d", value);
}
/** 业务类型只能使用两至四位大写字母。 */
private static void requirePrefix(String prefix) {
if (prefix == null || !prefix.matches("[A-Z]{2,4}")) {
throw new IllegalArgumentException("业务单据前缀必须为2至4位大写字母");
}
}
}
@@ -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
@@ -58,8 +58,8 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
| 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 227 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 228 |
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 229 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 234 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
@@ -82,7 +82,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/228`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=229/234`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
## 平台管理员密码
@@ -178,3 +178,7 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
货物废弃通用库存与散箱字段迁移:`migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql`。新增库存仓位关联及箱唛快照,须先迁移再部署 Lastleg;不回填历史废弃单。
2026-10-16 拉取合并配套结构清单及执行边界见 [数据库变更汇总](20261016-数据库变更汇总.md),已有尾程库使用 `migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql`;空库初始化海外表数同步为 209。该增量可重复执行,尚未执行到任何实际数据库。
## 统一业务单号
编号规则见 `../../../docs/业务单据编号契约.md`。Order、Warehouse、Lastleg 空库基线新增各自的 `ord_document_sequence`、`wh_document_sequence`、`ll_document_sequence`;Firstleg 与 Finance 复用本库编号表。Lastleg 空库基线移除无生产调用的客户日序列、逻辑秒时钟及旧尾程日序列表,保留排车日序列。采购费用新增可空 `idempotency_key` 及订单/用途/请求键唯一约束,用于退款重放,业务号统一使用 PF。此处只准备基线,不执行 SQL、回填或删除测试存量。
@@ -1981,11 +1981,11 @@ CREATE TABLE fl_customs_clearance_result (
) ENGINE=InnoDB COMMENT='正式申报后的海关结果不可变事实';
CREATE TABLE fl_document_sequence (
sequence_key varchar(32) NOT NULL COMMENT '单据序列键;日序列如CB:20260818,柜次长期序列固定为CABINET',
sequence_key varchar(32) NOT NULL COMMENT '单据序列键;日序列如LP:20260818,柜次长期序列固定为CABINET',
current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号',
PRIMARY KEY (sequence_key),
CONSTRAINT chk_fl_document_sequence_value CHECK ((current_value > 0))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程业务编号序列;普通单据按日,柜次全局连续,实物标签独立保留';
CREATE TABLE fl_fee_item (
id bigint unsigned NOT NULL COMMENT '费用项目ID',
@@ -4870,7 +4870,7 @@ CREATE TABLE fin_document_sequence (
current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号',
PRIMARY KEY (sequence_key),
CONSTRAINT chk_fin_document_sequence_value CHECK ((current_value > 0))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Finance业务单号日序列;生成YYMMDD加四位当日序号';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='财务业务单号日序列;生成前缀加yyyyMMdd加四位当日序号';
CREATE TABLE fin_fee_source (
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
@@ -4997,7 +4997,7 @@ CREATE TABLE fin_receipt_bill (
CREATE TABLE fin_recharge_request (
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键',
request_no varchar(32) NOT NULL COMMENT '充值申请业务编号RC-yyMMdd-####',
request_no varchar(32) NOT NULL COMMENT '充值申请业务编号RCyyyyMMdd####',
amount decimal(20,2) NOT NULL COMMENT '申请充值金额',
voucher_file_id bigint unsigned NOT NULL COMMENT '付款截图File ID',
voucher_file_name varchar(255) NOT NULL COMMENT '付款截图文件名快照',
@@ -5248,7 +5248,7 @@ CREATE TABLE fin_wallet_hold (
CREATE TABLE fin_wallet_ledger (
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键',
business_no varchar(32) NOT NULL COMMENT '账户变动业务编号AC-yyMMdd-####',
business_no varchar(32) NOT NULL COMMENT '账户变动业务编号ACyyyyMMdd####',
change_type varchar(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整',
amount decimal(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负',
actual_balance_after decimal(20,2) NOT NULL COMMENT '变动后实际余额',
@@ -8397,4 +8397,20 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102213,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:direct-unload:confirm','登记直送目的仓实际卸货',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3));
SELECT 'PASS' AS initialization_status, 'CN' AS region, 228 AS initialized_tables;
USE baiyue_order;
CREATE TABLE ord_document_sequence (
sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键',
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
PRIMARY KEY (sequence_key),
CONSTRAINT chk_ord_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
USE baiyue_warehouse;
CREATE TABLE wh_document_sequence (
sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键',
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
PRIMARY KEY (sequence_key),
CONSTRAINT chk_wh_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
SELECT 'PASS' AS initialization_status, 'CN' AS region, 230 AS initialized_tables;
+21 -19
View File
@@ -1656,6 +1656,12 @@ CREATE TABLE ll_monthly_exchange_rate_history (
KEY idx_ll_exchange_rate_history (exchange_rate_id,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci 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',
PRIMARY KEY(business_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
CREATE TABLE ll_order (
id bigint NOT NULL COMMENT '雪花 ID',
order_no varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '尾程单号',
@@ -1859,11 +1865,11 @@ CREATE TABLE ll_order_manual_node_history (
KEY idx_ll_order_manual_node_history (customer_id,order_id,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单人工节点覆盖不可变历史';
CREATE TABLE ll_order_no_daily_sequence (
business_date date NOT NULL COMMENT '业务日期(Asia/Shanghai)',
last_sequence int NOT NULL COMMENT '当日已分配尾程单流水号',
PRIMARY KEY (business_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程单号按日原子流水';
CREATE TABLE ll_dispatch_daily_sequence (
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
PRIMARY KEY(business_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
CREATE TABLE ll_order_operation_log (
id bigint NOT NULL COMMENT '操作记录主键',
@@ -2071,6 +2077,7 @@ CREATE TABLE ll_overseas_procurement_fee (
settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件',
source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID',
parent_fee_id BIGINT NULL COMMENT '原费用或预估引用',
idempotency_key VARCHAR(128) NULL COMMENT '退款请求幂等键,独立于业务单号',
request_hash CHAR(64) NULL COMMENT '确认请求摘要',
id bigint NOT NULL COMMENT '主键ID',
order_id bigint NOT NULL COMMENT '采购订单ID',
@@ -2092,6 +2099,7 @@ CREATE TABLE ll_overseas_procurement_fee (
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')),
UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version),
UNIQUE KEY uk_ll_procurement_refund_request(order_id,purpose,idempotency_key),
PRIMARY KEY (id),
UNIQUE KEY uk_ll_procurement_fee_no (fee_no),
KEY idx_ll_procurement_fee_order (customer_id,order_id,created_at),
@@ -2362,14 +2370,6 @@ CREATE TABLE ll_pda_stocktake_task (
KEY idx_ll_pda_stocktake_list (overseas_warehouse_id,status,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA库存盘点任务';
CREATE TABLE ll_procurement_daily_sequence (
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID',
business_date date NOT NULL COMMENT '北京时间业务日期',
sequence_value int NOT NULL COMMENT '客户当日已分配最大序号',
PRIMARY KEY (customer_id,business_date),
CONSTRAINT chk_ll_procurement_sequence CHECK ((sequence_value between 1 and 9999))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购客户日序列';
CREATE TABLE ll_receiving_container_operation (
id bigint NOT NULL COMMENT '柜号收货操作ID',
session_id bigint NOT NULL COMMENT '柜号签收会话ID',
@@ -4822,12 +4822,6 @@ CREATE TABLE ll_appointment_work_history (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令';
-- 海外业务短单号分配时钟。
CREATE TABLE ll_compact_number_clock (
number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写',
last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒',
PRIMARY KEY (number_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟';
-- 仓储转运上门取货表归尾程服务,仅用于空库初始化。
USE baiyue_lastleg;
CREATE TABLE ll_fleet_pickup_plan (
@@ -4961,4 +4955,12 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history (
CONSTRAINT ck_owned_business_history_version CHECK (record_version>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史';
USE baiyue_lastleg;
CREATE TABLE ll_document_sequence (
sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键',
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
PRIMARY KEY (sequence_key),
CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 232 AS initialized_tables;
+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、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。
@@ -0,0 +1,21 @@
package com.baiyue.finance.mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/** 本服务业务编号序列,更新与读取必须处于同一事务连接。 */
@Mapper
public interface DocumentSequenceMapper {
/** 唯一序列键原子递增,并在连接内保存本次分配值。 */
@Insert("INSERT INTO fin_document_sequence(sequence_key,current_value) VALUES(#{key},LAST_INSERT_ID(1)) "
+ "ON DUPLICATE KEY UPDATE current_value=LAST_INSERT_ID(current_value+1)")
int allocate(@Param("key") String key);
/** 禁用一级和二级缓存,返回本事务刚刚分配的值。 */
@Select("SELECT LAST_INSERT_ID()")
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
Long allocatedValue();
}
@@ -0,0 +1,24 @@
package com.baiyue.finance.repository;
import com.baiyue.finance.mapper.DocumentSequenceMapper;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/** 各业务共用本库序列,独立短事务避免持有编号锁进入业务资源锁。 */
@Repository
public class DocumentSequenceRepository {
private final DocumentSequenceMapper mapper;
/** 只访问所属服务的编号表。 */
public DocumentSequenceRepository(DocumentSequenceMapper mapper) { this.mapper = mapper; }
/** 已分配号不因外层业务回滚而复用,允许失败请求产生断号。 */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public long next(String key) {
mapper.allocate(key);
Long value = mapper.allocatedValue();
if (value == null || value < 1) throw new IllegalStateException("业务编号分配失败");
return value;
}
}
@@ -1,55 +1,34 @@
package com.baiyue.finance.service;
import com.baiyue.common.core.number.DailyDocumentNumber;
import com.baiyue.finance.repository.DocumentSequenceRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.Set;
/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */
@Component
public class FinanceDocumentNumberGenerator {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
private static final Set<String> SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC", "VC", "VP");
private static final long MAX_DAILY_SEQUENCE = 9_999L;
private final JdbcTemplate jdbc;
private static final Set<String> PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC", "VC", "VP", "PAY");
private final DocumentSequenceRepository sequences;
private final Clock clock;
/** 正式运行使用统一业务时区。 */
@Autowired
public FinanceDocumentNumberGenerator(JdbcTemplate jdbc) {
this(jdbc, Clock.system(BUSINESS_ZONE));
public FinanceDocumentNumberGenerator(DocumentSequenceRepository sequences) {
this(sequences, Clock.system(DailyDocumentNumber.BUSINESS_ZONE));
}
FinanceDocumentNumberGenerator(JdbcTemplate jdbc, Clock clock) {
this.jdbc = jdbc;
/** 固定时钟用于跨日及前缀独立性验证。 */
FinanceDocumentNumberGenerator(DocumentSequenceRepository sequences, Clock clock) {
this.sequences = sequences;
this.clock = clock;
}
@Transactional(propagation = Propagation.MANDATORY)
/** 只为本服务已登记的业务类型分配号码。 */
public String next(String prefix) {
if (!SUPPORTED_PREFIXES.contains(prefix)) {
throw new IllegalArgumentException("unsupported finance document prefix: " + prefix);
}
LocalDate sequenceDate = LocalDate.now(clock);
String sequenceKey = prefix + ":" + DateTimeFormatter.BASIC_ISO_DATE.format(sequenceDate);
jdbc.update(
"INSERT INTO fin_document_sequence(sequence_key,current_value) " +
"VALUES(?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE " +
"current_value=LAST_INSERT_ID(current_value+1)",
sequenceKey);
Long sequence = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
if (sequence == null || sequence <= 0 || sequence > MAX_DAILY_SEQUENCE) {
throw new FinanceEventException("FIN_409", "当日" + prefix + "单据编号已用尽");
}
return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" +
String.format(Locale.ROOT, "%04d", sequence);
if (!PREFIXES.contains(prefix)) throw new IllegalArgumentException("未登记的业务编号前缀:" + prefix);
return DailyDocumentNumber.next(prefix, clock, sequences::next);
}
}
@@ -66,11 +66,7 @@ public class FinanceSettlementService {
private final FinanceSupplierValidator supplierValidator;
private final FinanceDocumentNumberGenerator documentNumbers;
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
FinanceSupplierValidator supplierValidator) {
this(jdbc, objectMapper, voucherValidator, supplierValidator, new FinanceDocumentNumberGenerator(jdbc));
}
/** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */
@Autowired
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
FinanceSupplierValidator supplierValidator,
@@ -82,6 +78,7 @@ public class FinanceSettlementService {
this.documentNumbers = documentNumbers;
}
/** 创建应收结算单,幂等请求恢复原单,首次建单分配 AR 日流水。 */
@Transactional
public SettlementDetail createReceivableSettlement(long operatorId, String key,
CreateReceivableSettlementRequest request) {
@@ -521,9 +518,9 @@ public class FinanceSettlementService {
"outstandingAmount", decimal(settlement, "outstanding_amount"), "status", status);
}
/** 结算收付款使用独立 PAY 日流水,重试由原业务幂等记录恢复。 */
private String transactionNo() {
return "PAY-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" +
UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(Locale.ROOT);
return documentNumbers.next("PAY");
}
private String hash(Object value) {
@@ -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) {}
@@ -29,6 +29,8 @@ import java.util.stream.Collectors;
@Service
public class ContainerCustomsService {
@jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers;
public static final String WAITING = "WAITING_CUSTOMER_DOCUMENTS";
public static final String READY = "READY_TO_START";
public static final String DECLARING = "DECLARING";
@@ -179,6 +181,7 @@ public class ContainerCustomsService {
return platformView(planId);
}
/** 首次发送报关资料时生成 BR 批次号,重放沿用已有批次。 */
@Transactional
public ContainerView sendInitialToBroker(long planId, long operatorId, String operatorName, String key,
ActionRequest request) {
@@ -197,7 +200,7 @@ public class ContainerCustomsService {
throw conflict("报关资料尚未全部冻结,不能发送给报关行");
}
long submissionId = IdWorker.getId();
String batchNo = brokerBatchNo(planId);
String batchNo = documentNumbers.next("BR");
String snapshot = brokerSubmissionSnapshot(container.id());
if (mapper.insertBrokerSubmission(submissionId, container.id(), planId, batchNo, container.version(), snapshot,
operatorId, operatorName) != 1) throw conflict("首次发送报关行批次创建失败");
@@ -356,11 +359,12 @@ public class ContainerCustomsService {
return mapper.declarationBatches(container.id()).stream().map(this::declarationBatchView).toList();
}
/** 首次创建分配独立申报批次号,重复命令返回已创建批次。 */
@Transactional
public DeclarationBatchView createDeclarationBatch(long planId, long operatorId, String operatorName,
String key, DeclarationBatchRequest request) {
String action = "CREATE_CUSTOMS_DECLARATION_BATCH_" + planId;
String requestHash = hash(action, request.expectedVersion(), request.batchNo(), request.remark());
String requestHash = hash(action, request.expectedVersion(), request.remark());
CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) return declarationBatchView(mapper.declarationBatch(replay.resultId()));
ContainerRow container = mapper.lockContainer(planId);
@@ -371,8 +375,8 @@ public class ContainerCustomsService {
}
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
long batchId = IdWorker.getId();
String batchNo = trim(request.batchNo());
if (batchNo == null) batchNo = "DECL-" + batchId;
// 申报批次号由服务端分配,外部报关单号独立保存在 customsEntryNo。
String batchNo = documentNumbers.next("DECL");
String snapshot = declarationSnapshot(container.id());
if (mapper.insertDeclarationBatch(batchId, container.id(), planId, batchNo, container.version(), snapshot,
trim(request.remark())) != 1) throw conflict("正式申报批次创建失败");
@@ -381,20 +385,20 @@ public class ContainerCustomsService {
return declarationBatchView(mapper.declarationBatch(batchId));
}
/** 提交只回填外部报关单号,内部批次号保持创建时的值。 */
@Transactional
public DeclarationBatchView submitDeclarationBatch(long batchId, long operatorId, String operatorName,
String key, DeclarationBatchRequest request) {
String action = "SUBMIT_CUSTOMS_DECLARATION_BATCH_" + batchId;
String entryNo = trim(request.customsEntryNo());
if (entryNo == null) throw bad("正式申报必须回填报关单号");
String requestHash = hash(action, request.expectedVersion(), request.batchNo(), entryNo, request.remark());
String requestHash = hash(action, request.expectedVersion(), entryNo, request.remark());
CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) return declarationBatchView(mapper.declarationBatch(batchId));
DeclarationBatchRow batch = mapper.lockDeclarationBatch(batchId);
if (batch == null || !"READY".equals(batch.status())) throw conflict("正式申报批次不在可提交状态");
if (!Objects.equals(batch.version(), request.expectedVersion())) throw concurrent();
String batchNo = trim(request.batchNo());
if (batchNo == null) batchNo = batch.batchNo();
String batchNo = batch.batchNo();
if (mapper.submitDeclarationBatch(batchId, batch.version(), batchNo, entryNo, operatorId, operatorName,
trim(request.remark())) != 1) throw concurrent();
ContainerRow container = mapper.lockContainerById(batch.customsContainerId());
@@ -501,14 +505,6 @@ public class ContainerCustomsService {
catch (JsonProcessingException ex) { throw new IllegalStateException(ex); }
}
private String brokerBatchNo(long planId) {
PlanRow plan = requiredPlan(planId);
String planNo = trim(plan.planNo());
if (planNo != null && planNo.matches("[A-Za-z0-9_-]{1,52}")) return "BR-" + planNo + "-01";
String suffix = UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase(Locale.ROOT);
return "BR-" + LocalDate.now() + "-" + suffix;
}
private String brokerSubmissionSnapshot(long containerId) {
List<Map<String,Object>> snapshot = mapper.documents(containerId, null).stream().map(document -> {
List<ScopeRow> scopes = mapper.frozenScopes(document.id());
@@ -38,6 +38,8 @@ import java.util.Set;
/** 同柜不可拆组的唯一状态机与平台编排服务。 */
@Service
public class ColoadGroupService implements ColoadGroupAssignmentPort {
@jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers;
private static final Set<String> CURRENT_GROUP_STATUSES = Set.of("ACTIVE", "LOCKED", "CONSUMED");
private static final Set<String> PRE_LOADING_GROUP_STATUSES = Set.of(
"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
@@ -64,7 +66,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
validateSameRoute(candidates);
Long lockedPlanId = validateAllocationState(candidates);
long groupId = IdWorker.getId();
String groupNo = "CLG-" + groupId;
String groupNo = documentNumbers.next("CLG");
InboundCandidateRow first = candidates.get(0);
mapper.insertGroup(groupId, groupNo, first.getWarehouseId(), first.getChannelCode().trim(),
operatorId, trim(operatorName));
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/** 本服务业务编号序列,更新与读取必须处于同一事务连接。 */
@Mapper
public interface DocumentSequenceMapper {
/** 唯一序列键原子递增,并在连接内保存本次分配值。 */
@Insert("INSERT INTO fl_document_sequence(sequence_key,current_value) VALUES(#{key},LAST_INSERT_ID(1)) "
+ "ON DUPLICATE KEY UPDATE current_value=LAST_INSERT_ID(current_value+1)")
int allocate(@Param("key") String key);
/** 禁用一级和二级缓存,返回本事务刚刚分配的值。 */
@Select("SELECT LAST_INSERT_ID()")
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
Long allocatedValue();
}
@@ -32,6 +32,8 @@ import java.util.Set;
@Service
public class PortScheduleService {
@jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers;
private static final Set<String> SOURCE_TYPES = Set.of("MANUAL", "CARRIER", "TERMINAL", "API");
@Resource private PortScheduleRepository repository;
@@ -72,7 +74,7 @@ public class PortScheduleService {
validateLinks(request.data());
PortScheduleEntity value = new PortScheduleEntity();
value.setId(IdWorker.getId());
value.setScheduleNo("PS" + value.getId());
value.setScheduleNo(documentNumbers.next("PS"));
copy(value, request.data());
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName);
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.DocumentSequenceMapper;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/** 各业务共用本库序列,独立短事务避免持有编号锁进入业务资源锁。 */
@Repository
public class DocumentSequenceRepository {
private final DocumentSequenceMapper mapper;
/** 只访问所属服务的编号表。 */
public DocumentSequenceRepository(DocumentSequenceMapper mapper) { this.mapper = mapper; }
/** 已分配号不因外层业务回滚而复用,允许失败请求产生断号。 */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public long next(String key) {
mapper.allocate(key);
Long value = mapper.allocatedValue();
if (value == null || value < 1) throw new IllegalStateException("业务编号分配失败");
return value;
}
}
@@ -40,6 +40,8 @@ import static com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.*;
@Service
public class SailingCalendarService {
@jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers;
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
private static final String OPEN = "OPEN";
@Resource private SailingCalendarRepository repository;
@@ -77,7 +79,7 @@ public class SailingCalendarService {
if (version(request.expectedVersion()) != 0) throw bad("新建模板expectedVersion必须为0");
SailingChannelEntity channel = resolvePriceBookChannel(request.data().channelId(), request.data().warehouseId(), null); validateTemplate(request.data(), channel);
if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能创建模板");
SailingTemplateEntity value = new SailingTemplateEntity(); value.setId(IdWorker.getId()); value.setTemplateNo("ST-" + value.getId());
SailingTemplateEntity value = new SailingTemplateEntity(); value.setId(IdWorker.getId()); value.setTemplateNo(documentNumbers.next("ST"));
copy(value, request.data(), channel); value.setStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value);
repository.insert(value); return value;
@@ -310,7 +312,7 @@ public class SailingCalendarService {
if (existing != null) return isCopyOf(existing, copyReason) ? detail(existing.getId())
: throwCopyConflict(request.etdAt(), source.getVoyageNo());
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId()); value.setTemplateId(source.getTemplateId()); value.setChannelId(source.getChannelId()); value.setChannelCodeSnapshot(source.getChannelCodeSnapshot()); value.setChannelNameSnapshot(source.getChannelNameSnapshot()); value.setWarehouseId(source.getWarehouseId()); value.setWarehouseNameSnapshot(source.getWarehouseNameSnapshot()); value.setDeparturePortCode(source.getDeparturePortCode()); value.setDestinationPortCode(source.getDestinationPortCode()); value.setVesselName(source.getVesselName()); value.setVoyageNo(source.getVoyageNo()); value.setEtdAt(request.etdAt()); value.setWarehouseCutoffAt(request.cutoffAt()); value.setEtaAt(copyEta(source, request.etdAt())); value.setStatus("OPEN"); value.setSourceType("COPY"); value.setPlannedCapacityCbm(source.getPlannedCapacityCbm()); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value);
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo(documentNumbers.next("SL")); value.setTemplateId(source.getTemplateId()); value.setChannelId(source.getChannelId()); value.setChannelCodeSnapshot(source.getChannelCodeSnapshot()); value.setChannelNameSnapshot(source.getChannelNameSnapshot()); value.setWarehouseId(source.getWarehouseId()); value.setWarehouseNameSnapshot(source.getWarehouseNameSnapshot()); value.setDeparturePortCode(source.getDeparturePortCode()); value.setDestinationPortCode(source.getDestinationPortCode()); value.setVesselName(source.getVesselName()); value.setVoyageNo(source.getVoyageNo()); value.setEtdAt(request.etdAt()); value.setWarehouseCutoffAt(request.cutoffAt()); value.setEtaAt(copyEta(source, request.etdAt())); value.setStatus("OPEN"); value.setSourceType("COPY"); value.setPlannedCapacityCbm(source.getPlannedCapacityCbm()); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value);
try { repository.insert(value); } catch (DuplicateKeyException ex) {
SailingEntity duplicate = repository.sailingByBusinessKey(source.getWarehouseId(), source.getChannelCodeSnapshot(),
source.getDeparturePortCode(), source.getDestinationPortCode(), request.etdAt(), source.getVoyageNo());
@@ -362,7 +364,7 @@ public class SailingCalendarService {
if (version(request.expectedVersion()) != 0) throw bad("新建船期expectedVersion必须为0");
validateSailing(request.data()); SailingChannelEntity channel = resolveChannel(request.data());
if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能创建船期");
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId());
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo(documentNumbers.next("SL"));
copy(value, request.data(), channel); value.setStatus(OPEN); value.setSourceType(normalizeSource(request.data().sourceType()));
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value);
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("船期业务唯一键冲突"); }
@@ -398,7 +400,7 @@ public class SailingCalendarService {
validateTemplateSnapshot(template);
List<SailingEntity> result = new java.util.ArrayList<>();
for (GenerationPreview preview : generationPreview(templateId, from, to)) if (!preview.conflict()) {
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId()); value.setTemplateId(templateId);
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo(documentNumbers.next("SL")); value.setTemplateId(templateId);
value.setChannelId(channel.getId()); value.setChannelCodeSnapshot(channel.getChannelCode()); value.setChannelNameSnapshot(channel.getChannelName());
value.setWarehouseId(template.getWarehouseId()); value.setWarehouseNameSnapshot(template.getWarehouseNameSnapshot()); value.setDeparturePortCode(template.getDeparturePortCode()); value.setDestinationPortCode(template.getDestinationPortCode()); value.setVesselName(template.getVesselName()); value.setVoyageNo(preview.voyageNo()); value.setEtdAt(preview.etdAt()); value.setWarehouseCutoffAt(preview.cutoffAt()); value.setStatus(OPEN); value.setSourceType("TEMPLATE"); value.setGeneratedKey(key(template, preview.etdAt(), preview.voyageNo())); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value); repository.insert(value); recordHistory(value.getId(), "GENERATE", null, OPEN, "按固定周模板生成", operatorId, operatorName, value.getCreatedAt()); result.add(value);
}
@@ -1,71 +1,47 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.number.DailyDocumentNumber;
import com.baiyue.firstleg.repository.DocumentSequenceRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.Set;
/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */
@Component
public class FirstlegDocumentNumberGenerator {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
private static final Set<String> SUPPORTED_PREFIXES = Set.of("OB", "TA", "LP", "DP");
private static final long MAX_DAILY_SEQUENCE = 9_999L;
private final JdbcTemplate jdbc;
private static final Set<String> PREFIXES = Set.of("OB", "TA", "LP", "DECL", "BR", "PS", "SL", "ST", "CLG");
private final DocumentSequenceRepository sequences;
private final Clock clock;
/** 正式运行使用统一业务时区。 */
@Autowired
public FirstlegDocumentNumberGenerator(JdbcTemplate jdbc) {
this(jdbc, Clock.system(BUSINESS_ZONE));
public FirstlegDocumentNumberGenerator(DocumentSequenceRepository sequences) {
this(sequences, Clock.system(DailyDocumentNumber.BUSINESS_ZONE));
}
FirstlegDocumentNumberGenerator(JdbcTemplate jdbc, Clock clock) {
this.jdbc = jdbc;
/** 固定时钟用于跨日及前缀独立性验证。 */
FirstlegDocumentNumberGenerator(DocumentSequenceRepository sequences, Clock clock) {
this.sequences = sequences;
this.clock = clock;
}
@Transactional(propagation = Propagation.MANDATORY)
/** 只为本服务已登记的业务类型分配号码。 */
public String next(String prefix) {
if (!SUPPORTED_PREFIXES.contains(prefix)) {
throw new IllegalArgumentException("unsupported firstleg document prefix: " + prefix);
// 实物标签未纳入本次改号,DP 保留原六位日期及横杠。
if ("DP".equals(prefix)) {
java.time.LocalDate date = java.time.LocalDate.now(clock.withZone(DailyDocumentNumber.BUSINESS_ZONE));
long value = sequences.next("DP:" + date.format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE));
DailyDocumentNumber.format("DP", date, value);
return "DP-" + date.format(java.time.format.DateTimeFormatter.ofPattern("yyMMdd"))
+ "-" + String.format(java.util.Locale.ROOT, "%04d", value);
}
LocalDate sequenceDate = LocalDate.now(clock);
String sequenceKey = prefix + ":" + DateTimeFormatter.BASIC_ISO_DATE.format(sequenceDate);
long sequence = nextSequence(sequenceKey);
if (sequence > MAX_DAILY_SEQUENCE) {
throw new BizException(ResultCode.CONFLICT, "当日" + prefix + "单据编号已用尽");
}
return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" +
String.format(Locale.ROOT, "%04d", sequence);
if (!PREFIXES.contains(prefix)) throw new IllegalArgumentException("未登记的业务编号前缀:" + prefix);
return DailyDocumentNumber.next(prefix, clock, sequences::next);
}
/** 平台柜次号是长期业务身份,使用不按日期重置的 D 序号。 */
@Transactional(propagation = Propagation.MANDATORY)
/** 柜次号保持现有全局连续规则,不按日期重置,也不限制为四位。 */
public String nextCabinetNo() {
return "D" + String.format(Locale.ROOT, "%04d", nextSequence("CABINET"));
}
private long nextSequence(String sequenceKey) {
jdbc.update(
"INSERT INTO fl_document_sequence(sequence_key,current_value) " +
"VALUES(?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE " +
"current_value=LAST_INSERT_ID(current_value+1)",
sequenceKey);
Long sequence = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
if (sequence == null || sequence <= 0) {
throw new BizException(ResultCode.CONFLICT, "业务编号生成失败");
}
return sequence;
return "D" + String.format(java.util.Locale.ROOT, "%04d", sequences.next("CABINET"));
}
}
@@ -91,12 +91,13 @@ public class SmartLoadingService {
}
return repository.candidates(warehouseId);
}
/** 按柜次渠道查询候选,关联订舱客户快照不限制其他客户的货物。 */
public List<Candidate> candidates(long bookingId, long warehouseId) {
ContainerBookingEntity booking = requireBooking(bookingId);
if (!Objects.equals(booking.getWarehouseId(), warehouseId)) throw validation("柜次仓库与候选仓库不一致");
if (!StringUtils.hasText(booking.getChannelGroupCode())) throw validation("柜次缺少计划渠道,请重新创建模拟柜");
List<Candidate> values = candidates(warehouseId).stream()
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList();
.filter(candidate -> sameChannel(booking, candidate)).toList();
return enrichColoadCandidates(booking, repository.planByBooking(bookingId), values);
}
@@ -108,10 +109,11 @@ public class SmartLoadingService {
return uniqueCandidateUnits(grouped.values().stream().flatMap(details -> inboundCandidates(details).stream()).toList());
}
/** 推荐保留渠道和显式筛选范围,允许同柜包含多个客户。 */
private List<InboundCandidate> recommendationCandidates(ContainerBookingEntity booking,
Long customerId, String inboundCode) {
List<Candidate> values = repository.candidates(null).stream()
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate))
.filter(candidate -> sameChannel(booking, candidate))
.filter(candidate -> customerId == null || Objects.equals(customerId, candidate.accountId()))
.filter(candidate -> !StringUtils.hasText(inboundCode)
|| inboundCode.trim().equals(candidate.inboundCode()))
@@ -513,6 +515,7 @@ public class SmartLoadingService {
return view(assigned, requireBooking(assigned.getContainerBookingId()));
}
/** 原子新增明细或整托组成,使用货物自身客户归属而非订舱客户快照。 */
private Plan assign(long planId, AssignRequest request, long operatorId, String operatorName,
boolean wholeGroupAssignment, Long palletId, String palletNo,
Integer actualSkuQuantity, boolean primaryPalletLine, Candidate frozenCandidate,
@@ -535,7 +538,6 @@ public class SmartLoadingService {
}
inboundHoldGuard.requireInboundNotHeld(candidate.accountId(), candidate.inboundId());
if (!wholeGroupAssignment) requireStandalone(candidate.accountId(), candidate.inboundId());
requireSameCustomerScope(booking, candidate);
var task = loadingPickService.mutationSnapshot(planId);
if (task.taskId() != null && "LOADING".equals(task.taskStatus())) {
splitPreloadService.ensureForRunningLoadingAllocation(candidate.accountId(), candidate.inboundId(),
@@ -604,10 +606,7 @@ public class SmartLoadingService {
return assignInbound(planId, request, operatorId, operatorName, false, null, true);
}
/**
* Reserves declared cartons in a cabinet before order review. Review status is not an allocation
* gate; confirmation/PDA remains blocked until the resulting inbound reaches WAIT_SPLIT.
*/
/** 待审核订单可跨客户占位;权威订单归属和后续审核测量门禁仍必须满足。 */
@Transactional
@BusinessAudit(event = "SMART_LOADING_PENDING_REVIEW_ASSIGN", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "待审核订单加入装柜清单")
@@ -652,7 +651,6 @@ public class SmartLoadingService {
for (InboundAllocation allocation : allocations) {
if (allocation.packageCount() == 0) continue;
Candidate candidate = allocation.candidate();
requireSameCustomerScope(booking, candidate);
checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true);
OrderCargoClient.CargoBinding cargo = declared.stream()
.map(DeclaredCargo::cargo)
@@ -722,7 +720,7 @@ public class SmartLoadingService {
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
List<Candidate> details = frozenDetails == null
? manualCandidates(booking).stream()
? manualCandidates().stream()
.filter(candidate -> Objects.equals(candidate.inboundId(), request.inboundId())).toList()
: List.copyOf(frozenDetails);
if (details.isEmpty()) throw conflict("入仓码不存在、已分配完或尚未完成入库");
@@ -756,6 +754,7 @@ public class SmartLoadingService {
return view(assigned, booking);
}
/** 按入仓单自己的客户和申报事实建立占位,不以订舱客户限制拼柜。 */
private Plan assignDeclaredInbound(long planId, AssignInboundRequest request,
InboundOrderEntity projection, String operation,
String requestHash, long operatorId, String operatorName) {
@@ -786,7 +785,6 @@ public class SmartLoadingService {
for (InboundAllocation allocation : allocations) {
if (allocation.packageCount() == 0) continue;
Candidate candidate = allocation.candidate();
requireSameCustomerScope(booking, candidate);
checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true);
OrderCargoClient.CargoBinding cargo = declared.stream().map(DeclaredCargo::cargo)
.filter(value -> Objects.equals(value.cargoId(), candidate.inboundDetailId()))
@@ -1007,6 +1005,7 @@ public class SmartLoadingService {
@Transactional
@BusinessAudit(event = "SMART_LOADING_ITEM_ADJUST", resourceType = "LOADING_PLAN_ITEM",
resourceId = "#itemId", description = "调整智能装柜明细")
/** 调整本计划明细箱数,保留原客户归属、可用量、拣货和容量校验。 */
public Plan adjust(long planId, long itemId, AdjustRequest request, long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("ADJUST|" + planId + '|' + itemId + '|'
@@ -1053,7 +1052,6 @@ public class SmartLoadingService {
if (request.packageCount() > maxPackageCount) {
throw validation("调整箱数超过可用箱数");
}
requireSameCustomerScope(booking, candidate);
List<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));
others.removeIf(x -> Objects.equals(x.getId(), itemId));
checkRouteAndCapacity(booking, others, candidate, request.packageCount(), true);
@@ -1360,7 +1358,7 @@ public class SmartLoadingService {
if (!"ACTIVE".equals(group.status()) || group.lockedPlanId() != null) {
throw conflict("同柜不可拆组已锁定或已消费");
}
List<Candidate> available = manualCandidates(booking);
List<Candidate> available = manualCandidates();
Map<Long, List<Candidate>> result = new LinkedHashMap<>();
for (Member member : group.members()) {
if (!repository.activeItemsByInbound(member.tenantId(), member.inboundId()).isEmpty()) {
@@ -1382,14 +1380,15 @@ public class SmartLoadingService {
return result;
}
/** 整组推荐可跨客户,但仍按柜次渠道选择候选。 */
private List<Candidate> availableCandidates(ContainerBookingEntity booking) {
return repository.candidates(null).stream()
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList();
.filter(candidate -> sameChannel(booking, candidate)).toList();
}
private List<Candidate> manualCandidates(ContainerBookingEntity booking) {
return repository.candidates(null).stream()
.filter(candidate -> sameCustomerScope(booking, candidate)).toList();
/** 平台人工分配可拼装多个客户,明细始终保留各自的真实客户归属。 */
private List<Candidate> manualCandidates() {
return repository.candidates(null);
}
private boolean groupFits(ContainerBookingEntity booking, List<LoadingPlanItemEntity> planItems,
@@ -1807,12 +1806,6 @@ public class SmartLoadingService {
return StringUtils.hasText(booking.getChannelGroupCode()) && candidateGroup != null
&& booking.getChannelGroupCode().equals(candidateGroup.code());
}
private static boolean sameCustomerScope(ContainerBookingEntity booking, Candidate candidate) {
return booking.getCustomerId() == null || Objects.equals(booking.getCustomerId(), candidate.accountId());
}
private static void requireSameCustomerScope(ContainerBookingEntity booking, Candidate candidate) {
if (!sameCustomerScope(booking, candidate)) throw forbidden();
}
/**
* A draft may legitimately contain pre-review or pre-measurement members without a fee snapshot.
* The pricing port reports that as NOT_READY, while any sent/customer-confirmed member fails and
@@ -1911,5 +1904,5 @@ public class SmartLoadingService {
}
private static String trim(String x){return x==null?null:x.trim();} private static BigDecimal unit(BigDecimal x){return x==null?BigDecimal.ZERO:x.max(BigDecimal.ZERO);}
private static void requireAccount(long x){if(x<=0)throw new BizException(ResultCode.BAD_REQUEST,"accountId必须大于零");} private static BizException conflict(String x){return new BizException(ResultCode.CONFLICT,x);} private static BizException validation(String x){return new BizException(ResultCode.VALIDATION_FAILED,x);} private static BizException forbidden(){return new BizException(ResultCode.FORBIDDEN,"客户归属不匹配");}
private static void requireAccount(long x){if(x<=0)throw new BizException(ResultCode.BAD_REQUEST,"accountId必须大于零");} private static BizException conflict(String x){return new BizException(ResultCode.CONFLICT,x);} private static BizException validation(String x){return new BizException(ResultCode.VALIDATION_FAILED,x);}
}
@@ -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 = 230;
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");
}
}
@@ -462,8 +462,9 @@ class SmartLoadingServiceTest {
verify(repository).occupiedOrderIds(eq(query), anySet());
}
/** 待审核订单来自其他客户时仍可拼柜,客户归属和费用重算维持原值。 */
@Test
void manuallyAssignsCrossChannelPendingReviewOrderAsAProvisionalContainerAllocation() {
void manuallyAssignsCrossCustomerAndChannelPendingReviewOrderAsAProvisionalContainerAllocation() {
long customerId = 2092797167699816450L;
long orderId = 2095052002489335810L;
String inboundCode = "EXXHK2609020361";
@@ -503,6 +504,7 @@ class SmartLoadingServiceTest {
assertThat(result.version()).isEqualTo(1);
ArgumentCaptor<LoadingPlanItemEntity> item = ArgumentCaptor.forClass(LoadingPlanItemEntity.class);
verify(repository).insert(item.capture());
assertThat(item.getValue().getAccountId()).isEqualTo(customerId);
assertThat(item.getValue().getInboundId()).isEqualTo(10L);
assertThat(item.getValue().getInboundDetailId()).isEqualTo(20L);
assertThat(item.getValue().getPackageCount()).isEqualTo(5);
@@ -1378,20 +1380,31 @@ class SmartLoadingServiceTest {
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any());
}
/** 订舱客户仅为关联资料;平台可将其他客户的货物加入同一柜次。 */
@Test
void rejectsManualAssignmentOutsideLinkedBookingCustomerScope() {
LoadingPlanEntity plan = plan("EDITING", 0);
ContainerBookingEntity linked = booking();
linked.setCustomerId(88L);
when(repository.plan(1L)).thenReturn(plan);
when(repository.booking(99L)).thenReturn(linked);
when(repository.candidate(1L, 10L, 20L)).thenReturn(candidate(5));
void allowsManualAssignmentFromAnotherCustomerWithoutRewritingCargoOwnership() {
stubAssignment();
BizException error = catchThrowableOfType(() -> service.assign(1L,
new AssignRequest(10L, 20L, 1, 0, "assign-customer-mismatch"), 7L, "u"), BizException.class);
Plan result = service.assign(1L,
new AssignRequest(10L, 20L, 1, 0, "assign-cross-customer"), 7L, "u");
assertThat(error.getResultCode()).isEqualTo(ResultCode.FORBIDDEN);
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any());
assertThat(result.version()).isEqualTo(1);
ArgumentCaptor<LoadingPlanItemEntity> inserted = ArgumentCaptor.forClass(LoadingPlanItemEntity.class);
verify(repository).insert(inserted.capture());
assertThat(inserted.getValue().getAccountId()).isEqualTo(9L);
assertThat(inserted.getValue().getOrderId()).isEqualTo(30L);
verify(cargoClient).detail(anyString(), eq(9L), eq(30L), eq(20L));
}
/** 同渠道候选包含不同客户,不能被订舱客户快照裁剪。 */
@Test
void listsCandidatesFromBothCustomersInALinkedBooking() {
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidates(100L)).thenReturn(List.of(
candidate(9L, 10L, 20L, 30L, 5), candidate(88L, 11L, 21L, 31L, 4)));
assertThat(service.candidates(99L, 100L)).extracting(Candidate::accountId)
.containsExactly(9L, 88L);
}
@Test
@@ -2075,9 +2088,11 @@ class SmartLoadingServiceTest {
return group;
}
/** 所有流程默认带历史订舱客户,覆盖候选、占位、整托、调整和推荐的跨客户拼柜。 */
private static ContainerBookingEntity booking() {
ContainerBookingEntity value = new ContainerBookingEntity();
value.setId(99L); value.setWarehouseId(100L); value.setDestinationWarehouseCode("FBA1");
value.setCustomerId(88L);
value.setChannelGroupCode("MATSON_CLX"); value.setChannelCode(null);
value.setContainerNo("MSCU1234567");
value.setCapacityCbm(new BigDecimal("60")); value.setMaxWeightKg(new BigDecimal("20000"));
@@ -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),
@@ -28,8 +28,9 @@ public class SmartDispatchController {
@PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_LIST+"')")
public ApiResponse<CandidateList> candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String warehouseId,
@RequestParam(required=false)List<Long> planIds,@RequestParam(required=false)String orderNo,
@RequestParam(required=false)String destinationWarehouseCode,@RequestParam(required=false)String sourceStage,@RequestParam(required=false)String draftToken) {
return ApiResponse.success(aggregate.candidates(DispatchActorFactory.platform(user),new CandidateQuery(warehouseId,planIds,orderNo,destinationWarehouseCode,sourceStage,draftToken)));
@RequestParam(required=false)String destinationWarehouseCode,@RequestParam(required=false)String sourceStage,@RequestParam(required=false)String draftToken,
@RequestParam(defaultValue="1")int page,@RequestParam(defaultValue="20")int size,@RequestParam(defaultValue="ALL")String warehouseTab) {
return ApiResponse.success(aggregate.candidates(DispatchActorFactory.platform(user),new CandidateQuery(warehouseId,planIds,orderNo,destinationWarehouseCode,sourceStage,draftToken,page,size,warehouseTab)));
}
/** 只列出当前拥有者未失效草稿。 */
@GetMapping("/drafts")
@@ -9,6 +9,21 @@ import java.util.List;
@Mapper
public interface DispatchCandidateMapper {
/** 自动预留托盘不是实收;尚未开始收货时仍以计划来源预排,不生成库存或实托装载事实。 */
String PLAN_ONLY = "(p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0))";
/** 在途计划的全部真实来源必须已离港;人工节点覆盖后不再借用原柜节点。 */
String DEPARTED_PLAN =
"EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " +
"ON dl.id=da.line_id WHERE da.pallet_id=p.id AND dl.customer_id=ip.customer_id) AND NOT EXISTS (" +
"SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " +
"ON dl.id=da.line_id LEFT JOIN ll_order departure_order " +
"ON departure_order.id=dl.order_id AND departure_order.customer_id=dl.customer_id WHERE da.pallet_id=p.id AND dl.customer_id=ip.customer_id AND " +
"(departure_order.id IS NULL OR COALESCE(departure_order.order_type,'')!='FIRST_LEG' OR NOT (" +
"CASE WHEN COALESCE(departure_order.manual_node_override,FALSE)=TRUE THEN EXISTS (" +
"SELECT 1 FROM ll_order_manual_node dn WHERE dn.order_id=departure_order.id AND dn.customer_id=departure_order.customer_id " +
"AND dn.node_code='ACTUAL_DEPARTURE' AND dn.node_at IS NOT NULL) ELSE EXISTS (" +
"SELECT 1 FROM ll_container_shipping_gate dg WHERE dg.container_booking_id=departure_order.source_container_booking_id " +
"AND dg.node_sequence>=40 AND dg.actual_at IS NOT NULL) END)))";
// Only the receiving party's confirmed appointment is a transport reference; it is not a loading plan.
String CONFIRMED_APPOINTMENT_AT =
"(SELECT CASE WHEN COUNT(DISTINCT fa.id)=1 THEN MAX(fa.appointment_at) END FROM ll_inbound_plan_pallet_allocation pa " +
@@ -41,7 +56,7 @@ public interface DispatchCandidateMapper {
List<String> findCustomerScopesByPlanPalletIds(@Param("planPalletIds") List<Long> planPalletIds);
String AVAILABLE_PLAN_BODY =
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
"SELECT p.plan_id AS planId,p.id AS planPalletId,CASE WHEN " + PLAN_ONLY + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId," +
"MIN(l.order_id) AS orderId," +
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
@@ -53,16 +68,16 @@ public interface DispatchCandidateMapper {
"MIN(ip.container_no) AS containerNo," +
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
// 仓ID文本统一保留128字符,避免混合CASE的NONE排序规则及无长度CHAR截断;来源优先级与身份等值不变。
"CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
"CAST(CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
"AND pos.available_packages &gt; 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')," +
"(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " +
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS CHAR(128)) AS overseasWarehouseId," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_code) " +
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_code) " +
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0) " +
"ELSE MIN(o.overseas_warehouse_code) END AS overseasWarehouseCode," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " +
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_name) " +
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0) " +
"ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
@@ -70,11 +85,11 @@ public interface DispatchCandidateMapper {
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
"COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
"CASE WHEN NOT " + PLAN_ONLY + " THEN COALESCE((SELECT SUM(pos.available_packages) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id),0) " +
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count &gt; 0 THEN a.picked_package_count " +
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
"SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
@@ -108,12 +123,13 @@ public interface DispatchCandidateMapper {
"<if test='orderNo != null and orderNo != \"\"'> AND (o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%'))</if> " +
"<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
"<if test='sourceStage != null and sourceStage != \"\"'> AND " +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
"AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " +
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " +
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
"WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' " +
@@ -150,7 +166,7 @@ public interface DispatchCandidateMapper {
@Param("fbaWarehouseCode") String fbaWarehouseCode,@Param("sourceStage") String sourceStage,@Param("draftToken") String draftToken);
String SELECTED_PLAN_SQL = "<script>" +
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
"SELECT p.plan_id AS planId,p.id AS planPalletId,CASE WHEN " + PLAN_ONLY + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId," +
"MIN(l.order_id) AS orderId," +
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
@@ -161,16 +177,16 @@ public interface DispatchCandidateMapper {
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," +
"MIN(ip.container_no) AS containerNo," +
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
"AND pos.available_packages &gt; 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')," +
"(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " +
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS overseasWarehouseId," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_code) " +
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_code) " +
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0) " +
"ELSE MIN(o.overseas_warehouse_code) END AS overseasWarehouseCode," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " +
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_name) " +
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0) " +
"ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
@@ -178,11 +194,11 @@ public interface DispatchCandidateMapper {
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
"COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
"CASE WHEN NOT " + PLAN_ONLY + " THEN COALESCE((SELECT SUM(pos.available_packages) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count &gt; 0 THEN a.picked_package_count " +
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
"SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
@@ -216,7 +232,8 @@ public interface DispatchCandidateMapper {
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " +
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " +
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
@@ -284,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 " +
@@ -0,0 +1,65 @@
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.annotations.*;
import java.util.*;
import java.util.stream.Collectors;
/** 四种来源共用数据库分页;页外来源不进入测量、完整组和预约的逐组装配。 */
@Mapper
public interface DispatchCandidatePageMapper {
/** 统计相同筛选下的来源行,不把当前页条数当全仓总数。 */
@SelectProvider(type=Sql.class,method="count")
long count(@Param("query")CandidateQuery query);
/** 稳定排序后在数据库限页,完整组可包含页外成员。 */
@SelectProvider(type=Sql.class,method="page")
List<DispatchPalletCandidate> page(@Param("query")CandidateQuery query,@Param("offset")long offset);
/** 复用既有资格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(","));
private static final String BINDINGS="<bind name='customerId' value='null'/><bind name='relabelNo' value='null'/>"
+"<bind name='planIds' value='query.planIds'/><bind name='orderNo' value='query.orderNo'/>"
+"<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,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(boolean count) {
var planFields=new HashSet<>(Arrays.asList(FIELDS.split(",")));planFields.removeAll(PLAN_MISSING);
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,count)+"</if>"
+"<if test=\"sourceStage == null or sourceStage == 'RECEIVED' or sourceStage == 'RELABEL'\"> UNION ALL "
+project(DispatchCandidateMapper.RELABEL_BODY,planFields,count)+" UNION ALL "+project(DispatchCandidateMapper.NATIVE_RELABEL_BODY,NATIVE_FIELDS,count)+"</if></if>";
}
/** 仓库页签也是服务端筛选,分页后不能再用本地筛选伪装完整结果。 */
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(true)+"</script>";}
/** LIMIT发生在任何逐组Java装配之前,最后补来源身份列以保证同名来源不乱序。 */
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>";}
}
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.dispatch.mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDate;
/** 排车日期流水的原子分配与连接级结果读取,必须加入同一个短事务。 */
@Mapper
public interface DispatchNumberSequenceMapper {
/** 日期主键串行递增,同时在当前数据库连接记住本次实际分配值。 */
@Insert("INSERT INTO ll_dispatch_daily_sequence(business_date,last_sequence) VALUES(#{date},LAST_INSERT_ID(1)) "
+ "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)")
int allocate(@Param("date") LocalDate date);
/** 连接级值不能使用会话或二级查询缓存,须读取本事务刚刚分配的值。 */
@Select("SELECT LAST_INSERT_ID()")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
Long allocatedValue();
}
@@ -11,6 +11,8 @@ public interface DispatchSourceMapper {
long allocatedPackages, long pickedPackages, long version) { }
record Receipt(long id, long orderId, long inventoryPositionId, long packages, String inboundCode) { }
record ReturnedReceipt(long id,long orderId,long inventoryPositionId,long packages) { }
record PalletReceipt(long palletId,long id,long orderId,long inventoryPositionId,long packages,String inboundCode) { }
record PalletReturnedReceipt(long palletId,long id,long orderId,long inventoryPositionId,long packages) { }
record Claim(Long id, Long dispatchId, String draftToken) { }
record RelabelBasis(String status,String fulfillmentStatus,LocalDateTime updatedAt,LocalDateTime customerConfirmedAt) { }
record NativeApplication(String customerId,String relabelNo) { }
@@ -112,6 +114,27 @@ public interface DispatchSourceMapper {
@Select("SELECT r.id,r.order_id AS orderId,r.inventory_position_id AS inventoryPositionId,r.received_packages AS packages FROM ll_return_stock_receipt r JOIN ll_inventory_position p ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id=#{pallet} ORDER BY r.id")
List<ReturnedReceipt> returnedReceipts(@Param("customer")String customer,@Param("pallet")Long pallet);
/** 同客户整页实托一次读齐库存版本,仍保留原仓身份供服务校验。 */
@Select("<script>SELECT id,balance_id AS balanceId,pallet_id AS palletId,overseas_warehouse_id AS warehouseId,"
+ "available_packages AS availablePackages,hold_packages AS holdPackages,allocated_packages AS allocatedPackages,"
+ "picked_packages AS pickedPackages,version FROM ll_inventory_position WHERE customer_id=#{customer} AND pallet_id IN "
+ "<foreach collection='pallets' item='pallet' open='(' separator=',' close=')'>#{pallet}</foreach> ORDER BY id</script>")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
List<Stock> stocks(@Param("customer")String customer,@Param("pallets")List<Long> pallets);
/** 批读保留托盘身份和原实收顺序,拆分后与单托版本依据完全相同。 */
@Select("<script>SELECT warehouse_pallet_id AS palletId,id,order_id AS orderId,inventory_position_id AS inventoryPositionId,"
+ "received_packages AS packages,inbound_code AS inboundCode FROM ll_inbound_stock_receipt WHERE customer_id=#{customer} AND warehouse_pallet_id IN "
+ "<foreach collection='pallets' item='pallet' open='(' separator=',' close=')'>#{pallet}</foreach> ORDER BY id</script>")
List<PalletReceipt> palletReceipts(@Param("customer")String customer,@Param("pallets")List<Long> pallets);
/** 退回实收按客户与当前实托位置批读,不混入另一客户的同托记录。 */
@Select("<script>SELECT p.pallet_id AS palletId,r.id,r.order_id AS orderId,r.inventory_position_id AS inventoryPositionId,"
+ "r.received_packages AS packages FROM ll_return_stock_receipt r JOIN ll_inventory_position p "
+ "ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id IN "
+ "<foreach collection='pallets' item='pallet' open='(' separator=',' close=')'>#{pallet}</foreach> ORDER BY r.id</script>")
List<PalletReturnedReceipt> palletReturnedReceipts(@Param("customer")String customer,@Param("pallets")List<Long> pallets);
/** 只使本客户本完整来源键的到期草稿成员失效,物理键由独立物理层管理。 */
@Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=NULL,updated_at=#{at} "
+ "WHERE status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} AND customer_id=#{customer} AND source_pallet_key=#{key}")
@@ -12,7 +12,8 @@ public final class DispatchPlanningModels {
private DispatchPlanningModels() { }
public record CandidateQuery(String warehouseId, List<Long> planIds, String orderNo,
String destinationWarehouseCode, String sourceStage, String draftToken) { }
String destinationWarehouseCode, String sourceStage, String draftToken,
int page, int size, String warehouseTab) { }
/** sourceVersion为服务端事实摘要,不能用客户端时间或固定零替代。 */
public record Candidate(DispatchCommands.CargoSelection selection, String sourceKey, String sourceVersion,
@@ -36,7 +37,9 @@ public final class DispatchPlanningModels {
public record SelectionGroupPallet(Long warehousePalletId, String palletNo, List<String> sourceKeys,
long packageCount) { }
public record CandidateList(List<Candidate> candidates, Instant snapshotAt, boolean complete) { }
/** complete只在当前响应包含筛选全集时为true;组内complete独立保证跨页完整来源。 */
public record CandidateList(List<Candidate> candidates, Instant snapshotAt, boolean complete,
long total, int page, int size, long pages) { }
public record DraftContainer(Long draftContainerId, int sequenceNo, int capacity,
List<String> sourceKeys) { }
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.dispatch.repository;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.mapper.DispatchNumberSequenceMapper;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
/** 整车单号独立日流水;独立短事务归还序列锁后才进入车辆、司机及货物锁。 */
@Repository
public class DispatchNumberSequence {
private final DispatchNumberSequenceMapper mapper;
/** 流水与排车主单使用相同服务数据库。 */
public DispatchNumberSequence(DispatchNumberSequenceMapper mapper) { this.mapper = mapper; }
/** 唯一日期行原子递增,连接内取回本次值;失败不复用已分配的业务号。 */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public long next(LocalDate date) {
// 两个实际 Mapper 操作加入本次 REQUIRES_NEW,连接级结果不跨连接也不读缓存。
mapper.allocate(date);
Long value = mapper.allocatedValue();
if (value == null || value < 1) throw new IllegalStateException("排车日流水分配失败");
if (value > 9999) throw new BizException(ResultCode.CONFLICT, "当日排车单四位流水号已用满");
return value;
}
}
@@ -11,6 +11,13 @@ import java.util.List;
/** 真实货物来源的数据访问封装,不在此层决定阶段或审核。 */
@Repository
public class DispatchSourceRepository {
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.mapper.DispatchCandidatePageMapper pages;
/** 筛选全集的数据库行数,与分页使用同一只读事务。 */
public long candidateCount(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery query){return pages.count(query);}
/** 只返回当前页来源,禁止先全量装配再截取。 */
public List<DispatchPalletCandidate> candidatePage(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery query){
return pages.page(query,Math.multiplyExact((long)query.page()-1,query.size()));
}
private final DispatchCandidateMapper plans;
private final ReturnDispatchCandidateMapper returns;
private final DispatchSourceMapper source;
@@ -75,6 +82,24 @@ public class DispatchSourceRepository {
public List<DispatchSourceMapper.Receipt> receipts(String customer, Long pallet) { return source.receipts(customer,pallet); }
/** 同一实托的退回实收不得从来源证明丢失。 */
public List<DispatchSourceMapper.ReturnedReceipt> returnedReceipts(String customer,Long pallet){return source.returnedReceipts(customer,pallet);}
/** 一条来源的库存与实收版本依据;无实托的在途计划三项均为空。 */
public record SourceFacts(List<DispatchSourceMapper.Stock> stock,List<DispatchSourceMapper.Receipt> receipts,
List<DispatchSourceMapper.ReturnedReceipt> returnedReceipts) { }
/** 同客户的候选托盘只用三次查询;拆分顺序与原单托查询一致,不跨请求缓存。 */
public java.util.Map<Long,SourceFacts> sourceFacts(String customer,List<Long> pallets) {
if(pallets.isEmpty())return java.util.Map.of();
var stocks=source.stocks(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy(DispatchSourceMapper.Stock::palletId));
var receipts=source.palletReceipts(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy(
DispatchSourceMapper.PalletReceipt::palletId,java.util.stream.Collectors.mapping(
row->new DispatchSourceMapper.Receipt(row.id(),row.orderId(),row.inventoryPositionId(),row.packages(),row.inboundCode()),java.util.stream.Collectors.toList())));
var returned=source.palletReturnedReceipts(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy(
DispatchSourceMapper.PalletReturnedReceipt::palletId,java.util.stream.Collectors.mapping(
row->new DispatchSourceMapper.ReturnedReceipt(row.id(),row.orderId(),row.inventoryPositionId(),row.packages()),java.util.stream.Collectors.toList())));
var result=new java.util.LinkedHashMap<Long,SourceFacts>();
for(var pallet:pallets)result.put(pallet,new SourceFacts(List.copyOf(stocks.getOrDefault(pallet,List.of())),
List.copyOf(receipts.getOrDefault(pallet,List.of())),List.copyOf(returned.getOrDefault(pallet,List.of()))));
return java.util.Collections.unmodifiableMap(result);
}
/** 释放已到期草稿键。 */
public int expire(String customer, String key, LocalDateTime at) { return source.expire(customer,key,at); }
/** 锁定当前活动资源占用。 */
@@ -32,6 +32,8 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
/** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */
@Service
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort {
private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class);
@jakarta.annotation.Resource private DispatchNumberGenerator numbers;
@jakarta.annotation.Resource private DispatchAppointmentAllocations appointments;
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
@@ -146,11 +148,17 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
requirePlatform(actor);
if(query==null || query.warehouseId()==null || !query.warehouseId().matches("[1-9][0-9]*")) throw bad("请选择真实海外起运仓");
if(query.planIds()!=null && (query.planIds().size()>500 || query.planIds().stream().anyMatch(id->id==null || id<=0))) throw bad("计划筛选不合法");
if(query.page()<1 || query.size()<1 || query.size()>50 || !Set.of("ALL","LAX","NYC").contains(Objects.toString(query.warehouseTab(),"")))throw bad("候选分页或仓库页签不合法");
var normalized=new DispatchPlanningModels.CandidateQuery(query.warehouseId(),query.planIds(),trim(query.orderNo()),
trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken()));
var rows=sourceRules.wholeCandidates(actor.operatorId(),normalized);
trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken()),query.page(),query.size(),query.warehouseTab());
long started=System.nanoTime();
var page=sourceRules.candidatePage(actor.operatorId(),normalized);
var rows=page.rows();
long candidatesRead=System.nanoTime();
var groups=sourceRules.selectionGroups(rows);
long groupsRead=System.nanoTime();
var batchFacts=appointments.candidates(rows);
long batchesRead=System.nanoTime();
var result=new ArrayList<DispatchPlanningModels.Candidate>();
for(var row:rows) {
SourceType type=Set.of("RELABEL","RETURN_RELABEL").contains(Objects.toString(row.sourceStage(),""))?SourceType.RELABEL:
@@ -180,7 +188,15 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
row.overseasWarehouseCode(),row.overseasWarehouseName(),row.orderNo(),row.inboundCode(),row.planNo(),row.palletNo(),row.containerNo(),
destination,row.packageCount()==null?0:row.packageCount(),row.grossWeightKg(),row.volumeCbm(),"SOURCE_SUGGESTION",List.copyOf(references),List.copyOf(reasons),group));
}
return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),true);
long finished=System.nanoTime();
// 慢查询只记录阶段耗时和条数,不记录客户、订单、仓库或草稿凭据。
if(finished-started>=1_000_000_000L)LOG.warn(
"dispatch_candidates_slow count={} candidatesMs={} groupsMs={} appointmentsMs={} assembleMs={} totalMs={}",
rows.size(),(candidatesRead-started)/1_000_000L,(groupsRead-candidatesRead)/1_000_000L,
(batchesRead-groupsRead)/1_000_000L,(finished-batchesRead)/1_000_000L,(finished-started)/1_000_000L);
return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),
normalized.page()==1 && page.total()<=normalized.size(),page.total(),normalized.page(),normalized.size(),
(page.total()+normalized.size()-1)/normalized.size());
}
/** 作业弹框读取真实明细ID和来源,不能从订单展示键或仓托ID推断删除身份。 */
@@ -269,7 +285,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var replay = commands.replay(actor,null,identity);
if (replay.isPresent()) return replay.get();
long id=IdWorker.getId();
String number="DS-"+UUID.randomUUID().toString().replace("-","").toUpperCase(Locale.ROOT);
String number=numbers.next();
var shell=fleet.createDispatchShell(actor.operatorId(),new FleetExecutionModels.DispatchShellRequest(id,number,
Long.parseLong(request.warehouseId()),request.idempotencyKey(),identity.commandHash(),request.plannedDepartureAt(),
fleetVehicle(request.vehicle()),fleetDriver(request.driver()),request.remark()));
@@ -508,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("请确认本车全部实托测量");
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.repository.DispatchNumberSequence;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
/** 平台排车单号:PC、北京时间日期与每日独立四位流水。 */
@Component
public class DispatchNumberGenerator {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private final DispatchNumberSequence sequences;
private final Clock clock;
/** 正式运行按统一业务时区取当天日期。 */
@Autowired
public DispatchNumberGenerator(DispatchNumberSequence sequences) { this(sequences, Clock.systemUTC()); }
/** 固定时钟用于核实跨午夜归零边界。 */
DispatchNumberGenerator(DispatchNumberSequence sequences, Clock clock) { this.sequences = sequences; this.clock = clock; }
/** 已有命令重放由上层账本恢复原号,本方法只用于首次建单。 */
public String next() {
LocalDate date = LocalDate.now(clock.withZone(BUSINESS_ZONE));
long value = sequences.next(date);
if (value < 1 || value > 9999) throw new IllegalStateException("排车日流水不在四位范围内");
return "PC" + date.format(DateTimeFormatter.BASIC_ISO_DATE) + String.format(Locale.ROOT, "%04d", value);
}
}
@@ -16,6 +16,8 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
@Service
public class DispatchPdaQueryService {
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository sourceOrigins;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository sources;
@jakarta.annotation.Resource private DispatchService sourceRules;
private final DispatchPdaReadRepository repository;private final DispatchAggregateService workflow;
/** 共用主流程资格,PDA不维护第二套版本和阶段。 */
public DispatchPdaQueryService(DispatchPdaReadRepository repository,DispatchAggregateService workflow){this.repository=repository;this.workflow=workflow;}
@@ -28,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));
@@ -59,6 +61,8 @@ public class DispatchPdaQueryService {
var marks=Arrays.stream(Objects.requireNonNullElse(item.getCartonMarks(),"").split(",")).map(String::trim).filter(s->!s.isEmpty()).collect(Collectors.toSet());
var sourcePositions=positions.getOrDefault(item.getCustomerId()+":"+item.getWarehousePalletId(),List.of()).stream()
.map(p->new DispatchViews.Position(p.getId(),p.getBalanceId(),n(p.getAvailablePackages()),n(p.getAllocatedPackages()),n(p.getPickedPackages()))).toList();
if(item.getWarehousePalletId()==null && item.getPlanPalletId()!=null)
sourcePositions=receivedPlanPositions(actor,item,items);
if(normalRelabel(item)) {
var contributions=byItem.getOrDefault(item.getId(),List.of());var ownPositions=new ArrayList<DispatchViews.Position>();
if(contributions.isEmpty() || contributions.stream().mapToLong(s->n(s.getPackageCount())).sum()!=n(item.getPackageCount()))sourceValid=false;
@@ -89,6 +93,39 @@ public class DispatchPdaQueryService {
}
return List.copyOf(result);
}
/** 预排首次扫码前只解析原计划实收仓位,不在GET补写实托;真正绑定仍由pick锁内重验。 */
private List<DispatchViews.Position> receivedPlanPositions(Actor actor,DispatchItem item,List<DispatchItem> items) {
if(!Objects.equals(actor.boundWarehouseId(),item.getOverseasWarehouseId()))return List.of();
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
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())
|| !Objects.equals(actual.destinationType(),item.getDestinationType())
|| !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode())
|| !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))return List.of();
var originalOrders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId());
if(originalOrders.isEmpty() || !new HashSet<>(originalOrders).containsAll(
sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return List.of();
// 同托跨客户、换标及整申请来源必须全部已在原车;不能把新合托货物暴露为原计划可拣库存。
var group=sourceRules.selectionGroup(actor.boundWarehouseId(),new DispatchCommands.CargoSelection(
SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null));
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())
|| n(selected.getPackageCount())!=member.packages())return List.of();
}
var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false);
if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),actor.boundWarehouseId())
|| !Objects.equals(p.palletId(),actual.warehousePalletId()))
|| stock.stream().mapToLong(p->p.availablePackages()).sum()!=n(item.getPackageCount()))return List.of();
return stock.stream().filter(p->p.availablePackages()>0)
.map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList();
}
/** 仓和操作者均来自受信身份,不接收请求自报。 */
private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");}
/** 缺失计数不作为业务标识使用。 */
@@ -4,6 +4,7 @@ import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository.SourceFacts;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
@@ -34,16 +35,64 @@ public class DispatchService {
/** 同一完整组只解析一次,隐藏的真实成员也随服务器组返回。 */
public Map<String,SelectionGroup> selectionGroups(List<DispatchPalletCandidate> rows) {
var reads=new CandidateReadScope(rows);
var result=new LinkedHashMap<String,SelectionGroup>();
for(var row:rows) {
String key=wholeSourceKey(selection(row,null));
if(result.containsKey(key))continue;
var group=sourceGroup(row,false);
var group=sourceGroup(row,false,reads);
for(var source:group.view().sources())result.put(source.sourceKey(),group.view());
}
return Collections.unmodifiableMap(result);
}
/** 仅当前候选事务内批读普通来源;写入和锁内校验继续逐组读取当前事实。 */
private final class CandidateReadScope {
private final Map<String,Set<Long>> loaded=new HashMap<>();
private final Map<String,Map<Long,List<DispatchPalletCandidate>>> available=new HashMap<>();
private final Map<String,Map<Long,SourceFacts>> sourceFacts=new HashMap<>();
/** 同页同仓托盘一次读取,避免每一行再次执行完整计划和退回来源投影。 */
private CandidateReadScope(List<DispatchPalletCandidate> rows) {
var pallets=new TreeMap<String,Set<Long>>();
for(var row:rows)if(row.warehousePalletId()!=null)
pallets.computeIfAbsent(row.overseasWarehouseId(),key->new TreeSet<>()).add(row.warehousePalletId());
pallets.forEach((warehouse,ids)->read(warehouse,List.copyOf(ids)));
loadFacts(rows);
}
/** 换标闭包发现页外实托时补读缺失集合,不拿筛选后的页面充当完整组成。 */
private List<DispatchPalletCandidate> read(String warehouse,List<Long> pallets) {
var known=loaded.computeIfAbsent(warehouse,key->new HashSet<>());
var byPallet=available.computeIfAbsent(warehouse,key->new HashMap<>());
var missing=pallets.stream().filter(id->!known.contains(id)).distinct().sorted().toList();
if(!missing.isEmpty()) {
var rows=wholeSources.physicalAvailable(warehouse,missing);
for(var row:rows)
byPallet.computeIfAbsent(row.warehousePalletId(),key->new ArrayList<>()).add(row);
loadFacts(rows);
known.addAll(missing);
}
return pallets.stream().flatMap(id->byPallet.getOrDefault(id,List.of()).stream()).toList();
}
/** 只补齐本请求尚未读取的客户实托,隐藏组成员也必须有完整版本依据。 */
private void loadFacts(Collection<DispatchPalletCandidate> rows) {
var missing=new TreeMap<String,Set<Long>>();
for(var row:rows)if(row.warehousePalletId()!=null
&& !sourceFacts.getOrDefault(row.customerId(),Map.of()).containsKey(row.warehousePalletId()))
missing.computeIfAbsent(row.customerId(),key->new TreeSet<>()).add(row.warehousePalletId());
missing.forEach((customer,pallets)->sourceFacts.computeIfAbsent(customer,key->new HashMap<>())
.putAll(wholeSources.sourceFacts(customer,List.copyOf(pallets))));
}
/** 在途计划没有库存身份,不向数据库发出三个必为空的实收查询。 */
private SourceFacts facts(DispatchPalletCandidate row) {
return row.warehousePalletId()==null?new SourceFacts(List.of(),List.of(),List.of()):
Objects.requireNonNull(sourceFacts.getOrDefault(row.customerId(),Map.of()).get(row.warehousePalletId()),"实托版本依据缺失");
}
}
/** 草稿单次操作先从实际入口解析完整组,不让前端循环单来源凑整托。 */
public SelectionGroup selectionGroup(String warehouse,DispatchCommands.CargoSelection requested) {
var row=readSelection(warehouse,requested);return sourceGroup(row,false).view();
@@ -78,6 +127,11 @@ public class DispatchService {
/** 实托→申请→整申请实托求固定点;未证实来源只阻断,不补造第一客户或伪业务号。 */
private SourceGroup sourceGroup(DispatchPalletCandidate seed,boolean locked) {
return sourceGroup(seed,locked,null);
}
/** 只读候选复用同事务批读,单组和写入路径不复用候选读取作用域。 */
private SourceGroup sourceGroup(DispatchPalletCandidate seed,boolean locked,CandidateReadScope reads) {
var rows=new TreeMap<String,DispatchPalletCandidate>();rows.put(wholeSourceKey(selection(seed,null)),seed);
var pallets=new TreeSet<Long>();if(seed.warehousePalletId()!=null)pallets.add(seed.warehousePalletId());
var applications=new TreeMap<String,String>();var loaded=new HashSet<String>();var reasons=new LinkedHashSet<String>();
@@ -111,7 +165,8 @@ public class DispatchService {
// 原生退货的资格由其真实箱生产者负责;普通位置提供者明确提示走原生分支。
reasons.removeIf(reason->nativeNos.stream().anyMatch(no->reason.startsWith(no+":")));
if(!pallets.isEmpty()) {
var available=wholeSources.physicalAvailable(seed.overseasWarehouseId(),List.copyOf(pallets));
var available=reads==null?wholeSources.physicalAvailable(seed.overseasWarehouseId(),List.copyOf(pallets)):
reads.read(seed.overseasWarehouseId(),List.copyOf(pallets));
if(available.size()>1000)reasons.add("实托可用来源超过完整核验上限");
var buckets=available.stream().filter(r->r.packageCount()!=null && r.packageCount()>0).collect(Collectors.groupingBy(r->r.customerId()+":"+r.warehousePalletId(),TreeMap::new,Collectors.toList()));
for(var entry:buckets.entrySet()) {
@@ -132,7 +187,8 @@ public class DispatchService {
reasons.add("实托还有未完整选择或重复计入的真实可用库存来源");
}
}
var bases=new TreeMap<String,String>();for(var entry:rows.entrySet())bases.put(entry.getKey(),baseSourceVersion(entry.getValue(),entry.getKey(),false));
if(reads!=null)reads.loadFacts(rows.values());
var bases=new TreeMap<String,String>();for(var entry:rows.entrySet())bases.put(entry.getKey(),baseSourceVersion(entry.getValue(),entry.getKey(),reads));
String version=wholeHashes.source(Arrays.asList(bases,facts==null?null:facts.compositionVersion(),pallets));
var versions=new TreeMap<String,String>();bases.forEach((key,basis)->versions.put(key,wholeHashes.source(List.of(basis,version))));
var sources=new ArrayList<SelectionGroupSource>();
@@ -156,32 +212,22 @@ public class DispatchService {
public record WholeSource(DispatchPalletCandidate candidate,
com.baiyue.lastleg.dispatch.port.DispatchCommands.CargoSelection selection, String sourceKey, String sourceVersion) { }
/** 新公开候选一次读取同仓多客户;草稿排除只能使用当前操作者实际拥有的草稿。 */
public List<DispatchPalletCandidate> wholeCandidates(String operator,
com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery query) {
/** 总数与当前页同事务读取;页外候选不参与逐组装配。 */
public record CandidatePage(List<DispatchPalletCandidate> rows,long total) { }
/** 草稿所有权先验证,数据库分页后才读取测量和原生退货物理身份。 */
public CandidatePage candidatePage(String operator,CandidateQuery query) {
validateSourceStage(query.sourceStage());
if(query.draftToken()!=null && drafts.findActiveByOperator(operator).stream()
.noneMatch(d -> query.draftToken().equals(d.getDraftToken())))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"不能读取其他操作者的草稿来源");
var result=new java.util.ArrayList<DispatchPalletCandidate>();
if(!"RELABEL".equals(query.sourceStage())) result.addAll(wholeSources.availablePlans(query));
if(query.planIds()==null || query.planIds().isEmpty()) {
if(planIdsForReturns(query.sourceStage())) result.addAll(wholeSources.availableReturns(query));
if(query.sourceStage()==null || Set.of("RECEIVED","RELABEL").contains(query.sourceStage())) {
var rows=wholeSources.availableRelabel(query);
if(rows.size()>1000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"候选来源超过1000项,请缩小筛选范围");
result.addAll(measuredRelabelCandidates(rows));
result.addAll(returnRelabelStock.wholeCandidates(query.warehouseId(),query.orderNo()));
}
}
if(result.size()>1000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"候选来源超过1000项,请缩小筛选范围");
return result.stream().filter(row -> query.destinationWarehouseCode()==null
|| query.destinationWarehouseCode().equals(row.fbaWarehouseCode()))
.sorted(java.util.Comparator.comparing((DispatchPalletCandidate row) -> Objects.toString(row.customerId(),""))
.thenComparing(row -> Objects.toString(row.orderNo(),""))
.thenComparing(row -> Objects.toString(row.palletNo(),""))).toList();
long total=wholeSources.candidateCount(query);
var rows=wholeSources.candidatePage(query);
measuredRelabelCandidates(rows.stream().filter(row->"RELABEL".equals(row.sourceStage()) && !row.returnCartonSource()).toList());
var nativeRows=rows.stream().filter(DispatchPalletCandidate::returnCartonSource).toList();
if(!nativeRows.isEmpty())returnRelabelStock.resolvePage(nativeRows);
return new CandidatePage(List.copyOf(rows),total);
}
/** 新主流程按来源真客户解析并先锁全部来源订单,最后锁库存;调用者已持整车父锁。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public List<WholeSource> lockWholeSources(String warehouseId,
@@ -399,7 +445,7 @@ public class DispatchService {
}
/** 单来源基础摘要不递归包含组版本,组版本再绑定全部成员基础摘要。 */
private String baseSourceVersion(DispatchPalletCandidate row,String key,boolean lockStock) {
private String baseSourceVersion(DispatchPalletCandidate row,String key,CandidateReadScope reads) {
var facts = new java.util.TreeMap<String,Object>();
facts.put("sourceKey",key); facts.put("customerId",row.customerId()); facts.put("warehouseId",row.overseasWarehouseId());
facts.put("planPalletId",row.planPalletId()); facts.put("returnReceiptId",row.returnReceiptId());
@@ -411,12 +457,15 @@ public class DispatchService {
facts.put("grossWeightKg",row.grossWeightKg()); facts.put("volumeCbm",row.volumeCbm());
facts.put("cartonMarks",row.relabelCartonMarks());
if(key.startsWith("RELABEL:")) facts.put("relabelBasis",wholeSources.relabelBasis(key.substring(8,key.lastIndexOf(':'))));
var stock=wholeSources.stock(row.customerId(),row.warehousePalletId(),lockStock);
var basis=row.warehousePalletId()==null?new SourceFacts(List.of(),List.of(),List.of()):
reads!=null?reads.facts(row):new SourceFacts(wholeSources.stock(row.customerId(),row.warehousePalletId(),false),
wholeSources.receipts(row.customerId(),row.warehousePalletId()),wholeSources.returnedReceipts(row.customerId(),row.warehousePalletId()));
var stock=basis.stock();
if(stock.stream().anyMatch(position->!Objects.equals(row.overseasWarehouseId(),position.warehouseId())))
throw new ResponseStatusException(HttpStatus.CONFLICT,"实托库存起运仓不一致,请核对来源");
facts.put("stock",stock);
facts.put("receipts",wholeSources.receipts(row.customerId(),row.warehousePalletId()));
facts.put("returnedReceipts",wholeSources.returnedReceipts(row.customerId(),row.warehousePalletId()));
facts.put("receipts",basis.receipts());
facts.put("returnedReceipts",basis.returnedReceipts());
return wholeHashes.source(facts);
}
@@ -27,11 +27,9 @@ public class ReturnRelabelDispatchStock {
return resolve(candidates.nativeReturnRelabelAvailable(customer,orderNo,relabelNo));
}
/** 整车候选只解析已经按仓限定且有界的原生退货记录。 */
public List<DispatchPalletCandidate> wholeCandidates(String warehouse,String orderNo) {
var rows=candidates.wholeNativeRelabelAvailable(warehouse,null,orderNo,null);
if(rows.size()>1000) throw conflict("候选来源超过1000项,请缩小筛选范围");
return resolve(rows);
/** 分页后仅核实本页原生退货实托;身份失效明确拒绝,不能静默缩页并返回错误总数。 */
public void resolvePage(List<DispatchPalletCandidate> rows) {
if(resolve(rows).size()!=rows.size())throw conflict("当前页退货托盘的实际仓或状态已变化,请核对后重查");
}
/** 物理托盘身份必须由原提供者核实,不能用显示托盘码伪造ID。 */
@@ -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());
@@ -21,13 +21,13 @@ import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*;
@Service
public class FleetPoolOrderBridge {
private final FleetPoolOrderRepository repository;
private final FleetPoolOrderNumberGenerator numbers;
private final com.baiyue.lastleg.service.LastlegDocumentNumberGenerator numbers;
private final ObjectMapper json;
private final FleetPoolDeliveryEvidence deliveryEvidence;
private final com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources;
/** 注入同库商业委托仓储及履约协作服务。 */
public FleetPoolOrderBridge(FleetPoolOrderRepository repository,
FleetPoolOrderNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence,
com.baiyue.lastleg.service.LastlegDocumentNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence,
com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources) {
this.repository=repository;
this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence;
@@ -50,7 +50,7 @@ public class FleetPoolOrderBridge {
var now=LocalDateTime.now();var row=new FleetPoolOrderLink();
row.setId(IdWorker.getId());row.setBookingId(in.bookingId());row.setBookingNo(in.bookingNo());
row.setCustomerId(in.customerId());row.setBusinessType(in.businessType());row.setBusinessOrderId(IdWorker.getId());
row.setBusinessOrderNo(numbers.next("US_TRUCK".equals(in.businessType())?"TRUCK":"LOOSEBOARD",in.destinationLabel(),row.getBusinessOrderId()));
row.setBusinessOrderNo(numbers.next("US_TRUCK".equals(in.businessType())?"TR":"LB"));
row.setWarehouseId(in.warehouseId());row.setDestinationKey(in.destinationKey());
row.setDeliveryMode(in.deliveryMode());row.setDeliveryInstructions(in.deliveryInstructions());
row.setFulfillmentStage("WAITING_INBOUND");row.setStageOccurredAt(now);row.setRecordVersion(0L);
@@ -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,7 @@ import java.util.*;
@Service
public class FleetCapacityOfferService {
@jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers;
public static final String LIST="platform:fleet:capacity-offer:list", MANAGE="platform:fleet:capacity-offer:manage";
private final FleetCapacityOfferMapper offers;
private final FleetCapacityOfferCommandMapper commands;
@@ -125,6 +126,7 @@ public class FleetCapacityOfferService {
var row=offer(number,false);requireVisible(row);customers.required(query.customerId());
return bookingService.quoteCandidates(row.getTripNo(),query);
}
/** 发布车队运力并分配 FO 日流水,重放复用原发布记录。 */
@Transactional
public FleetCapacityOfferModels.OfferResult create(FleetCapacityOfferModels.Write request){
requireManage();var trip=trip(request.tripNo(),true);
@@ -133,7 +135,7 @@ public class FleetCapacityOfferService {
tripVersion(trip,request.tripRecordVersion());
if(offers.byTrip(trip.getId())!=null)throw conflict("该车次已有发布,请从既有发布修改");
validateCutoff(trip,request.cutoffAt());
var row=new FleetCapacityOfferEntity();row.setId(IdWorker.getId());row.setOfferNo("FO-"+row.getId());row.setTripId(trip.getId());row.setTripNo(trip.getTripNo());
var row=new FleetCapacityOfferEntity();row.setId(IdWorker.getId());row.setOfferNo(documentNumbers.next("FO"));row.setTripId(trip.getId());row.setTripNo(trip.getTripNo());
row.setStatus("DRAFT");row.setRecordVersion(0L);row.setCreatedBy(operator());row.setCreatedAt(now());apply(row,request);
if(offers.insert(row)!=1)throw conflict("发布创建失败");
var result=new FleetCapacityOfferModels.OfferResult(project(row,trip),false);saveCommand(trip,"CREATE",trip.getTripNo(),request.idempotencyKey(),request,result);return result;
@@ -27,6 +27,7 @@ import java.util.*;
@Service
public class FleetTripService {
@jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers;
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workChanges;
@jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations;
private final FleetTripRepository repository;
@@ -107,7 +108,7 @@ public class FleetTripService {
var warehouse = warehouses.required(request.warehouseId(), "OVERSEAS");
var trip = new FleetTripEntity();
trip.setId(IdWorker.getId());
trip.setTripNo("TR-" + new java.math.BigInteger(UUID.randomUUID().toString().replace("-", ""), 16).toString(36).toUpperCase(Locale.ROOT));
trip.setTripNo(documentNumbers.next("CT"));
trip.setIdempotencyKey(request.idempotencyKey()); trip.setRequestHash(request.commandHash());
trip.setStatus("PLANNED"); trip.setTripPurpose("DELIVERY"); trip.setWarehouseId(request.warehouseId());
trip.setWarehouseCode(warehouse.code()); trip.setWarehouseName(warehouse.name());
@@ -383,7 +384,7 @@ public class FleetTripService {
var warehouse = warehouses.required(request.warehouseId(), "OVERSEAS");
var trip = new FleetTripEntity();
trip.setId(IdWorker.getId());
trip.setTripNo("TR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT));
trip.setTripNo(documentNumbers.next("CT"));
trip.setIdempotencyKey(request.idempotencyKey()); trip.setRequestHash(hash);
trip.setTripPurpose(purpose); trip.setStatus("PLANNED"); trip.setWarehouseId(Long.valueOf(warehouse.id()));
trip.setWarehouseCode(warehouse.code()); trip.setWarehouseName(warehouse.name());
@@ -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);
@@ -18,10 +18,10 @@ public interface AppointmentWorkDispatchMapper {
record ReferenceRow(long dispatchId,String batchKey,String batchNo,Long recordVersion,String customerId,String sourceKey,String destinationKey,int packages) { }
record CandidateSource(long planPalletId,String customerId,String sourceStage,boolean locked,Long orderId,String destinationKey,Integer plannedPackages,Integer countedPackages) { }
record CandidateBatch(String batchKey,String batchNo,Long recordVersion,String customerId,Long orderId,String sourceKey,String destinationKey,Integer packages,long allocated) { }
/** 一次读取整页真实客户/托盘的逐票实物及全来源有效占用。 */
/** 一次读取整页逐票来源及占用;仅预留但未收货的托盘仍按计划箱数预排。 */
@Select("""
<script>
SELECT p.id plan_pallet_id,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END source_stage,
SELECT p.id plan_pallet_id,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0) THEN 'PLAN' ELSE 'RECEIVED' END source_stage,
EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id))
AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked,
l.order_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,
@@ -31,7 +31,7 @@ public interface AppointmentWorkDispatchMapper {
LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id
LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
WHERE <foreach collection='items' item='i' open='(' separator=' OR ' close=')'>(p.id=#{i.planPalletId} AND p.customer_id=#{i.customerId})</foreach>
GROUP BY p.id,p.customer_id,p.warehouse_pallet_id,l.order_id,l.destination_type,l.fba_warehouse_code
GROUP BY p.id,p.customer_id,p.warehouse_pallet_id,p.assignment_status,p.actual_package_count,l.order_id,l.destination_type,l.fba_warehouse_code
ORDER BY p.id,l.order_id,l.destination_type,l.fba_warehouse_code LIMIT 20001
</script>
""")
@@ -98,7 +98,7 @@ public interface AppointmentWorkDispatchMapper {
List<TransportRow> transportRows(@Param("batches") List<String> batches,@Param("trips") List<String> trips);
@Select("SELECT i.id item_id,i.plan_pallet_id,i.customer_id,i.source_stage,i.package_count packages FROM ll_dispatch_item i JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id WHERE d.dispatch_no=#{number}") List<DispatchPhysical> dispatchPhysical(String number);
@Select("SELECT id item_id,plan_pallet_id,customer_id,source_stage,package_count packages FROM ll_dispatch_item WHERE dispatch_order_id=#{id} ORDER BY id") List<DispatchPhysical> dispatchPhysicalById(long id);
@Select("SELECT DISTINCT p.id plan_pallet_id,p.pallet_no,p.plan_id,n.plan_no,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END source_stage,EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id)) AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan n ON n.id=p.plan_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id JOIN ll_inbound_fba_appointment_item i ON i.order_id=l.order_id AND i.customer_id=l.customer_id AND i.active=1 WHERE i.appointment_id=#{id} ORDER BY p.id LIMIT 501")
@Select("SELECT DISTINCT p.id plan_pallet_id,p.pallet_no,p.plan_id,n.plan_no,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0) THEN 'PLAN' ELSE 'RECEIVED' END source_stage,EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id)) AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan n ON n.id=p.plan_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id JOIN ll_inbound_fba_appointment_item i ON i.order_id=l.order_id AND i.customer_id=l.customer_id AND i.active=1 WHERE i.appointment_id=#{id} ORDER BY p.id LIMIT 501")
List<Candidate> candidates(long id);
@Select("SELECT l.order_id,l.customer_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,CASE WHEN COUNT(a.planned_package_count)=COUNT(a.line_id) THEN SUM(a.planned_package_count) END planned_packages,CASE WHEN COUNT(a.counted_package_count)=COUNT(a.line_id) THEN SUM(a.counted_package_count) END counted_packages FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id WHERE a.pallet_id=#{pallet} AND a.customer_id=#{customer} GROUP BY l.order_id,l.customer_id,l.destination_type,l.fba_warehouse_code")
List<Physical> physical(@Param("pallet") long pallet,@Param("customer") String customer);
@@ -63,7 +63,7 @@ public class LocalLooseboardService {
LocalLooseboardOrder order = new LocalLooseboardOrder();
order.setId(IdWorker.getId());
order.setCustomerId(customerId);
order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD", request.destination(), order.getId()));
order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD"));
apply(order, request.origin(),
request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(),
request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(),
@@ -277,7 +277,7 @@ public class LocalLooseboardService {
private LastlegOrder createLastlegOrder(LocalLooseboardOrder source, String requestHash, LocalDateTime now) {
LastlegOrder order = new LastlegOrder();
order.setId(IdWorker.getId());
order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD", source.getDestination(), order.getId()));
order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD"));
order.setCustomerId(source.getCustomerId());
order.setOrderType("LOCAL_LOOSEBOARD");
order.setInboundCode(null);
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/** 本服务业务编号序列,更新与读取必须处于同一事务连接。 */
@Mapper
public interface DocumentSequenceMapper {
/** 唯一序列键原子递增,并在连接内保存本次分配值。 */
@Insert("INSERT INTO ll_document_sequence(sequence_key,current_value) VALUES(#{key},LAST_INSERT_ID(1)) "
+ "ON DUPLICATE KEY UPDATE current_value=LAST_INSERT_ID(current_value+1)")
int allocate(@Param("key") String key);
/** 禁用一级和二级缓存,返回本事务刚刚分配的值。 */
@Select("SELECT LAST_INSERT_ID()")
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
Long allocatedValue();
}
@@ -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);
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.repository;
import com.baiyue.lastleg.mapper.DocumentSequenceMapper;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/** 各业务共用本库序列,独立短事务避免持有编号锁进入业务资源锁。 */
@Repository
public class DocumentSequenceRepository {
private final DocumentSequenceMapper mapper;
/** 只访问所属服务的编号表。 */
public DocumentSequenceRepository(DocumentSequenceMapper mapper) { this.mapper = mapper; }
/** 已分配号不因外层业务回滚而复用,允许失败请求产生断号。 */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public long next(String key) {
mapper.allocate(key);
Long value = mapper.allocatedValue();
if (value == null || value < 1) throw new IllegalStateException("业务编号分配失败");
return value;
}
}
@@ -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();
}
}
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.service;
import com.baiyue.common.core.number.DailyDocumentNumber;
import com.baiyue.lastleg.repository.DocumentSequenceRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.util.Set;
/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */
@Component
public class LastlegDocumentNumberGenerator {
private static final Set<String> PREFIXES = Set.of("IP", "CK", "DFCK", "HBCK", "THCK", "DL", "EX", "CD", "TB", "ZY", "TH", "THHB", "RR", "RB", "HX", "YY", "TR", "LB", "CG", "PR", "PJ", "PF", "PN", "PX", "PG", "CT", "FB", "FO", "VR");
private final DocumentSequenceRepository sequences;
private final Clock clock;
/** 正式运行使用统一业务时区。 */
@Autowired
public LastlegDocumentNumberGenerator(DocumentSequenceRepository sequences) {
this(sequences, Clock.system(DailyDocumentNumber.BUSINESS_ZONE));
}
/** 固定时钟用于跨日及前缀独立性验证。 */
LastlegDocumentNumberGenerator(DocumentSequenceRepository sequences, Clock clock) {
this.sequences = sequences;
this.clock = clock;
}
/** 只为本服务已登记的业务类型分配号码。 */
public String next(String prefix) {
if (!PREFIXES.contains(prefix)) throw new IllegalArgumentException("未登记的业务编号前缀:" + prefix);
return DailyDocumentNumber.next(prefix, clock, sequences::next);
}
}
@@ -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,11 +35,11 @@ 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 '当日已分配尾程单流水号',
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',
PRIMARY KEY(business_date)
) ENGINE=InnoDB COMMENT='尾程单号按日原子流水';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
CREATE TABLE IF NOT EXISTS ll_order (
id BIGINT NOT NULL COMMENT '雪花 ID',
@@ -3203,16 +3203,6 @@ CREATE TABLE IF NOT EXISTS ll_wallet_event_inbox (
CREATE TABLE IF NOT EXISTS ll_procurement_daily_sequence (
customer_id VARCHAR(128) NOT NULL COMMENT '客户ID',
business_date DATE NOT NULL COMMENT '北京时间业务日期',
sequence_value INT NOT NULL COMMENT '客户当日已分配最大序号',
PRIMARY KEY (customer_id,business_date),
CONSTRAINT chk_ll_procurement_sequence CHECK (sequence_value BETWEEN 1 AND 9999)
) ENGINE=InnoDB COMMENT='海外采购客户日序列';
CREATE TABLE IF NOT EXISTS ll_overseas_procurement_file (
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID',
category VARCHAR(16) NOT NULL COMMENT 'RECEIPT实收、VOUCHER费用、LABEL面单、PURCHASE采购、BUDGET预算、EXCEPTION异常凭证', file_id BIGINT NOT NULL COMMENT 'File服务文件ID',
@@ -3240,6 +3230,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_fee (
settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件',
source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID',
parent_fee_id BIGINT NULL COMMENT '原费用或预估引用',
idempotency_key VARCHAR(128) NULL COMMENT '退款请求幂等键,独立于业务单号',
request_hash CHAR(64) NULL COMMENT '确认请求摘要',
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID',
fee_no VARCHAR(64) NOT NULL COMMENT '费用业务号', fee_item_code VARCHAR(32) NOT NULL COMMENT '费用项编码', fee_item_name VARCHAR(128) NOT NULL COMMENT '费用项名称',
@@ -3250,6 +3241,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_fee (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')),
UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version),
UNIQUE KEY uk_ll_procurement_refund_request(order_id,purpose,idempotency_key),
PRIMARY KEY(id), UNIQUE KEY uk_ll_procurement_fee_no(fee_no), KEY idx_ll_procurement_fee_order(customer_id,order_id,created_at),
CONSTRAINT chk_ll_procurement_fee_amount CHECK(amount>=0), CONSTRAINT chk_ll_procurement_fee_currency CHECK(currency='CNY')
) ENGINE=InnoDB COMMENT='海外采购费用明细及资金生命周期';
@@ -4492,11 +4484,6 @@ CREATE TABLE IF NOT EXISTS ll_appointment_work_history (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令';
-- 新单据短号:相同业务及客户名称缩写共享逻辑秒,避免同秒或多实例重号。
CREATE TABLE IF NOT EXISTS ll_compact_number_clock (
number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写',
last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒',
PRIMARY KEY (number_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟';
-- 库存转运无需再次预约入仓;目标库:lastleg-service 所属数据库。可重复执行。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='estimated_arrival_at' AND is_nullable='NO'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT ''预计入仓时间,库存转运不填写''', 'SELECT 1'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -4632,3 +4619,11 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history (
CONSTRAINT fk_owned_business_history_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_history_version CHECK (record_version>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史';
-- 统一业务单号:前缀 + yyyyMMdd + 四位日流水。
CREATE TABLE IF NOT EXISTS ll_document_sequence (
sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键',
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
PRIMARY KEY (sequence_key),
CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
@@ -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());

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