Compare commits

...
Author SHA1 Message Date
ouyangxq a2c3d36c20 fix(calendar): publish isolated dispatch container lookup to TEST 2026-10-21 11:38:58 +08:00
ouyangxq f00d0e16cf 优化 2026-10-21 07:53:31 +08:00
ouyangxq af5cf1e3bd 优化 2026-10-20 23:49:55 +08:00
ouyangxq 35a1b835fe Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-10-20 23:35:38 +08:00
ouyangxq 9f6e43ef3f 优化 2026-10-20 23:34:22 +08:00
liuyihe 868557d79d 提交 2026-10-20 21:48:47 +08:00
liuyihe 137efc6809 提交 2026-10-20 21:47:51 +08:00
liuyihe 34b90f311a 提交 2026-10-20 18:44:23 +08:00
259 changed files with 5635 additions and 1240 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); }
}
}
+9 -3
View File
@@ -1,5 +1,7 @@
# 单测试环境数据库空库初始化
仓储转运到仓取货、本土卡车接入现有排车的**可重复执行汇总SQL**为 `lastleg-dispatch-transport-tasks.sql`,文件内固定 `USE baiyue_lastleg`,对已存在字段、表安全跳过。新增 `ll_dispatch_order.task_type` 字段、`ll_dispatch_truck_task`、`ll_dispatch_transport_create`、`ll_dispatch_transport_command` 三张表,并确保既有正式排车单号日流水表存在;不回填或删除业务数据。前置是目标库已具备现有整车排车主表、FleetTrip、取货任务及本土卡车原单结构。按序先执行此 SQL,再配套发布 Lastleg 与 Admin;取消释放有效原单关联、完成保留关联。`migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql` 是迁移系统中的新增结构记录。本次仅在已授权本机执行并验收,不代表远端已部署。
排车单号使用`PCyyyyMMdd0001`,日期取`Asia/Shanghai`,全平台跨客户/仓库共用独立日流水。`ll_dispatch_daily_sequence`同步Lastleg源基线与海外空库基线;已有结构的定向新增入口为`lastleg-dispatch-daily-sequence.sql`,只建序列表,不改已有业务号、不回填历史、不重放空库初始化。源码和DDL须配套发布;文件存在不表示已执行或部署。流水在独立短事务分配,回滚或取消不复用,四位范围每日0001至9999,准确单量以主单统计为准。
自有业务运营视图的结构在 Lastleg 源基线及海外统一空库基线新增四张附加表:`ll_fleet_owned_business_ownership/record/command/history`,分别保存原排车装车完成时的车辆归属证据、运营信息、命令与不可变历史;不新增排车主单或第二套运输状态。权限为 IAM `lastleg-permissions.sql` 中的自有业务 list/edit/upload,并同步国内统一空库基线。业务唯一规则见工作区 `docs/海外仓业务/自有业务首版开发契约.md`。本次仅准备源码基线,无 SQL 执行、存量回填或部署;旧已装车 TEST 数据不倒推历史归属,匹配版本按新原生样本验收。
@@ -58,8 +60,8 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
| 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 227 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 228 |
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 229 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 234 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
@@ -82,7 +84,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/228`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=229/234`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
## 平台管理员密码
@@ -178,3 +180,7 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
货物废弃通用库存与散箱字段迁移:`migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql`。新增库存仓位关联及箱唛快照,须先迁移再部署 Lastleg;不回填历史废弃单。
2026-10-16 拉取合并配套结构清单及执行边界见 [数据库变更汇总](20261016-数据库变更汇总.md),已有尾程库使用 `migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql`;空库初始化海外表数同步为 209。该增量可重复执行,尚未执行到任何实际数据库。
## 统一业务单号
编号规则见 `../../../docs/业务单据编号契约.md`。Order、Warehouse、Lastleg 空库基线新增各自的 `ord_document_sequence`、`wh_document_sequence`、`ll_document_sequence`;Firstleg 与 Finance 复用本库编号表。Lastleg 空库基线移除无生产调用的客户日序列、逻辑秒时钟及旧尾程日序列表,保留排车日序列。采购费用新增可空 `idempotency_key` 及订单/用途/请求键唯一约束,用于退款重放,业务号统一使用 PF。此处只准备基线,不执行 SQL、回填或删除测试存量。
@@ -0,0 +1,108 @@
-- MySQL 8 可重复执行增量:仓储转运到仓取货与本土卡车接入现有排车。
-- 目标数据库固定为 baiyue_lastleg;仅新增运输类型、原单关联及请求账本,不回填或删除业务数据。
-- 前置:已完成 Lastleg 现有整车排车结构升级;如部署为缺少每日流水表的旧库,也创建正式排车流水表。
USE baiyue_lastleg;
SET @dispatch_transport_column_ddl = IF(
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_order' AND column_name = 'task_type'
),
'SELECT 1',
'ALTER TABLE ll_dispatch_order ADD COLUMN task_type VARCHAR(32) NOT NULL DEFAULT ''DELIVERY'' COMMENT ''任务类型:仓库派送、到仓取货、本土卡车'' AFTER dispatch_no'
);
PREPARE dispatch_transport_column_stmt FROM @dispatch_transport_column_ddl;
EXECUTE dispatch_transport_column_stmt;
DEALLOCATE PREPARE dispatch_transport_column_stmt;
-- 旧库兼容:整车主单跨客户,原来单客户排车头字段不再要求填写。
-- 如目标库已删除旧字段则跳过;已有客户值保留,不置空、不修改。
SET @dispatch_transport_legacy_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_order' AND column_name = 'customer_id' AND is_nullable = 'NO'),
'ALTER TABLE ll_dispatch_order MODIFY COLUMN customer_id VARCHAR(128) NULL COMMENT ''旧客户排车归属,整车主单不使用''',
'SELECT 1'
);
PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl;
EXECUTE dispatch_transport_legacy_stmt;
DEALLOCATE PREPARE dispatch_transport_legacy_stmt;
-- 旧本土卡车结构兼容:新单沿用已有的起终点联系人及pickup_at/delivery_at字段。
-- 旧单字段保留原值,仅放宽空值约束,避免新单被已弃用字段阻断。
SET @dispatch_transport_legacy_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'contact_name' AND is_nullable = 'NO'),
'ALTER TABLE ll_us_truck_order MODIFY COLUMN contact_name VARCHAR(128) NULL COMMENT ''旧本土卡车联系人,新单使用起终点联系人''',
'SELECT 1'
);
PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl;
EXECUTE dispatch_transport_legacy_stmt;
DEALLOCATE PREPARE dispatch_transport_legacy_stmt;
SET @dispatch_transport_legacy_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'contact_info' AND is_nullable = 'NO'),
'ALTER TABLE ll_us_truck_order MODIFY COLUMN contact_info VARCHAR(128) NULL COMMENT ''旧本土卡车联系方式,新单使用起终点联系方式''',
'SELECT 1'
);
PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl;
EXECUTE dispatch_transport_legacy_stmt;
DEALLOCATE PREPARE dispatch_transport_legacy_stmt;
SET @dispatch_transport_legacy_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'requested_date' AND is_nullable = 'NO'),
'ALTER TABLE ll_us_truck_order MODIFY COLUMN requested_date DATE NULL COMMENT ''旧本土卡车请求日期,新单使用取货和送货时间''',
'SELECT 1'
);
PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl;
EXECUTE dispatch_transport_legacy_stmt;
DEALLOCATE PREPARE dispatch_transport_legacy_stmt;
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 IF NOT EXISTS ll_dispatch_truck_task (
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识',
order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号',
active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留',
PRIMARY KEY (dispatch_no),
UNIQUE KEY uk_transport_trip (trip_no),
UNIQUE KEY uk_transport_source (active_order),
KEY idx_transport_order (customer_id, order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联';
CREATE TABLE IF NOT EXISTS ll_dispatch_transport_create (
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要',
PRIMARY KEY (trip_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本';
CREATE TABLE IF NOT EXISTS ll_dispatch_transport_command (
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键',
request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要',
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
PRIMARY KEY (dispatch_no, request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本';
-- 当前承运占用查询还依赖散板来源标识(与V33一致);仅补结构,不创建散板业务记录。
SET @dispatch_transport_pool_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_fleet_external_booking' AND column_name = 'source_looseboard_order_id'),
'SELECT 1',
'ALTER TABLE ll_fleet_external_booking ADD COLUMN source_looseboard_order_id BIGINT NULL COMMENT ''客户原始散板商业单ID,取消后保留来源'''
);
PREPARE dispatch_transport_pool_stmt FROM @dispatch_transport_pool_ddl;
EXECUTE dispatch_transport_pool_stmt;
DEALLOCATE PREPARE dispatch_transport_pool_stmt;
SET @dispatch_transport_pool_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_fleet_external_booking' AND index_name = 'idx_booking_looseboard_source'),
'SELECT 1',
'ALTER TABLE ll_fleet_external_booking ADD KEY idx_booking_looseboard_source (customer_id, source_looseboard_order_id, status)'
);
PREPARE dispatch_transport_pool_stmt FROM @dispatch_transport_pool_ddl;
EXECUTE dispatch_transport_pool_stmt;
DEALLOCATE PREPARE dispatch_transport_pool_stmt;
@@ -0,0 +1,23 @@
-- 仓储取货与本土卡车接入现有排车;保留旧排车用途默认值。
ALTER TABLE ll_dispatch_order ADD COLUMN task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车' AFTER dispatch_no;
CREATE TABLE ll_dispatch_truck_task (
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识',
order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号',
active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留',
PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order),
KEY idx_transport_order(customer_id,order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联';
CREATE TABLE ll_dispatch_transport_create (
trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号',
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本';
CREATE TABLE ll_dispatch_transport_command (
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键',
request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要',
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
PRIMARY KEY(dispatch_no,request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本';
@@ -0,0 +1,13 @@
-- Region: OVERSEAS
-- Schema: baiyue_lastleg
-- Purpose: 客户散板原单接入拼车需求池,未确认登记不占位。
-- Preconditions: MySQL 8;已选择 baiyue_lastleg;预订表已存在。
-- Postconditions: 预订原单来源列及客户来源索引存在,不回填历史来源。
SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND column_name='source_looseboard_order_id'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD COLUMN source_looseboard_order_id BIGINT NULL COMMENT ''客户原始散板商业单ID,取消后保留来源''');
PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl;
EXECUTE looseboard_demand_stmt;
DEALLOCATE PREPARE looseboard_demand_stmt;
SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND index_name='idx_booking_looseboard_source'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status)');
PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl;
EXECUTE looseboard_demand_stmt;
DEALLOCATE PREPARE looseboard_demand_stmt;
@@ -1981,11 +1981,11 @@ CREATE TABLE fl_customs_clearance_result (
) ENGINE=InnoDB COMMENT='正式申报后的海关结果不可变事实';
CREATE TABLE fl_document_sequence (
sequence_key varchar(32) NOT NULL COMMENT '单据序列键;日序列如CB:20260818,柜次长期序列固定为CABINET',
sequence_key varchar(32) NOT NULL COMMENT '单据序列键;日序列如LP:20260818,柜次长期序列固定为CABINET',
current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号',
PRIMARY KEY (sequence_key),
CONSTRAINT chk_fl_document_sequence_value CHECK ((current_value > 0))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程业务编号序列;普通单据按日,柜次全局连续,实物标签独立保留';
CREATE TABLE fl_fee_item (
id bigint unsigned NOT NULL COMMENT '费用项目ID',
@@ -4856,7 +4856,7 @@ CREATE TABLE fin_document_sequence (
current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号',
PRIMARY KEY (sequence_key),
CONSTRAINT chk_fin_document_sequence_value CHECK ((current_value > 0))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Finance业务单号日序列;生成YYMMDD加四位当日序号';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='财务业务单号日序列;生成前缀加yyyyMMdd加四位当日序号';
CREATE TABLE fin_fee_source (
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
@@ -4983,7 +4983,7 @@ CREATE TABLE fin_receipt_bill (
CREATE TABLE fin_recharge_request (
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键',
request_no varchar(32) NOT NULL COMMENT '充值申请业务编号RC-yyMMdd-####',
request_no varchar(32) NOT NULL COMMENT '充值申请业务编号RCyyyyMMdd####',
amount decimal(20,2) NOT NULL COMMENT '申请充值金额',
voucher_file_id bigint unsigned NOT NULL COMMENT '付款截图File ID',
voucher_file_name varchar(255) NOT NULL COMMENT '付款截图文件名快照',
@@ -5234,7 +5234,7 @@ CREATE TABLE fin_wallet_hold (
CREATE TABLE fin_wallet_ledger (
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键',
business_no varchar(32) NOT NULL COMMENT '账户变动业务编号AC-yyMMdd-####',
business_no varchar(32) NOT NULL COMMENT '账户变动业务编号ACyyyyMMdd####',
change_type varchar(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整',
amount decimal(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负',
actual_balance_after decimal(20,2) NOT NULL COMMENT '变动后实际余额',
@@ -6848,6 +6848,12 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107391,0,CURRENT_TIMESTAMP(3));
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 (5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107392,0,CURRENT_TIMESTAMP(3));
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 (5107400,0,'PLATFORM','MENU',0,'platform:fleet:outsourced-business:list','外配业务','/overseas-master-data/outsourced-business','GET',996,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107400,0,CURRENT_TIMESTAMP(3));
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 (5107401,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:edit','登记外配业务承运信息',NULL,'PUT',997,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107401,0,CURRENT_TIMESTAMP(3));
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 (5107402,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:upload','上传外配业务照片',NULL,'POST',998,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107402,0,CURRENT_TIMESTAMP(3));
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 (5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107370,0,CURRENT_TIMESTAMP(3));
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 (5107380,0,'PLATFORM','API',5105000,'platform:finance:vehicle-cost:list','查看公司车辆成本','/api/platform/finance/v1/vehicle-costs','GET',1010,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
@@ -8377,4 +8383,20 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102213,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:direct-unload:confirm','登记直送目的仓实际卸货',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3));
SELECT 'PASS' AS initialization_status, 'CN' AS region, 227 AS initialized_tables;
USE baiyue_order;
CREATE TABLE ord_document_sequence (
sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键',
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
PRIMARY KEY (sequence_key),
CONSTRAINT chk_ord_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
USE baiyue_warehouse;
CREATE TABLE wh_document_sequence (
sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键',
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
PRIMARY KEY (sequence_key),
CONSTRAINT chk_wh_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables;
@@ -0,0 +1,21 @@
# 排车运输接入数据库变更汇总
执行文件:[lastleg-dispatch-transport-tasks.sql](lastleg-dispatch-transport-tasks.sql)。目标为 **MySQL 8 / baiyue_lastleg**,文件内已有 `USE baiyue_lastleg`。可整份重复执行;按 information_schema 判断字段及索引,按 IF NOT EXISTS 创建表。已在本机既有业务数据环境连续执行两次通过;未在远端环境执行。
| 对象 | 新增或调整 | 用途 |
|---|---|---|
| ll_dispatch_order.task_type | 新增 VARCHAR(32) NOT NULL DEFAULT DELIVERY | 区分仓库派送、到仓取货、本土卡车;旧单保留派送类型 |
| ll_dispatch_truck_task | 新表;dispatch_no、trip_no、customer_id、order_no、active_order | 真实排车关联原单;运输记录唯一、有效原单唯一;取消释放有效键,完成保留 |
| ll_dispatch_transport_create | 新表;trip_no、request_hash | 原安排请求摘要,避免重复建单及同键更换原单 |
| ll_dispatch_transport_command | 新表;dispatch_no、request_key、request_hash、evidence_json、created_at | 执行命令幂等、已确认完成凭证持久化 |
| ll_dispatch_daily_sequence | 既有正式日流水表,缺失时补建 | 统一 PC 日期与四位流水;不修改历史单号 |
| ll_dispatch_order.customer_id | 仅旧库存在且为 NOT NULL 时改为允许 NULL | 兼容整车跨客户主单;保留原值,不回填或置空 |
| ll_us_truck_order.contact_name / contact_info / requested_date | 仅旧库仍为 NOT NULL 时改为允许 NULL | 兼容现有原单起终点联系人及取送时间模型,保留历史字段和数据 |
| ll_fleet_external_booking.source_looseboard_order_id | 缺失时补 BIGINT NULL | 当前承运占用查询依赖,等同 V33;不创建散板业务 |
| ll_fleet_external_booking.idx_booking_looseboard_source | 缺失时补客户、原散板ID、状态联合索引 | 原单承运占用查询 |
所有新增表、字段均有中文注释。SQL不创建账号、不改角色权限、不删除表或记录、不改库存及历史单号,也不回填旧任务。
前置:目标库已具备当前整车排车、FleetTrip、仓储转运取货及本土卡车业务结构;本文件是本次运输接入增量,不替代这些模块的完整升级。执行完成后配套发布 Lastleg 和 Admin。迁移系统记录为 V32,承运占用依赖结构为 V33;直接交付执行时使用本份汇总 SQL 即可,不再重复手动执行两个版本文件。
本机验收中发现的旧字段约束与缺少日流水表已一并纳入。业务数据由正式 Gateway HTTP 创建,结构验证未写业务SQL。
+38 -21
View File
@@ -416,6 +416,7 @@ CREATE TABLE ll_dispatch_item (
CREATE TABLE ll_dispatch_order (
id BIGINT NOT NULL COMMENT '整车排车主单内部主键',
dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号',
task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车',
trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID',
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID',
overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照',
@@ -1859,12 +1860,6 @@ CREATE TABLE ll_order_manual_node_history (
KEY idx_ll_order_manual_node_history (customer_id,order_id,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单人工节点覆盖不可变历史';
CREATE TABLE ll_order_no_daily_sequence (
business_date date NOT NULL COMMENT '业务日期(Asia/Shanghai)',
last_sequence int NOT NULL COMMENT '当日已分配尾程单流水号',
PRIMARY KEY (business_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程单号按日原子流水';
CREATE TABLE ll_dispatch_daily_sequence (
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
@@ -2077,6 +2072,7 @@ CREATE TABLE ll_overseas_procurement_fee (
settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件',
source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID',
parent_fee_id BIGINT NULL COMMENT '原费用或预估引用',
idempotency_key VARCHAR(128) NULL COMMENT '退款请求幂等键,独立于业务单号',
request_hash CHAR(64) NULL COMMENT '确认请求摘要',
id bigint NOT NULL COMMENT '主键ID',
order_id bigint NOT NULL COMMENT '采购订单ID',
@@ -2098,6 +2094,7 @@ CREATE TABLE ll_overseas_procurement_fee (
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')),
UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version),
UNIQUE KEY uk_ll_procurement_refund_request(order_id,purpose,idempotency_key),
PRIMARY KEY (id),
UNIQUE KEY uk_ll_procurement_fee_no (fee_no),
KEY idx_ll_procurement_fee_order (customer_id,order_id,created_at),
@@ -2368,14 +2365,6 @@ CREATE TABLE ll_pda_stocktake_task (
KEY idx_ll_pda_stocktake_list (overseas_warehouse_id,status,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA库存盘点任务';
CREATE TABLE ll_procurement_daily_sequence (
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID',
business_date date NOT NULL COMMENT '北京时间业务日期',
sequence_value int NOT NULL COMMENT '客户当日已分配最大序号',
PRIMARY KEY (customer_id,business_date),
CONSTRAINT chk_ll_procurement_sequence CHECK ((sequence_value between 1 and 9999))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购客户日序列';
CREATE TABLE ll_receiving_container_operation (
id bigint NOT NULL COMMENT '柜号收货操作ID',
session_id bigint NOT NULL COMMENT '柜号签收会话ID',
@@ -3222,6 +3211,7 @@ CREATE TABLE ll_fleet_external_booking (
binding_key VARCHAR(128) NULL COMMENT '关联发运请求键',
binding_hash CHAR(64) NULL COMMENT '关联发运摘要',
offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空',
source_looseboard_order_id BIGINT NULL COMMENT '客户原始散板商业单ID,取消后保留来源',
sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人',
business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK',
delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓',
@@ -3236,6 +3226,7 @@ CREATE TABLE ll_fleet_external_booking (
KEY idx_fleet_booking_sales(sales_owner_user_id,offer_id,id),
CONSTRAINT fk_fleet_booking_offer FOREIGN KEY(offer_id) REFERENCES ll_fleet_capacity_offer(id),
CONSTRAINT chk_fleet_booking_expected_pallet CHECK(expected_pallet_count IS NULL OR expected_pallet_count > 0),
KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status),
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_booking_no(booking_no),
UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key),
@@ -4828,12 +4819,6 @@ CREATE TABLE ll_appointment_work_history (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令';
-- 海外业务短单号分配时钟。
CREATE TABLE ll_compact_number_clock (
number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写',
last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒',
PRIMARY KEY (number_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟';
-- 仓储转运上门取货表归尾程服务,仅用于空库初始化。
USE baiyue_lastleg;
CREATE TABLE ll_fleet_pickup_plan (
@@ -4968,4 +4953,36 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history (
CONSTRAINT ck_owned_business_history_version CHECK (record_version>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 228 AS initialized_tables;
-- 运输原单接入现有排车,不产生仓内库存事实。
USE baiyue_lastleg;
CREATE TABLE ll_dispatch_truck_task (
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识',
order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号',
active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留',
PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order),
KEY idx_transport_order(customer_id,order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联';
CREATE TABLE ll_dispatch_transport_create (
trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号',
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本';
CREATE TABLE ll_dispatch_transport_command (
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键',
request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要',
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
PRIMARY KEY(dispatch_no,request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本';
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, 234 AS initialized_tables;
+1 -1
View File
@@ -50,7 +50,7 @@
- 平台写操作必须校验权限、目标客户、幂等键、请求哈希、乐观锁版本和审计;客户接口只能从登录态取得客户范围,不接受客户端指定客户 ID。
- 出账后的业务来源和账单不得原地覆盖或物理删除;调整、作废、收款分配和修订必须保留可追溯记录。金额使用定点小数,当前业务币种固定为 CNY,长整型 ID 跨前端按字符串传输。
- 对账单出账后将客户确认状态置为 `PENDING_CONFIRMATION`;已出账账单调整后必须清除原确认快照并重新待确认,作废或重开时为 `NOT_REQUIRED`。客户只能确认本企业且状态为 `ISSUED`、`PARTIALLY_PAID` 或 `PAID` 的待确认账单,必须校验登录成员、乐观锁版本和幂等键,并记录确认人姓名快照与确认时间。
- 对账单、应收结算单和应付结算单编号统一按北京时间生成 `REC/AR/AP-yyMMdd-####`,各前缀每日独立递增且单日最多 9999;不得回退为数据库 ID 拼接、随机数或跨时区日期。
- 财务单据编号遵循 `../../docs/业务单据编号契约.md`,由本服务 Repository/Mapper 按前缀与北京时间日期原子分配;不得回退为数据库 ID 拼接、随机数或跨时区日期。
- 客户授信必须带晚于当天的到期日且不限制最远日期;到期日当天仍有效。有效期内消费先用实际资金,不足部分累计为已用授信;次日失效时已用授信自动并入实际余额并允许为负,充值先归还已用授信或负余额。Order 在客户提交新订单时必须拒绝负数实际余额,已存在的资金冻结仍按原业务单结算或释放。
- 应收款统计只按正式应收结算单的生成月份汇总 CNY 应收、已收和未收,支持可选客户筛选和分页;导出必须通过 Finance Provider + File `FINANCE_RECEIVABLE_STATISTICS` 异步任务,不在 Controller 或 Admin 直接生成文件。
- 费用入池只接受满足上游发布与结算条件的不可变来源版本;来源 ID、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。
@@ -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();
}
@@ -64,13 +64,13 @@ public interface MerchantWorkbenchFilterMapper {
ORDER BY i.order_id
""")
List<Long> pendingCustoms(@Param("tenantId") long tenantId);
/** Only physically loaded fragments can expose container milestones to the customer. */
/** 仅按本客户实际装柜片段读取进度;清关资料保存后立即显示已清关。 */
@Select("""
<script>
SELECT o.order_id, CASE WHEN owned.container_booking_id IS NULL THEN 0
WHEN EXISTS (SELECT 1 FROM fl_carrier_schedule_node_fact n
WHERE n.container_booking_id=owned.container_booking_id
AND n.standard_node='PICKUP_READY' AND n.actual_at IS NOT NULL) THEN 3
WHEN EXISTS (SELECT 1 FROM fl_container_document d
WHERE d.container_booking_id=owned.container_booking_id
AND d.document_type='DESTINATION_CLEARANCE' AND d.file_id IS NOT NULL) THEN 3
WHEN EXISTS (SELECT 1 FROM fl_customs_container c
WHERE c.loading_plan_id=owned.loading_plan_id AND c.status='COMPLETED') THEN 2
ELSE 1 END AS stage
@@ -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);}
}
@@ -17,7 +17,8 @@ public interface WorkflowFactMapper {
+"EXISTS(SELECT 1 "+ACTUAL+" AND EXISTS(SELECT 1 FROM fl_carrier_schedule_node_fact n WHERE n.container_booking_id=b.id AND n.standard_node='DEPARTED_ORIGIN_PORT' AND n.actual_at IS NOT NULL)) departed,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND EXISTS(SELECT 1 FROM fl_container_document d WHERE d.container_booking_id=b.id AND d.document_type='DESTINATION_CLEARANCE' AND d.file_id IS NOT NULL)) clearance_uploaded,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND i.tax_mode='TAX_REFUND' AND NOT EXISTS(SELECT 1 FROM fl_customs_customer_document_inbound s JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' JOIN fl_customs_container c ON c.id=d.customs_container_id AND c.loading_plan_id=p.id WHERE s.inbound_id=i.id AND s.tenant_id=i.tenant_id)) missing_customs,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND (i.tax_mode='BUY_DECLARATION' OR EXISTS(SELECT 1 FROM fl_customs_customer_document_inbound s JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' JOIN fl_customs_container c ON c.id=d.customs_container_id AND c.loading_plan_id=p.id WHERE s.inbound_id=i.id AND s.tenant_id=i.tenant_id))) export_customs_ready "
+"EXISTS(SELECT 1 "+ACTUAL+" AND (i.tax_mode='BUY_DECLARATION' OR EXISTS(SELECT 1 FROM fl_customs_customer_document_inbound s JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' JOIN fl_customs_container c ON c.id=d.customs_container_id AND c.loading_plan_id=p.id WHERE s.inbound_id=i.id AND s.tenant_id=i.tenant_id))) export_customs_ready,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND EXISTS(SELECT 1 FROM fl_customs_container c WHERE c.loading_plan_id=p.id AND c.status='COMPLETED')) customs_completed "
+"FROM fl_inbound_order i WHERE i.deleted=0 AND NOT EXISTS(SELECT 1 FROM fl_inbound_order child WHERE child.tenant_id=i.tenant_id AND child.parent_inbound_id=i.id AND child.inbound_role='CHILD' AND (child.split_purpose IS NULL OR child.split_purpose != 'REMAINDER') AND child.deleted=0) AND i.process_status != 'CANCELLED' AND i.id IN "
+"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<WorkflowFacts.Fact> facts(@Param("ids") List<Long> ids);
@@ -5,7 +5,7 @@ public final class WorkflowFacts {
/** 仅承载事实查询契约,不允许实例化。 */
private WorkflowFacts() {}
public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus,
boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady) {}
boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady,boolean customsCompleted) {}
public record Source(long tenantId,long inboundId) {}
public record Request(List<Source> sources) {}
public record Overseas(long tenantId,long inboundId,boolean outboundExists,boolean pendingOutbound,boolean delivering,boolean unloaded) {}
@@ -27,7 +27,7 @@ public class WorkflowTabService {
if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable();
result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee);
}
var sources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
var sources=facts.stream().filter(f->f.departed() || f.clearanceUploaded()).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
Map<String,WorkflowFacts.Overseas> overseas=new HashMap<>();
if(!sources.isEmpty()) {
try {
@@ -56,16 +56,18 @@ public class WorkflowTabService {
Set<String> keys=new LinkedHashSet<>();
if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW");
boolean signed="DELIVERED".equals(process);
// 报关页签按资料与实装事实汇总,不依赖点击开始报关;实际开船后转入清关链路。
if(!f.departed() && !signed) {
// 报关完成即进入待清关;不再等待船期接口补齐实际离港节点。
boolean clearanceStage=f.customsCompleted() || f.departed() || f.clearanceUploaded();
if(!clearanceStage && !signed) {
if(f.missingCustoms()) keys.add("WAIT_CUSTOMS_DOCS");
if(f.exportCustomsReady()) keys.add("EXPORT_CUSTOMS");
}
if(signed) keys.add("SIGNED");
if(f.departed() && !signed) {
if(clearanceStage && !signed) {
if(!f.clearanceUploaded()) keys.add("SAILING_CLEARANCE");
if(o!=null) {
if(f.clearanceUploaded() && !o.unloaded()) keys.add("WAIT_UNLOAD");
// 清关资料保存成功后才进入海外待办;出库事实优先于拆柜和排车待办。
if(f.clearanceUploaded() && o!=null) {
if(!o.outboundExists() && !o.unloaded()) keys.add("WAIT_UNLOAD");
// 取消的出库不构成有效覆盖,读取事实后自动回显,不能保存永久隐藏标志。
if(!o.outboundExists()) keys.add("WAIT_DISPATCH");
if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT");
@@ -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}}}
@@ -392,7 +392,7 @@ paths:
customsOrderIds: {type: array, items: {type: integer, format: int64}}
progress:
type: array
description: 实际装载片段投影;报关要求COMPLETED,目的港清关要求PICKUP_READY实际时间,缺失节点不推测。
description: 实际装载片段投影;报关要求COMPLETED,目的港清关以本柜DESTINATION_CLEARANCE资料成功绑定file_id为准,不等待PICKUP_READY船节点。
items:
type: object
required: [orderId, stage, partial]
@@ -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 后才可继续物理装柜。
@@ -1674,7 +1679,7 @@ components:
type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW]
nullable: true
description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,实际开船后移出;SAILING_CLEARANCE 按手动或拉取的实际离港节点进入,清关资料上传后进入 WAIT_UNLOAD,实际卸柜完成后移出。
description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,报关完成或实际开船后移出;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;生成出库单后进入 OUTBOUND_DOCUMENT,实际出库后进入 DELIVERING,派送完成进入 SIGNED。
customerId: {type: integer, format: int64, nullable: true}
receivingWarehouseId: {type: integer, format: int64, nullable: true}
destinationWarehouseId: {type: integer, format: int64, nullable: true}
@@ -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);
});
}
}
@@ -30,6 +30,7 @@ class MerchantWorkbenchFilterMapperTest {
jdbc.execute("ALTER TABLE fl_loading_plan_item ADD id BIGINT GENERATED BY DEFAULT AS IDENTITY");
jdbc.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT,task_status VARCHAR(32))");
jdbc.execute("CREATE TABLE fl_loading_pick_item(pick_task_id BIGINT,loading_plan_item_id BIGINT,item_status VARCHAR(32),picked_package_count INT)");
jdbc.execute("CREATE TABLE fl_container_document(container_booking_id BIGINT,document_type VARCHAR(40),file_id BIGINT)");
jdbc.execute("CREATE TABLE fl_carrier_schedule_node_fact(container_booking_id BIGINT,standard_node VARCHAR(64),actual_at TIMESTAMP)");
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(MerchantWorkbenchFilterMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
@@ -95,6 +96,12 @@ class MerchantWorkbenchFilterMapperTest {
jdbc.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(100,'PICKUP_READY',NULL)");
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==2);
jdbc.update("UPDATE fl_carrier_schedule_node_fact SET actual_at=CURRENT_TIMESTAMP");
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==2);
// 其他柜、其他资料类型或未绑定文件不能视为清关资料提交成功。
jdbc.update("INSERT INTO fl_container_document VALUES(999,'DESTINATION_CLEARANCE',1),(100,'DELIVERY_ORDER',2),(100,'DESTINATION_CLEARANCE',NULL)");
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==2);
jdbc.update("UPDATE fl_carrier_schedule_node_fact SET actual_at=NULL");
jdbc.update("UPDATE fl_container_document SET file_id=3 WHERE document_type='DESTINATION_CLEARANCE' AND container_booking_id=100");
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==3);
assertThat(mapper.progressFragments(11,List.of(101L))).singleElement().satisfies(f->{assertThat(f.orderId()).isEqualTo(101);assertThat(f.stage()).isEqualTo(3);});
jdbc.update("UPDATE fl_container_booking SET record_status='CANCELLED'");
@@ -80,7 +80,7 @@ class Phase3FinalUnifiedSqlContractTest {
@Test
void initializationPassIsUniqueAndFinal() throws Exception {
String sql = sql();
int expectedTables = 228;
int expectedTables = 229;
long actualTables = java.util.regex.Pattern.compile("(?m)^CREATE TABLE\\s+")
.matcher(sql).results().count();
assertThat(actualTables).isEqualTo(expectedTables);
@@ -24,6 +24,8 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class SailingCalendarServiceTest {
@org.mockito.Spy private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers = com.baiyue.firstleg.service.TestDocumentNumbers.documents();
@Mock private SailingCalendarRepository repository;
@InjectMocks private SailingCalendarService service;
private final LocalDateTime copiedDeparture = LocalDateTime.now(ZoneId.of("Asia/Shanghai")).plusDays(14);
@@ -1,73 +1,52 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.repository.DocumentSequenceRepository;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FirstlegDocumentNumberGeneratorTest {
private static final Clock CLOCK = Clock.fixed(
Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai"));
@Test
void startsAsASpringBeanWhenTheTestClockConstructorAlsoExists() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
context.registerBean(JdbcTemplate.class, () -> jdbc);
context.register(FirstlegDocumentNumberGenerator.class);
context.refresh();
private static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC);
/** 多构造器情况下正式依赖可以被 Spring 正确选择。 */
@Test void springCreatesGeneratorWithRepository() {
try(var context=new AnnotationConfigApplicationContext()) {
context.registerBean(DocumentSequenceRepository.class,()->mock(DocumentSequenceRepository.class));
context.register(FirstlegDocumentNumberGenerator.class);context.refresh();
assertThat(context.getBean(FirstlegDocumentNumberGenerator.class)).isNotNull();
}
}
@Test
void formatsAllOperationalDocumentPrefixes() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class))
.thenReturn(1L, 1L, 1L, 1L, 1L);
FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK);
assertThat(generator.next("OB")).isEqualTo("OB-260818-0001");
assertThat(generator.next("TA")).isEqualTo("TA-260818-0001");
assertThatThrownBy(() -> generator.next("CB")).isInstanceOf(IllegalArgumentException.class);
assertThat(generator.next("LP")).isEqualTo("LP-260818-0001");
assertThat(generator.nextCabinetNo()).isEqualTo("D0001");
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fl_document_sequence"),
eq("OB:20260818"));
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fl_document_sequence"),
eq("CABINET"));
/** 主机使用 UTC 时仍按北京时间跨午夜,且四位流水不截断。 */
@Test void usesShanghaiDateAndIndependentSequenceKey() {
var sequences=mock(DocumentSequenceRepository.class);
when(sequences.next("LP:20261020")).thenReturn(1L,2L,9999L);
var numbers=new FirstlegDocumentNumberGenerator(sequences,CLOCK);
assertThat(numbers.next("LP")).isEqualTo("LP202610200001");
assertThat(numbers.next("LP")).isEqualTo("LP202610200002");
assertThat(numbers.next("LP")).isEqualTo("LP202610209999");
verify(sequences,times(3)).next("LP:20261020");
}
/** 未登记类型不能消耗流水,分配异常或溢出时拒绝出号。 */
@Test void rejectsUnknownTypeAndOverflow() {
var sequences=mock(DocumentSequenceRepository.class);
var numbers=new FirstlegDocumentNumberGenerator(sequences,CLOCK);
assertThatThrownBy(()->numbers.next("ZZZZ")).isInstanceOf(IllegalArgumentException.class);
verifyNoInteractions(sequences);
when(sequences.next("LP:20261020")).thenReturn(10000L);
assertThatThrownBy(()->numbers.next("LP")).isInstanceOf(BizException.class);
}
@Test
void cabinetSequenceDoesNotResetOrStopAtFourDigits() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(9_999L, 10_000L);
FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK);
assertThat(generator.nextCabinetNo()).isEqualTo("D9999");
assertThat(generator.nextCabinetNo()).isEqualTo("D10000");
}
@Test
void rejectsUnsupportedTypeAndDailyOverflow() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK);
assertThatThrownBy(() -> generator.next("MSCU")).isInstanceOf(IllegalArgumentException.class);
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L);
assertThatThrownBy(() -> generator.next("LP")).isInstanceOf(BizException.class);
/** 柜次和实物标签明确不改格式,柜次允许超过四位。 */
@Test void preservesCabinetAndPalletRules() {
var sequences=mock(DocumentSequenceRepository.class);
when(sequences.next("CABINET")).thenReturn(9999L,10000L);
when(sequences.next("DP:20261020")).thenReturn(1L);
var numbers=new FirstlegDocumentNumberGenerator(sequences,CLOCK);
assertThat(numbers.nextCabinetNo()).isEqualTo("D9999");
assertThat(numbers.nextCabinetNo()).isEqualTo("D10000");
assertThat(numbers.next("DP")).isEqualTo("DP-261020-0001");
}
}
@@ -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));
}
}
@@ -13,7 +13,7 @@ import static org.mockito.Mockito.*;
class WorkflowBoundaryTest {
/** 真实开船来源的测试事实。 */
private WorkflowFacts.Fact fact() {
return new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,false);
return new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,false,false);
}
/** 下游漏来源或返回其他客户均整体失败。 */
@@ -33,4 +33,23 @@ class WorkflowBoundaryTest {
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
}
/** 未记录离港时,报关完成即可待清关;上传清关资料后也必须查询精确海外来源。 */
@Test void completedCustomsAndClearanceUploadDoNotWaitForDeparture() {
var repository=mock(WorkflowFactRepository.class);
var downstream=mock(LastlegWorkflowClient.class);
var service=new WorkflowTabService(repository,downstream);
var inbound=new InboundOrderEntity();inbound.setId(1L);inbound.setTenantId(11L);
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
when(repository.facts(List.of(1L))).thenReturn(List.of(
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,true)));
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("SAILING_CLEARANCE");
verifyNoInteractions(downstream);
when(repository.facts(List.of(1L))).thenReturn(List.of(
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true,true)));
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
new WorkflowFacts.Overseas(11,1,false,false,false,false))));
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("WAIT_UNLOAD","WAIT_DISPATCH");
verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1)))));
}
}
@@ -3,33 +3,46 @@ import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
/** 取消回显、分柜隔离和资料页签回归。 */
class WorkflowBusinessTest {
/** 报关资料齐备进入汇总,实际开船及清关资料分别驱动后续页签。 */
/** 报关完成进入待清关,清关资料保存后才进入拆柜与排车待办。 */
@Test void customsDocumentsDepartureAndClearanceDriveTabs() {
var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,true,false);
var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true);
var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true);
var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,true,false,true);
var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true);
var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,true,false,false);
var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,false);
var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true,false);
var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,true,false,true,false);
var completed=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,true);
var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true,false);
var pendingUnload=new WorkflowFacts.Overseas(11,1,false,false,false,false);
var unloaded=new WorkflowFacts.Overseas(11,1,false,false,false,true);
assertThat(WorkflowTabService.classify(missing,"LOADED",null))
.contains("WAIT_CUSTOMS_DOCS").doesNotContain("EXPORT_CUSTOMS","SAILING_CLEARANCE","WAIT_UNLOAD");
assertThat(WorkflowTabService.classify(uploaded,"LOADED",null))
.contains("EXPORT_CUSTOMS").doesNotContain("WAIT_CUSTOMS_DOCS","SAILING_CLEARANCE","WAIT_UNLOAD");
assertThat(WorkflowTabService.classify(completed,"LOADED",null))
.containsExactly("SAILING_CLEARANCE");
assertThat(WorkflowTabService.classify(uploadedBeforeDeparture,"LOADED",pendingUnload))
.contains("EXPORT_CUSTOMS").doesNotContain("SAILING_CLEARANCE","WAIT_UNLOAD");
.containsExactly("WAIT_UNLOAD","WAIT_DISPATCH");
assertThat(WorkflowTabService.classify(departed,"LOADED",pendingUnload))
.contains("SAILING_CLEARANCE").doesNotContain("WAIT_CUSTOMS_DOCS","EXPORT_CUSTOMS","WAIT_UNLOAD");
.containsExactly("SAILING_CLEARANCE");
assertThat(WorkflowTabService.classify(cleared,"LOADED",pendingUnload))
.contains("WAIT_UNLOAD").doesNotContain("WAIT_CUSTOMS_DOCS","EXPORT_CUSTOMS","SAILING_CLEARANCE");
.containsExactly("WAIT_UNLOAD","WAIT_DISPATCH");
assertThat(WorkflowTabService.classify(cleared,"LOADED",unloaded))
.doesNotContain("WAIT_UNLOAD","SAILING_CLEARANCE","EXPORT_CUSTOMS");
assertThat(WorkflowTabService.classify(uploaded,"DELIVERED",unloaded))
.contains("SIGNED").doesNotContain("EXPORT_CUSTOMS","WAIT_CUSTOMS_DOCS");
}
/** 清关前不能被海外建单事实提前推进,清关后出库与派送依次替换待办。 */
@Test void downstreamFactsCannotBypassClearanceAndOutboundReplacesQueues() {
var pending=new WorkflowFacts.Overseas(11,1,true,true,false,false);
var shipped=new WorkflowFacts.Overseas(11,1,true,false,true,false);
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",pending)).containsExactly("SAILING_CLEARANCE");
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",shipped)).containsExactly("SAILING_CLEARANCE");
assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",pending)).containsExactly("OUTBOUND_DOCUMENT");
assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",shipped)).containsExactly("DELIVERING");
assertThat(WorkflowTabService.classify(fact(1,true),"DELIVERED",shipped)).containsExactly("SIGNED");
}
/** 构造独立分柜来源。 */
private WorkflowFacts.Fact fact(long id,boolean uploaded) {
return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",true,uploaded,false,false);
return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",true,uploaded,false,false,false);
}
/** 有效建单移出待排车,取消后恢复;实际出库不计未出库单。 */
@Test void cancellationRestoresDispatchQueue() {
@@ -45,9 +58,9 @@ class WorkflowBusinessTest {
/** 一柜资料上传不覆盖另一柜;签收不再进入待排。 */
@Test void independentSlicesAndUploadedClearance() {
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",new WorkflowFacts.Overseas(11,1,false,false,false,false)))
.contains("SAILING_CLEARANCE","WAIT_DISPATCH").doesNotContain("WAIT_UNLOAD");
.containsExactly("SAILING_CLEARANCE");
assertThat(WorkflowTabService.classify(fact(2,true),"LOADED",new WorkflowFacts.Overseas(11,2,true,true,false,false)))
.contains("WAIT_UNLOAD","OUTBOUND_DOCUMENT").doesNotContain("SAILING_CLEARANCE","WAIT_DISPATCH");
.containsExactly("OUTBOUND_DOCUMENT");
assertThat(WorkflowTabService.classify(fact(1,false),"DELIVERED",null)).contains("SIGNED").doesNotContain("WAIT_DISPATCH");
}
}
@@ -60,10 +60,11 @@ class WorkflowFactPersistenceTest {
db.update("INSERT INTO fl_customs_customer_document VALUES(10,11,'ACTIVE',8)");
db.update("INSERT INTO fl_customs_customer_document_inbound VALUES(1,11,10)");
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{assertThat(f.missingCustoms()).isFalse();assertThat(f.exportCustomsReady()).isTrue();});
// 本票上传后即可汇总,不必等同柜其他票或点击开始报关;完成报关后仍等待实际开船。
// 本票资料上传后进入出口报关汇总,报关完成事实独立驱动待清关。
for (String status : List.of("READY_TO_START", "DECLARING", "COMPLETED")) {
db.update("UPDATE fl_customs_container SET status=? WHERE id=8", status);
assertThat(mapper.facts(List.of(1L)).get(0).exportCustomsReady()).isTrue();
assertThat(mapper.facts(List.of(1L)).get(0).customsCompleted()).isEqualTo("COMPLETED".equals(status));
}
db.update("UPDATE fl_customs_customer_document SET status='DELETED' WHERE id=10");
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{
@@ -101,6 +101,9 @@ VALUES
(5107390,0,'PLATFORM','MENU',0,'platform:fleet:owned-business:list','自有业务','/overseas-master-data/owned-business','GET',993,'ACTIVE',1,0),
(5107391,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:edit','登记自有业务运营信息',NULL,'PUT',994,'ACTIVE',1,0),
(5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,0),
(5107400,0,'PLATFORM','MENU',0,'platform:fleet:outsourced-business:list','外配业务','/overseas-master-data/outsourced-business','GET',996,'ACTIVE',1,0),
(5107401,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:edit','登记外配业务承运信息',NULL,'PUT',997,'ACTIVE',1,0),
(5107402,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:upload','上传外配业务照片',NULL,'POST',998,'ACTIVE',1,0),
(5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,0),
(5107385,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:record-finance-submit','提交维修救援公司成本',NULL,'POST',1015,'ACTIVE',1,0),
(5107340,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cost-create','登记车次直接成本',NULL,'POST',970,'ACTIVE',1,0),
@@ -164,7 +167,7 @@ INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, creat
SELECT 0, role.id, permission.id, NOW(3)
FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code IN ('platform:fleet:trip:cost-create','platform:fleet:trip:cost-edit','platform:fleet:quote:list','platform:fleet:quote:create','platform:fleet:quote:edit','platform:fleet:quote:status','platform:fleet:quote:delete','platform:fleet:quote:import','platform:fleet:quote:export','platform:fleet:operations:list','platform:fleet:operations:export','platform:fleet:operations:edit','platform:fleet:owned-business:list','platform:fleet:owned-business:edit','platform:fleet:owned-business:upload','platform:fleet:trip:handover','platform:overseas-master-data:vehicle:record-finance-submit')
AND permission.permission_code IN ('platform:fleet:trip:cost-create','platform:fleet:trip:cost-edit','platform:fleet:quote:list','platform:fleet:quote:create','platform:fleet:quote:edit','platform:fleet:quote:status','platform:fleet:quote:delete','platform:fleet:quote:import','platform:fleet:quote:export','platform:fleet:operations:list','platform:fleet:operations:export','platform:fleet:operations:edit','platform:fleet:owned-business:list','platform:fleet:owned-business:edit','platform:fleet:owned-business:upload','platform:fleet:outsourced-business:list','platform:fleet:outsourced-business:edit','platform:fleet:outsourced-business:upload','platform:fleet:trip:handover','platform:overseas-master-data:vehicle:record-finance-submit')
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
@@ -0,0 +1,45 @@
package com.baiyue.iam.repository;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
/** 两类装车后运营入口的读写权限独立,初始化不向客户/PDA扩权。 */
class FleetBusinessPermissionSeedSqlContractTest {
/** 两份权威种子保留页面与两个按钮的固定层级,不能拿写权限代替查看。 */
@Test void seedsSeparatePlatformReadAndWriteResourcesInBothEntries() throws Exception {
String local=Files.readString(Path.of("src/main/resources/db/lastleg-permissions.sql"));
String unified=Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
for(String sql:List.of(local,unified)) {
for(var scope:List.of("owned-business","outsourced-business")) {
long base=scope.equals("owned-business")?5107390L:5107400L;
assertThat(sql).contains("("+base+",0,'PLATFORM','MENU',0,'platform:fleet:"+scope+":list'");
assertThat(sql).contains("'/overseas-master-data/"+scope+"','GET'");
assertThat(sql).contains("("+(base+1)+",0,'PLATFORM','BUTTON',"+base+",'platform:fleet:"+scope+":edit'");
assertThat(sql).contains("("+(base+2)+",0,'PLATFORM','BUTTON',"+base+",'platform:fleet:"+scope+":upload'");
}
}
}
/** 模块授权语句必须同时限制有效平台管理员和有效平台权限。 */
@Test void moduleGrantsOnlyToActivePlatformAdministrators() throws Exception {
String sql=Files.readString(Path.of("src/main/resources/db/lastleg-permissions.sql"));
var grants=Pattern.compile("INSERT IGNORE INTO iam_role_permission[^;]+;",Pattern.DOTALL).matcher(sql).results()
.map(result->result.group()).filter(statement->statement.contains("platform:fleet:outsourced-business:")).toList();
assertThat(grants).hasSize(1);
assertThat(grants.get(0)).contains("role.tenant_id=0", "role.app_code='PLATFORM'", "role.role_code='PLATFORM_ADMIN'",
"role.status='ACTIVE'", "role.deleted=0", "permission.status='ACTIVE'", "permission.deleted=0");
}
/** 空库基线三项显式授权只能落到平台管理员种子,不授予客户模板或PDA。 */
@Test void unifiedBaselineDoesNotGrantExternalOperationsToOtherRoleSeeds() throws Exception {
String sql=Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
for(long id:List.of(5107400L,5107401L,5107402L)) {
var grants=Pattern.compile("INSERT INTO iam_role_permission[^;]+;",Pattern.DOTALL).matcher(sql).results()
.map(result->result.group()).filter(statement->statement.contains(","+id+",")).toList();
assertThat(grants).hasSize(1);
assertThat(grants.get(0)).contains("VALUES (4000001,"+id+",0,CURRENT_TIMESTAMP(3))");
}
}
}
+2
View File
@@ -17,6 +17,8 @@
拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;整车建单与准备查 `dispatch/controller/DispatchAggregateController.java` 和 `dispatch/service/DispatchAggregateService.java`,本土业务接线查 `fleet/business/`;既有外接登记衔接合同为 `fleet-preparation-v1.yaml`,排车流程按 `../../docs/海外仓业务/排车列表统一作业改造契约.md`。
原排车装车后运营视图分别查 `fleet/ownedbusiness/`、`fleet/outsourcedbusiness/` 与各自 `fleet-*-business-v1.yaml`;自有/外配按同事务捕获的车辆归属,不按货源区分。两入口可信作用域及权限独立,外配仅整车承运金额、逐目的仓到达和五装车照片;复用四张附加表,不修改原履约/库存/财务状态。共同业务规则见 `../../docs/海外仓业务/自有业务首版开发契约.md`、`../../docs/海外仓业务/外配业务首版开发契约.md`,实际发布及HTTP只查部署主契约。
事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。
沿代码追踪;状态、权限、资源或接口边界不明时,定向检索 `docs/business-index.md`;局部任务不扩读无关约束。
+1
View File
@@ -86,6 +86,7 @@
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。
- 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按装车时自有车辆归属展示,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。
- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按冻结DISPATCHED归属展示,包括正式MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五装车照片,无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。
- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。
- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。
@@ -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。分析器显式分类并验证这些边界,旧统计及退役安全投影断言不代表当前架构事实。
@@ -16,6 +16,7 @@ const ownedBusinessReadEdges = [
['service.OwnedBusinessQueryService', 'DispatchTypes'],
['service.OwnedBusinessQueryService', 'DispatchDetailReader'],
].map(([source, target]) => `com.baiyue.lastleg.fleet.ownedbusiness.${source}->com.baiyue.lastleg.dispatch.port.${target}`).sort();
const outsourcedBusinessReadEdges = ['com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels->com.baiyue.lastleg.dispatch.port.DispatchViews'];
test('selfoperated analytics is independent of customer outbound and inventory slices', () => {
const report = analyze();
@@ -49,13 +50,16 @@ test('fleet stays a business slice and its policies have no infrastructure depen
const policyClasses = classes.filter(entry => entry.packageName === 'com.baiyue.lastleg.fleet.domain');
assert.equal(policyClasses.length, 3);
for (const entry of policyClasses) assert.ok(entry.imports.every(imported => imported.startsWith('java.')));
assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'fleet').map(edge => edge.target).sort(), ['dispatch-delivery', 'overseas-master-data', 'shared-technical']);
const reviewed = JSON.parse(readFileSync(contract, 'utf8')).reviewedClassDependencies;
const commercialEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && /com\.baiyue\.lastleg\.(trucking|looseboard|procurement)\./.test(edge.target));
assert.ok(commercialEdges.every(edge => reviewed.some(allowed => allowed.source === edge.source && allowed.target === edge.target)), 'commercial integration only uses individually reviewed class references');
assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'fleet').map(edge => edge.target).sort(), ['dispatch-delivery', 'overseas-master-data', ...(commercialEdges.length ? ['procurement-trucking-looseboard'] : []), 'shared-technical']);
for (const edge of report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && edge.target.startsWith(vehiclePrefix))) {
assert.ok(edge.target.startsWith(vehiclePrefix + '.application.'), 'fleet must use vehicle application ports, not persistence adapters');
}
assert.equal(report.packageSccs.some(component => component.some(name => name.startsWith('com.baiyue.lastleg.fleet.'))), false);
assert.deepEqual(report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.')
&& /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)).map(edge => `${edge.source}->${edge.target}`).sort(), ownedBusinessReadEdges);
&& /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)).map(edge => `${edge.source}->${edge.target}`).sort(), [...ownedBusinessReadEdges, ...outsourcedBusinessReadEdges].sort());
const dispatchEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.dispatch.')
&& edge.target.startsWith('com.baiyue.lastleg.fleet.'));
assert.ok(dispatchEdges.length > 0);
@@ -67,7 +71,13 @@ test('fleet stays a business slice and its policies have no infrastructure depen
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.PdaFleetHandoverController'
&& edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery'
|| edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchAggregateService'
&& edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture'),
&& edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture'
|| [
'TransportTaskModels->pickup.FleetPickupModels', 'TransportTaskModels->trip.FleetTripModels',
'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged',
'TransportTaskService->business.FleetPoolOrderQueries', 'TransportTaskService->driver.DriverService',
'TransportTaskService->reporting.TripOperationsEvidenceGateway',
].some(reference => `${edge.source}->${edge.target}` === `com.baiyue.lastleg.dispatch.transport.${reference.replace('->', '->com.baiyue.lastleg.fleet.')}`)),
'dispatch must use fleet ports and guards, not its persistence mappers');
const incomingEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.transfer.') && edge.target.startsWith('com.baiyue.lastleg.fleet.'));
assert.ok(incomingEdges.length > 0);
@@ -92,6 +102,21 @@ test('owned business reads only reviewed dispatch ports and cannot inherit persi
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${unreviewed.source}->${unreviewed.target}`)));
});
test('outsourced business reads only its reviewed original dispatch view and cannot reach persistence', () => {
const report = analyze();
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
const prefix = 'com.baiyue.lastleg.fleet.outsourcedbusiness.';
const reviewed = baseline.reviewedClassDependencies.filter(edge => edge.source.startsWith(prefix)
&& edge.target.startsWith('com.baiyue.lastleg.dispatch.'));
assert.deepEqual(reviewed.map(edge => `${edge.source}->${edge.target}`).sort(), outsourcedBusinessReadEdges);
assert.equal(baseline.allowedSliceEdges.includes('fleet->dispatch-delivery'), false);
for (const target of ['entity.DispatchAggregate', 'mapper.DispatchAggregateMapper', 'service.DispatchAggregateService']) {
const edge = { source: prefix + 'service.OutsourcedBusinessService', target: 'com.baiyue.lastleg.dispatch.' + target };
report.classDependencies.push(edge);
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`)));
}
});
function analyze(sourceSet = 'main') {
return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], {
cwd: moduleRoot,
@@ -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")
@@ -8,6 +8,7 @@ import java.time.LocalDateTime;
public class DispatchAggregate {
private Long id;
private String dispatchNo;
private String taskType;
private Long tripId;
private String overseasWarehouseId;
private String overseasWarehouseCode;
@@ -14,7 +14,11 @@ public interface DispatchAggregateMapper {
<if test='q.dispatchNo != null'> AND d.dispatch_no LIKE CONCAT('%',#{q.dispatchNo},'%')</if>
<if test='q.vehiclePlate != null'> AND d.vehicle_plate LIKE CONCAT('%',#{q.vehiclePlate},'%')</if>
<if test='q.warehouseId != null'> AND d.overseas_warehouse_id=#{q.warehouseId}</if>
<if test='q.customerId != null'> AND EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=d.id AND i.customer_id=#{q.customerId})</if>
<if test='q.customerId != null'> AND (
EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=d.id AND i.customer_id=#{q.customerId})
OR EXISTS(SELECT 1 FROM ll_dispatch_truck_task t WHERE t.dispatch_no=d.dispatch_no AND t.customer_id=#{q.customerId})
OR EXISTS(SELECT 1 FROM ll_fleet_pickup_task p JOIN ll_fleet_trip f ON f.trip_no=p.trip_no WHERE f.id=d.trip_id AND p.customer_id=#{q.customerId})
)</if>
<if test='q.fbaAppointmentNo != null'> AND EXISTS(SELECT 1 FROM ll_dispatch_appointment_allocation a JOIN ll_inbound_fba_appointment ap ON a.batch_key=CONCAT('FBA_APPOINTMENT:',ap.id) WHERE a.dispatch_id=d.id AND ap.confirmation_no=#{q.fbaAppointmentNo})</if>
""";
String REVIEW_DISPATCH = "CASE d.review_state WHEN 'DRAFT' THEN 'NOT_SUBMITTED' WHEN 'DISPATCH_PENDING' THEN 'PENDING' WHEN 'DISPATCH_REJECTED' THEN 'REJECTED' ELSE 'APPROVED' END";
@@ -33,12 +37,12 @@ public interface DispatchAggregateMapper {
/** 首次创建整车所有权及初始版本,不写customer_id。 */
@Insert("""
INSERT INTO ll_dispatch_order(id,dispatch_no,trip_id,overseas_warehouse_id,overseas_warehouse_code,
INSERT INTO ll_dispatch_order(id,dispatch_no,task_type,trip_id,overseas_warehouse_id,overseas_warehouse_code,
overseas_warehouse_name,status,stage,record_version,cargo_revision,cargo_hash,review_state,review_round,
dispatch_review_json,loading_review_json,resources_json,vehicle_plate,route,planned_departure_at,
estimated_arrival_at,item_count,pallet_count,package_count,picked_package_count,shipped_package_count,
remark,created_by,created_at,updated_at)
VALUES(#{r.id},#{r.dispatchNo},#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode},
VALUES(#{r.id},#{r.dispatchNo},COALESCE(#{r.taskType},'DELIVERY'),#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode},
#{r.overseasWarehouseName},#{r.status},#{r.stage},#{r.recordVersion},#{r.cargoRevision},#{r.cargoHash},
#{r.reviewState},#{r.reviewRound},#{r.dispatchReviewJson},#{r.loadingReviewJson},#{r.resourcesJson},
#{r.vehiclePlate},#{r.route},#{r.plannedDepartureAt},#{r.estimatedArrivalAt},#{r.itemCount},#{r.palletCount},
@@ -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 " +
@@ -261,7 +278,8 @@ public interface DispatchCandidateMapper {
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
"AND pos.hold_packages &gt; 0 " +
// 未绑定实托的位置不是可选运输来源;完整申请中的缺失组成仍由提供者返回阻断原因。
"AND pos.hold_packages &gt; 0 AND pos.pallet_id &gt; 0 " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " +
"ON existing_dispatch.id=di.dispatch_order_id " +
"WHERE di.customer_id=a.customer_id AND di.source_stage='RELABEL' AND di.order_refs=a.relabel_no " +
@@ -283,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) { }
@@ -29,7 +29,7 @@ public final class DispatchViews {
public record Page(List<ListRow> records, long total, long page, long size, long pages,
Statistics statistics, Instant snapshotAt) { }
public record ListRow(String dispatchNo, Status status, Stage stage, long recordVersion,
public record ListRow(String dispatchNo, String taskType, Status status, Stage stage, long recordVersion,
ReviewState reviewState, Reviews reviews, Resources resources,
String warehouseId, String warehouseCode, String warehouseName,
Instant plannedDepartureAt, Instant departedAt, Counts counts,
@@ -56,7 +56,7 @@ public final class DispatchViews {
String sourceBusinessType, String sourceBusinessNo) { }
/** 页头元数据和四个内容对象固定,附加能力不扩展为新页签。 */
public record Detail(String dispatchNo, Status status, Stage stage, long recordVersion,
public record Detail(String dispatchNo, String taskType, Status status, Stage stage, long recordVersion,
ReviewState reviewState, Reviews reviews, Counts counts,
boolean pickingReconciled, Map<Action, Gate> actions,
VehicleInfo vehicleInfo, DispatchInfo dispatchInfo, Documents documents,
@@ -2,7 +2,7 @@ package com.baiyue.lastleg.dispatch.repository;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.springframework.jdbc.core.JdbcTemplate;
import com.baiyue.lastleg.dispatch.mapper.DispatchNumberSequenceMapper;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@@ -11,17 +11,17 @@ import java.time.LocalDate;
/** 整车单号独立日流水;独立短事务归还序列锁后才进入车辆、司机及货物锁。 */
@Repository
public class DispatchNumberSequence {
private final JdbcTemplate jdbc;
private final DispatchNumberSequenceMapper mapper;
/** 流水与排车主单使用相同服务数据库。 */
public DispatchNumberSequence(JdbcTemplate jdbc) { this.jdbc = jdbc; }
public DispatchNumberSequence(DispatchNumberSequenceMapper mapper) { this.mapper = mapper; }
/** 唯一日期行原子递增,连接内取回本次值;失败不复用已分配的业务号。 */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public long next(LocalDate date) {
jdbc.update("INSERT INTO ll_dispatch_daily_sequence(business_date,last_sequence) VALUES(?,LAST_INSERT_ID(1)) "
+ "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)", date);
Long value = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
// 两个实际 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,7 @@ 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;
@@ -147,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:
@@ -181,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推断删除身份。 */
@@ -509,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("请确认本车全部实托测量");
@@ -647,7 +665,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
d.outboundNo(),d.deliveryNo(),new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(d.type(),d.warehouseCode(),d.warehouseCode(),d.address(),d.postalCode()),d.type(),d.warehouseId(),d.warehouseCode(),d.warehouseName(),d.address(),d.postalCode(),d.contactName(),d.contactPhone()),
d.status(),d.packageCount(),d.palletCount())).toList();
var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark());
return new DispatchViews.Detail(number,Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
return new DispatchViews.Detail(number,row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver()),
new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs),
documents,orderDetails(cargo));
@@ -754,6 +772,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
/** 动作快照只反映实际当前资格,写入仍须再次加锁校验。 */
Map<Action,Gate> actions(Actor actor,DispatchAggregate row) {
// 到仓取货和本土卡车沿运输任务执行,不能进入仓内拣货、扣库存和转派送链。
if (row.getTaskType()!=null && !"DELIVERY".equals(row.getTaskType())) {
var blocked=new EnumMap<Action,Gate>(Action.class);
for(var action:Action.values())blocked.put(action,new Gate(false,"TRANSPORT_TASK","请在排车运输任务中操作"));
return Collections.unmodifiableMap(blocked);
}
var gates=new EnumMap<Action,Gate>(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus());
Stage stage=Stage.valueOf(row.getStage()); ReviewState review=ReviewState.valueOf(row.getReviewState());
for(var action:Action.values()) {
@@ -807,7 +831,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
/** 列表不逐行读取明细、附件或派送。 */
private DispatchViews.ListRow listRow(Actor actor,DispatchAggregate row) {
return new DispatchViews.ListRow(row.getDispatchNo(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
return new DispatchViews.ListRow(row.getDispatchNo(),row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
reviews(row),read(row.getResourcesJson(),Resources.class),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),counts(row),reconciled(row),row.getArrangementKey()!=null,actions(actor,row));
}
@@ -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>();
@@ -90,7 +144,9 @@ public class DispatchService {
if(facts==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"实托完整来源快照缺失");
reasons.addAll(facts.blockReasons());
if(!facts.complete() && facts.blockReasons().isEmpty())reasons.add("实托实际绑定组成未核齐");
facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId).filter(Objects::nonNull).forEach(pallets::add);
// 缺失的实托身份保留在事实与阻断原因中,不能拿零值扩组或传给严格的实托查询入口。
facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId)
.filter(id->id!=null && id>0).forEach(pallets::add);
for(var app:facts.applications())applications.put(app.relabelNo(),app.customerId());
var codes=rows.values().stream().map(DispatchPalletCandidate::palletNo).filter(Objects::nonNull).distinct().toList();
for(var app:wholeSources.nativeApplications(seed.overseasWarehouseId(),codes))applications.put(app.relabelNo(),app.customerId());
@@ -109,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()) {
@@ -130,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>();
@@ -154,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()));
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);
}
}
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();
}
/** 新主流程按来源真客户解析并先锁全部来源订单,最后锁库存;调用者已持整车父锁。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public List<WholeSource> lockWholeSources(String warehouseId,
@@ -397,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());
@@ -409,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。 */
@@ -0,0 +1,91 @@
package com.baiyue.lastleg.dispatch.transport;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.pickup.*;
import com.baiyue.lastleg.fleet.trip.*;
import jakarta.validation.Valid;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
/** 两类原单通过现有排车权限接入,禁止客户主体借同名权限调用。 */
@RestController
@RequestMapping("/api/platform/v1/last-leg/dispatches/transport-tasks")
public class TransportTaskController {
private final TransportTaskService service;
private final FleetPickupService pickups;
private final FleetTripService trips;
private final FleetTripCompletionService completion;
/** 注入运输业务和现有现场执行能力。 */
public TransportTaskController(TransportTaskService service,FleetPickupService pickups,FleetTripService trips,FleetTripCompletionService completion){this.service=service;this.pickups=pickups;this.trips=trips;this.completion=completion;}
/** 自动汇入审核后的上门取货需求。 */
@GetMapping("/pickup-candidates") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<?> pickupCandidates(@AuthenticationPrincipal AuthenticatedUser user,@Valid FleetPickupModels.Query query){operator(user);return ApiResponse.success(pickups.candidates(query));}
/** 自动汇入本土卡车待派车需求。 */
@GetMapping("/truck-candidates") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<?> truckCandidates(@AuthenticationPrincipal AuthenticatedUser user){operator(user);return ApiResponse.success(service.truckCandidates());}
/** 创建入仓取货排车。 */
@PostMapping("/pickup") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:create')")
public ApiResponse<?> pickup(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody FleetPickupModels.Create request){return ApiResponse.success(service.createPickup(operator(user),request));}
/** 创建本土卡车排车。 */
@PostMapping("/truck") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:create')")
public ApiResponse<?> truck(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody TransportTaskModels.TruckCreate request){return ApiResponse.success(service.createTruck(operator(user),request));}
/** 当前排车详情的运输执行面板。 */
@GetMapping("/{number}") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<?> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number){operator(user);return ApiResponse.success(service.detail(number));}
/** 分配或改派真实车辆司机。 */
@PostMapping("/{number}/assign") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<?> assign(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetTripModels.Assign request){return ApiResponse.success(service.assign(number,operator(user),request));}
/** 取消未发车任务,来源回到待安排。 */
@PostMapping("/{number}/cancel") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:cancel')")
public ApiResponse<?> cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetTripModels.Cancel request){return ApiResponse.success(service.cancel(number,operator(user),request));}
/** 候选搜索复用统一资源占用真相。 */
@PostMapping("/{number}/resource-occupancy") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<?> occupancy(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetTripModels.ResourceQuery query){operator(user);return ApiResponse.success(trips.resourceOccupancy(service.transport(number,false).getTripNo(),query));}
/** 实际任务结束后分别释放车辆与司机。 */
@PostMapping("/{number}/release") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<?> release(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetCompletionModels.Release request){return ApiResponse.success(completion.release(service.transport(number,false).getTripNo(),operator(user),request));}
/** 取货发车仅推进运输。 */
@PostMapping("/{number}/pickup/depart") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:depart')")
public ApiResponse<?> departPickup(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetPickupModels.Command request){return ApiResponse.success(pickups.depart(service.transport(number,false).getTripNo(),operator(user),request));}
/** 全部送仓后结束取货运输。 */
@PostMapping("/{number}/pickup/complete") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<?> completePickup(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetPickupModels.Command request){return ApiResponse.success(pickups.complete(service.transport(number,false).getTripNo(),operator(user),request));}
/** 凭证归属具体客户原单。 */
@PostMapping(value="/{number}/pickup/{customer}/{source}/files",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<?> pickupFile(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable String customer,@PathVariable String source,@RequestPart MultipartFile file){return ApiResponse.success(pickups.upload(service.transport(number,false).getTripNo(),customer,source,operator(user),file));}
/** 确认实提箱数和原单凭证。 */
@PostMapping("/{number}/pickup/{customer}/{source}/picked") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<?> picked(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable String customer,@PathVariable String source,@Valid @RequestBody FleetPickupModels.Pickup request){return ApiResponse.success(pickups.picked(service.transport(number,false).getTripNo(),customer,source,operator(user),request));}
/** 送仓只开放原单收货门禁。 */
@PostMapping("/{number}/pickup/{customer}/{source}/arrive") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<?> arrive(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable String customer,@PathVariable String source,@Valid @RequestBody FleetPickupModels.Command request){return ApiResponse.success(pickups.arrive(service.transport(number,false).getTripNo(),customer,source,operator(user),request));}
/** 取货凭证同时核对当前排车和原单归属。 */
@GetMapping("/{number}/pickup/{customer}/{source}/files/{fileId}") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ResponseEntity<byte[]> pickupEvidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable String customer,@PathVariable String source,@PathVariable long fileId){
operator(user);var trip=service.transport(number,false);
if(pickups.tasks(trip.getTripNo()).stream().noneMatch(task->task.customerId().equals(customer)&&task.transferNo().equals(source)))throw new ResponseStatusException(HttpStatus.NOT_FOUND,"当前排车没有该原单");
return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM).body(pickups.evidence(customer,source,fileId));
}
/** 本土卡车按真实运输发车。 */
@PostMapping("/{number}/truck/depart") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:depart')")
public ApiResponse<?> departTruck(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody TransportTaskModels.Command request){return ApiResponse.success(service.truckCommand(number,operator(user),"depart",request));}
/** 本土卡车提供运输完成凭证后结案。 */
@PostMapping("/{number}/truck/complete") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<?> completeTruck(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody TransportTaskModels.Command request){return ApiResponse.success(service.truckCommand(number,operator(user),"complete",request));}
/** 本土卡车凭证绑定本次排车资源记录。 */
@PostMapping(value="/{number}/truck/files",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<?> truckFile(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@RequestPart MultipartFile file){return ApiResponse.success(service.upload(number,operator(user),file));}
/** 完成凭证经排车归属授权后下载。 */
@GetMapping("/{number}/truck/files/{fileId}") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ResponseEntity<byte[]> evidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable long fileId){operator(user);return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=transport-evidence").body(service.evidence(number,fileId));}
/** 认证身份来自网关安全上下文。 */
private static String operator(AuthenticatedUser user){
if(user==null || user.userId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"请重新登录");
if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可以操作排车");return user.userId().toString();
}
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.dispatch.transport;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import org.apache.ibatis.annotations.*;
/** 运输任务与现有排车主单的一对一关系及本土卡车原单关联。 */
@Mapper
public interface TransportTaskMapper {
/** 已成功命令持久化的凭证引用可在刷新后恢复。 */
@Select("SELECT evidence_json FROM ll_dispatch_transport_command WHERE dispatch_no=#{number} ORDER BY created_at,request_key")
java.util.List<String> evidence(String number);
/** 运输命令独立幂等账本,摘要包含动作与版本。 */
@Select("SELECT request_hash FROM ll_dispatch_transport_command WHERE dispatch_no=#{number} AND request_key=#{key}")
String commandHash(@Param("number") String number,@Param("key") String key);
/** 仅在执行成功同事务记账。 */
@Insert("INSERT INTO ll_dispatch_transport_command(dispatch_no,request_key,request_hash,evidence_json) VALUES(#{number},#{key},#{hash},#{evidence})")
int command(@Param("number") String number,@Param("key") String key,@Param("hash") String hash,@Param("evidence") String evidence);
/** 创建键关联原始请求摘要,避免修改来源后重放。 */
@Select("SELECT request_hash FROM ll_dispatch_transport_create WHERE trip_no=#{trip}")
String createHash(String trip);
/** 保存首次本土卡车安排内容。 */
@Insert("INSERT INTO ll_dispatch_transport_create(trip_no,request_hash) VALUES(#{trip},#{hash})")
int createHashSave(@Param("trip") String trip,@Param("hash") String hash);
/** 仅返回已完成原单收货分配且没有承运安排的本土卡车需求。 */
@Select("SELECT o.* FROM ll_us_truck_order o WHERE o.status='PENDING_DISPATCH' AND NOT EXISTS(SELECT 1 FROM ll_dispatch_truck_task t WHERE t.customer_id=o.customer_id AND t.order_no=o.order_no AND t.active_order IS NOT NULL) AND NOT EXISTS(SELECT 1 FROM ll_fleet_pool_order_link p WHERE p.customer_id=o.customer_id AND p.business_type='US_TRUCK' AND p.business_order_id=o.id) ORDER BY o.created_at,o.id LIMIT 100")
java.util.List<com.baiyue.lastleg.trucking.entity.UsTruckOrder> candidates();
/** 原取货创建请求重放时恢复同一排车号。 */
@Select("SELECT * FROM ll_dispatch_order WHERE trip_id=#{trip}")
DispatchAggregate byTrip(long trip);
/** 原单仅允许一个有效承运任务,取消后保留历史。 */
@Insert("INSERT INTO ll_dispatch_truck_task(dispatch_no,trip_no,customer_id,order_no,active_order) VALUES(#{dispatchNo},#{tripNo},#{customerId},#{orderNo},CONCAT(#{customerId},':',#{orderNo}))")
int insert(TransportTaskModels.TruckLink row);
/** 按排车号读取本土卡车关联。 */
@Select("SELECT dispatch_no,trip_no,customer_id,order_no FROM ll_dispatch_truck_task WHERE dispatch_no=#{number}")
TransportTaskModels.TruckLink truck(String number);
/** 取消任务解除有效单唯一约束,历史关联不删除。 */
@Update("UPDATE ll_dispatch_truck_task SET active_order=NULL WHERE dispatch_no=#{number}")
int cancel(String number);
/** 查询排车唯一资源记录。 */
@Select("SELECT trip_no FROM ll_fleet_trip WHERE id=#{id}")
String tripNo(long id);
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.dispatch.transport;
import com.baiyue.lastleg.fleet.trip.FleetTripModels;
import com.baiyue.lastleg.fleet.pickup.FleetPickupModels;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.time.Instant;
import java.util.List;
/** 排车列表中的到仓取货与本土卡车运输命令。 */
public final class TransportTaskModels {
/** 仅承载契约。 */
private TransportTaskModels() { }
public record TruckCreate(@NotBlank String customerId,@NotBlank String orderNo,
@NotNull @Positive Long warehouseId,@NotNull Instant plannedDepartureAt,
@NotNull Instant expectedDriverEndAt,@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record Command(@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
@NotBlank @Size(max=1000) String reason,List<@Positive Long> evidenceFileIds) { }
public record Detail(String dispatchNo,String taskType,FleetTripModels.Detail execution,
String customerId,String orderNo,List<FleetPickupModels.Task> pickups,
List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> completionEvidence) { }
public record TruckLink(String dispatchNo,String tripNo,String customerId,String orderNo) { }
}
@@ -0,0 +1,84 @@
package com.baiyue.lastleg.dispatch.transport;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
import com.baiyue.lastleg.dispatch.service.DispatchNumberGenerator;
import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
import com.baiyue.lastleg.fleet.trip.TransportTripChanged;
import com.baiyue.lastleg.trucking.entity.*;
import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 现有排车主单与运输事实同步,旧执行入口也不能留下不同步的单据。 */
@Service
public class TransportTaskProjection {
private final DispatchAggregateRepository roots;
private final TransportTaskRepository links;
private final UsTruckOrderRepository trucks;
private final DispatchNumberGenerator numbers;
private final ObjectMapper json;
/** 注入本地聚合与原单持久化能力,无跨服务数据库访问。 */
public TransportTaskProjection(DispatchAggregateRepository roots,TransportTaskRepository links,UsTruckOrderRepository trucks,DispatchNumberGenerator numbers,ObjectMapper json) {
this.roots=roots;this.links=links;this.trucks=trucks;this.numbers=numbers;this.json=json;
}
/** 注册真实排车号;来源行锁和唯一 trip_id 防止重复建单。 */
public DispatchAggregate register(FleetTripEntity trip,int items,int pallets,int packages,String operator) {
var existing=links.byTrip(trip.getId());if(existing!=null)return existing;
var row=new DispatchAggregate();row.setId(IdWorker.getId());row.setDispatchNo(numbers.next());row.setTaskType(trip.getTripPurpose());
row.setTripId(trip.getId());row.setOverseasWarehouseId(trip.getWarehouseId().toString());
row.setOverseasWarehouseCode(trip.getWarehouseCode());row.setOverseasWarehouseName(trip.getWarehouseName());
row.setStatus("ACTIVE");row.setStage("BEFORE_LOADING");row.setRecordVersion(trip.getRecordVersion());row.setCargoRevision(0L);
row.setCargoHash(trip.getRequestHash());row.setReviewState("DRAFT");row.setReviewRound(0L);
var review=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,0,null,null);
row.setDispatchReviewJson(write(review));row.setLoadingReviewJson(write(review));
resources(row,trip);row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt());
row.setRoute(("PICKUP".equals(trip.getTripPurpose())?"到仓取货":"本土卡车")+" · "+items+"单 · "+trip.getWarehouseCode());row.setItemCount(items);row.setPalletCount(pallets);row.setPackageCount(packages);
row.setPickedPackageCount(0);row.setShippedPackageCount(0);row.setRemark(trip.getRemark());row.setCreatedBy(operator);
row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));row.setUpdatedAt(row.getCreatedAt());roots.insert(row);
roots.history(IdWorker.getId(),row,"TRANSPORT_CREATE",operator,"接入现有排车",write(row),row.getUpdatedAt());return row;
}
/** 与资源事实同事务更新主单,兼容仍被集成调用的旧执行接口。 */
@EventListener
public void changed(TransportTripChanged event) {
var trip=event.trip();if(!Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(trip.getTripPurpose(),"")))return;
var found=links.byTrip(trip.getId());if(found==null)return;
var row=roots.lock(found.getDispatchNo());long version=row.getRecordVersion();
row.setRecordVersion(trip.getRecordVersion());row.setUpdatedAt(trip.getUpdatedAt());
row.setStatus("CANCELLED".equals(trip.getStatus())?"CANCELLED":"ACTIVE");
row.setStage(switch(trip.getStatus()){case "IN_TRANSIT"->"DELIVERING";case "COMPLETED"->"DELIVERED";default->"BEFORE_LOADING";});
row.setDepartedAt(trip.getDepartedAt());row.setDeliveryCompletedAt(trip.getCompletedAt());
row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt());row.setRemark(trip.getRemark());resources(row,trip);
if(roots.saveState(row,version)!=1)throw new IllegalStateException("运输排车版本保存失败");
roots.history(IdWorker.getId(),row,event.action(),event.operator(),event.reason(),write(row),trip.getUpdatedAt());
var link=links.truck(row.getDispatchNo());if(link==null)return;
String target=switch(trip.getStatus()){case "CANCELLED"->"PENDING_DISPATCH";case "IN_TRANSIT"->"IN_TRANSIT";case "COMPLETED"->"COMPLETED";default->"DISPATCHED";};
transition(link.customerId(),link.orderNo(),target,event.action(),event.operator(),event.reason(),row.getDispatchNo()+":"+trip.getRecordVersion());
if("CANCELLED".equals(trip.getStatus()))links.cancel(row.getDispatchNo());
}
/** 本土卡车只回写真实运输状态并追加原业务不可变历史。 */
public void transition(String customer,String number,String target,String action,String operator,String reason,String key) {
var order=trucks.lockNo(customer,number);if(order==null)throw new IllegalStateException("关联本土卡车单不存在");
String from=order.getStatus();if(target.equals(from))return;
boolean allowed=("PENDING_DISPATCH".equals(from)&&"DISPATCHED".equals(target)) || ("DISPATCHED".equals(from)&&Set.of("PENDING_DISPATCH","IN_TRANSIT").contains(target)) || ("IN_TRANSIT".equals(from)&&"COMPLETED".equals(target));
if(!allowed)throw new IllegalStateException("本土卡车原单状态与排车执行不一致");
order.setStatus(target);order.setVersion(order.getVersion()+1);order.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));trucks.updateOrder(order);
var history=new UsTruckOrderStatusHistory();history.setId(IdWorker.getId());history.setOrderId(order.getId());history.setCustomerId(customer);
history.setOperationType(action);history.setFromStatus(from);history.setToStatus(target);history.setOperatorId(operator);history.setIdempotencyKey(key);
history.setRemark(reason);history.setCreatedAt(order.getUpdatedAt());trucks.insertHistory(history);
}
/** 主单仅展示真实主档分配,不伪造临时证照或仓库装载事实。 */
private void resources(DispatchAggregate row,FleetTripEntity trip) {
row.setVehiclePlate(trip.getVehiclePlate());row.setResourcesJson(write(new Resources(
trip.getVehicleId()==null?null:new VehicleSnapshot(ResourceMode.MASTER,trip.getVehicleId(),trip.getVehiclePlate(),null,null,new Capacity(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),List.of()),
trip.getDriverId()==null?null:new DriverSnapshot(ResourceMode.MASTER,trip.getDriverId(),trip.getDriverName(),trip.getDriverPhone(),null))));
}
/** 保存明确的业务快照,失败时终止事务。 */
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("运输快照序列化失败",e);}}
}
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.dispatch.transport;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import org.springframework.stereotype.Repository;
/** 运输关联持久化边界,不在此决定业务状态。 */
@Repository
public class TransportTaskRepository {
private final TransportTaskMapper mapper;
/** 注入本域 Mapper。 */
public TransportTaskRepository(TransportTaskMapper mapper) { this.mapper=mapper; }
/** 已确认完成凭证引用。 */
public java.util.List<String> evidence(String number){return mapper.evidence(number);}
/** 读取已成功执行的运输命令摘要。 */
public String commandHash(String number,String key){return mapper.commandHash(number,key);}
/** 成功后记账。 */
public void command(String number,String key,String hash,String evidence){mapper.command(number,key,hash,evidence);}
/** 读取原创建内容摘要。 */
public String createHash(String trip){return mapper.createHash(trip);}
/** 保存创建内容摘要。 */
public void createHash(String trip,String hash){mapper.createHashSave(trip,hash);}
/** 按资源记录恢复排车主单。 */
public DispatchAggregate byTrip(long trip) { return mapper.byTrip(trip); }
/** 保存原本土卡车单关联。 */
public void insert(TransportTaskModels.TruckLink row) { mapper.insert(row); }
/** 读取原单关联。 */
public TransportTaskModels.TruckLink truck(String number) { return mapper.truck(number); }
/** 取消后允许原单重新安排。 */
public void cancel(String number) { mapper.cancel(number); }
/** 解析主单对应资源业务号。 */
public String tripNo(long id) { return mapper.tripNo(id); }
/** 最早的一百票待派车原单,安排成功后自动移出候选。 */
public java.util.List<com.baiyue.lastleg.trucking.entity.UsTruckOrder> candidates() { return mapper.candidates(); }
}
@@ -0,0 +1,158 @@
package com.baiyue.lastleg.dispatch.transport;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
import com.baiyue.lastleg.fleet.pickup.*;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.fleet.driver.DriverService;
import com.baiyue.lastleg.fleet.reporting.TripOperationsEvidenceGateway;
import com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries;
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.multipart.MultipartFile;
import java.time.*;
import java.util.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/** 现有排车入口编排运输任务,不调用仓内拣货、装车审核及扣库存命令。 */
@Service
public class TransportTaskService {
private final DispatchAggregateRepository roots;
private final TransportTaskRepository links;
private final TransportTaskProjection projection;
private final FleetTripRepository tripRepo;
private final FleetTripService trips;
private final FleetPickupService pickups;
private final FleetPickupRepository pickupFacts;
private final UsTruckOrderRepository trucks;
private final FleetPoolOrderQueries pool;
private final DriverService drivers;
private final FleetTransportVehicleGuard vehicles;
private final TripOperationsEvidenceGateway files;
private final ObjectMapper json;
/** 复用已有资格、占用、原单及凭证能力。 */
public TransportTaskService(DispatchAggregateRepository roots,TransportTaskRepository links,TransportTaskProjection projection,
FleetTripRepository tripRepo,FleetTripService trips,FleetPickupService pickups,FleetPickupRepository pickupFacts,
UsTruckOrderRepository trucks,FleetPoolOrderQueries pool,DriverService drivers,FleetTransportVehicleGuard vehicles,TripOperationsEvidenceGateway files,ObjectMapper json) {
this.roots=roots;this.links=links;this.projection=projection;this.tripRepo=tripRepo;this.trips=trips;this.pickups=pickups;this.pickupFacts=pickupFacts;
this.trucks=trucks;this.pool=pool;this.drivers=drivers;this.vehicles=vehicles;this.files=files;this.json=json;
}
/** 待排本土卡车仅展示未被独立排车或拼车承运占用的原单。 */
public List<UsTruckOrder> truckCandidates(){return links.candidates();}
/** 同仓取货沿用原任务规则,并注册一个真实排车主单。 */
@Transactional
public TransportTaskModels.Detail createPickup(String operator,FleetPickupModels.Create request) {
var created=pickups.create(operator,request);var trip=tripRepo.lockTrip(created.trip().tripNo());
var tasks=pickups.tasks(trip.getTripNo());var row=projection.register(trip,tasks.size(),0,tasks.stream().mapToInt(FleetPickupModels.Task::packageCount).sum(),operator);
return detail(row.getDispatchNo());
}
/** 原单锁和有效关联唯一约束共同阻止同单重复排车。 */
@Transactional
public TransportTaskModels.Detail createTruck(String operator,TransportTaskModels.TruckCreate request) {
String hash=hash(write(request));var prior=tripRepo.findCreated(operator,request.idempotencyKey());
if(prior!=null)return replayCreate(prior,hash);
var order=trucks.lockNo(request.customerId(),request.orderNo());if(order==null)throw missing("本土卡车单不存在");
prior=tripRepo.findCreated(operator,request.idempotencyKey());if(prior!=null)return replayCreate(prior,hash);
pool.requireStandalone(order.getCustomerId(),"US_TRUCK",order.getId());
if(!"PENDING_DISPATCH".equals(order.getStatus()) || trucks.hasActiveDispatch(order.getCustomerId(),order.getOrderNo()))throw conflict("仅待派车且未安排承运的本土卡车单可以接入排车");
if(order.getOriginAddress()==null || order.getOriginAddress().isBlank() || order.getDestinationAddress()==null || order.getDestinationAddress().isBlank())throw conflict("请先补齐原单起终点地址");
var created=trips.createLocalTruck(operator,new FleetTripModels.Create(request.warehouseId(),request.plannedDepartureAt(),request.expectedDriverEndAt(),
List.of(new FleetTripModels.Stop(order.getOrigin(),order.getOriginAddress(),null),new FleetTripModels.Stop(order.getDestination(),order.getDestinationAddress(),null)),"本土卡车:"+order.getOrderNo(),request.idempotencyKey()));
var trip=tripRepo.lockTrip(created.trip().tripNo());var row=projection.register(trip,1,value(order.getPalletCount()),value(order.getPackageCount()),operator);
links.insert(new TransportTaskModels.TruckLink(row.getDispatchNo(),trip.getTripNo(),order.getCustomerId(),order.getOrderNo()));links.createHash(trip.getTripNo(),hash);
projection.transition(order.getCustomerId(),order.getOrderNo(),"DISPATCHED","TRANSPORT_ASSIGN",operator,"接入排车:"+row.getDispatchNo(),request.idempotencyKey());
return detail(row.getDispatchNo());
}
/** 重放只返回同一原始请求,不受后续原单状态推进影响。 */
private TransportTaskModels.Detail replayCreate(FleetTripEntity trip,String hash) {
if(!"LOCAL_TRUCK".equals(trip.getTripPurpose()) || !hash.equals(links.createHash(trip.getTripNo())))throw conflict("同一安排请求不能更换来源或时间");
var row=links.byTrip(trip.getId());if(row==null)throw conflict("原安排尚未完成,请重试");return detail(row.getDispatchNo());
}
/** 通过排车号读取运输执行,不要求访问已隐藏的管理页面。 */
@Transactional(readOnly=true)
public TransportTaskModels.Detail detail(String number) {
var trip=transport(number,false);var row=links.byTrip(trip.getId());var link=links.truck(number);
return new TransportTaskModels.Detail(number,row.getTaskType(),trips.detail(trip.getTripNo()),link==null?null:link.customerId(),link==null?null:link.orderNo(),
"PICKUP".equals(row.getTaskType())?pickups.tasks(trip.getTripNo()):List.of(),
evidenceIds(number).stream().map(id->files.metadata(trip.getId(),id)).toList());
}
/** 仅允许读取已确认属于本排车的完成凭证,文件元数据再次核对资源归属。 */
public byte[] evidence(String number,long id){var trip=transport(number,false);if(!evidenceIds(number).contains(id))throw missing("当前排车没有该完成凭证");files.metadata(trip.getId(),id);return files.download(trip.getId(),id);}
/** 账本引用去重,不把上传但未确认的文件当作运输完成证据。 */
private List<Long> evidenceIds(String number){return links.evidence(number).stream().filter(Objects::nonNull).flatMap(value->{try{return json.readValue(value,new com.fasterxml.jackson.core.type.TypeReference<List<Long>>(){}).stream();}catch(Exception e){throw new IllegalStateException("运输凭证引用读取失败",e);}}).distinct().toList();}
/** 所有命令先锁资源父记录,再由既有服务核对公共执行版本。 */
public FleetTripEntity transport(String number,boolean lock) {
var root=roots.find(number);if(root==null)throw missing("排车单不存在");
if(!Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(root.getTaskType(),"")))throw conflict("当前排车单不是运输任务");
String tripNo=links.tripNo(root.getTripId());var trip=lock?tripRepo.lockTrip(tripNo):tripRepo.findTrip(tripNo);if(trip==null)throw missing("运输资源记录不存在");return trip;
}
/** 分配实际资源,容量超限时整次分配回滚。 */
@Transactional
public TransportTaskModels.Detail assign(String number,String operator,FleetTripModels.Assign request) {
var trip=transport(number,true);trips.assign(trip.getTripNo(),operator,request);
if("LOCAL_TRUCK".equals(trip.getTripPurpose()))capacity(tripRepo.findTrip(trip.getTripNo()),truck(number));return detail(number);
}
/** 取消运输并保留原单、排车及资源审计。 */
@Transactional
public TransportTaskModels.Detail cancel(String number,String operator,FleetTripModels.Cancel request) {
var trip=transport(number,true);trips.cancel(trip.getTripNo(),operator,request);return detail(number);
}
/** 本土卡车发车和完成不制造仓库收发货记录。 */
@Transactional
public TransportTaskModels.Detail truckCommand(String number,String operator,String action,TransportTaskModels.Command request) {
var trip=transport(number,true);if(!"LOCAL_TRUCK".equals(trip.getTripPurpose()))throw conflict("当前不是本土卡车运输任务");
String hash=hash(action+":"+write(request));String prior=links.commandHash(number,request.idempotencyKey());
if(prior!=null){if(!prior.equals(hash))throw conflict("同一请求标识不能提交不同内容");return detail(number);}
if(!Objects.equals(trip.getRecordVersion(),request.recordVersion()))throw conflict("运输任务已变化,请刷新后重试");
var order=truck(number);
if("depart".equals(action)) {
if(!"PLANNED".equals(trip.getStatus()) || !"DISPATCHED".equals(order.getStatus()))throw conflict("仅已派车的计划任务可发车");
requireResources(trip);capacity(trip,order);trip.setStatus("IN_TRANSIT");trip.setDepartedAt(now());
} else if("complete".equals(action)) {
if(!"IN_TRANSIT".equals(trip.getStatus()) || !"IN_TRANSIT".equals(order.getStatus()))throw conflict("请先确认发车");
if(request.evidenceFileIds()==null || request.evidenceFileIds().isEmpty())throw conflict("请上传至少一份本次运输完成凭证");
for(long id:request.evidenceFileIds())files.metadata(trip.getId(),id);
trip.setStatus("COMPLETED");trip.setCompletedAt(now());
trip.setCompletionJson(write(new FleetCompletionModels.Completed(request.idempotencyKey(),hash,trip.getCompletedAt().toInstant(ZoneOffset.UTC),Instant.now(),operator,List.of())));
} else throw conflict("不支持的运输动作");
trips.recordExecution(trip,request.recordVersion(),"LOCAL_TRUCK_"+action.toUpperCase(Locale.ROOT),request.reason(),operator);
links.command(number,request.idempotencyKey(),hash,write(request.evidenceFileIds()==null?List.of():request.evidenceFileIds()));return detail(number);
}
/** 上传只接受当前任务执行期间的真实文件,文件服务强制归属此资源记录。 */
@Transactional
public com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile upload(String number,String operator,MultipartFile file) {
var trip=transport(number,true);if(!"LOCAL_TRUCK".equals(trip.getTripPurpose()) || !Set.of("PLANNED","IN_TRANSIT").contains(trip.getStatus()))throw conflict("当前任务不能上传运输凭证");
if(file==null || file.isEmpty() || file.getSize()>10L*1024*1024)throw conflict("请上传不超过10MB的凭证");return files.upload(trip.getId(),operator,file);
}
/** 原单在执行时锁定,不能通过普通原单手工推进绕过排车。 */
private UsTruckOrder truck(String number){var link=links.truck(number);if(link==null)throw missing("本土卡车关联不存在");var order=trucks.lockNo(link.customerId(),link.orderNo());if(order==null)throw missing("本土卡车原单不存在");return order;}
/** 发车再次核对占用及证照,不把候选阶段校验当成执行资格。 */
private void requireResources(FleetTripEntity trip) {
if(trip.getVehicleId()==null || trip.getDriverId()==null || !pickupFacts.ownsResources(trip.getId(),trip.getVehicleId(),trip.getDriverId()))throw conflict("请先分配有效车辆和司机");
vehicles.validate(trip);
var driver=drivers.requireDispatchable(trip.getDriverId(),trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC));if(!"OVERSEAS".equals(driver.details().region()))throw conflict("请选择海外司机");
}
/** 原单实收托量、申报重量和体积均不得超过已分配车辆能力。 */
private void capacity(FleetTripEntity trip,UsTruckOrder order) {
if(trip.getMaxPalletSpaces()==null || value(order.getPalletCount())>trip.getMaxPalletSpaces() || trip.getMaxLoadKg()==null || trip.getMaxVolumeCbm()==null
|| (order.getTotalWeightKg()!=null && order.getTotalWeightKg().compareTo(trip.getMaxLoadKg())>0)
|| (order.getTotalVolumeCbm()!=null && order.getTotalVolumeCbm().compareTo(trip.getMaxVolumeCbm())>0))throw conflict("本土卡车货量超过所选车辆托位、载重或容积");
}
/** 可空业务数量按零显示,不产生虚构实收。 */
private static int value(Integer value){return value==null?0:value;}
/** 固定 UTC 记录执行时刻。 */
private static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC).withNano(0);}
/** 稳定序列化请求摘要。 */
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}}
/** 不在账本保存原始隐私内容。 */
private static String hash(String value){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}}
/** 返回业务冲突。 */
private static ResponseStatusException conflict(String value){return new ResponseStatusException(HttpStatus.CONFLICT,value);}
/** 返回不可访问资源。 */
private static ResponseStatusException missing(String value){return new ResponseStatusException(HttpStatus.NOT_FOUND,value);}
}
@@ -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());
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.fleet.business;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import org.apache.ibatis.annotations.*;
import java.util.Collection;
import java.util.List;
@Mapper
public interface FleetLooseboardDemandMapper {
/** 分页读取未匹配且没有独立实物履约的客户散板需求。 */
@Select("<script>SELECT o.* FROM ll_local_looseboard_order o WHERE o.status='PENDING_ACCEPTANCE' AND o.lastleg_order_id IS NULL "
+ "AND NOT EXISTS (SELECT 1 FROM ll_fleet_external_booking b WHERE b.customer_id=o.customer_id AND b.source_looseboard_order_id=o.id AND b.status!='CANCELLED') "
+ "<if test='keyword != null and keyword != &quot;&quot;'>AND (o.order_no LIKE CONCAT('%',#{keyword},'%') OR o.origin LIKE CONCAT('%',#{keyword},'%') OR o.destination LIKE CONCAT('%',#{keyword},'%'))</if> "
+ "ORDER BY o.created_at DESC,o.id DESC</script>")
Page<LocalLooseboardOrder> pending(Page<LocalLooseboardOrder> page,@Param("keyword") String keyword);
/** 原单锁内查询生效匹配,防止跨车次重复接单。 */
@Select("SELECT 1 FROM ll_fleet_external_booking WHERE customer_id=#{customer} AND source_looseboard_order_id=#{id} AND status!='CANCELLED' LIMIT 1 FOR UPDATE")
Integer active(@Param("customer") String customer,@Param("id") long id);
/** 保存预订不可变来源,取消后保留历史。 */
@Update("UPDATE ll_fleet_external_booking SET source_looseboard_order_id=#{id} WHERE id=#{booking} AND customer_id=#{customer} AND source_looseboard_order_id IS NULL")
int attach(@Param("booking") long booking,@Param("customer") String customer,@Param("id") long id);
/** 锁定当前预订对应的同客户原单。 */
@Select("SELECT o.* FROM ll_local_looseboard_order o JOIN ll_fleet_external_booking b ON b.source_looseboard_order_id=o.id AND b.customer_id=o.customer_id WHERE b.id=#{booking} AND b.customer_id=#{customer} FOR UPDATE")
LocalLooseboardOrder source(@Param("booking") long booking,@Param("customer") String customer);
/** 批量读取草稿或取消记录的原单编号。 */
@Select("<script>SELECT b.id AS booking_id,o.order_no AS business_order_no FROM ll_fleet_external_booking b JOIN ll_local_looseboard_order o ON o.id=b.source_looseboard_order_id AND o.customer_id=b.customer_id WHERE b.id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<FleetPoolOrderLink> sourceNumbers(@Param("ids") Collection<Long> ids);
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.fleet.business;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
@Repository
public class FleetLooseboardDemandRepository {
private final FleetLooseboardDemandMapper demands;
private final LocalLooseboardOrderMapper orders;
/** 注入需求查询和原单仓储。 */
public FleetLooseboardDemandRepository(FleetLooseboardDemandMapper demands,LocalLooseboardOrderMapper orders) { this.demands=demands;this.orders=orders; }
/** 分页读取待匹配原单。 */
public Page<LocalLooseboardOrder> pending(int page,int size,String keyword) { return demands.pending(Page.of(page,size),keyword); }
/** 锁定客户原单。 */
public LocalLooseboardOrder lock(String customer,String number) { return orders.lockNo(customer,number); }
/** 核对生效匹配。 */
public boolean active(String customer,long id) { return demands.active(customer,id)!=null; }
/** 关联不可变来源。 */
public int attach(long booking,String customer,long id) { return demands.attach(booking,customer,id); }
/** 锁定匹配来源。 */
public LocalLooseboardOrder source(long booking,String customer) { return demands.source(booking,customer); }
/** 批量读取原单编号。 */
public List<FleetPoolOrderLink> sourceNumbers(Collection<Long> ids) { return demands.sourceNumbers(ids); }
}
@@ -0,0 +1,52 @@
package com.baiyue.lastleg.fleet.business;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.util.Objects;
@Service
public class FleetLooseboardDemandService {
private final FleetLooseboardDemandRepository repository;
/** 注入原单需求仓储。 */
public FleetLooseboardDemandService(FleetLooseboardDemandRepository repository) { this.repository=repository; }
/** 已授权平台入口读取待匹配原单。 */
@Transactional(readOnly=true)
public PageResult<Demand> pending(int page,int size,String keyword) {
var rows=repository.pending(page,size,keyword);
return new PageResult<>(rows.getRecords().stream().map(Demand::from).toList(),rows.getCurrent(),rows.getSize(),rows.getTotal(),rows.getPages());
}
public record Demand(String orderNo,String customerId,long version,String origin,String destination,String productName,
Integer estimatedPalletCount,Integer packageCount,java.math.BigDecimal totalWeightKg,java.math.BigDecimal totalVolumeCbm,
String currency,String originContactName,String originContactPhone,String remark) {
/** 仅输出匹配所需资料,不泄露原单幂等键与请求哈希。 */
static Demand from(LocalLooseboardOrder o) { return new Demand(o.getOrderNo(),o.getCustomerId(),o.getVersion(),o.getOrigin(),o.getDestination(),o.getProductName(),o.getEstimatedPalletCount(),o.getPackageCount(),o.getTotalWeightKg(),o.getTotalVolumeCbm(),o.getCurrency(),o.getOriginContactName(),o.getOriginContactPhone(),o.getRemark()); }
}
/** 锁定原单并校验客户、版本与唯一匹配,不预留容量。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public LocalLooseboardOrder prepare(String customer,String type,String number,Long version) {
if(number==null||number.isBlank()) { if(version!=null)throw conflict("来源版本必须与散板原单一起提交"); return null; }
if(!"LOCAL_LOOSEBOARD".equals(type))throw conflict("散板原单只能匹配本土散板承运");
var order=repository.lock(customer,number);
if(order==null||version==null||!Objects.equals(version,order.getVersion()))throw conflict("散板原单不存在或已变化,请刷新待匹配需求");
if(!"PENDING_ACCEPTANCE".equals(order.getStatus())||order.getLastlegOrderId()!=null||repository.active(customer,order.getId()))throw conflict("散板原单已有承运或独立履约,请核对原单");
return order;
}
/** 原单锁保持到事务提交,跨车次匹配只允许成功一次。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void attach(long booking,LocalLooseboardOrder order) {
if(order!=null&&repository.attach(booking,order.getCustomerId(),order.getId())!=1)throw conflict("原单匹配失败,请刷新重试");
}
/** 匹配后禁止改写业务类型或替换来源,允许省略已有来源参数。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void validateEdit(long booking,String customer,String type,String number) {
var source=repository.source(booking,customer);
if(source!=null&&(!"LOCAL_LOOSEBOARD".equals(type)||number!=null&&!number.equals(source.getOrderNo())))throw conflict("已匹配散板原单不能替换,请取消登记后重新匹配");
if(source==null&&number!=null&&!number.isBlank())throw conflict("请从待匹配散板需求创建新的登记");
}
/** 返回业务冲突。 */
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT,message); }
}
@@ -20,14 +20,15 @@ import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*;
/** Same-database bridge. Never creates inventory, publishes fees, or locks a parent trip. */
@Service
public class FleetPoolOrderBridge {
@jakarta.annotation.Resource private FleetLooseboardDemandRepository demands;
private final FleetPoolOrderRepository repository;
private final FleetPoolOrderNumberGenerator numbers;
private final com.baiyue.lastleg.service.LastlegDocumentNumberGenerator numbers;
private final ObjectMapper json;
private final FleetPoolDeliveryEvidence deliveryEvidence;
private final com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources;
/** 注入同库商业委托仓储及履约协作服务。 */
public FleetPoolOrderBridge(FleetPoolOrderRepository repository,
FleetPoolOrderNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence,
com.baiyue.lastleg.service.LastlegDocumentNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence,
com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources) {
this.repository=repository;
this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence;
@@ -47,16 +48,20 @@ public class FleetPoolOrderBridge {
validate(in);
String hash=hash(in); var prior=repository.lockBooking(in.bookingId());
if(prior!=null){if(!hash.equals(prior.getCreationRequestHash()))throw conflict("同一承运来源不能创建不同商业订单");return ref(prior);}
var source=demands.source(in.bookingId(),in.customerId());
if(source!=null&&(!"LOCAL_LOOSEBOARD".equals(in.businessType())||!"PENDING_ACCEPTANCE".equals(source.getStatus())||source.getLastlegOrderId()!=null))throw conflict("散板原单履约状态已变化");
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.setCustomerId(in.customerId());row.setBusinessType(in.businessType());row.setBusinessOrderId(source==null?IdWorker.getId():source.getId());
row.setBusinessOrderNo(source==null?numbers.next("US_TRUCK".equals(in.businessType())?"TR":"LB"):source.getOrderNo());
row.setWarehouseId(in.warehouseId());row.setDestinationKey(in.destinationKey());
row.setDeliveryMode(in.deliveryMode());row.setDeliveryInstructions(in.deliveryInstructions());
row.setFulfillmentStage("WAITING_INBOUND");row.setStageOccurredAt(now);row.setRecordVersion(0L);
row.setCreationRequestHash(hash);row.setCreatedBy(operator);row.setCreatedAt(now);row.setUpdatedAt(now);
String transport=in.destinationKey().startsWith("FBA:")?"WAREHOUSE_TO_WAREHOUSE":"WAREHOUSE_TO_DOOR";
if("LOCAL_LOOSEBOARD".equals(in.businessType())) {
if(source!=null) {
source.setStatus("WAITING_INBOUND");source.setVersion(source.getVersion()+1);source.setUpdatedAt(now);repository.updateLoose(source);
} else if("LOCAL_LOOSEBOARD".equals(in.businessType())) {
var order=new LocalLooseboardOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo());
order.setTransportType(transport);
// 货名与预计托数沿真实询价保存;不从数量推断打盘服务,也不预设取送时间。
@@ -79,7 +84,7 @@ public class FleetPoolOrderBridge {
order.setRequestHash(hash);order.setCreatedBy(operator);order.setVersion(0L);order.setCreatedAt(now);order.setUpdatedAt(now);
repository.insertTruck(order); // No simulated ll_us_truck_order_pallet rows.
}
repository.insertLink(row);history(row,null,row.getFulfillmentStage(),operator,"确认承运并建立商业委托",now);
repository.insertLink(row);history(row,source==null?null:"PENDING_ACCEPTANCE",row.getFulfillmentStage(),operator,source==null?"确认承运并建立商业委托":"原单匹配车次并确认承运",now);
return ref(row);
}
/** 锁定预订并关联唯一真实来货,不创建第二份实物来源。 */
@@ -177,6 +182,7 @@ public class FleetPoolOrderBridge {
/** 批量读取预订的业务编号。 */
public Map<Long,String> businessOrderNumbers(Collection<Long> ids) {
if(ids==null||ids.isEmpty())return Map.of();var result=new HashMap<Long,String>();
demands.sourceNumbers(ids).forEach(row->result.put(row.getBookingId(),row.getBusinessOrderNo()));
repository.bookings(ids).forEach(row->result.put(row.getBookingId(),row.getBusinessOrderNo()));return result;
}
static void validate(ConfirmedBooking in) {
@@ -38,7 +38,9 @@ public interface FleetPoolOrderMapper extends BaseMapper<FleetPoolOrderLink> {
FleetPoolOrderLink lockIncoming(@Param("customerId") String customerId,@Param("no") String no);
@Select("SELECT * FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND dispatch_no=#{no} ORDER BY id FOR UPDATE")
List<FleetPoolOrderLink> lockDispatch(@Param("customerId") String customerId,@Param("no") String no);
@Select("SELECT COUNT(*) FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND business_type=#{type} AND business_order_id=#{id}")
/** 原单锁内使用当前读,避免等待匹配提交后仍按旧快照放行独立修改。 */
@Select("SELECT COALESCE((SELECT 1 FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND business_type=#{type} AND business_order_id=#{id} LIMIT 1 FOR UPDATE),0) + "
+ "COALESCE((SELECT 1 FROM ll_fleet_external_booking WHERE customer_id=#{customerId} AND #{type}='LOCAL_LOOSEBOARD' AND source_looseboard_order_id=#{id} AND status!='CANCELLED' LIMIT 1 FOR UPDATE),0)")
int linked(@Param("customerId") String customerId,@Param("type") String type,@Param("id") long id);
@Select("SELECT COUNT(*) FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND incoming_no=#{no}")
int linkedIncoming(@Param("customerId") String customerId,@Param("no") String no);
@@ -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);
@@ -0,0 +1,80 @@
package com.baiyue.lastleg.fleet.outsourcedbusiness.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels.*;
import com.baiyue.lastleg.fleet.outsourcedbusiness.service.OutsourcedBusinessService;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Query;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
/** 外配八类路径固定独立权限,不接收浏览器业务作用域。 */
@RestController
@Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/outsourced-business")
public class OutsourcedBusinessController {
private final OutsourcedBusinessService service;
private final OwnedBusinessSupport access;
/** 会话只转真实平台操作者及其原权限集合。 */
public OutsourcedBusinessController(OutsourcedBusinessService service,OwnedBusinessSupport access){this.service=service;this.access=access;}
/** 同筛选分页和完整阶段统计一次返回。 */
@GetMapping
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
public ApiResponse<ListResult> list(@AuthenticationPrincipal AuthenticatedUser user,@Valid Query query){return ApiResponse.success(service.list(actor(user),query));}
/** 列表权限包含详情,不借原排车或自有写权限。 */
@GetMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo){return ApiResponse.success(service.detail(actor(user),dispatchNo));}
/** 原主单和本页附加历史保持各自版本字段。 */
@GetMapping("/{dispatchNo}/history")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
public ApiResponse<HistoryPage> history(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
@RequestParam(defaultValue="1") @Min(1) Integer page,@RequestParam(defaultValue="20") @Min(1) @Max(200) Integer size) {
return ApiResponse.success(service.history(actor(user),dispatchNo,page,size));
}
/** 只登记整车金额和逐目的仓到达,不分摊或触发财务。 */
@PutMapping("/{dispatchNo}/operations")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:edit')")
public ApiResponse<Detail> save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@Valid @RequestBody SaveOperations request) {
return ApiResponse.success(service.save(actor(user),dispatchNo,request));
}
/** 上传仅准备照片文件,幂等键必须在multipart内。 */
@PostMapping(value="/{dispatchNo}/photos/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:upload')")
public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
return ApiResponse.success(service.upload(actor(user),dispatchNo,slot,idempotencyKey,file));
}
/** 绑定使用独立原键和运营附加版本,失败保留fileId。 */
@PutMapping("/{dispatchNo}/photos/{slot}")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:upload')")
public ApiResponse<Detail> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) {
return ApiResponse.success(service.bind(actor(user),dispatchNo,slot,request));
}
/** 当前或指定历史引用经过外配归属与权限校验后代理读取。 */
@GetMapping("/{dispatchNo}/photos/{slot}/files/{fileId}/content")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,
@PathVariable @Positive long fileId,@RequestParam(required=false) @Min(1) Long revisionVersion) {
var result=service.content(actor(user),dispatchNo,slot,fileId,revisionVersion);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(result.photo().contentType()))
.header("X-Content-Type-Options","nosniff").body(result.content());
}
/** 原键仅返回原操作者的结果,不自动补写或换键重试。 */
@GetMapping("/{dispatchNo}/commands/{idempotencyKey}")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
public ApiResponse<Command> command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
return ApiResponse.success(service.command(actor(user),dispatchNo,idempotencyKey));
}
/** 固定外配入口保留会话真实权限,不接受任何浏览器作用域。 */
private Actor actor(AuthenticatedUser user){return access.actor(user,OwnedBusinessScope.OUTSOURCED);}
}
@@ -0,0 +1,48 @@
package com.baiyue.lastleg.fleet.outsourcedbusiness.model;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.lastleg.dispatch.port.DispatchViews;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Arrival;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;
/** 外配公开合同单独表达五框、承运金额和到达,不公开里程油耗字段。 */
public final class OutsourcedBusinessModels {
/** DTO容器不允许实例化。 */
private OutsourcedBusinessModels() {}
public static final String LIST="platform:fleet:outsourced-business:list";
public static final String EDIT="platform:fleet:outsourced-business:edit";
public static final String UPLOAD="platform:fleet:outsourced-business:upload";
public enum PhotoSlot { LOADING_SCENE,LOADING_LIST,PRE_DEPARTURE_CHECK,DRIVER_LICENSE,RECEIPT }
public record Operations(@Valid OwnedBusinessModels.Money money,@NotNull List<@NotNull @Valid Arrival> arrivals) {
/** 不依赖全局Jackson未知字段开关,明确拒绝油耗、里程及浏览器归属。 */
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
}
public record SaveOperations(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
@NotNull @Valid Operations operations,@Size(max=1000) String reason) {
/** 固定外配入口不接收浏览器作用域或其他协议字段。 */
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
}
public record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {
/** 绑定只接受原版本、原键和File编号,不能提交车辆归属。 */
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
}
public record Vehicle(String vehicleId,String plate,String ownership,String carrierName) {}
public record PhotoSummary(int loadingCount) {}
public record Photo(PhotoSlot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {}
public record Row(String dispatchNo,OwnedBusinessModels.Stage stage,String plannedDepartureAt,String loadingCompletedAt,String departedAt,String deliveryCompletedAt,
OwnedBusinessModels.Origin origin,Vehicle vehicle,OwnedBusinessModels.Driver driver,List<OwnedBusinessModels.Destination> destinations,
Integer actualPalletCount,Operations operations,String unitPrice,String recordVersion,String dispatchRecordVersion,
PhotoSummary photoSummary,OwnedBusinessModels.Actions actions,List<String> sourceIssues) {}
public record ListResult(List<Row> records,int page,int size,int total,OwnedBusinessModels.Summary summary) {}
public record Detail(Row row,DispatchViews.Detail dispatch,List<OwnedBusinessModels.Cargo> cargo,List<Photo> photos) {}
public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason,Operations operations,List<Photo> photos) {}
public record HistoryPage(List<History> records,int page,int size,int total) {}
public record Command(String dispatchNo,String action,String idempotencyKey,String state,String recordVersion,boolean replayed,Object result,String errorCode,boolean retryAllowed) {}
/** 不支持字段作为明确业务输入错误,不能静默隐藏或降级解析。 */
private static BizException unsupportedField(String field){return new BizException(ResultCode.BAD_REQUEST,"外配业务不支持字段:"+field);}
}
@@ -0,0 +1,94 @@
package com.baiyue.lastleg.fleet.outsourcedbusiness.service;
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessQueryService;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessRecordService;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessPhotoService;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessProjection;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.bad;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.conflict;
/** 固定外配入口复用四表及原键机制,不伪造权限、不复制业务写实现。 */
@Service
public class OutsourcedBusinessService {
private static final OwnedBusinessScope SCOPE=OwnedBusinessScope.OUTSOURCED;
private final OwnedBusinessQueryService queries;
private final OwnedBusinessRecordService records;
private final OwnedBusinessPhotoService photos;
private final OwnedBusinessProjection projection;
private final OwnedBusinessSupport codec;
/** 只通过内部服务复用事务与存储,Controller不接触Repository。 */
public OutsourcedBusinessService(OwnedBusinessQueryService queries,OwnedBusinessRecordService records,OwnedBusinessPhotoService photos,
OwnedBusinessProjection projection,OwnedBusinessSupport codec){this.queries=queries;this.records=records;this.photos=photos;this.projection=projection;this.codec=codec;}
/** 列表承运商随批量来源读取,四项统计保持同筛选完整集合。 */
public ListResult list(OwnedBusinessModels.Actor actor,OwnedBusinessModels.Query query) {
var result=queries.listScoped(actor,query,SCOPE);var page=result.result();
return new ListResult(page.records().stream().map(r->row(r,result.carrierNames().get(r.dispatchNo()))).toList(),page.page(),page.size(),page.total(),page.summary());
}
/** 三个页签读取同一原排车详情和外配附加记录。 */
public Detail detail(OwnedBusinessModels.Actor actor,String number){return detail(queries.detail(actor,number,SCOPE));}
/** 原主单与运营历史共用分页,不在外配DTO输出油耗字段。 */
public HistoryPage history(OwnedBusinessModels.Actor actor,String number,Integer page,Integer size) {
var result=queries.history(actor,number,page,size,SCOPE);
return new HistoryPage(result.records().stream().map(h->new History(h.eventSource(),h.recordVersion(),h.dispatchRecordVersion(),h.action(),h.operatorName(),
h.occurredAt(),h.reason(),h.operations()==null?null:operations(h.operations()),h.photos().stream().map(this::photo).toList())).toList(),result.page(),result.size(),result.total());
}
/** 外配只生成金额和到达的规范内部请求,不接收归属或隐藏油耗写入。 */
public Detail save(OwnedBusinessModels.Actor actor,String number,SaveOperations request) {
if(request==null || request.operations()==null)throw bad("运营登记内容缺失");
var ops=request.operations();
return detail(records.save(actor,number,new OwnedBusinessModels.SaveOperations(request.recordVersion(),request.idempotencyKey(),
new OwnedBusinessModels.Operations(ops.money(),null,null,ops.arrivals()),request.reason()),SCOPE));
}
/** 五框使用已发布装车File分类,上传与绑定分别保留稳定键。 */
public Photo upload(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,String key,MultipartFile file) {
return photo(photos.upload(actor,number,slot(slot),key,file,SCOPE));
}
/** 原File编号由共享绑定事务校验,不创建第二份附件或写原主单。 */
public Detail bind(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,BindPhoto request) {
if(request==null)throw bad("照片绑定请求缺失");
return detail(photos.bind(actor,number,slot(slot),new OwnedBusinessModels.BindPhoto(request.recordVersion(),request.idempotencyKey(),request.fileId()),SCOPE));
}
/** 当前或历史引用读取始终限定原车、照片框和外配权限。 */
public OwnedBusinessPhotoService.Download content(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,long fileId,Long revision) {
return photos.content(actor,number,slot(slot),fileId,revision,SCOPE);
}
/** 原键结果转换成外配原始快照,不重算金额或使用现在的车辆档案。 */
public Command command(OwnedBusinessModels.Actor actor,String number,String key) {
var original=photos.command(actor,number,key,SCOPE);Object result=null;
if(original.result()!=null)result=switch(original.action()) {
case "SAVE_OPERATIONS","BIND_PHOTO"->detail(codec.read(codec.write(original.result()),OwnedBusinessModels.Detail.class));
case "UPLOAD_PHOTO"->photo(codec.read(codec.write(original.result()),OwnedBusinessModels.Photo.class));
default->throw conflict("原命令类型不属于外配业务");
};
return new Command(original.dispatchNo(),original.action(),original.idempotencyKey(),original.state(),original.recordVersion(),original.replayed(),result,original.errorCode(),original.retryAllowed());
}
/** 回执公开形状不带内部里程、油耗或油耗照片摘要。 */
private Detail detail(OwnedBusinessModels.Detail detail) {
return new Detail(row(detail.row(),projection.carrierName(detail)),detail.dispatch(),detail.cargo(),detail.photos().stream().map(this::photo).toList());
}
/** 临时车辆合法保留空vehicleId;承运商缺失保留null供页面显示破折号。 */
private Row row(OwnedBusinessModels.Row r,String carrierName) {
if(!"DISPATCHED".equals(r.vehicle().ownership()) || r.photoSummary().fuelCount()!=0)throw conflict("外配运营快照与冻结归属不一致");
return new Row(r.dispatchNo(),r.stage(),r.plannedDepartureAt(),r.loadingCompletedAt(),r.departedAt(),r.deliveryCompletedAt(),r.origin(),
new Vehicle(r.vehicle().vehicleId(),r.vehicle().plate(),r.vehicle().ownership(),carrierName==null || carrierName.isBlank()?null:carrierName),r.driver(),r.destinations(),
r.actualPalletCount(),operations(r.operations()),r.unitPrice(),r.recordVersion(),r.dispatchRecordVersion(),new PhotoSummary(r.photoSummary().loadingCount()),r.actions(),r.sourceIssues());
}
/** 拒绝异常持久化油耗,不用DTO隐藏不受支持的数据。 */
private Operations operations(OwnedBusinessModels.Operations ops) {
if(ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null)throw conflict("外配业务不能包含里程或油耗登记");
return new Operations(ops.money(),ops.arrivals());
}
/** 外配照片枚举只有五框,异常油耗历史不能借复用向外输出。 */
private Photo photo(OwnedBusinessModels.Photo p) {
SCOPE.requireSlot(p.slot());return new Photo(PhotoSlot.valueOf(p.slot().name()),p.category(),p.fileId(),p.fileName(),p.contentType(),p.sizeBytes(),p.uploadedAt(),p.savedAt());
}
/** 显式枚举转换不接受别名、兼容值或浏览器分类。 */
private OwnedBusinessModels.PhotoSlot slot(PhotoSlot slot){if(slot==null)throw bad("请选择装车照片框");return OwnedBusinessModels.PhotoSlot.valueOf(slot.name());}
}
@@ -68,14 +68,20 @@ public interface OwnedBusinessMapper {
#{reason},#{snapshotJson},#{occurredAt})
""")
int insertHistory(History row);
/** 历史按本页版本顺序分页。 */
/** 两表文本转连接字符规则后合并,长度取原值以保留中文及真实尾空格;不改原历史或JSON。 */
@Select("""
SELECT * FROM (
SELECT id,dispatch_id,record_version,NULL AS dispatch_record_version,'OPERATIONS' AS event_source,
action,operator_id,reason,snapshot_json,occurred_at FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id}
RPAD(CAST(action AS CHAR(40)),CHAR_LENGTH(action),' ') AS action,
RPAD(CAST(operator_id AS CHAR(128)),CHAR_LENGTH(operator_id),' ') AS operator_id,
RPAD(CAST(reason AS CHAR(2000)),CHAR_LENGTH(reason),' ') AS reason,
snapshot_json,occurred_at FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id}
UNION ALL
SELECT id,dispatch_id,NULL AS record_version,record_version AS dispatch_record_version,'DISPATCH' AS event_source,
action,operator_id,reason,snapshot_json,occurred_at FROM ll_dispatch_history WHERE dispatch_id=#{id}
RPAD(CAST(action AS CHAR(40)),CHAR_LENGTH(action),' ') AS action,
RPAD(CAST(operator_id AS CHAR(128)),CHAR_LENGTH(operator_id),' ') AS operator_id,
RPAD(CAST(reason AS CHAR(2000)),CHAR_LENGTH(reason),' ') AS reason,
snapshot_json,occurred_at FROM ll_dispatch_history WHERE dispatch_id=#{id}
) events ORDER BY occurred_at DESC,event_source,id DESC LIMIT #{limit} OFFSET #{offset}
""")
List<History> history(@Param("id") long id,@Param("offset") int offset,@Param("limit") int limit);
@@ -16,10 +16,14 @@ public interface OwnedBusinessQueryMapper {
o.loading_completion_hash AS owned_loading_completion_hash,o.loading_completed_at AS owned_loading_completed_at
FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id
""";
/** 摘要按列上限128字节完整比较,不受新旧表默认排序规则影响,不能省略二进制宽度。 */
String FILTER="""
d.status='ACTIVE' AND d.stage IN ('AFTER_LOADING','DELIVERING','DELIVERED')
AND d.loading_completed_at IS NOT NULL AND d.loading_confirmation_json IS NOT NULL
AND o.vehicle_ownership='OWNED' AND o.loading_completion_hash=d.loading_completion_hash
AND o.vehicle_ownership=#{q.scope.ownership}
AND CAST(o.loading_completion_hash AS BINARY(128))=CAST(d.loading_completion_hash AS BINARY(128))
AND o.loading_completed_at=d.loading_completed_at
AND (o.resource_mode='MASTER' OR (#{q.scope.ownership}='DISPATCHED' AND o.resource_mode='EXTERNAL'))
<if test='q.from != null'> AND d.planned_departure_at &gt;= #{q.from}</if>
<if test='q.to != null'> AND d.planned_departure_at &lt; #{q.to}</if>
<if test='q.stage != null'> AND d.stage=#{q.stage}</if>

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