Compare commits

..
Author SHA1 Message Date
ouyangxq 55436e3a0a fix(firstleg): align warehouse arrival sources on latest observed port fact 2026-10-21 16:45:42 +08:00
ouyangxq c962f614e6 优化 2026-10-21 16:40:18 +08:00
ouyangxq 090591076f 优化 2026-10-21 15:53:33 +08:00
ouyangxq 94595c810b fix(ci): retain configured image repository defaults 2026-10-21 15:28:44 +08:00
ouyangxq 6fe632f28e 优化 2026-10-21 15:10:44 +08:00
ouyangxq 40a03978cd 优化 2026-10-21 14:30:41 +08:00
ouyangxq 0af977bdef fix(fleet): compare pooled cargo identifiers without collation conflicts 2026-10-21 14:16:17 +08:00
ouyangxq be79b90a4a fix(fleet): hash pooled dispatch source keys without object cast 2026-10-21 13:45:21 +08:00
ouyangxq 337da6d67c 优化 2026-10-21 13:22:35 +08:00
ouyangxq b4ff441a70 fix(numbering): expose coload and settlement payment business numbers 2026-10-21 11:43:55 +08:00
ouyangxq 9fb39604c6 fix(calendar): include dispatch container numbers in calendar 2026-10-21 11:38:18 +08:00
ouyangxq 6b2df287bd test(return): align parcel counts and physical carton assertions 2026-10-21 11:18:02 +08:00
ouyangxq 7a9ab27fd2 Merge remote-tracking branch 'origin/test' into test 2026-10-21 11:05:59 +08:00
ouyangxq 4d12b56cac test(numbering): align return fixtures with receipt contract 2026-10-21 11:01:24 +08:00
ouyangxq 91b6574d53 fix(finance): canonicalize wallet and reconciliation command hashes 2026-10-21 10:56:06 +08:00
liuyihe e5f9cdcf01 提交 2026-10-21 10:52:17 +08:00
ouyangxq b072c0a884 fix(finance): stabilize settlement idempotency request hashes 2026-10-21 10:41:38 +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
222 changed files with 5268 additions and 1810 deletions
+7 -3
View File
@@ -108,14 +108,14 @@
## 数据库访问与中文代码注释(所有服务及公共模块强制)
- 本节适用于全部后端服务和公共模块;新增、修改的方法必须执行,触及的存量代码同步整改。登记本约束不代表存量代码已全部完成迁移和注释补齐。
- 本节适用于全部后端服务和公共模块;新增代码遵守下列规范,存量分层仅在影响本次正确性或用户明确要求时整改,不因局部修改迁移整条链路;以上为目标规范,不代表存量均已达标。
- 生产代码的关系型数据库读写统一经 MyBatis/MyBatis-Plus Mapper 执行;Controller、Service、定时任务、消费者、编号生成器和公共基础组件不得直接使用 `JdbcTemplate`、`NamedParameterJdbcTemplate`、原生 JDBC/Connection/Statement 或其他通道执行 SQL。仅改成 Repository 包装 JDBC 不算整改,SQL 必须进入 Mapper;无数据库职责的模块不得为满足本条新增数据库访问。
- 正常业务数据库访问路径统一为 `Controller → Service → Repository → Mapper`,四层职责明确,不得省略 Repository,也不得由 Controller 直接调用 Repository/Mapper,或由 Service 直接调用 Mapper。定时任务、消费者等非 HTTP 入口从 Service 进入,同样经 Repository → Mapper 完成持久化访问。
- Controller 负责请求接收、参数校验、入口权限和响应转换;Service 负责业务规则、编排及业务事务;Repository 负责领域数据访问封装、查询组合与持久化模型转换,对 Service 提供有业务含义的数据操作;Mapper 负责 SQL 执行及数据库记录映射。Repository 不承载业务状态流转,不直接用 JDBC 执行 SQL;Service 不继承 MyBatis-Plus `ServiceImpl` 等方式绕过 Repository。
- 复杂查询、批量处理、原子计数、锁定读取、Outbox 和数据库监控查询同样经 Repository → Mapper,不以 SQL 特殊或实现方便为由跳层。公共模块需要数据库能力时,按现有模块边界定义 Repository/持久化接口,由所属服务的 Repository → Mapper 实现,禁止借此跨服务读库。
- 迁移数据库操作须保留客户隔离条件、事务传播、行锁、幂等和并发语义;`LAST_INSERT_ID()` 等连接级操作必须在同一事务连接完成,并明确关闭相关读取缓存,不能仅机械搬移 SQL。Mapper 注册及扫描仍遵守下文统一规范。
- 测试代码可用 `JdbcTemplate` 创建隔离测试表、准备数据及核对结果;被测生产路径仍必须调用实际 Mapper,不得把测试造数做法带入生产。数据库基线和迁移脚本按现有部署契约管理。
- 每个手写方法及构造器均须添加简短中文注释,通常 1—3 行说明业务用途;接口方法和 Mapper 方法优先使用中文 Javadoc。覆盖方法同样须有简短中文说明,实现中的关键规则仍须就地注释;自动生成的方法不手工逐个补注释。
- 本次新增或修改的手写方法及构造器须有简短中文业务注释,通常 1—3 行;接口与 Mapper 方法优先用中文 Javadoc,覆盖方法同样适用,关键规则就地说明。不补齐未修改或自动生成方法的注释。
- 核心业务、复杂 SQL、状态流转、资金与库存变更、权限隔离、事务、锁、幂等、并发、重试和补偿逻辑,须在关键代码前增加适量中文注释,解释业务规则、处理原因、约束边界及失败影响;不逐行翻译代码,不添加无信息量或与实现不符的注释。
- 新增和修改的代码说明注释统一使用中文;必要的技术标识、协议名、类名可保留原文,第三方许可证和生成标记保持原样。修改方法时同步更新相关存量英文或过期说明;审查时同时核对 Controller → Service → Repository → Mapper 分层及中文方法、核心逻辑注释。
@@ -132,7 +132,11 @@
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;开发环境按最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql` 收敛,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作结构来源和追溯,部署说明见 `deploy/sql/README.md`;生产升级另按明确发布规则处理。
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
- 通用配置在 `config/nacos/test/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
- 后端本地验证默认使用 `scripts/backend-check.ps1`,必须显式指定 `-Modules`,不传模块直接拒绝;命令及参数见 `scripts/README.md` 的“后端分层检查”。入口自动经 `local-check.ps1` 限流,`-DryRun` 只检查选择范围,不代表测试通过。
- 局部逻辑使用 `-Mode Focused -Tests <精确测试类名>`,覆盖修改行为及必要边界即可交付,不因收尾再跑 `Module`。Java 签名变化须覆盖生产与测试源码编译;定向测试已编译时不重复跑 `-Mode Compile`。只有影响扩及服务内多个行为且定向覆盖不足才选 `Module`,先说明原因。跨服务 DTO、接口或事件变化显式列入生产者和实际消费者;`-am` 仅补上游构建依赖,不自动包含下游或 HTTP/事件消费者。
- 仅注释/文档调整不触发 Java 回归;脚本调整执行受影响脚本测试和登记合同。API、Mapper、AGENTS 等静态合同仍按各自触发条件执行。纯重构也应覆盖受影响行为,不能只用编译宣称回归通过。`-Dmaven.test.skip=true` 跳过测试编译,不得作为签名变更交付验证。
- 全仓回归仅用于已确认影响广泛且定向覆盖不足的公共依赖/聚合拓扑/认证/序列化/基础模型变化,或用户要求的全量/跨域集成验收;先说明具体影响,不能因未查清范围而全跑。根 POM、依赖或测试配置改动先按实际影响选编译或测试,不一律全仓。入口为 `scripts/backend-full-regression.ps1`,CI 全量保留。普通提交、单服务任务或对话收尾不触发;有效结果复用,输入变化只补跑受影响范围。
- 不以缩短耗时为由删除金额、库存、权限隔离、状态流转、幂等和并发测试;冗余测试须根据重复场景及维护成本单独评估。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
- 同一批 `AGENTS.md` 索引修改完成后从后端根统一运行一次 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,不访问 HTTP、数据库或部署环境。
- Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
Vendored
+2 -1
View File
@@ -161,7 +161,8 @@ pipeline {
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试执行(仍编译测试源码)')
string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库地址 host[:port]/owner/image')
// 地址由当前 Job 配置提供;参数声明刷新时保留已配置值,不将环境地址写入源码。
string(name: 'IMAGE_REPOSITORY', defaultValue: params.IMAGE_REPOSITORY ?: '', description: '沿用当前 Job 已配置的镜像仓库地址;首次配置或更换仓库时填写 host[:port]/owner/image')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀')
}
+2 -1
View File
@@ -49,7 +49,8 @@ pipeline {
booleanParam(name: 'ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false, description: '仅确认相同协议与Schema时允许回滚;架构切换保持关闭')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库基础路径,不包含服务名和标签')
// 独立构建沿用本 Job 配置,总控调度时保留其明确传入的地址。
string(name: 'IMAGE_REPOSITORY', defaultValue: params.IMAGE_REPOSITORY ?: '', description: '沿用当前 Job 已配置的镜像仓库基础路径,不包含服务名和标签;首次配置或更换仓库时填写')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
}
+8 -3
View File
@@ -23,11 +23,16 @@
## 本地构建
```powershell
mvn -DskipTests validate
node --test scripts/java-topology-contract.test.mjs
mvn test
# 日常按受影响模块检查;跨服务变更显式列入消费者。
.\scripts\backend-check.ps1 -Modules order -Mode Compile
.\scripts\backend-check.ps1 -Modules order
# 根构建/拓扑变化等全量触发条件见 AGENTS.md。
.\scripts\local-check.ps1 -Command @('node', '--test', 'scripts/java-topology-contract.test.mjs')
.\scripts\backend-full-regression.ps1
```
开发中可通过 `backend-check.ps1 -Mode Focused -Modules <模块> -Tests <精确测试类名>` 快速反馈;参数、DryRun 和结果边界见 [后端分层检查](scripts/README.md#后端分层检查)。单服务修改收尾执行模块回归,不默认执行上面的全仓命令。
GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。
2026-07-30 订单与客户前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。
@@ -40,8 +40,14 @@ public class GlobalExceptionHandler {
@Autowired(required = false)
private MeterRegistry meterRegistry;
/** 系统类业务失败必须记录链路和异常,普通业务校验仍只返回统一响应。 */
@ExceptionHandler(BizException.class)
public ResponseEntity<ApiResponse<Void>> handleBusiness(BizException exception, HttpServletRequest request) {
if (exception.getResultCode().httpStatus().is5xxServerError()) {
log.error("系统业务请求失败 method={} path={} code={} requestId={} traceId={}",
request.getMethod(), request.getRequestURI(), exception.getResultCode().code(),
CorrelationContext.requestId(request), CorrelationContext.traceId(request), exception);
}
return failure(exception.getResultCode(), exception.getMessage(), request);
}
@@ -5,6 +5,9 @@ import com.baiyue.common.core.exception.BizException;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.core.MethodParameter;
import org.springframework.validation.BeanPropertyBindingResult;
@@ -39,6 +42,37 @@ class GlobalExceptionHandlerTest {
assertThat(response.getBody().requestId()).isEqualTo("r1");
}
/** 系统类业务失败留下路径、关联标识和根因,但不打印请求参数或令牌。 */
@Test
@ExtendWith(OutputCaptureExtension.class)
void systemBusinessFailureLogsCorrelationAndCause(CapturedOutput output) {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/platform/v1/orders/status-counts");
request.setAttribute(CorrelationContext.REQUEST_ID, "status-count-request");
request.setAttribute(CorrelationContext.TRACE_ID, "status-count-trace");
request.setQueryString("keyword=private-query");
request.addHeader("X-Internal-Service-Token", "private-service-token");
BizException exception = new BizException(ResultCode.SERVICE_UNAVAILABLE, "统计暂不可用");
exception.initCause(new IllegalStateException("缺少尾程来源"));
var response = handler.handleBusiness(exception, request);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody().code()).isEqualTo("S0503");
assertThat(response.getBody().message()).isEqualTo("统计暂不可用");
assertThat(output.getOut()).contains("ERROR", "GET", "/api/platform/v1/orders/status-counts",
"S0503", "status-count-request", "status-count-trace", "缺少尾程来源")
.doesNotContain("private-query", "private-service-token");
}
/** 预期的业务冲突不应升级为系统错误日志。 */
@Test
@ExtendWith(OutputCaptureExtension.class)
void normalBusinessFailureDoesNotLogSystemError(CapturedOutput output) {
handler.handleBusiness(new BizException(ResultCode.CONFLICT, "normal-business-conflict"),
new MockHttpServletRequest());
assertThat(output.getAll()).doesNotContain("系统业务请求失败", "normal-business-conflict");
}
@Test
void invalidQueryParameterTypeMapsToBadRequest() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
+2 -1
View File
@@ -5,7 +5,8 @@ spring:
password: ${FINANCE_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:}
# TEST 流程验收复用 Client 的示例图片,图片标注不可付款且不含真实收款信息。
baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:/customer-recharge-qr.svg}
baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00}
baiyue.event-delivery.operations-token: ${EVENT_OPERATIONS_TOKEN:}
baiyue.event-delivery.connect-timeout-ms: ${EVENT_CONNECT_TIMEOUT_MS:3000}
+2
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 数据不倒推历史归属,匹配版本按新原生样本验收。
@@ -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;
@@ -4437,20 +4437,6 @@ CREATE TABLE fl_inbound_split_item (
SET SESSION foreign_key_checks = 1;
-- 客户独立确认计费重,不回填既有费用确认。
CREATE TABLE fl_chargeable_weight_confirmation (
id BIGINT NOT NULL COMMENT '确认记录ID',
tenant_id BIGINT NOT NULL COMMENT '客户隔离ID',
inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID',
operator_id BIGINT NOT NULL COMMENT '确认客户成员ID',
command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键',
fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要',
chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克',
confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key),
KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id)
) ENGINE=InnoDB COMMENT='客户计费重独立确认历史';
-- 服务 schema:baiyue_file
CREATE DATABASE IF NOT EXISTS baiyue_file DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_file;
@@ -8413,4 +8399,4 @@ CREATE TABLE wh_document_sequence (
CONSTRAINT chk_wh_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
SELECT 'PASS' AS initialization_status, 'CN' AS region, 230 AS initialized_tables;
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。
+34 -11
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 '起运海外仓编码快照',
@@ -428,6 +429,7 @@ CREATE TABLE ll_dispatch_order (
review_state VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '审核子流程当前状态',
review_round BIGINT NOT NULL DEFAULT 0 COMMENT '当前审核轮次',
review_basis_hash CHAR(64) NULL COMMENT '本审核轮次核对的货物和资源依据摘要',
exceptions_json JSON NULL COMMENT '派送异常登记记录及图片引用,不改变派送及库存状态',
dispatch_review_json JSON NULL COMMENT '当前调度审核事实快照',
loading_review_json JSON NULL COMMENT '当前理货审核事实快照',
resources_json JSON NULL COMMENT '本趟主档或临时车辆司机的真实资源快照',
@@ -1656,12 +1658,6 @@ CREATE TABLE ll_monthly_exchange_rate_history (
KEY idx_ll_exchange_rate_history (exchange_rate_id,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='月度汇率变更历史';
CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
PRIMARY KEY(business_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
CREATE TABLE ll_order (
id bigint NOT NULL COMMENT '雪花 ID',
order_no varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '尾程单号',
@@ -3216,6 +3212,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 '客户送仓或人工安排提货回仓',
@@ -3230,6 +3227,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),
@@ -4876,7 +4874,8 @@ CREATE TABLE ll_fleet_pickup_history (
KEY idx_pickup_history_trip(trip_no,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership (
-- 自有业务仅保存原排车的归属证据和附加运营信息,不复制履约主单。
CREATE TABLE ll_fleet_owned_business_ownership (
dispatch_id BIGINT NOT NULL COMMENT '原排车内部主键',
resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL',
vehicle_id BIGINT NULL COMMENT '真实车辆档案主键,临时外配为空',
@@ -4894,7 +4893,7 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership (
OR (resource_mode='EXTERNAL' AND vehicle_id IS NULL AND vehicle_record_version IS NULL AND vehicle_ownership='DISPATCHED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车装车完成时的车辆归属证据';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record (
CREATE TABLE ll_fleet_owned_business_record (
id BIGINT NOT NULL COMMENT '附加运营记录内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
record_version BIGINT NOT NULL COMMENT '附加运营记录并发版本,独立于原排车版本',
@@ -4919,7 +4918,7 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record (
CONSTRAINT ck_owned_business_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command (
CREATE TABLE ll_fleet_owned_business_command (
id BIGINT NOT NULL COMMENT '附加运营命令内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作人标识',
@@ -4940,7 +4939,7 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command (
CONSTRAINT ck_owned_business_command_status CHECK (status IN ('PENDING','SUCCEEDED','REJECTED','UNKNOWN_RESULT'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车附加运营写命令及可恢复结果';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history (
CREATE TABLE ll_fleet_owned_business_history (
id BIGINT NOT NULL COMMENT '附加运营历史内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
record_version BIGINT NOT NULL COMMENT '成功写入后的附加记录版本',
@@ -4955,6 +4954,30 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history (
CONSTRAINT ck_owned_business_history_version CHECK (record_version>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史';
-- 运输原单接入现有排车,不产生仓内库存事实。
USE baiyue_lastleg;
CREATE TABLE ll_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 '业务前缀与北京时间日期组成的全局序列键',
@@ -4963,4 +4986,4 @@ CREATE TABLE ll_document_sequence (
CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 232 AS initialized_tables;
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 234 AS initialized_tables;
@@ -11,6 +11,8 @@ enum BusinessFileCategory {
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
@@ -78,9 +80,9 @@ enum BusinessFileCategory {
&& tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台");
}
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE)
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE || this == OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE)
&& tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台");
throw new BizException(ResultCode.BAD_REQUEST, "排车证据必须归属平台");
}
if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台");
@@ -4,9 +4,9 @@ info:
version: 1.0.0
description: >-
复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。
两类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示,
三类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示,
不使用车辆 ID、司机 ID、车牌或客户 ID。车辆与司机证据分别使用
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE,不可互换。
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE,不可互换。司机本人照片DRIVER_PHOTO和排车异常图片EXCEPTION仅支持JPG/PNG;其他两类沿PDF/JPG/PNG。
File 校验内部服务令牌、平台归属、正数业务 ID、文件非空、最大 10MiB(10485760 字节,包含边界)、
PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId
联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。
@@ -126,7 +126,7 @@ components:
description: 正数Long的十进制字符串,值不得超过9223372036854775807
DispatchEvidenceType:
type: string
enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE]
enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE, OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE]
MetadataEnvelope:
type: object
required: [code, data]
@@ -30,6 +30,8 @@ import static org.mockito.Mockito.*;
class DispatchEvidenceFileTest {
private static final String VEHICLE = "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE";
private static final String EXCEPTION = "OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE";
private static final String PHOTO = "OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE";
private static final String DRIVER = "OVERSEAS_DISPATCH_DRIVER_EVIDENCE";
private static final int MAX_BYTES = 10 * 1024 * 1024;
@TempDir Path root;
@@ -40,9 +42,9 @@ class DispatchEvidenceFileTest {
FileObjectEntity.class);
}
/** 两类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
/** 三类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO, EXCEPTION})
void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) {
var files = mock(FileObjectMapper.class);
var storage = mock(BusinessFileStorage.class);
@@ -64,6 +66,19 @@ class DispatchEvidenceFileTest {
verifyNoInteractions(files, storage);
}
/** 司机照片与异常图片使用独立平台类别,拒绝PDF和伪造图片。 */
@ParameterizedTest @ValueSource(strings = {PHOTO, EXCEPTION})
void photoAcceptsOnlyRealImages(String type) throws Exception {
var storage=storage();var category=BusinessFileCategory.require(type);
for(var format:new String[]{"jpeg","png"}) {
var upload=new MockMultipartFile("file","driver."+format,"image/"+format,image(format));
var saved=storage.store(0,"71",upload,category);
assertThat(storage.read(saved.objectKey())).isEqualTo(upload.getBytes());
}
assertThatThrownBy(()->storage.store(0,"71",pdf(),category)).isInstanceOf(BizException.class);
assertThatThrownBy(()->storage.store(0,"71",new MockMultipartFile("file","driver.png","image/png",pdf().getBytes()),category)).isInstanceOf(BizException.class);
}
/** 真实 PDF、JPG、PNG 均可写入并保持内容;上传不以扩展名代替 MIME 和签名。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
@@ -149,7 +164,7 @@ class DispatchEvidenceFileTest {
/** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO, EXCEPTION})
void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception {
var files = mock(FileObjectMapper.class);
var storage = mock(BusinessFileStorage.class);
@@ -37,7 +37,7 @@ public final class FinanceSettlementDtos {
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency,
String reviewStatus, String sourceStatus,
boolean statementGenerated) {}
public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate,
public record SettlementTransaction(long transactionId, String transactionNo, String transactionType, LocalDate transactionDate,
BigDecimal amount, String method, String referenceNo,
List<String> voucherFileIds, long createdBy, String createdByName,
LocalDateTime createdAt,
@@ -0,0 +1,43 @@
package com.baiyue.finance.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Map;
/** 结算详情只读SQL;子查询均保留所属客户和原结算范围。 */
@Mapper
public interface FinanceSettlementDetailMapper {
String SUMMARY_SELECT =
"SELECT s.id,s.tenant_id,s.settlement_no,s.direction,s.customer_name_snapshot,s.supplier_id," +
"s.supplier_name_snapshot,s.reconciliation_statement_id,b.bill_no reconciliation_statement_no," +
"s.business_period,(SELECT GROUP_CONCAT(DISTINCT fs.fee_item_name ORDER BY fs.fee_item_name SEPARATOR '、') " +
"FROM fin_settlement_line sl JOIN fin_fee_source fs ON fs.id=sl.fee_source_id " +
"WHERE sl.tenant_id=s.tenant_id AND sl.settlement_id=s.id) fee_item_summary," +
"s.currency_code,s.total_amount,s.paid_amount,s.outstanding_amount,s.generated_date,s.due_date," +
"s.payment_status,s.status,s.created_by,(SELECT a.operator_name_snapshot FROM fin_audit_log a " +
"WHERE a.tenant_id=s.tenant_id AND a.resource_type='FIN_SETTLEMENT' AND a.resource_id=s.id " +
"AND a.action='CREATE' ORDER BY a.created_at,a.id LIMIT 1) created_by_name,s.created_at,s.updated_at," +
"CASE WHEN s.direction='RECEIVABLE' THEN (SELECT MAX(r.created_at) FROM fin_receipt_bill rb " +
"JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
"WHERE rb.tenant_id=s.tenant_id AND rb.bill_id=s.reconciliation_statement_id AND r.status='CONFIRMED') " +
"ELSE (SELECT MAX(p.created_at) FROM fin_settlement_payment p WHERE p.tenant_id=s.tenant_id " +
"AND p.settlement_id=s.id) END last_payment_at,s.version FROM fin_settlement s " +
"LEFT JOIN fin_bill b ON b.tenant_id=s.tenant_id AND b.id=s.reconciliation_statement_id ";
/** 读取平台结算主单及来源摘要。 */
@Select(SUMMARY_SELECT + "WHERE s.id=#{settlementId}")
List<Map<String, Object>> summary(@Param("settlementId") long settlementId);
/** 读取该客户该结算的费用明细。 */
@Select("SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id,f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.review_status,f.status,f.source_snapshot FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id WHERE sl.tenant_id=#{customerId} AND sl.settlement_id=#{settlementId} ORDER BY sl.id")
List<Map<String, Object>> lines(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
/** 读取该客户付款流水及落库业务号,不能用ID替代。 */
@Select("SELECT id,transaction_no,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_by_name,created_at,remark FROM fin_settlement_payment WHERE tenant_id=#{customerId} AND settlement_id=#{settlementId} ORDER BY created_at,id")
List<Map<String, Object>> payments(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
/** 读取对账单已确认收款;收款凭证号不是PAY业务号。 */
@Select("SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id WHERE rb.tenant_id=#{customerId} AND rb.bill_id=#{statementId} AND r.status='CONFIRMED' ORDER BY r.created_at,r.id")
List<Map<String, Object>> receipts(@Param("customerId") long customerId, @Param("statementId") long statementId);
/** 读取该客户结算修订历史。 */
@Select("SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log WHERE tenant_id=#{customerId} AND resource_type='FIN_SETTLEMENT' AND resource_id=#{settlementId} ORDER BY created_at,id")
List<Map<String, Object>> audits(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
}
@@ -0,0 +1,29 @@
package com.baiyue.finance.repository;
import com.baiyue.finance.mapper.FinanceSettlementDetailMapper;
import org.springframework.stereotype.Repository;
import org.springframework.util.LinkedCaseInsensitiveMap;
import java.util.List;
import java.util.Map;
/** 结算详情查询封装,列名大小写差异不改变已有字段映射语义。 */
@Repository
public class FinanceSettlementDetailRepository {
private final FinanceSettlementDetailMapper mapper;
/** 注入结算详情持久化入口。 */
public FinanceSettlementDetailRepository(FinanceSettlementDetailMapper mapper) { this.mapper = mapper; }
/** 保留原数据库列名不区分大小写的读取语义。 */
private List<Map<String, Object>> rows(List<Map<String, Object>> rows) {
return rows.stream().map(row -> { Map<String, Object> copy = new LinkedCaseInsensitiveMap<>(); copy.putAll(row); return copy; }).toList();
}
/** 读取平台结算主单及来源摘要。 */
public List<Map<String, Object>> summary(long settlementId) { return rows(mapper.summary(settlementId)); }
/** 读取该客户该结算的费用明细。 */
public List<Map<String, Object>> lines(long customerId, long settlementId) { return rows(mapper.lines(customerId, settlementId)); }
/** 读取该客户付款流水及落库业务号,不能用ID替代。 */
public List<Map<String, Object>> payments(long customerId, long settlementId) { return rows(mapper.payments(customerId, settlementId)); }
/** 读取对账单已确认收款;收款凭证号不是PAY业务号。 */
public List<Map<String, Object>> receipts(long customerId, long statementId) { return rows(mapper.receipts(customerId, statementId)); }
/** 读取该客户结算修订历史。 */
public List<Map<String, Object>> audits(long customerId, long settlementId) { return rows(mapper.audits(customerId, settlementId)); }
}
@@ -12,6 +12,7 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest;
import com.baiyue.finance.client.FirstlegFeeSourceClient;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
@@ -437,7 +438,14 @@ public class FinancePhase1OperationService {
private static Long nullableLong(ResultSet rs, String column) throws SQLException { long value = rs.getLong(column); return rs.wasNull() ? null : value; }
private JsonNode parse(String json) { try { return objectMapper.readTree(json); } catch (Exception e) { throw error("FIN-DATA-500", "费用来源快照损坏"); } }
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw error("FIN-DATA-500", "财务数据序列化失败"); } }
private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw error("FIN-DATA-500", "幂等请求哈希失败"); } }
/** 固定映射键序,使同一请求在服务重启前后使用相同摘要。 */
private String hash(Object value) {
try {
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
.writeValueAsBytes(value);
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical));
} catch (Exception failure) { throw error("FIN-DATA-500", "幂等请求哈希失败"); }
}
private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isMissingNode() || value.isNull() ? null : value.asText(); }
private static Long jsonLong(JsonNode node, String field) {
JsonNode value = node.path(field);
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
@@ -306,7 +307,14 @@ public class FinanceService {
default -> throw new IllegalArgumentException("未知财务幂等操作: " + operation);
};
}
private String hash(Object request) { try { byte[] bytes = java.security.MessageDigest.getInstance("SHA-256").digest(json(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)); return java.util.HexFormat.of().formatHex(bytes); } catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); } }
/** 固定请求映射键序,避免服务重启后拒绝同一财务命令。 */
private String hash(Object request) {
try {
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
.writeValueAsBytes(request == null ? List.of() : request);
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(canonical));
} catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); }
}
private static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); }
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
@@ -12,6 +12,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.slf4j.MDC;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
@@ -44,23 +45,9 @@ import java.util.UUID;
public class FinanceSettlementService {
private static final String CNY = "CNY";
private static final Set<String> PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
private static final String SETTLEMENT_SUMMARY_SELECT =
"SELECT s.id,s.tenant_id,s.settlement_no,s.direction,s.customer_name_snapshot,s.supplier_id," +
"s.supplier_name_snapshot,s.reconciliation_statement_id,b.bill_no reconciliation_statement_no," +
"s.business_period,(SELECT GROUP_CONCAT(DISTINCT fs.fee_item_name ORDER BY fs.fee_item_name SEPARATOR '、') " +
"FROM fin_settlement_line sl JOIN fin_fee_source fs ON fs.id=sl.fee_source_id " +
"WHERE sl.tenant_id=s.tenant_id AND sl.settlement_id=s.id) fee_item_summary," +
"s.currency_code,s.total_amount,s.paid_amount,s.outstanding_amount,s.generated_date,s.due_date," +
"s.payment_status,s.status,s.created_by,(SELECT a.operator_name_snapshot FROM fin_audit_log a " +
"WHERE a.tenant_id=s.tenant_id AND a.resource_type='FIN_SETTLEMENT' AND a.resource_id=s.id " +
"AND a.action='CREATE' ORDER BY a.created_at,a.id LIMIT 1) created_by_name,s.created_at,s.updated_at," +
"CASE WHEN s.direction='RECEIVABLE' THEN (SELECT MAX(r.created_at) FROM fin_receipt_bill rb " +
"JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
"WHERE rb.tenant_id=s.tenant_id AND rb.bill_id=s.reconciliation_statement_id AND r.status='CONFIRMED') " +
"ELSE (SELECT MAX(p.created_at) FROM fin_settlement_payment p WHERE p.tenant_id=s.tenant_id " +
"AND p.settlement_id=s.id) END last_payment_at,s.version FROM fin_settlement s " +
"LEFT JOIN fin_bill b ON b.tenant_id=s.tenant_id AND b.id=s.reconciliation_statement_id ";
private static final String SETTLEMENT_SUMMARY_SELECT = com.baiyue.finance.mapper.FinanceSettlementDetailMapper.SUMMARY_SELECT;
private final JdbcTemplate jdbc;
private final com.baiyue.finance.repository.FinanceSettlementDetailRepository details;
private final ObjectMapper objectMapper;
private final FinanceVoucherValidator voucherValidator;
private final FinanceSupplierValidator supplierValidator;
@@ -70,8 +57,10 @@ public class FinanceSettlementService {
@Autowired
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
FinanceSupplierValidator supplierValidator,
FinanceDocumentNumberGenerator documentNumbers) {
FinanceDocumentNumberGenerator documentNumbers,
com.baiyue.finance.repository.FinanceSettlementDetailRepository details) {
this.jdbc = jdbc;
this.details = details;
this.objectMapper = objectMapper;
this.voucherValidator = voucherValidator;
this.supplierValidator = supplierValidator;
@@ -182,33 +171,21 @@ public class FinanceSettlementService {
return getSettlement(settlementId);
}
/** 读取结算及其业务流水号;全部详情数据访问经Repository和Mapper。 */
public SettlementDetail getSettlement(long settlementId) {
if (settlementId <= 0) throw error("FIN_404", "结算单不存在");
Map<String, Object> row = one(
SETTLEMENT_SUMMARY_SELECT + "WHERE s.id=?",
"结算单不存在", settlementId);
SettlementSummary summary = summary(row);
List<SettlementLine> lines = jdbc.queryForList(
"SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id," +
"f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.review_status,f.status,f.source_snapshot " +
"FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id " +
"WHERE sl.tenant_id=? AND sl.settlement_id=? ORDER BY sl.id", summary.customerId(), settlementId)
List<Map<String, Object>> records = details.summary(settlementId);
if (records.isEmpty()) throw error("FIN_404", "结算单不存在");
SettlementSummary summary = summary(records.get(0));
List<SettlementLine> lines = details.lines(summary.customerId(), settlementId)
.stream().map(source -> line(source, summary)).toList();
List<SettlementTransaction> transactions = new ArrayList<>(jdbc.queryForList(
"SELECT id,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_by_name,created_at,remark " +
"FROM fin_settlement_payment WHERE tenant_id=? AND settlement_id=? ORDER BY created_at,id",
summary.customerId(), settlementId).stream().map(this::transaction).toList());
List<SettlementTransaction> transactions = new ArrayList<>(details.payments(summary.customerId(), settlementId)
.stream().map(this::transaction).toList());
if ("RECEIVABLE".equals(summary.direction()) && summary.reconciliationStatementId() != null) {
transactions.addAll(jdbc.queryForList(
"SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark " +
"FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
"WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.created_at,r.id",
summary.customerId(), summary.reconciliationStatementId()).stream().map(this::transaction).toList());
transactions.addAll(details.receipts(summary.customerId(), summary.reconciliationStatementId())
.stream().map(this::transaction).toList());
}
List<Map<String, Object>> audits = jdbc.queryForList(
"SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log " +
"WHERE tenant_id=? AND resource_type='FIN_SETTLEMENT' AND resource_id=? ORDER BY created_at,id",
summary.customerId(), settlementId);
List<Map<String, Object>> audits = details.audits(summary.customerId(), settlementId);
List<SettlementRevision> revisions = new ArrayList<>();
for (int index = 0; index < audits.size(); index++) revisions.add(revision(index + 1, audits.get(index)));
return new SettlementDetail(summary, lines, transactions, List.copyOf(revisions));
@@ -476,8 +453,9 @@ public class FinanceSettlementService {
text(row, "status"), true);
}
/** 返回真实付款业务号;未生成PAY号的收款记录保持为空,不回退成内部ID。 */
private SettlementTransaction transaction(Map<String, Object> row) {
return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_type"),
return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_no"), text(row, "transaction_type"),
date(row, "transaction_date"), decimal(row, "amount"), text(row, "method"), text(row, "reference_no"),
stringList(text(row, "voucher_file_ids")), number(row, "created_by").longValue(),
text(row, "created_by_name"),
@@ -523,10 +501,13 @@ public class FinanceSettlementService {
return documentNumbers.next("PAY");
}
/** 固定映射键序,避免 JVM 重启改变 Map.of 遍历顺序而误拒绝原请求。 */
private String hash(Object value) {
try {
String canonical = objectMapper.writer()
.with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsString(value);
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(json(value).getBytes(StandardCharsets.UTF_8)));
.digest(canonical.getBytes(StandardCharsets.UTF_8)));
} catch (Exception failure) {
throw error("FIN_001", "幂等请求哈希失败");
}
@@ -14,6 +14,7 @@ import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletPage;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.ResponseEntity;
@@ -694,8 +695,9 @@ public class FinanceWalletService {
timestamp(rs.getTimestamp("reviewed_at")));
}
/** 摘要固定映射键序,保证充值和资金指令跨进程重放稳定。 */
private String hash(Object value) {
try { return sha256(objectMapper.writeValueAsBytes(value)); }
try { return sha256(objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsBytes(value)); }
catch (Exception exception) { throw error("FIN_500", "请求摘要生成失败"); }
}
private String fileHash(MultipartFile file) {
@@ -10,6 +10,7 @@ import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.slf4j.MDC;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
@@ -521,9 +522,13 @@ public class ReconciliationStatementService {
catch (Exception exception) { throw error("FIN_001", "财务数据序列化失败"); }
}
/** 固定映射键序,使同一请求在服务重启前后使用相同摘要。 */
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); }
catch (Exception exception) { throw error("FIN_001", "幂等请求哈希失败"); }
try {
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
.writeValueAsBytes(value);
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical));
} catch (Exception failure) { throw error("FIN_001", "幂等请求哈希失败"); }
}
private static LocalDateTime localTime(Object value) {
@@ -93,7 +93,7 @@ class FinancePlatformListContractTest {
assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision.class,
"operatorId", "operatorName");
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementTransaction.class,
"createdBy", "createdByName");
"createdBy", "createdByName", "transactionNo");
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementRevision.class,
"operatorId", "operatorName");
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary.class,
@@ -236,8 +236,8 @@ class FinanceSettlementBehaviorTest {
eq("SETTLEMENT_PAYMENT_CREATE"), eq("pay-key"))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(after));
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(10L), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(10L), eq(88L))).thenReturn(List.of(Map.ofEntries(
Map.entry("id", 501L), Map.entry("transaction_type", "PAYMENT"),
when(jdbc.queryForList(startsWith("SELECT id,transaction_no,transaction_type"), eq(10L), eq(88L))).thenReturn(List.of(Map.ofEntries(
Map.entry("id", 501L), Map.entry("transaction_no", "PAY202610210001"), Map.entry("transaction_type", "PAYMENT"),
Map.entry("transaction_date", Date.valueOf(LocalDate.of(2026, 8, 11))),
Map.entry("amount", new BigDecimal("40.00")), Map.entry("method", "BANK_TRANSFER"),
Map.entry("reference_no", "BANK-1"), Map.entry("voucher_file_ids", "[\"91\"]"),
@@ -258,6 +258,7 @@ class FinanceSettlementBehaviorTest {
assertThat(result.transactions()).singleElement().extracting(transaction -> transaction.transactionType())
.isEqualTo("PAYMENT");
assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin");
assertThat(result.transactions().get(0).transactionNo()).isEqualTo("PAYMENT".equals(result.transactions().get(0).transactionType()) ? "PAY202610210001" : null);
verify(jdbc).update(startsWith("UPDATE fin_settlement SET paid_amount"),
any(), any(), any(), any(), any(), any(), any(), any());
}
@@ -522,6 +523,7 @@ class FinanceSettlementBehaviorTest {
.isEqualTo("RECEIPT");
assertThat(result.transactions().get(0).amount()).isEqualByComparingTo("40.00");
assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin");
assertThat(result.transactions().get(0).transactionNo()).isEqualTo("PAYMENT".equals(result.transactions().get(0).transactionType()) ? "PAY202610210001" : null);
}
@Test
@@ -623,8 +625,15 @@ class FinanceSettlementBehaviorTest {
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");
// 既有业务测试保留JDBC替身,新详情路径通过真实Repository和Mapper接口适配执行。
var mapper = org.mockito.Mockito.mock(com.baiyue.finance.mapper.FinanceSettlementDetailMapper.class, invocation -> {
var select = invocation.getMethod().getAnnotation(org.apache.ibatis.annotations.Select.class);
if (select == null) return RETURNS_DEFAULTS.answer(invocation);
String sql = String.join(" ", select.value()).replaceAll("#\\{[a-zA-Z]+\\}", "?");
return jdbc.queryForList(sql, invocation.getArguments());
});
return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(),
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers);
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers, new com.baiyue.finance.repository.FinanceSettlementDetailRepository(mapper));
}
private static FinanceSupplierValidator supplierValidator() {
@@ -657,7 +666,7 @@ class FinanceSettlementBehaviorTest {
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(settlement));
long customerId = ((Number) settlement.get("tenant_id")).longValue();
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(customerId), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,transaction_no,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT action,reason"), eq(customerId), eq(88L))).thenReturn(List.of());
}
@@ -0,0 +1,36 @@
package com.baiyue.finance;
import com.baiyue.finance.mapper.FinanceSettlementDetailMapper;
import com.baiyue.finance.repository.FinanceSettlementDetailRepository;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceSettlementDetailMapperTest {
/** 真实执行付款查询,业务号必须来自本客户本结算的流水,空号不以其他字段替代。 */
@Test
void paymentNumbersRemainScopedToCustomerAndSettlement() {
var source = new DriverManagerDataSource("jdbc:h2:mem:settlement-number;MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var jdbc = new JdbcTemplate(source);
jdbc.execute("CREATE TABLE fin_settlement_payment (id BIGINT PRIMARY KEY,tenant_id BIGINT,settlement_id BIGINT,transaction_no VARCHAR(32),transaction_type VARCHAR(32),transaction_date DATE,amount DECIMAL(18,2),method VARCHAR(32),reference_no VARCHAR(64),voucher_file_ids VARCHAR(255),created_by BIGINT,created_by_name VARCHAR(64),created_at TIMESTAMP,remark VARCHAR(255))");
jdbc.update("INSERT INTO fin_settlement_payment (id,tenant_id,settlement_id,transaction_no,transaction_type,reference_no) VALUES (1,10,20,'PAY202610210001','PAYMENT','BANK-1'),(2,11,20,'PAY202610210002','PAYMENT','BANK-2'),(3,10,21,'PAY202610210003','PAYMENT','BANK-3'),(4,10,22,NULL,'PAYMENT','BANK-4')");
var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), source));
configuration.addMapper(FinanceSettlementDetailMapper.class);
try (var session = new SqlSessionFactoryBuilder().build(configuration).openSession()) {
var repository = new FinanceSettlementDetailRepository(session.getMapper(FinanceSettlementDetailMapper.class));
var rows = repository.payments(10, 20);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).get("transaction_no")).isEqualTo("PAY202610210001");
assertThat(rows.get(0).get("reference_no")).isEqualTo("BANK-1");
assertThat(repository.payments(11, 21)).isEmpty();
assertThat(repository.payments(10, 22)).hasSize(1);
assertThat(repository.payments(10, 22).get(0).get("transaction_no")).isNull();
}
}
}
@@ -0,0 +1,47 @@
package com.baiyue.finance;
import com.baiyue.finance.service.FinanceSettlementService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import com.baiyue.finance.service.FinanceWalletService;
import com.baiyue.finance.service.FinancePhase1OperationService;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.service.ReconciliationStatementService;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceSettlementRequestHashTest {
/** 模拟不同 JVM 的 Map 遍历顺序;同一付款请求必须得到相同摘要。 */
@ParameterizedTest
@ValueSource(classes = {FinanceSettlementService.class, FinanceWalletService.class,
FinancePhase1OperationService.class, FinanceService.class, ReconciliationStatementService.class})
void equivalentRequestsIgnoreMapIterationOrderButDetectChangedPayment(Class<?> serviceType) {
Object service = org.mockito.Mockito.mock(serviceType, org.mockito.Mockito.CALLS_REAL_METHODS);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
Map<String, Object> body = new LinkedHashMap<>();
body.put("amount", 190); body.put("expectedVersion", 0);
Map<String, Object> first = new LinkedHashMap<>();
first.put("path", "/settlements/202/transactions"); first.put("method", "POST");
first.put("customerId", 10L); first.put("body", body);
Map<String, Object> reversedBody = new LinkedHashMap<>();
reversedBody.put("expectedVersion", 0); reversedBody.put("amount", 190);
Map<String, Object> second = new LinkedHashMap<>();
second.put("body", reversedBody); second.put("customerId", 10L);
second.put("method", "POST"); second.put("path", "/settlements/202/transactions");
String original = ReflectionTestUtils.invokeMethod(service, "hash", first);
String reordered = ReflectionTestUtils.invokeMethod(service, "hash", second);
assertThat(reordered).isEqualTo(original);
reversedBody.put("amount", 191);
String changed = ReflectionTestUtils.invokeMethod(service, "hash", second);
assertThat(changed).isNotEqualTo(original);
second.put("customerId", 11L); reversedBody.put("amount", 190);
String otherCustomer = ReflectionTestUtils.invokeMethod(service, "hash", second);
assertThat(otherCustomer).isNotEqualTo(original);
}
}
@@ -67,10 +67,11 @@ public class PlatformColoadGroupController {
idempotencyKey, user.userId(), user.username()));
}
/** 按入仓码返回同柜组业务编号及当前有效成员,保留平台只读权限边界。 */
@GetMapping("/by-inbound-code/{inboundCode}")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
@Operation(summary = "按入仓码查询同柜组业务摘要",
description = "仅返回业务状态、有效成员数及去重后的全部成员入仓码;内部组ID和组号不作为页面识别键。")
description = "返回真实业务组号、业务状态、有效成员数及去重后的全部成员入仓码;不返回内部组ID。")
public ApiResponse<BusinessSummary> byInboundCode(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Size(min = 1, max = 40) String inboundCode) {
@@ -71,6 +71,7 @@ public final class ColoadGroupModels {
/** 页面只读业务摘要;内部组标识不作为用户识别键。 */
public record BusinessSummary(
String groupNo,
String status,
int memberCount,
List<BusinessMember> members
@@ -108,7 +108,7 @@ public interface ColoadGroupMapper {
@Param("inboundId") long inboundId);
@Select("""
SELECT DISTINCT group_row.id groupId,group_row.group_status groupStatus
SELECT DISTINCT group_row.id groupId,group_row.group_no groupNo,group_row.group_status groupStatus
FROM fl_inbound_order requested
JOIN fl_coload_group_member member
ON member.tenant_id=requested.tenant_id AND member.inbound_id=requested.id
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.loading.coload.persistence;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 平台同柜组摘要的只读数据访问,保留原入仓片段和组状态范围。 */
@Repository
public class ColoadGroupQueryRepository {
private final ColoadGroupMapper mapper;
/** 注入同服务持久化入口。 */
public ColoadGroupQueryRepository(ColoadGroupMapper mapper) { this.mapper = mapper; }
/** 按业务入仓码查询当前组及其真实业务组号。 */
public List<ColoadPersistenceRows.InboundGroupLookupRow> groupsByInboundCode(String code) {
return mapper.groupsByInboundCode(code);
}
/** 读取被冻结的成员,交由服务核对缺失和重复片段。 */
public List<ColoadPersistenceRows.MemberCodeRow> effectiveMemberCodes(long groupId) {
return mapper.effectiveMemberCodes(groupId);
}
}
@@ -59,6 +59,7 @@ public final class ColoadPersistenceRows {
@Setter
public static class InboundGroupLookupRow {
private Long groupId;
private String groupNo;
private String groupStatus;
}
@@ -47,6 +47,8 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
@Resource
private ColoadGroupMapper mapper;
@Resource
private com.baiyue.firstleg.loading.coload.persistence.ColoadGroupQueryRepository queryRepository;
@Resource private PendingReviewOrderClient pendingReviewOrders;
@Resource private OrderCargoClient orderCargoClient;
@Resource private InboundOrderCreationService inboundOrderCreationService;
@@ -118,10 +120,11 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
return requiredSnapshot(groupId);
}
/** 以入仓码读取同柜组业务号及全部有效成员,不返回内部组ID。 */
@Transactional(readOnly = true)
public BusinessSummary queryByInboundCode(String inboundCode) {
String code = normalizedInboundCode(inboundCode);
var groups = mapper.groupsByInboundCode(code).stream()
var groups = queryRepository.groupsByInboundCode(code).stream()
.filter(row -> row.getGroupId() != null && CURRENT_GROUP_STATUSES.contains(row.getGroupStatus()))
.toList();
long distinctGroups = groups.stream().map(row -> row.getGroupId()).distinct().limit(2).count();
@@ -131,7 +134,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
if (distinctGroups > 1) throw conflict("入仓码关联多个有效同柜不可拆组");
long groupId = groups.get(0).getGroupId();
String status = groups.get(0).getGroupStatus();
var memberRows = mapper.effectiveMemberCodes(groupId);
var memberRows = queryRepository.effectiveMemberCodes(groupId);
if (memberRows.isEmpty()) throw conflict("同柜不可拆组不存在有效入仓片段");
if (memberRows.stream().anyMatch(row -> row.getMemberId() == null || row.getInboundId() == null
|| trim(row.getInboundCode()) == null)) {
@@ -142,7 +145,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
throw conflict("同柜不可拆组存在重复入仓片段业务号");
}
List<BusinessMember> members = inboundCodes.stream().sorted().map(BusinessMember::new).toList();
return new BusinessSummary(status, members.size(), members);
return new BusinessSummary(groups.get(0).getGroupNo(), status, members.size(), members);
}
private long requiredCurrentGroupId(String inboundCode) {
@@ -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
@@ -63,6 +63,7 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
""")
CarrierScheduleQueryEntity findByContainerBookingId(@Param("containerBookingId") long containerBookingId);
/** 到港与日历统一取最新观测的实际记录,后续修正为更早时间时不能被历史最大值覆盖。 */
@Select("""
<script>
SELECT b.id AS containerBookingId,q.id AS queryId,q.version AS queryVersion,
@@ -70,7 +71,7 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
current.actual_at AS latestActualAt,
MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE' THEN f.actual_at END) AS departedWarehouseAt,
MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT' THEN f.actual_at END) AS departedOriginPortAt,
MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT' THEN f.actual_at END) AS arrivedDestinationPortAt,
MAX(arrival.actual_at) AS arrivedDestinationPortAt,
MAX(CASE WHEN f.standard_node='PICKUP_READY' THEN f.actual_at END) AS pickupReadyAt,
MAX(CASE WHEN f.standard_node='PICKED_UP' THEN f.actual_at END) AS pickedUpAt,
MAX(CASE WHEN f.standard_node='EMPTY_RETURNED' THEN f.actual_at END) AS emptyReturnedAt
@@ -81,6 +82,10 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
ORDER BY f2.observed_at DESC,f2.id DESC LIMIT 1)
LEFT JOIN fl_carrier_schedule_query q ON q.id=current.query_id
LEFT JOIN fl_carrier_schedule_node_fact f ON f.container_booking_id=b.id AND f.actual_at IS NOT NULL
LEFT JOIN fl_carrier_schedule_node_fact arrival ON arrival.id=(
SELECT a.id FROM fl_carrier_schedule_node_fact a
WHERE a.container_booking_id=b.id AND a.standard_node='ARRIVED_DESTINATION_PORT' AND a.actual_at IS NOT NULL
ORDER BY a.observed_at DESC,a.id DESC LIMIT 1)
WHERE b.id IN
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
GROUP BY b.id,q.id,q.version,q.status,current.standard_node,current.actual_at
@@ -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) {}
@@ -24,27 +24,32 @@ public class WorkflowTabService {
var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList());
// 确认页签复用已推送费用版本。
for(var fee:repository.feeConfirmationRows(facts.stream().map(WorkflowFacts.Fact::inboundId).toList())) {
if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable();
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 {
var response=lastleg.read(token,new WorkflowFacts.Request(sources));
if(response==null || !response.successful() || response.data()==null) throw unavailable();
if(response==null || !response.successful() || response.data()==null) throw unavailable(
"尾程事实响应无效 code="+(response==null?null:response.code())
+" requestId="+(response==null?null:response.requestId())+" traceId="+(response==null?null:response.traceId()));
var allowed=new HashSet<>(sources);
for(var fact:response.data()) {
if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable();
if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable("尾程事实包含空记录或本批以外的客户/入仓来源");
overseas.merge(fact.tenantId()+":"+fact.inboundId(),fact,(a,b)->new WorkflowFacts.Overseas(a.tenantId(),a.inboundId(),a.outboundExists()||b.outboundExists(),a.pendingOutbound()||b.pendingOutbound(),a.delivering()||b.delivering(),a.unloaded()&&b.unloaded()));
}
if(overseas.size()!=sources.size()) throw unavailable();
} catch(BizException e) { throw e; } catch(Exception e) { throw unavailable(); }
if(overseas.size()!=sources.size()) throw unavailable("尾程事实缺少来源 expected="+sources.size()+" actual="+overseas.size()
+" missing="+sources.stream().filter(s->!overseas.containsKey(s.tenantId()+":"+s.inboundId())).limit(20).toList());
} catch(BizException e) { throw e; } catch(Exception e) {
throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试").initCause(e);
}
}
// SQL排除仅作汇总的父票;保留承接实际装柜数量的来源票及独立余量票。
for(var fact:facts) {
var inbound=expected.get(fact.inboundId());
if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable();
if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable("头程事实与入仓单归属不一致 inboundId="+fact.inboundId());
var o=overseas.get(fact.tenantId()+":"+fact.inboundId());
for(String key:classify(fact,inbound.getProcessStatus(),o)) result.putIfAbsent(fact.orderId()+":"+key,new StatusRow(fact.tenantId(),fact.orderId(),key));
}
@@ -56,16 +61,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");
@@ -75,5 +82,9 @@ public class WorkflowTabService {
return keys;
}
/** 下游缺失或归属异常不能生成部分数量。 */
private static BizException unavailable() { return new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试"); }
private static BizException unavailable(String reason) {
// 诊断信息只进入服务日志,公开响应保持稳定,不暴露内部来源标识。
return (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试")
.initCause(new IllegalStateException(reason));
}
}
@@ -45,8 +45,8 @@ paths:
summary: 按入仓码查询同柜组业务摘要
description: >-
需要 platform:firstleg:smart-loading:list。使用任一有效成员的全局唯一 inboundCode 查询;
仅返回组业务状态、有效成员数和全部唯一成员入仓码。任一冻结成员缺少有效 inboundCode 时失败关闭;
页面不持有或展示 groupId、groupNo、orderId,也不得从技术ID或订单号推导入仓码。
返回真实业务组号 groupNo、组业务状态、有效成员数和全部唯一成员入仓码。任一冻结成员缺少有效 inboundCode 时失败关闭;
页面不持有或展示 groupId、orderId,也不得从技术ID或订单号推导入仓码。
parameters:
- name: inboundCode
in: path
@@ -147,8 +147,9 @@ components:
ColoadGroupBusinessSummary:
type: object
additionalProperties: false
required: [status, memberCount, members]
required: [groupNo, status, memberCount, members]
properties:
groupNo: {type: string, pattern: "^CLG[0-9]{12}$", description: 真实同柜业务组号;与创建及重放结果一致}
status: {type: string, enum: [ACTIVE, LOCKED, CONSUMED]}
memberCount: {type: integer, minimum: 1}
members:
@@ -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]
@@ -1679,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}
@@ -55,7 +55,7 @@ class ColoadGroupOpenApiContractTest {
.doesNotContain("destinationCode")
.doesNotContain("expectedVersion")
.doesNotContain("platform:firstleg:coload");
assertThat(summaryProperties).containsOnlyKeys("status", "memberCount", "members");
assertThat(summaryProperties).containsOnlyKeys("groupNo", "status", "memberCount", "members");
assertThat(memberProperties).containsOnlyKeys("inboundCode");
}
}
@@ -37,7 +37,7 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig({ColoadGroupService.class, ColoadGroupServiceTest.Numbers.class})
@SpringJUnitConfig({ColoadGroupService.class, ColoadGroupServiceTest.Numbers.class, com.baiyue.firstleg.loading.coload.persistence.ColoadGroupQueryRepository.class})
class ColoadGroupServiceTest {
@org.springframework.context.annotation.Configuration
static class Numbers {
@@ -71,6 +71,7 @@ class ColoadGroupServiceTest {
var result = service.queryByInboundCode(" IN-A ");
assertThat(result.groupNo()).isEqualTo("CLG202610210001");
assertThat(result.status()).isEqualTo("ACTIVE");
assertThat(result.memberCount()).isEqualTo(2);
assertThat(result.members()).extracting("inboundCode").containsExactly("IN-A", "IN-B");
@@ -574,6 +575,7 @@ class ColoadGroupServiceTest {
private static InboundGroupLookupRow lookup(long groupId, String status) {
InboundGroupLookupRow value = new InboundGroupLookupRow();
value.setGroupId(groupId);
value.setGroupNo("CLG202610210001");
value.setGroupStatus(status);
return value;
}
@@ -86,7 +86,8 @@ class ColoadGroupSqlContractTest {
.contains("requested.inbound_code=#{inboundCode}")
.contains("requested.deleted=0")
.contains("'ACTIVE','LOCKED','CONSUMED'")
.doesNotContain("group_no", "order_id=#{");
.contains("group_row.group_no groupNo")
.doesNotContain("order_id=#{");
assertThat(memberSql)
.contains("member.id memberId", "member.inbound_id inboundId", "inbound.inbound_code inboundCode")
.contains("LEFT JOIN fl_inbound_order inbound")
@@ -44,8 +44,9 @@ class CarrierScheduleQuerySqlContractTest {
}
}
/** 保留批量读取,实际到港与日历采用相同的最新观测顺序。 */
@Test
void batchesActualContainerFactsWithoutEstimatedOrPerNodeQueries() throws Exception {
void batchesActualContainerFactsWithLatestObservedArrivalAndWithoutEstimatedTimes() throws Exception {
String sql = String.join("\n", CarrierScheduleQueryMapper.class
.getMethod("selectContainerActualFacts", List.class)
.getAnnotation(Select.class).value());
@@ -55,13 +56,16 @@ class CarrierScheduleQuerySqlContractTest {
.contains("f.actual_at IS NOT NULL")
.contains("MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE'")
.contains("MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT'")
.contains("MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT'")
.contains("MAX(arrival.actual_at) AS arrivedDestinationPortAt")
.contains("a.standard_node='ARRIVED_DESTINATION_PORT' AND a.actual_at IS NOT NULL")
.contains("ORDER BY a.observed_at DESC,a.id DESC LIMIT 1")
.doesNotContain("MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT'")
.contains("MAX(CASE WHEN f.standard_node='PICKUP_READY'")
.contains("MAX(CASE WHEN f.standard_node='PICKED_UP'")
.contains("MAX(CASE WHEN f.standard_node='EMPTY_RETURNED'")
.contains("ORDER BY b.id")
.doesNotContain("estimated_at");
assertThat(count(sql, "fl_carrier_schedule_node_fact")).isEqualTo(3);
assertThat(count(sql, "fl_carrier_schedule_node_fact")).isEqualTo(5);
assertThat(count(sql, "SELECT f2.id FROM fl_carrier_schedule_node_fact f2")).isEqualTo(1);
}
@@ -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 = 230;
int expectedTables = 229;
long actualTables = java.util.regex.Pattern.compile("(?m)^CREATE TABLE\\s+")
.matcher(sql).results().count();
assertThat(actualTables).isEqualTo(expectedTables);
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.terminaldrayage.mapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
@@ -10,6 +11,51 @@ import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
class ContainerCalendarSourceMapperTest {
/** 后续观测可将实际到港修正为更早日期,两入口须取同一条最新实际记录。 */
@Test
void usesLatestObservedArrivalIncludingSameObservationIdTieBreakAcrossBothEntrypoints() throws Exception {
var ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:arrival-source-correction;MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
sql.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,cabinet_no VARCHAR,container_no VARCHAR," +
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," +
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_carrier_schedule_query(id BIGINT,version INT,status VARCHAR)");
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,query_id BIGINT,container_booking_id BIGINT," +
"standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT,actual_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_outbound_dispatch(container_booking_id BIGINT,confirmed_at TIMESTAMP,reversed_at TIMESTAMP,status VARCHAR)");
sql.execute("INSERT INTO fl_container_booking(id,cabinet_no,estimated_port_arrival_at,record_status) VALUES " +
"(1,'D1','2026-10-29 08:00:00','ACTIVE'),(2,'D2','2026-10-29 08:00:00','ACTIVE')");
sql.execute("INSERT INTO fl_carrier_schedule_query VALUES(1,0,'READY'),(2,0,'READY')");
sql.execute("INSERT INTO fl_outbound_dispatch SELECT id,'2026-10-01 00:00:00',NULL,'WAITING_FOR_LOADING' FROM fl_container_booking");
sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES " +
"(1,1,1,'ARRIVED_DESTINATION_PORT','2026-10-23 14:00:00','2026-10-24 14:00:00')," +
"(2,1,1,'ARRIVED_DESTINATION_PORT','2026-10-21 14:00:00','2026-10-25 14:00:00')," +
"(3,1,1,'ARRIVED_DESTINATION_PORT',NULL,'2026-10-26 14:00:00')," +
"(4,2,2,'ARRIVED_DESTINATION_PORT','2026-10-24 14:00:00','2026-10-25 14:00:00')," +
"(5,2,2,'ARRIVED_DESTINATION_PORT','2026-10-22 14:00:00','2026-10-25 14:00:00')");
}
var config = new MybatisConfiguration();
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
config.addMapper(ContainerCalendarSourceMapper.class);
config.addMapper(CarrierScheduleQueryMapper.class);
try (var session = new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var from = LocalDateTime.parse("2026-10-01T00:00:00");
var calendar = session.getMapper(ContainerCalendarSourceMapper.class).sources(from, from.minusDays(2), from.minusDays(4), from.plusMonths(1));
var workbench = session.getMapper(CarrierScheduleQueryMapper.class).selectContainerActualFacts(java.util.List.of(1L, 2L));
assertEquals(2, calendar.size());
assertEquals(2, workbench.size());
for (int index = 0; index < calendar.size(); index++) {
var expected = LocalDateTime.parse(index == 0 ? "2026-10-21T14:00:00" : "2026-10-22T14:00:00");
assertEquals(expected, calendar.get(index).arrivalAt());
assertEquals("ACTUAL", calendar.get(index).arrivalType());
assertEquals(expected, workbench.get(index).arrivedDestinationPortAt());
assertEquals(calendar.get(index).arrivalAt().plusDays(2).toLocalDate(), workbench.get(index).arrivedDestinationPortAt().plusDays(2).toLocalDate());
}
}
}
/** 使用实际 Mapper 核对跨月默认日期候选、缺日期与真实出仓边界。 */
@Test
void selectsShiftedArrivalUndatedAndOlderReturnCandidatesWithoutInactiveOrUndepartedContainers() throws Exception {
@@ -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);
}
/** 下游漏来源或返回其他客户均整体失败。 */
@@ -25,12 +25,34 @@ class WorkflowBoundaryTest {
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
when(repository.facts(List.of(1L))).thenReturn(List.of(fact()));
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of()));
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class)
.hasMessage("订单业务页签事实暂不可用,请稍后重试")
.hasRootCauseMessage("尾程事实缺少来源 expected=1 actual=0 missing=[Source[tenantId=11, inboundId=1]]");
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
new WorkflowFacts.Overseas(22,1,true,true,false,true))));
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
when(downstream.read(any(),any())).thenThrow(new IllegalStateException("unavailable"));
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
var failure=new IllegalStateException("unavailable");
when(downstream.read(any(),any())).thenThrow(failure);
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure);
}
/** 未记录离港时,报关完成即可待清关;上传清关资料后也必须查询精确海外来源。 */
@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->{
+4 -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`;局部任务不扩读无关约束。
@@ -55,7 +57,7 @@
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java` |
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java` |
| 平台排车查询、整车四块详情、审核与实载 | `dispatch/controller/DispatchQueryController.java`、`DispatchAggregateController.java`;公开契约`dispatch/port/`,业务规则见`../../docs/海外仓业务/排车列表统一作业改造契约.md` |
| 草稿、双端装发与派送安排 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`,派送组合`delivery/controller/DeliveryTransferController.java` |
| 草稿、双端装车发运 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`;自动发运编排`dispatch/service/DispatchLoadingDepartureService.java` |
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` |
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java` |
| 一件代发接收、SKU/箱唛库存 | `dropship/controller/DropshipOrderController.java` |
@@ -75,7 +77,7 @@
| 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java` 的提交及两级审核;同一排车先调度审核、再理货审核,通过后才允许真实扫码。资源、货物或路线关键变更使原依据失效;零扫码显式退回后重新审核,不复用旧客户子单审核状态 |
| 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 |
| 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 |
| 实物装载、逐段容量与双端整车发车 | `dispatch/pda/controller/DispatchPdaController.java` → `dispatch/service/DispatchAggregateService.java`(装车)、`DispatchDepartureService.java`(发车);平台与PDA均使用本端`dispatches/{dispatchNo}/loading-complete`及`/depart`,公共DTO在`dispatch/port/`,已有单写入唯一`recordVersion`。装车前须真实PDA拣货核平,发车前须全车派送安排完整;装车及发车不重复执行扫码已完成的库存SHIP,Fleet容量和资源资格继续由真实提供者复核 |
| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,同一事务复用实载确认、派送单准备及发车,成功直接`DELIVERING`;请求携带唯一`recordVersion`,原键恢复最终结果。公开手工安排派送、`/depart`入口已移除;内部`AFTER_LOADING`和步骤命令只用于事务编排。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 |
| 车次货物结案、车辆和司机分别释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;实际任务结束时间/地点及车辆结束状态单独记录,不以结案或收款自动释放资源 |
| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` |
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` |
+4 -3
View File
@@ -85,7 +85,8 @@
- 车次成本:`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/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按无实际拼车货物展示,车辆归属独立,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。
- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按实际接入需求池拼车货物展示,车辆归属独立,包含MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五装车照片,无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。
- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。
- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。
@@ -94,9 +95,9 @@
- 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。
- 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。
- 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。创建后出库单按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进;海外 PDA 扫描托盘码时通过整托命令原子拣取该托盘在当前任务内全部剩余箱件和 SKU,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。
- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车,装车完成后、实际发车前由后台安排派送,按客户与目的仓拆分派送单。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。
- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车;Admin/PDA确认`loading-complete`时,同一事务自动按客户与目的仓生成派送单并发车,成功直接进入`DELIVERING`。公开手工安排派送及`/depart`入口已删除;内部`AFTER_LOADING`仅是同事务中间态。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。
- 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。
- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积必须来自实际确认,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车、安排派送和发车不重复执行已完成的库存扣减。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。
- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积必须来自实际确认,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。
- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。
- 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。
- 排车执行与 `delivery/` Controller 一同注册;平台排车查询和智能预排入口分别沿 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。
@@ -50,7 +50,10 @@ 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');
}
@@ -68,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);
@@ -37,25 +37,6 @@ public class DeliveryTransferController {
return ApiResponse.success(dispatchDelivery.preview(actor(user),dispatchNo).groups());
}
/** 一次快照提供全部客户目的地、费用证据及既有安排的完整数组。 */
@GetMapping("/dispatches/{dispatchNo}/delivery-arrangement")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
public ApiResponse<ArrangementPreview> arrangement(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
requirePlatform(user);
return ApiResponse.success(dispatchDelivery.preview(actor(user),dispatchNo));
}
/** 平台一次安排本车全部客户,身份只从认证上下文获取。 */
@PostMapping("/dispatches/{dispatchNo}/transfer-delivery")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_TRANSFER_DELIVERY + "')")
@Operation(summary = "按真实客户生成出库单并按客户目的地生成派送单")
public ApiResponse<CommandResult> transfer(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo,
@Valid @RequestBody ArrangeDelivery request) {
requirePlatform(user);
return ApiResponse.success(dispatchDelivery.arrange(actor(user),dispatchNo,request));
}
@PostMapping("/outbound-orders/one-click-dropship")
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
@Operation(summary = "Create one-click dropship outbound and push a PDA picking task")
@@ -126,6 +126,51 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
var input=inputs.get(group.key());var coverage=fees.coverage(group,input,actor.operatorId(),external.get(group.customerId()));
fees.validate(group,input,coverage,external.get(group.customerId()));coverages.put(group.key(),coverage);
}
var fact=createDocuments(context,actor,request.idempotencyKey(),businessHash,groups,inputs,external,coverages);
return execution.recordArrangement(context,actor,identity,fact,false);
}
/** 装车父事务内自动生成全车单据;未知派送资料和未确认费用保持未登记。 */
@Override
@Transactional(propagation=Propagation.MANDATORY)
public ArrangementFact prepareForDeparture(Context context,Actor actor,String commandKey) {
if(actor==null || blank(actor.operatorId()) || blank(commandKey) || commandKey.length()>255)
throw conflict("缺少装车操作身份或原请求标识");
if(context==null || context.status()!=Status.ACTIVE || context.stage()!=Stage.AFTER_LOADING
|| !context.pickingReconciled() || context.loadingCompletedAt()==null || blank(context.loadingCompletionHash())
|| blank(context.cargoHash()) || context.cargo()==null || context.cargo().isEmpty())
throw conflict("请先完成真实拣货核平及装车确认");
if(actor.mode()==ActorMode.PDA && !Objects.equals(actor.boundWarehouseId(),context.warehouseId()))
throw conflict("排车不属于当前绑定仓");
var saved=repository.arrangement(context.dispatchId(),true);
if(saved!=null) {
if(!Objects.equals(saved.getOriginalCommandKey(),commandKey)
|| !Objects.equals(saved.getCargoHash(),context.cargoHash())
|| !Objects.equals(saved.getLoadingCompletionHash(),context.loadingCompletionHash()))
throw conflict("本车已有不同装车命令生成的派送事实");
var facts=departureFacts(context,true);
if(!facts.depart().allowed())throw conflict(facts.depart().blockReason());
return fact(saved);
}
// 不能把缺少安排事实的孤立单据补建成另一套;全部客户在同一事务中创建。
if(!repository.outbounds(context.dispatchId(),true).isEmpty() || !repository.deliveries(context.dispatchId(),true).isEmpty())
throw conflict("已存在关联单据但缺少完整安排事实,请核对");
var groups=grouping.groups(context);var external=fees.external(context);
var inputs=new HashMap<String,ArrangeGroup>();
for(var group:groups) {
String carrier=context.resources()==null || context.resources().vehicle()==null?null:context.resources().vehicle().carrierName();
inputs.put(group.key(),new ArrangeGroup(group.key(),group.cargoHash(),null,null,null,carrier,null,null,
null,null,null,null,null,external.get(group.customerId()).snapshotHash(),null,List.of(),List.of()));
}
String businessHash=grouping.hash(Arrays.asList(context.dispatchNo(),context.cargoHash(),context.loadingCompletionHash(),
groups.stream().map(g->inputs.get(g.key())).toList()));
return createDocuments(context,actor,commandKey,businessHash,groups,inputs,external,Map.of());
}
/** 自动装车与内部安排复用同一建单、来源锁及真实外接报价发布路径,不推定原费覆盖。 */
private ArrangementFact createDocuments(Context context,Actor actor,String commandKey,String businessHash,List<Group> groups,
Map<String,ArrangeGroup> inputs,Map<String,com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview> external,
Map<String,DeliveryFeeCoverageModels.Evidence> coverages) {
var now=Instant.now();String key="DLA"+IdWorker.getId();var outbounds=new LinkedHashMap<String,OutboundOrder>();
for (var customer:groups.stream().map(Group::customerId).distinct().sorted().toList()) {
var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList();
@@ -169,9 +214,9 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
var arrangement=new DispatchDeliveryArrangement();arrangement.setId(IdWorker.getId());arrangement.setDispatchOrderId(context.dispatchId());
arrangement.setDispatchNo(context.dispatchNo());arrangement.setArrangementKey(key);arrangement.setBusinessHash(businessHash);
arrangement.setCargoHash(context.cargoHash());arrangement.setLoadingCompletionHash(context.loadingCompletionHash());
arrangement.setOriginalCommandKey(request.idempotencyKey());arrangement.setOriginalOperatorId(actor.operatorId());
arrangement.setOriginalCommandKey(commandKey);arrangement.setOriginalOperatorId(actor.operatorId());
arrangement.setResultJson(write(result));arrangement.setCargoJson(write(context.cargo()));arrangement.setCreatedAt(local(now));repository.create(arrangement);
return execution.recordArrangement(context,actor,identity,fact(arrangement),false);
return fact(arrangement);
}
/** 发车先取得全部出库锁,再取得全部派送锁,当前读取不申请来源锁。 */
@@ -281,7 +326,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
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());
d.setDeliveryAddress(destination.address());d.setPostalCode(destination.postalCode());d.setContactName(destination.contactName());d.setContactPhone(destination.contactPhone());
d.setStatus("WAIT_DELIVERY");d.setVersion(0L);d.setDeliveryMethod(input.deliveryMethod());d.setPlannedDeliveryAt(local(input.plannedDeliveryAt()));
d.setStatus("WAIT_DELIVERY");d.setVersion(0L);d.setDeliveryMethod(input.deliveryMethod());d.setPlannedDeliveryAt(input.plannedDeliveryAt()==null?null:local(input.plannedDeliveryAt()));
d.setWaybillNo(input.waybillNo());d.setCarrierName(input.carrierName());d.setPickupMethod(input.pickupMethod());d.setLoadingType(input.loadingType());
d.setVehicleType(input.vehicleType());d.setVehicleSize(input.vehicleSize());d.setAppointmentAccount(input.appointmentAccount());d.setSellerName(input.sellerName());d.setRemark(input.remark());
if(context.resources()!=null){if(context.resources().vehicle()!=null)d.setVehiclePlate(context.resources().vehicle().plate());
@@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/api/platform/v1/last-leg/dispatches")
public class DispatchAggregateController {
private final DispatchAggregateService service;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchDepartureService departure;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchLoadingDepartureService loadingDeparture;
/** 注入本域应用服务。 */
public DispatchAggregateController(DispatchAggregateService service) { this.service=service; }
@@ -71,21 +71,21 @@ public class DispatchAggregateController {
public ApiResponse<com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.LoadingPreview> loading(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
return ApiResponse.success(service.loading(DispatchActorFactory.platform(user),dispatchNo));
}
/** 显式确认装车,不复用最后扫码或实际发车操作。 */
/** 显式确认装车后自动准备派送并发车,原请求恢复最终派送中结果。 */
@PostMapping("/{dispatchNo}/loading-complete")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:loading-complete')")
public ApiResponse<CommandResult> loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) {
return ApiResponse.success(service.loadingComplete(DispatchActorFactory.platform(user),dispatchNo,request));
return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.platform(user),dispatchNo,request));
}
/** 与PDA共用实际发车服务和公共版本,未完整安排派送不能发车。 */
@PostMapping("/{dispatchNo}/depart")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:depart')")
public ApiResponse<CommandResult> depart(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.Depart request) {
return ApiResponse.success(departure.depart(DispatchActorFactory.platform(user),dispatchNo,request));
}
/** 本车证据先上传,主单引用随后走版本化编辑。 */
@PostMapping(value="/{dispatchNo}/evidence/{kind}",consumes=org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)
/** 派送异常登记不触发签收或退货入库。 */
@PostMapping("/{dispatchNo}/exceptions")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<CommandResult> markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.MarkException request) {
return ApiResponse.success(service.markException(DispatchActorFactory.platform(user),dispatchNo,request));
}
/** 本车证据先上传;维护和当前待审权限分别由服务端核验,引用随对应命令保存。 */
@PostMapping(value="/{dispatchNo}/evidence/{kind}",consumes=org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAnyAuthority('platform:overseas-warehouse:dispatch:edit','platform:fleet:trip:dispatch-review','platform:fleet:trip:loading-review')")
public ApiResponse<com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File> uploadEvidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
@PathVariable com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,@RequestPart("file")org.springframework.web.multipart.MultipartFile file) {
return ApiResponse.success(service.uploadEvidence(DispatchActorFactory.platform(user),dispatchNo,kind,file));
@@ -26,7 +26,7 @@ public class SmartDispatchController {
/** 读取实际候选,不在草稿域复制来源筛选逻辑。 */
@GetMapping("/candidates")
@PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_LIST+"')")
public ApiResponse<CandidateList> candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String warehouseId,
public ApiResponse<CandidateList> candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam(required=false) 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,
@RequestParam(defaultValue="1")int page,@RequestParam(defaultValue="20")int size,@RequestParam(defaultValue="ALL")String warehouseTab) {
@@ -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;
@@ -23,6 +24,7 @@ public class DispatchAggregate {
private String dispatchReviewJson;
private String loadingReviewJson;
private String resourcesJson;
private String exceptionsJson;
private String vehiclePlate;
private String route;
private LocalDateTime plannedDepartureAt;
@@ -2,9 +2,9 @@ package com.baiyue.lastleg.dispatch.gateway;
import org.springframework.web.multipart.MultipartFile;
/** 临时车辆和司机证据属于本张整车,不能借司机主档或客户文件授权。 */
/** 车辆、司机及审核补充证据属于本张整车,不能借司机主档或客户文件授权。 */
public interface DispatchEvidenceGateway {
enum Kind { VEHICLE, DRIVER }
enum Kind { VEHICLE, DRIVER, DRIVER_PHOTO, EXCEPTION }
record File(Long fileId,String fileName,String contentType,long sizeBytes) { }
/** 上传只写本车、本类证据。 */
File upload(long dispatchId,Kind kind,String operator,MultipartFile file);
@@ -29,7 +29,7 @@ public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway {
private static String type(Kind kind) { return "OVERSEAS_DISPATCH_"+Objects.requireNonNull(kind).name()+"_EVIDENCE"; }
/** 本车证据上传完成后仍以File返回元数据核对复合归属。 */
@Override public File upload(long dispatch,Kind kind,String operator,MultipartFile file) {
if(file==null || file.isEmpty() || file.getSize()>MAX || !TYPES.contains(Objects.toString(file.getContentType(),"")))throw invalid();
if(file==null || file.isEmpty() || file.getSize()>MAX || !supportedType(kind,file.getContentType()))throw invalid();
var body=new MultipartBodyBuilder();
try { body.part("file",new ByteArrayResource(file.getBytes()) {
/** 保留用户文件名供审阅,不把路径当业务事实。 */
@@ -80,9 +80,13 @@ public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway {
var value=envelope.data();
if(!Objects.equals(value.tenantId(),0L) || !type(kind).equals(value.businessType()) || !Long.toString(dispatch).equals(value.businessId())
|| value.fileId()==null || value.fileId()<=0 || id!=null&&!id.equals(value.fileId()) || value.fileName()==null || value.fileName().isBlank()
|| !TYPES.contains(Objects.toString(value.contentType(),"")) || value.sizeBytes()==null || value.sizeBytes()<=0 || value.sizeBytes()>MAX)throw invalid();
|| !supportedType(kind,value.contentType()) || value.sizeBytes()==null || value.sizeBytes()<=0 || value.sizeBytes()>MAX)throw invalid();
return new File(value.fileId(),value.fileName(),value.contentType(),value.sizeBytes());
}
/** 司机本人照片与异常证据只接受图片;车辆和驾驶证沿现有PDF/图片证据规则。 */
private static boolean supportedType(Kind kind,String contentType) {
return TYPES.contains(Objects.toString(contentType,"")) && ((kind!=Kind.DRIVER_PHOTO && kind!=Kind.EXCEPTION) || !"application/pdf".equals(contentType));
}
/** 无效证据不能作为已核实资料。 */
private static ResponseStatusException invalid(){return new ResponseStatusException(HttpStatus.BAD_REQUEST,"证据须为本车有效PDF、JPG或PNG,最大10MiB");}
/** File明确拒绝归属或文件格式属于业务错误,不能伪装成服务不可用。 */
@@ -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";
@@ -22,6 +26,9 @@ public interface DispatchAggregateMapper {
String CURRENT_FILTER = """
<if test='q.status != "ALL"'> AND d.status=#{q.status}</if>
<if test='q.stage != null'> AND d.stage=#{q.stage}</if>
<if test='q.section == "VEHICLE_INFO"'> AND d.stage='BEFORE_LOADING' AND d.review_state IN ('DRAFT','DISPATCH_REJECTED','LOADING_REJECTED')</if>
<if test='q.section == "DISPATCH_REVIEW"'> AND d.stage='BEFORE_LOADING' AND d.review_state='DISPATCH_PENDING'</if>
<if test='q.section == "LOADING_REVIEW"'> AND d.stage='BEFORE_LOADING' AND d.review_state='LOADING_PENDING'</if>
<if test='q.reviewStatus != null and q.section == "DISPATCH_REVIEW"'> AND (
""" + REVIEW_DISPATCH + ")=#{q.reviewStatus}</if>"
+ "<if test='q.reviewStatus != null and q.section == \"LOADING_REVIEW\"'> AND (" + REVIEW_LOADING + ")=#{q.reviewStatus}</if>";
@@ -33,12 +40,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},
@@ -72,21 +79,22 @@ public interface DispatchAggregateMapper {
@Update("""
UPDATE ll_dispatch_order SET cargo_revision=cargo_revision+1,cargo_hash=#{hash},item_count=#{items},
pallet_count=#{pallets},package_count=#{packages},route=#{route},review_state='DRAFT',
review_basis_hash=NULL,dispatch_review_json=#{pending},loading_review_json=#{pending},
review_basis_hash=NULL,dispatch_review_json=#{dispatchReview},loading_review_json=#{loadingReview},
record_version=record_version+1,updated_at=#{at}
WHERE id=#{id} AND record_version=#{version} AND status='ACTIVE' AND stage='BEFORE_LOADING'
AND picked_package_count=0 AND shipped_package_count=0
""")
int changeCargo(@Param("id") long id, @Param("version") long version, @Param("hash") String hash,
@Param("items") int items, @Param("pallets") int pallets, @Param("packages") int packages,
@Param("route") String route, @Param("pending") String pending, @Param("at") java.time.LocalDateTime at);
@Param("route") String route, @Param("dispatchReview") String dispatchReview, @Param("loadingReview") String loadingReview,
@Param("at") java.time.LocalDateTime at);
/** 父锁内按公共版本保存状态及审核依据,显式清空旧依据而不依赖实体非空更新策略。 */
@Update("""
UPDATE ll_dispatch_order SET status=#{r.status},stage=#{r.stage},record_version=#{r.recordVersion},
review_state=#{r.reviewState},review_round=#{r.reviewRound},review_basis_hash=#{r.reviewBasisHash},
dispatch_review_json=#{r.dispatchReviewJson},loading_review_json=#{r.loadingReviewJson},
cargo_hash=#{r.cargoHash},cargo_revision=#{r.cargoRevision},resources_json=#{r.resourcesJson},vehicle_plate=#{r.vehiclePlate},planned_departure_at=#{r.plannedDepartureAt},
exceptions_json=#{r.exceptionsJson},cargo_hash=#{r.cargoHash},cargo_revision=#{r.cargoRevision},resources_json=#{r.resourcesJson},vehicle_plate=#{r.vehiclePlate},planned_departure_at=#{r.plannedDepartureAt},
estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt},
loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey},
arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt},
@@ -50,7 +50,8 @@ public interface DispatchCandidatePageMapper {
}
/** 仓库页签也是服务端筛选,分页后不能再用本地筛选伪装完整结果。 */
private static String filtered(boolean count) {
return " FROM ("+rows(count)+") candidates WHERE overseasWarehouseId=#{query.warehouseId}"
return " FROM ("+rows(count)+") candidates WHERE 1=1"
+"<if test='query.warehouseId != null'> AND overseasWarehouseId=#{query.warehouseId}</if>"
+"<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>";
@@ -20,7 +20,7 @@ public class DispatchPdaController {
@Resource private DispatchPdaQueryService queries;
@Resource private DispatchPickingService picking;
@Resource private DispatchAggregateService workflow;
@Resource private DispatchDepartureService departure;
@Resource private DispatchLoadingDepartureService loadingDeparture;
/** 批量读取当前绑定仓的整车作业。 */
@GetMapping
@@ -36,6 +36,13 @@ public class DispatchPdaController {
return ApiResponse.success(queries.task(DispatchActorFactory.pda(user),dispatchNo));
}
/** 理货审核与后台共用同一轮次、版本和幂等账本,驳回须填写原因。 */
@PostMapping("/{dispatchNo}/loading-review")
public ApiResponse<DispatchContracts.CommandResult> loadingReview(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,@Valid @RequestBody DispatchCommands.ReviewCommand request) {
return ApiResponse.success(workflow.review(DispatchActorFactory.pda(user),dispatchNo,true,request));
}
/** 共用公共版本和全局请求键执行一次真实库存扫码。 */
@PostMapping("/{dispatchNo}/items/{itemId}/pick")
public ApiResponse<DispatchContracts.CommandResult> pick(@AuthenticationPrincipal AuthenticatedUser user,
@@ -50,18 +57,10 @@ public class DispatchPdaController {
return ApiResponse.success(workflow.loading(DispatchActorFactory.pda(user),dispatchNo));
}
/** 最后一次扫码不代替显式装车确认,双方使用同一正式命令。 */
/** 真实扫码核平后确认装车即发车,两端使用同一事务命令。 */
@PostMapping("/{dispatchNo}/loading-complete")
public ApiResponse<DispatchContracts.CommandResult> loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) {
return ApiResponse.success(workflow.loadingComplete(DispatchActorFactory.pda(user),dispatchNo,request));
}
/** 与平台共用实际发车服务,不执行第二次库存扣减。 */
@PostMapping("/{dispatchNo}/depart")
public ApiResponse<DispatchContracts.CommandResult> depart(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,
@Valid @RequestBody DispatchCommands.Depart request) {
return ApiResponse.success(departure.depart(DispatchActorFactory.pda(user),dispatchNo,request));
return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.pda(user),dispatchNo,request));
}
/** 原动作权限与绑定仓同时核对未知结果,不按单客户任务恢复。 */
@@ -14,7 +14,7 @@ public interface DispatchWholePdaReadMapper {
/** 精确单号或有界阶段列表均先在SQL限制绑定仓。 */
@Select("<script>SELECT * FROM ll_dispatch_order WHERE overseas_warehouse_id=#{warehouse} AND status='ACTIVE' "
+"<choose><when test='number != null'>AND dispatch_no=#{number} </when><when test='stage != null'>AND stage=#{stage} </when>"
+"<otherwise>AND stage IN('LOADING','AFTER_LOADING','DELIVERING') </otherwise></choose>ORDER BY updated_at DESC,id DESC LIMIT 201</script>")
+"<otherwise>AND (stage IN('LOADING','AFTER_LOADING','DELIVERING') OR (stage='BEFORE_LOADING' AND review_state='LOADING_PENDING')) </otherwise></choose>ORDER BY updated_at DESC,id DESC LIMIT 201</script>")
List<DispatchAggregate> headers(@Param("warehouse")String warehouse,@Param("number")String number,@Param("stage")String stage);
String ROOTS="<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>";
/** 一次读取选中整车所有真实客户货物。 */
@@ -24,9 +24,6 @@ public final class DispatchApi {
public static final String LOADING_REVIEW = "/{dispatchNo}/loading-review";
public static final String RETURN_TO_REVIEW = "/{dispatchNo}/return-to-review";
public static final String LOADING_COMPLETE = "/{dispatchNo}/loading-complete";
public static final String DEPART = "/{dispatchNo}/depart";
public static final String DELIVERY_ARRANGEMENT = "/{dispatchNo}/delivery-arrangement";
public static final String TRANSFER_DELIVERY = "/{dispatchNo}/transfer-delivery";
/** 端点容器不允许实例化。 */
private DispatchApi() { }
@@ -49,9 +49,9 @@ public final class DispatchCommands {
}
public record ReviewCommand(Long recordVersion, String idempotencyKey, Decision decision,
String reason) {
/** 两级审核都执行相同的必填版本约束。 */
public ReviewCommand { recordVersion = requireVersion(recordVersion); }
String reason, ReviewAttachments attachments) {
/** 两级审核都执行相同的必填版本约束,未传附件表示没有本次补充。 */
public ReviewCommand { recordVersion = requireVersion(recordVersion); attachments = attachments == null ? ReviewAttachments.empty() : attachments; }
}
/** 只有零真实扫码时可退回,保留原单号与审核轮次历史。 */
@@ -73,6 +73,13 @@ public final class DispatchCommands {
public LoadingComplete { recordVersion = requireVersion(recordVersion); }
}
/** 派送异常仅登记事实,不改变派送、签收或库存。 */
public record MarkException(Long recordVersion, String idempotencyKey, ExceptionType exceptionType,
ReturnDirection returnDirection, String description, List<Long> imageFileIds) {
/** 必填版本用于阻止过期页面覆盖当前排车。 */
public MarkException { recordVersion = requireVersion(recordVersion); }
}
public record Depart(Long recordVersion, String idempotencyKey, String remark) {
/** 实际发车不得接受缺省版本。 */
public Depart { recordVersion = requireVersion(recordVersion); }
@@ -41,7 +41,27 @@ public final class DispatchContracts {
String contactName, String contactPhone) { }
public record Review(ReviewStatus status, String reason, String reviewerId, Instant reviewedAt,
long reviewRound, String basisHash, String invalidatedReason) { }
long reviewRound, String basisHash, String invalidatedReason, ReviewAttachments attachments) {
/** 无补充附件时返回明确空集合,旧决定不得从资源快照自动继承。 */
public Review { attachments = attachments == null ? ReviewAttachments.empty() : attachments; }
}
/** 本次审核补充证据按分类归属本车,不修改车辆或司机业务资料。 */
public record ReviewAttachments(List<Long> vehicleFileIds, List<Long> driverPhotoFileIds, List<Long> licenseFileIds) {
/** 每类附件有界、不可变且不允许空身份或重复引用。 */
public ReviewAttachments {
vehicleFileIds = checked(vehicleFileIds); driverPhotoFileIds = checked(driverPhotoFileIds); licenseFileIds = checked(licenseFileIds);
}
/** 未传附件表示本次没有补充证据。 */
public static ReviewAttachments empty() { return new ReviewAttachments(List.of(), List.of(), List.of()); }
/** 只规范引用形状;本车和类别归属由审核服务逐个验证。 */
private static List<Long> checked(List<Long> ids) {
if (ids == null) return List.of();
if (ids.size() > 20 || ids.stream().anyMatch(id -> id == null || id <= 0) || ids.stream().distinct().count() != ids.size())
throw new IllegalArgumentException("每类审核附件最多20个,文件引用须有效且不重复");
return List.copyOf(ids);
}
}
public record Reviews(Review dispatchReview, Review loadingReview) { }
@@ -7,6 +7,9 @@ import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
/** 派送域实现的整车安排和单据事实边界,禁止回写排车持久化实体。 */
public interface DispatchDeliveryPort {
/** 装车完成父事务自动准备全车真实客户单据,不要求人工安排或补造费用。 */
ArrangementFact prepareForDeparture(Context context, Actor actor, String commandKey);
/** 一次预览返回同快照下全部客户目的地及费用证据。 */
ArrangementPreview preview(Context context);
@@ -9,6 +9,8 @@ public final class DispatchTypes {
public enum Stage { BEFORE_LOADING, LOADING, AFTER_LOADING, DELIVERING, DELIVERED }
public enum ReviewState { DRAFT, DISPATCH_PENDING, DISPATCH_REJECTED, LOADING_PENDING, LOADING_REJECTED, APPROVED }
public enum ReviewStatus { NOT_SUBMITTED, PENDING, APPROVED, REJECTED }
public enum ExceptionType { REFUSED, PARTIAL_RECEIPT, VEHICLE_EXCEPTION }
public enum ReturnDirection { OVERSEAS_WAREHOUSE, OTHER }
public enum Decision { APPROVED, REJECTED }
public enum ResourceMode { MASTER, EXTERNAL }
public enum ActorMode { PLATFORM, PDA }
@@ -18,6 +20,6 @@ public final class DispatchTypes {
public enum Effect { APPLIED, EXISTING }
public enum Action {
CREATE, EDIT, ADD_CARGO, REMOVE_CARGO, CANCEL, SUBMIT_REVIEW, DISPATCH_REVIEW,
LOADING_REVIEW, RETURN_TO_REVIEW, PICK, LOADING_COMPLETE, ARRANGE_DELIVERY, DEPART
LOADING_REVIEW, RETURN_TO_REVIEW, PICK, LOADING_COMPLETE, ARRANGE_DELIVERY, DEPART, MARK_EXCEPTION
}
}
@@ -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,
@@ -44,7 +44,11 @@ public final class DispatchViews {
public record DispatchInfo(String route, Instant plannedDepartureAt, Instant departedAt,
Instant estimatedArrivalAt, List<FbaAppointment> fbaAppointments,
List<BatchReference> appointmentBatches) { }
List<BatchReference> appointmentBatches, List<ExceptionRecord> exceptions) { }
/** 排车级异常记录;退货去向只描述安排,不产生退货入库事实。 */
public record ExceptionRecord(ExceptionType exceptionType, ReturnDirection returnDirection,
String description, List<Long> imageFileIds, Instant occurredAt) { }
public record Documents(String dispatchNo, List<DispatchDeliveryModels.OutboundReference> outbounds,
List<DispatchDeliveryModels.DeliveryReference> deliveries,
@@ -56,14 +60,14 @@ 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,
List<OrderDetail> orderDetails) { }
public record PdaTask(String taskNo, String dispatchNo, String warehouseId, Status status,
Stage stage, long recordVersion, Reviews reviews, Counts counts,
Stage stage, long recordVersion, Reviews reviews, Resources resources, Counts counts,
boolean pickingReconciled, List<PdaItem> items, Map<Action, Gate> actions) { }
public record PdaItem(Long dispatchItemId, String customerId, String customerName, String palletNo,
@@ -46,8 +46,8 @@ public class DispatchAggregateRepository {
public int removeItem(long itemId) {return items.deleteById(itemId);}
/** 原子更新货量并作废旧审核依据。 */
public int changeCargo(long id, long version, String hash, int itemCount, int pallets, int packages,
String route, String pending, LocalDateTime at) {
return mapper.changeCargo(id, version, hash, itemCount, pallets, packages, route, pending, at);
String route, String dispatchReview, String loadingReview, LocalDateTime at) {
return mapper.changeCargo(id, version, hash, itemCount, pallets, packages, route, dispatchReview, loadingReview, at);
}
/** 追加不可变业务快照。 */
public int history(long id, DispatchAggregate row, String action, String operator, String reason, String snapshot, LocalDateTime at) {
@@ -146,7 +146,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchPlanningModels.CandidateList candidates(Actor actor,DispatchPlanningModels.CandidateQuery query) {
requirePlatform(actor);
if(query==null || query.warehouseId()==null || !query.warehouseId().matches("[1-9][0-9]*")) throw bad("请选择真实海外起运仓");
// 未指定起运仓时按区域浏览;写入草稿仍由来源确定唯一真实起运仓。
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()),
@@ -235,10 +236,18 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
return saveCommand(row,actor,identity,request.reason());
}
/** 上传不修改主单引用;返回本车文件ID后由公共版本编辑命令保存选择。 */
/** 上传只返回本车证据;待审按当前审核权限授权,客服阶段按维护权限授权。 */
public com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File uploadEvidence(Actor actor,String number,
com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,org.springframework.web.multipart.MultipartFile file) {
requirePlatform(actor);var row=required(actor,number,false);requireEditing(row,row.getRecordVersion());
requirePlatform(actor);var row=required(actor,number,false);
if(kind==com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.EXCEPTION) {
requireExceptionAllowed(actor,row,row.getRecordVersion());
return evidence.upload(row.getId(),kind,actor.operatorId(),file);
}
requireEditing(row,row.getRecordVersion());
Action permission = ReviewState.DISPATCH_PENDING.name().equals(row.getReviewState()) ? Action.DISPATCH_REVIEW
: ReviewState.LOADING_PENDING.name().equals(row.getReviewState()) ? Action.LOADING_REVIEW : Action.EDIT;
if(!hasPermission(actor,permission))throw new BizException(ResultCode.FORBIDDEN,"无当前阶段附件上传权限");
return evidence.upload(row.getId(),kind,actor.operatorId(),file);
}
/** 列表权限含详情只读;仅冻结MASTER证照走司机归属,其余保持本车附件复合归属。 */
@@ -392,7 +401,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(n(row.getPackageCount())<=0 || repository.cargo(row.getId()).isEmpty())throw bad("空排车不能提交审核");
long round=row.getReviewRound()+1;String basis=basis(row);
row.setReviewRound(round);row.setReviewBasisHash(basis);row.setReviewState(ReviewState.DISPATCH_PENDING.name());
row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null)));
row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null,null)));
row.setLoadingReviewJson(write(pending(round)));
return saveCommand(row,actor,identity,request.remark());
}
@@ -400,7 +409,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
/** 调度与理货审核按同一轮次、同一事实摘要串行执行;理货通过才进入装车。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult review(Actor actor,String number,boolean loading,DispatchCommands.ReviewCommand request) {
var row=lockPlatform(actor,number);Action action=loading?Action.LOADING_REVIEW:Action.DISPATCH_REVIEW;
Action action=loading?Action.LOADING_REVIEW:Action.DISPATCH_REVIEW;
// PDA仅可处理本绑定仓理货审核,原键重试也先重新核验权限和范围。
DispatchAggregate row;
if(actor!=null && actor.mode()==ActorMode.PDA) {
if(!loading || actor.boundWarehouseId()==null || !hasPermission(actor,action))
throw new BizException(ResultCode.FORBIDDEN,"仅当前绑定仓理货员可执行理货审核");
required(actor,number,false);fleet.lockDispatchShell(number);row=required(actor,number,true);
} else row=lockPlatform(actor,number);
var identity=identity(actor,number,action,request.recordVersion(),request.idempotencyKey(),request);
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
requireEditing(row,request.recordVersion());
@@ -413,14 +429,21 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var dispatch=reviews(row).dispatchReview();
if(dispatch.status()!=ReviewStatus.APPROVED || dispatch.reviewRound()!=row.getReviewRound() || !Objects.equals(dispatch.basisHash(),row.getReviewBasisHash()))throw conflict("调度审核尚未通过或依据已失效");
}
verifyReviewAttachments(row.getId(),request.attachments());
if(request.decision()==Decision.APPROVED)verifyReviewResources(row);
var reviewed=new Review(request.decision()==Decision.APPROVED?ReviewStatus.APPROVED:ReviewStatus.REJECTED,trim(request.reason()),actor.operatorId(),Instant.now().truncatedTo(ChronoUnit.MILLIS),row.getReviewRound(),row.getReviewBasisHash(),null);
var reviewed=new Review(request.decision()==Decision.APPROVED?ReviewStatus.APPROVED:ReviewStatus.REJECTED,trim(request.reason()),actor.operatorId(),Instant.now().truncatedTo(ChronoUnit.MILLIS),row.getReviewRound(),row.getReviewBasisHash(),null,request.attachments());
if(loading) {
row.setLoadingReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.APPROVED.name():ReviewState.LOADING_REJECTED.name());
if(request.decision()==Decision.APPROVED)row.setStage(Stage.LOADING.name());
} else {
row.setDispatchReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_REJECTED.name());
row.setLoadingReviewJson(write(request.decision()==Decision.APPROVED?new Review(ReviewStatus.PENDING,null,null,null,row.getReviewRound(),row.getReviewBasisHash(),null):pending(row.getReviewRound())));
row.setLoadingReviewJson(write(request.decision()==Decision.APPROVED?new Review(ReviewStatus.PENDING,null,null,null,row.getReviewRound(),row.getReviewBasisHash(),null,null):pending(row.getReviewRound())));
}
if(request.decision()==Decision.REJECTED) {
// 任一级驳回均回客服处理,旧调度批准不再作为可继续理货的有效依据。
row.setStage(Stage.BEFORE_LOADING.name());row.setReviewBasisHash(null);
if(loading)row.setDispatchReviewJson(write(new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,
row.getReviewRound(),null,"理货审核驳回,请客服修改后重新提交调度审核",reviews(row).dispatchReview().attachments())));
}
return saveCommand(row,actor,identity,request.reason());
}
@@ -517,7 +540,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
List.copyOf(pallets),row.getLoadingConfirmationJson()==null?null:read(row.getLoadingConfirmationJson(),ConfirmedLoad.class),gate,List.copyOf(missing));
}
/** 最后一次扫码只核平,显式装车完成才保存实载并进入装车后。 */
/** 内部实载步骤:扫码核平后保存装载证据,中间装车后状态由外层同事务继续推进发车。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult loadingComplete(Actor actor,String number,DispatchCommands.LoadingComplete request) {
required(actor,number,false);fleet.lockDispatchShell(number);var row=required(actor,number,true);
@@ -609,9 +632,24 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
/** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */
private void invalidate(DispatchAggregate row,String reason) {
row.setReviewState(ReviewState.DRAFT.name());row.setReviewBasisHash(null);
var invalid=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,row.getReviewRound(),null,reason);
row.setDispatchReviewJson(write(invalid));row.setLoadingReviewJson(write(invalid));
var invalid=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,row.getReviewRound(),null,reason,null);
var previous=reviews(row);
row.setDispatchReviewJson(write(retainRejection(previous.dispatchReview(),invalid)));
row.setLoadingReviewJson(write(retainRejection(previous.loadingReview(),invalid)));
}
/** 客服改资料期间保留本轮驳回原因;下一轮提交统一重置,不复用批准。 */
private static Review retainRejection(Review previous,Review invalid) {
return previous.status()==ReviewStatus.REJECTED
?new Review(previous.status(),previous.reason(),previous.reviewerId(),previous.reviewedAt(),previous.reviewRound(),null,invalid.invalidatedReason(),previous.attachments())
:new Review(invalid.status(),null,null,null,invalid.reviewRound(),null,invalid.invalidatedReason(),previous.attachments());
}
/** 审核附件逐个验证本车与种类;MASTER档案证照不能冒充本次上传证据。 */
private void verifyReviewAttachments(long dispatch,ReviewAttachments attachments) {
for(var id:attachments.vehicleFileIds())evidence.metadata(dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,id);
for(var id:attachments.driverPhotoFileIds())evidence.metadata(dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,id);
for(var id:attachments.licenseFileIds())evidence.metadata(dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,id);
}
/** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */
void verifyReviewResources(DispatchAggregate row) {
var r=read(row.getResourcesJson(),Resources.class);var v=r.vehicle();var d=r.driver();
@@ -631,6 +669,40 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
for(var id:ids)evidence.metadata(dispatch,kind,id);
}
/** 仅派送中登记异常;原键重试先恢复,避免追加重复记录。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult markException(Actor actor,String number,DispatchCommands.MarkException request) {
var row=lockPlatform(actor,number);
if(!hasPermission(actor,Action.MARK_EXCEPTION))throw new BizException(ResultCode.FORBIDDEN,"无标记异常权限");
var identity=identity(actor,number,Action.MARK_EXCEPTION,request.recordVersion(),request.idempotencyKey(),request);
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();
requireExceptionAllowed(actor,row,request.recordVersion());
if(request.exceptionType()==null)throw bad("请选择异常类型");
var description=trim(request.description());
if(description!=null && description.length()>1000)throw bad("异常说明不能超过1000字");
var ids=request.imageFileIds()==null?List.<Long>of():request.imageFileIds();
if(ids.size()>20 || ids.stream().anyMatch(id->id==null || id<=0) || ids.stream().distinct().count()!=ids.size())throw bad("异常图片引用无效或超过20张");
// 文件须属于本车异常类别,不能借用其他车辆或审核附件。
for(var id:ids)evidence.metadata(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.EXCEPTION,id);
var records=new ArrayList<>(exceptionRecords(row));
records.add(new DispatchViews.ExceptionRecord(request.exceptionType(),request.returnDirection(),description,List.copyOf(ids),Instant.now()));
row.setExceptionsJson(write(records));
return saveCommand(row,actor,identity,description);
}
/** 异常登记复用排车维护权限;版本、任务类型与阶段由服务端再次核验。 */
private void requireExceptionAllowed(Actor actor,DispatchAggregate row,long version) {
if(!hasPermission(actor,Action.MARK_EXCEPTION))throw new BizException(ResultCode.FORBIDDEN,"无标记异常权限");
if(row.getRecordVersion()!=version)throw conflict("排车版本已变化,请刷新后再标记异常");
if(!actions(actor,row).get(Action.MARK_EXCEPTION).allowed())throw conflict("仅派送中的仓库派送排车可以标记异常");
}
/** 尚未登记时为空集合,已登记记录按保存顺序完整回看。 */
private List<DispatchViews.ExceptionRecord> exceptionRecords(DispatchAggregate row) {
if(row.getExceptionsJson()==null)return List.of();
return Arrays.asList(read(row.getExceptionsJson(),DispatchViews.ExceptionRecord[].class));
}
/** 同一只读快照返回分页、总数和基集分布,不能在前端过滤当前页。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchViews.Page page(Actor actor, DispatchViews.Query query) {
@@ -665,9 +737,9 @@ 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),
new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs,exceptionRecords(row)),
documents,orderDetails(cargo));
}
@@ -726,7 +798,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
int pallets=(int)all.stream().map(i -> i.getWarehousePalletId()==null ? "PLAN:"+i.getPlanPalletId() : "PALLET:"+i.getWarehousePalletId()).distinct().count();
String route=all.stream().map(this::destinationLabel).filter(Objects::nonNull).distinct().sorted().collect(Collectors.joining(","));
var digest=all.stream().sorted(Comparator.comparing(DispatchItem::getSourceKey)).map(i -> List.of(i.getSourceKey(),i.getSourceVersion(),i.getCustomerId(),i.getPackageCount())).toList();
if(repository.changeCargo(row.getId(),row.getRecordVersion(),hashes.source(digest),all.size(),pallets,packages,route,write(pending(row.getReviewRound())),now())!=1) throw conflict("排车版本已变化,未保存本次货物");
invalidate(row,"排车货物已修改,请客服重新提交审核");
if(repository.changeCargo(row.getId(),row.getRecordVersion(),hashes.source(digest),all.size(),pallets,packages,route,row.getDispatchReviewJson(),row.getLoadingReviewJson(),now())!=1) throw conflict("排车版本已变化,未保存本次货物");
var stops=all.stream().map(i -> new FleetExecutionModels.DispatchStop(destinationLabel(i),i.getDeliveryAddress(),destination(i).key()))
.collect(Collectors.toMap(FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
fleet.updateDispatchCargo(row.getDispatchNo(),packages,stops);
@@ -772,10 +845,19 @@ 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())if(action!=Action.ARRANGE_DELIVERY && action!=Action.DEPART)
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()) {
if(action==Action.ARRANGE_DELIVERY || action==Action.DEPART)continue;
boolean state=switch(action) {
case MARK_EXCEPTION -> active && stage==Stage.DELIVERING;
case CREATE -> actor.mode()==ActorMode.PLATFORM;
case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0;
case SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0 && canSubmitReview(row.getReviewState());
@@ -793,11 +875,20 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
return Collections.unmodifiableMap(gates);
}
/** 装车发运及原结果恢复都按当前装车权限与PDA绑定身份校验。 */
void requireLoadingPermission(Actor actor) {
if(actor==null || actor.operatorId()==null || !hasPermission(actor,Action.LOADING_COMPLETE))
throw new BizException(ResultCode.FORBIDDEN,"当前无装车完成权限");
}
/** 主体类型和正式动作权限逐项判断,不把list权限等同写权限。 */
private static boolean hasPermission(Actor actor,Action action) {
if(action==Action.ARRANGE_DELIVERY || action==Action.DEPART)return false;
var auth=SecurityContextHolder.getContext().getAuthentication(); if(auth==null || actor==null) return false;
if(actor.mode()==ActorMode.PDA) return Set.of(Action.PICK,Action.LOADING_COMPLETE,Action.DEPART).contains(action)
&& auth.getAuthorities().stream().anyMatch(a->"lastleg:pda:operate".equals(a.getAuthority()));
if(actor.mode()==ActorMode.PDA) return Set.of(Action.LOADING_REVIEW,Action.PICK,Action.LOADING_COMPLETE,Action.DEPART).contains(action)
&& actor.boundWarehouseId()!=null
&& auth.getAuthorities().stream().anyMatch(a->"lastleg:pda:operate".equals(a.getAuthority()))
&& auth.getAuthorities().stream().anyMatch(a->"ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR".equals(a.getAuthority()));
if(action==Action.PICK) return false;
String permission=switch(action) {
case CREATE -> PREFIX+"create"; case CANCEL -> PREFIX+"cancel";
@@ -808,6 +899,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
return auth.getAuthorities().stream().anyMatch(a->permission.equals(a.getAuthority()));
}
/** PDA审核读取整车冻结资源,与后台共用相同快照,查询不更改主档。 */
Resources resources(DispatchAggregate row) { return read(row.getResourcesJson(),Resources.class); }
/** 统计按真整车去重并补齐全部零项。 */
private static DispatchViews.Statistics statistics(List<DispatchAggregateMapper.Summary> groups) {
var stages=new EnumMap<Stage,Long>(Stage.class); for(var stage:Stage.values()) stages.put(stage,0L);
@@ -815,9 +909,13 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
for(var group:groups) {
if(Status.CANCELLED.name().equals(group.status())) { cancelled+=group.quantity(); continue; }
active+=group.quantity(); var stage=Stage.valueOf(group.stage()); stages.merge(stage,group.quantity(),Long::sum);
if(stage==Stage.BEFORE_LOADING) { before+=group.quantity(); var review=ReviewState.valueOf(group.reviewState());
dispatch[review==ReviewState.DRAFT?0:review==ReviewState.DISPATCH_PENDING?1:review==ReviewState.DISPATCH_REJECTED?3:2]+=group.quantity();
loading[review==ReviewState.LOADING_PENDING?1:review==ReviewState.LOADING_REJECTED?3:review==ReviewState.APPROVED?2:0]+=group.quantity(); }
if(stage==Stage.BEFORE_LOADING) {
// 三分区表示当前待办,驳回只能计入客服处理,不能留在审核队列。
var review=ReviewState.valueOf(group.reviewState());
if(canSubmitReview(review.name()))before+=group.quantity();
else if(review==ReviewState.DISPATCH_PENDING)dispatch[1]+=group.quantity();
else if(review==ReviewState.LOADING_PENDING)loading[1]+=group.quantity();
}
}
return new DispatchViews.Statistics(active+cancelled,active,cancelled,Collections.unmodifiableMap(stages),new DispatchViews.BeforeLoadingCounts(before,
new DispatchViews.ReviewCounts(dispatch[0],dispatch[1],dispatch[2],dispatch[3]),new DispatchViews.ReviewCounts(loading[0],loading[1],loading[2],loading[3])));
@@ -825,7 +923,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));
}
@@ -877,7 +975,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
/** 当前审核记录,不以版本相等代替轮次有效性。 */
Reviews reviews(DispatchAggregate row) { return new Reviews(read(row.getDispatchReviewJson(),Review.class),read(row.getLoadingReviewJson(),Review.class)); }
/** 未提交的审核不伪造待审核。 */
private static Review pending(long round) { return new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,round,null,null); }
private static Review pending(long round) { return new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,round,null,null,null); }
/** 整车真实统计。 */
private static Counts counts(DispatchAggregate row) { return new Counts(n(row.getItemCount()),n(row.getPalletCount()),n(row.getPackageCount()),n(row.getPickedPackageCount()),n(row.getShippedPackageCount())); }
/** 空单永远不能核平为装车完成。 */
@@ -73,7 +73,7 @@ public final class DispatchCommandCodec {
case DEPART -> DispatchResults.Departed.class;
case ARRANGE_DELIVERY -> DispatchDeliveryModels.ArrangementResult.class;
case CREATE, EDIT, ADD_CARGO, REMOVE_CARGO, CANCEL, SUBMIT_REVIEW,
DISPATCH_REVIEW, LOADING_REVIEW, RETURN_TO_REVIEW -> DispatchResults.Saved.class;
DISPATCH_REVIEW, LOADING_REVIEW, RETURN_TO_REVIEW, MARK_EXCEPTION -> DispatchResults.Saved.class;
};
}
@@ -0,0 +1,58 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.port.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 两端装车完成即发车:所有中间状态只存在于同一事务,原请求只恢复最终结果。 */
@Service
public class DispatchLoadingDepartureService {
private final DispatchAggregateService workflow;
private final DispatchDeliveryPort delivery;
private final DispatchDepartureService departure;
private final DispatchCommandLedger commands;
private final DispatchCommandHasher hashes;
/** 复用装车、派送和发车的真实规则,不另建库存或运输状态实现。 */
public DispatchLoadingDepartureService(DispatchAggregateService workflow,DispatchDeliveryPort delivery,
DispatchDepartureService departure,DispatchCommandLedger commands,DispatchCommandHasher hashes) {
this.workflow=workflow;this.delivery=delivery;this.departure=departure;this.commands=commands;this.hashes=hashes;
}
/** 原键恢复先于阶段校验;任一步失败均回滚装载、子单、费用、发车与全部账本。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult complete(Actor actor,String number,DispatchCommands.LoadingComplete request) {
workflow.requireLoadingPermission(actor);
var context=workflow.lockForArrangement(actor,number);
var identity=new CommandIdentity(Action.LOADING_COMPLETE,request.idempotencyKey(),request.recordVersion(),
hashes.command(actor,Action.LOADING_COMPLETE,number,request.recordVersion(),request));
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();
// 先取得本车单据锁再申请真实来源锁,后续所有步骤保持同一个整车父锁。
delivery.departureFacts(context,true);
var loaded=workflow.loadingComplete(actor,number,new DispatchCommands.LoadingComplete(request.recordVersion(),
stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.measurements(),request.remark()));
context=workflow.preview(actor,number);
String arrangementKey=stepKey(number,request.idempotencyKey(),"ARRANGE");
var fact=delivery.prepareForDeparture(context,actor,arrangementKey);
var arrangementIdentity=new CommandIdentity(Action.ARRANGE_DELIVERY,arrangementKey,context.recordVersion(),
hashes.source(List.of(number,actor.operatorId(),context.recordVersion(),fact.arrangementBusinessHash())));
var arranged=workflow.recordArrangement(context,actor,arrangementIdentity,fact,false);
var departed=departure.depart(actor,number,new DispatchCommands.Depart(arranged.recordVersion(),
stepKey(number,request.idempotencyKey(),"DEPART"),request.remark()));
if(departed.stage()!=Stage.DELIVERING || !(loaded.result() instanceof DispatchResults.Loaded))
throw new BizException(ResultCode.CONFLICT,"装车发运结果未核平");
var result=new CommandResult(number,Action.LOADING_COMPLETE,request.idempotencyKey(),departed.recordVersion(),
departed.status(),departed.stage(),departed.occurredAt(),Effect.APPLIED,departed.counts(),loaded.result(),false,false);
commands.append(context.dispatchId(),actor,identity,result);return result;
}
/** 固定长度内部键绑定原请求和步骤,不向客户端暴露中间命令作为恢复入口。 */
private String stepKey(String number,String original,String step) {
return "AUTO_"+step+"_"+hashes.source(List.of(number,original,step));
}
}
@@ -89,7 +89,7 @@ public class DispatchPdaQueryService {
else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车"));
var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),n(root.getPackageCount()),n(root.getPickedPackageCount()),n(root.getShippedPackageCount()));
// 整车作业直接以排车业务号识别;真实客户子taskId只在服务端审计保存。
result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions)));
result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),workflow.resources(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions)));
}
return List.copyOf(result);
}
@@ -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,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);}
}
@@ -7,5 +7,6 @@ public record ContainerArrivalTimeView(
String sourceContainerBookingId,
LocalDateTime estimatedWarehouseArrivalAt,
LocalDateTime actualWarehouseArrivalAt,
int version
int version,
long planCount
) { }
@@ -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,6 +20,7 @@ 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 com.baiyue.lastleg.service.LastlegDocumentNumberGenerator numbers;
private final ObjectMapper json;
@@ -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())?"TR":"LB"));
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);
@@ -37,7 +37,7 @@ public class OutsourcedBusinessService {
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());
h.occurredAt(),h.reason(),h.operations()==null?null:operations(h.operations()),h.photos().stream().filter(p->"LOADING".equals(p.category())).map(this::photo).toList())).toList(),result.page(),result.size(),result.total());
}
/** 外配只生成金额和到达的规范内部请求,不接收归属或隐藏油耗写入。 */
public Detail save(OwnedBusinessModels.Actor actor,String number,SaveOperations request) {
@@ -71,11 +71,11 @@ public class OutsourcedBusinessService {
}
/** 回执公开形状不带内部里程、油耗或油耗照片摘要。 */
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());
return new Detail(row(detail.row(),projection.carrierName(detail)),detail.dispatch(),detail.cargo(),detail.photos().stream().filter(p->"LOADING".equals(p.category())).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("外配运营快照与冻结归属不一致");
if(!java.util.Set.of("OWNED","DISPATCHED").contains(r.vehicle().ownership()))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());
@@ -85,7 +85,7 @@ public class OutsourcedBusinessService {
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());
}
@@ -0,0 +1,76 @@
package com.baiyue.lastleg.fleet.ownedbusiness.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
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;
import java.util.List;
/** 排车资料入口复用车队原照片记录;签收及BOL仍由派送单管理。 */
@RestController
@Validated
@RequestMapping("/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents")
public class DispatchVehicleDocumentController {
private static final OwnedBusinessScope SCOPE=OwnedBusinessScope.DISPATCH;
private final OwnedBusinessQueryService queries;
private final OwnedBusinessPhotoService photos;
private final OwnedBusinessSupport access;
/** 沿已有服务复用来源校验、文件归属、版本和命令账本。 */
public DispatchVehicleDocumentController(OwnedBusinessQueryService queries,OwnedBusinessPhotoService photos,OwnedBusinessSupport access) {
this.queries=queries;this.photos=photos;this.access=access;
}
public record Documents(String dispatchNo,String recordVersion,boolean editable,List<Photo> photos) {}
/** 只输出油耗资料,避免把车队签收照片当作派送签收文件。 */
private Documents documents(Detail value) {
return new Documents(value.row().dispatchNo(),value.row().recordVersion(),value.row().actions().upload().allowed(),
value.photos().stream().filter(p->"FUEL".equals(p.category())).toList());
}
/** 排车列表权限包含车辆资料只读,不扩大派送单客户访问权限。 */
@GetMapping
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<Documents> get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
return ApiResponse.success(documents(queries.detail(access.actor(user,SCOPE),dispatchNo,SCOPE)));
}
/** 上传和引用保存分开,沿原请求键防止重传。 */
@PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
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(photos.upload(access.actor(user,SCOPE),dispatchNo,slot,idempotencyKey,file,SCOPE));
}
/** 只更新既有附加资料版本,不修改排车阶段或签收状态。 */
@PutMapping("/{slot}")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<Documents> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) {
return ApiResponse.success(documents(photos.bind(access.actor(user,SCOPE),dispatchNo,slot,request,SCOPE)));
}
/** 预览只读取本车当前已经绑定的照片。 */
@GetMapping("/{slot}/files/{fileId}/content")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@PathVariable @Positive long fileId) {
var value=photos.content(access.actor(user,SCOPE),dispatchNo,slot,fileId,null,SCOPE);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(value.photo().contentType()))
.header("X-Content-Type-Options","nosniff").body(value.content());
}
/** 恢复仅返回油耗上传结果,不通过排车入口暴露其他运营命令。 */
@GetMapping("/uploads/{idempotencyKey}")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<Photo> recover(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
var actor=access.actor(user,SCOPE);
access.require(actor,SCOPE.uploadPermission());
var command=photos.command(actor,dispatchNo,idempotencyKey,SCOPE);
if(!"UPLOAD_PHOTO".equals(command.action()) || !"SUCCEEDED".equals(command.state()) || command.result()==null)
throw OwnedBusinessSupport.conflict("原上传结果尚未确认,请保留原请求稍后核对");
var value=access.read(access.write(command.result()),Photo.class);SCOPE.requireSlot(value.slot());
return ApiResponse.success(value);
}
}
@@ -7,8 +7,26 @@ import java.util.List;
/** 在原排车事实之上做自有运营分页,不复制或推进履约模型。 */
@Mapper
public interface OwnedBusinessQueryMapper {
/** 拼车须实收且来源仍在本车;标识按完整字节精确比较,512字节覆盖128字符utf8mb4,避免列排序规则冲突或截断。 */
String POOLED_CARGO="""
EXISTS (SELECT 1 FROM ll_fleet_external_booking b
JOIN ll_warehouse_transfer_order incoming ON CAST(incoming.customer_id AS BINARY(512))=CAST(b.customer_id AS BINARY(512))
AND CAST(incoming.transfer_no AS BINARY(512))=CAST(b.incoming_no AS BINARY(512))
WHERE b.trip_id=d.trip_id AND CAST(b.dispatch_no AS BINARY(512))=CAST(d.dispatch_no AS BINARY(512)) AND b.offer_id IS NOT NULL
AND b.status='BOUND' AND b.receipt_json IS NOT NULL
AND EXISTS (SELECT 1 FROM ll_dispatch_item cargo
JOIN ll_inbound_plan_pallet_allocation allocation ON allocation.pallet_id=cargo.plan_pallet_id
AND allocation.plan_id=cargo.plan_id AND CAST(allocation.customer_id AS BINARY(512))=CAST(cargo.customer_id AS BINARY(512))
JOIN ll_inbound_plan_line line ON line.id=allocation.line_id AND line.plan_id=cargo.plan_id
WHERE cargo.dispatch_order_id=d.id AND CAST(cargo.customer_id AS BINARY(512))=CAST(b.customer_id AS BINARY(512))
AND CAST(line.customer_id AS BINARY(512))=CAST(b.customer_id AS BINARY(512))
AND CAST(line.inbound_code AS BINARY(512))=CAST(incoming.inbound_code AS BINARY(512))
AND cargo.warehouse_pallet_id IS NOT NULL AND cargo.package_count>0))
""";
String SOURCE="""
SELECT d.id,d.dispatch_no,d.status,d.stage,d.record_version,d.overseas_warehouse_id,
SELECT
"""+POOLED_CARGO+" AS pooled_cargo,"+"""
d.id,d.dispatch_no,d.status,d.stage,d.record_version,d.overseas_warehouse_id,
d.overseas_warehouse_code,d.overseas_warehouse_name,d.planned_departure_at,d.loading_completed_at,
d.departed_at,d.delivery_completed_at,d.loading_completion_hash,d.loading_confirmation_json,d.resources_json,
o.vehicle_id AS owned_vehicle_id,o.resource_mode AS owned_resource_mode,
@@ -20,10 +38,12 @@ public interface OwnedBusinessQueryMapper {
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=#{q.scope.ownership}
AND o.vehicle_ownership IN ('OWNED','DISPATCHED')
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'))
AND (o.resource_mode='MASTER' OR (o.vehicle_ownership='DISPATCHED' AND o.resource_mode='EXTERNAL'))
AND <if test='!q.scope.pooledCargo'>NOT</if>
"""+POOLED_CARGO+"""
<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>
@@ -14,6 +14,7 @@ public final class OwnedBusinessRows {
private LocalDateTime ownershipCapturedAt,loadingCompletedAt;
}
@Data public static class Source {
private Boolean pooledCargo;
private Long id,recordVersion;
private String dispatchNo,status,stage,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName;
private String loadingCompletionHash,loadingConfirmationJson,resourcesJson;
@@ -6,21 +6,23 @@ import com.baiyue.common.core.api.ResultCode;
/** 仅服务端固定入口选择的作用域;不得成为浏览器请求字段或权限别名。 */
public enum OwnedBusinessScope {
OWNED("OWNED","platform:fleet:owned-business:"),
OUTSOURCED("DISPATCHED","platform:fleet:outsourced-business:");
private final String ownership,prefix;
/** 车辆归属与入口权限一一固定,不能由请求指定。 */
OwnedBusinessScope(String ownership,String prefix){this.ownership=ownership;this.prefix=prefix;}
/** SQL仅绑定此可信枚举的归属值,不拼接客户端片段。 */
public String getOwnership(){return ownership;}
OWNED(false,"platform:fleet:owned-business:"),
OUTSOURCED(true,"platform:fleet:outsourced-business:"),
DISPATCH(false,"platform:overseas-warehouse:dispatch:");
private final boolean pooledCargo;
private final String prefix;
/** 业务入口按是否实际接入拼车货物分类,与车辆归属独立。 */
OwnedBusinessScope(boolean pooledCargo,String prefix){this.pooledCargo=pooledCargo;this.prefix=prefix;}
/** SQL绑定服务端固定分类,不接受浏览器指定作用域。 */
public boolean isPooledCargo(){return pooledCargo;}
/** 查看权限包含本作用域详情、历史和照片读取。 */
public String listPermission(){return prefix+"list";}
/** 运营写权限独立,外配权限不映射成自有权限。 */
public String editPermission(){return prefix+"edit";}
/** 照片写权限独立,不扩大到原排车。 */
public String uploadPermission(){return prefix+"upload";}
public String uploadPermission(){return prefix+(this==DISPATCH?"edit":"upload");}
/** 外配只支持装车五框,服务调用同样不能绕过。 */
public void requireSlot(PhotoSlot slot){if(slot==null || this==OUTSOURCED && !"LOADING".equals(slot.category()))throw new BizException(ResultCode.BAD_REQUEST,"当前业务不支持该照片框");}
public void requireSlot(PhotoSlot slot){if(slot==null || this==OUTSOURCED && !"LOADING".equals(slot.category()) || this==DISPATCH && !"FUEL".equals(slot.category()))throw new BizException(ResultCode.BAD_REQUEST,"当前业务不支持该照片框");}
/** 不存在只显示当前业务归属,不泄露内部身份。 */
public BizException missing(){return new BizException(ResultCode.NOT_FOUND,"排车单不存在或不属于当前"+(this==OWNED?"自有":"外配")+"业务");}
public BizException missing(){return new BizException(ResultCode.NOT_FOUND,this==DISPATCH?"排车单不存在":"排车单不存在或不属于当前"+(this==OWNED?"自有业务":"外配任务"));}
}
@@ -53,8 +53,7 @@ public class OwnedBusinessProjection {
var resources=codec.read(source.getResourcesJson(),Resources.class);
if(resources.vehicle()==null)throw OwnedBusinessSupport.conflict("原车辆快照缺失");
var ops=operations(record);var photos=photos(record);int pallets=palletCount(source);
if(scope==OwnedBusinessScope.OUTSOURCED && (ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null
|| photos.stream().anyMatch(p->!"LOADING".equals(p.category()))))throw OwnedBusinessSupport.conflict("外配运营记录含不支持的里程、油耗或照片");
if(scope==OwnedBusinessScope.OUTSOURCED && (ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null))throw OwnedBusinessSupport.conflict("外配运营记录含不支持的里程、油耗或照片");
var arrivals=new HashMap<String,String>();for(var a:ops.arrivals())arrivals.put(a.destinationKey(),a.actualArrivedAt());
var destinations=new LinkedHashMap<String,Destination>();
for(var item:cargo) {
@@ -68,7 +67,7 @@ public class OwnedBusinessProjection {
return new Row(source.getDispatchNo(),Stage.valueOf(source.getStage()),codec.iso(source.getPlannedDepartureAt()),
codec.iso(source.getLoadingCompletedAt()),codec.iso(source.getDepartedAt()),codec.iso(source.getDeliveryCompletedAt()),
new Origin(source.getOverseasWarehouseId(),source.getOverseasWarehouseCode(),source.getOverseasWarehouseName()),
new Vehicle(resources.vehicle().vehicleId()==null?null:String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),scope.getOwnership()),
new Vehicle(resources.vehicle().vehicleId()==null?null:String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),source.getOwnedVehicleOwnership()),
new Driver(driver==null?null:driver.name(),driver==null?null:driver.phone()),List.copyOf(destinations.values()),pallets,ops,price,
record==null?"0":String.valueOf(record.getRecordVersion()),String.valueOf(source.getRecordVersion()),
new PhotoSummary((int)photos.stream().filter(p->"LOADING".equals(p.category())).count(),(int)photos.stream().filter(p->"FUEL".equals(p.category())).count()),
@@ -17,7 +17,7 @@ import java.util.stream.Collectors;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
/** 自有车辆装车后视图;无归属快照的旧单不在GET时回填。 */
/** 装车后按实际拼车货物分组;车辆归属仅保留原装车证据。 */
@Service
public class OwnedBusinessQueryService {
private final OwnedBusinessRepository repository;
@@ -61,7 +61,7 @@ public class OwnedBusinessQueryService {
/** 对原排车详情做组合,不创建第二份主单或履约状态。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Detail detail(Actor actor,String number){codec.require(actor,LIST);return detailForWrite(actor,requireOwned(number));}
/** 外配入口只能读取其冻结归属,不能借自有权限取得原详情。 */
/** 外配入口只读实际拼车任务,不能借自有权限取得原详情。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Detail detail(Actor actor,String number,OwnedBusinessScope scope){codec.require(actor,scope.listPermission());return detailForWrite(actor,requireScoped(number,scope),scope);}
/** 写入已经有独立权限;回执仍由相同真实来源组成。 */
@@ -82,7 +82,7 @@ public class OwnedBusinessQueryService {
if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号");
var source=repository.source(number);if(source==null)throw missing();eligible(source,OwnedBusinessScope.OWNED);return source;
}
/** 共享附加表仍按原装车归属隔离,不接受浏览器业务域参数。 */
/** 共享附加表按实际拼车分类隔离,不接受浏览器业务域参数。 */
public Source requireScoped(String number,OwnedBusinessScope scope) {
if(scope==OwnedBusinessScope.OWNED)return requireOwned(number);
if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号");
@@ -91,13 +91,16 @@ public class OwnedBusinessQueryService {
/** 阶段、真实装车hash及车辆快照须与捕获证据一致。 */
private void eligible(Source source,OwnedBusinessScope scope) {
if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务");
if(!scope.getOwnership().equals(source.getOwnedVehicleOwnership()))throw scope.missing();
if(!Set.of("OWNED","DISPATCHED").contains(source.getOwnedVehicleOwnership()))throw conflict("装车车辆归属不合法");
if(source.getPooledCargo()==null)throw conflict("本单缺少实际拼车分类依据");
if(scope!=OwnedBusinessScope.DISPATCH && scope.isPooledCargo()!=source.getPooledCargo())throw scope.missing();
if(!"ACTIVE".equals(source.getStatus()) || source.getStage()==null || !Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(source.getStage())
|| source.getLoadingCompletedAt()==null || source.getLoadingConfirmationJson()==null)throw conflict("本单尚无合法装车完成事实");
if(scope==OwnedBusinessScope.DISPATCH && !Set.of("DELIVERING","DELIVERED").contains(source.getStage()))throw conflict("仅派送中和配送完成可管理车辆资料");
var resource=codec.read(source.getResourcesJson(),DispatchContracts.Resources.class).vehicle();
boolean master=resource!=null && resource.mode()!=null && "MASTER".equals(resource.mode().name()) && "MASTER".equals(source.getOwnedResourceMode())
&& resource.vehicleId()!=null && Objects.equals(source.getOwnedVehicleId(),resource.vehicleId());
boolean external=scope==OwnedBusinessScope.OUTSOURCED && resource!=null && resource.mode()!=null && "EXTERNAL".equals(resource.mode().name())
boolean external="DISPATCHED".equals(source.getOwnedVehicleOwnership()) && resource!=null && resource.mode()!=null && "EXTERNAL".equals(resource.mode().name())
&& "EXTERNAL".equals(source.getOwnedResourceMode()) && resource.vehicleId()==null && source.getOwnedVehicleId()==null;
if((!master && !external) || !Objects.equals(source.getOwnedLoadingCompletionHash(),source.getLoadingCompletionHash())
|| !Objects.equals(source.getOwnedLoadingCompletedAt(),source.getLoadingCompletedAt()))throw conflict("车辆归属与原装车事实不一致");
@@ -303,7 +303,8 @@ public class FleetBookingService {
private static void requireTrip(FleetTripEntity trip,long version){if("PICKUP".equals(trip.getTripPurpose()))throw conflict("取货车次不能承接出库拼车预订");if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次允许管理外接预订");if(trip.getRecordVersion()!=version)throw conflict("车次已被修改,请刷新");}
private static void requireVersion(FleetBookingEntity row,long version){if(row.getRecordVersion()!=version)throw conflict("外接预订已被修改,请刷新");}
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception error){throw new IllegalStateException(error);}}
private String hash(Object value){try{ObjectNode node=json.valueToTree(value);node.remove("idempotencyKey");node.remove("tripRecordVersion");return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(node).getBytes(StandardCharsets.UTF_8)));}catch(Exception error){throw new IllegalStateException(error);}}
/** 请求对象排除重放元数据;排车来源键的数组必须保留全部元素及顺序。 */
private String hash(Object value){try{com.fasterxml.jackson.databind.JsonNode node=json.valueToTree(value);if(node instanceof ObjectNode object){object.remove("idempotencyKey");object.remove("tripRecordVersion");}return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(node).getBytes(StandardCharsets.UTF_8)));}catch(Exception error){throw new IllegalStateException(error);}}
private static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC);}
private static String trim(String value){return value==null||value.isBlank()?null:value.trim();}
private static String reason(String value,String fallback){return trim(value)==null?fallback:trim(value);}
@@ -36,7 +36,16 @@ public final class FleetCapacityOfferModels {
@NotBlank @Pattern(regexp="SELF_DELIVERY|MANUAL_PICKUP_TO_WAREHOUSE") String deliveryMode,
Instant estimatedArrivalAt,@Min(1) Integer expectedPalletCount,
@NotNull @Valid FleetBookingModels.Cargo cargo,@Valid FleetQuoteCandidateModels.QuoteRef quoteRef,
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
@NotBlank @Size(min=8,max=128) String idempotencyKey,
@Size(max=64) String sourceOrderNo,@Min(0) Long sourceOrderVersion) {
/** 保留无原单人工登记的构造方式。 */
public InquiryWrite(Long tripRecordVersion,Long offerRecordVersion,Long recordVersion,Long customerId,
String businessType,String deliveryMode,Instant estimatedArrivalAt,Integer expectedPalletCount,
FleetBookingModels.Cargo cargo,FleetQuoteCandidateModels.QuoteRef quoteRef,String idempotencyKey) {
this(tripRecordVersion,offerRecordVersion,recordVersion,customerId,businessType,deliveryMode,
estimatedArrivalAt,expectedPalletCount,cargo,quoteRef,idempotencyKey,null,null);
}
}
public record InquiryCommand(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long offerRecordVersion,
@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
@Size(max=1000) String reason) { }
@@ -26,6 +26,8 @@ import java.util.*;
@Service
public class FleetCapacityOfferService {
@jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers;
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService demands;
public static final String LIST="platform:fleet:capacity-offer:list", MANAGE="platform:fleet:capacity-offer:manage";
private final FleetCapacityOfferMapper offers;
private final FleetCapacityOfferCommandMapper commands;
@@ -51,6 +53,11 @@ public class FleetCapacityOfferService {
this.offers=offers;this.commands=commands;this.trips=trips;this.bookings=bookings;this.bookingService=bookingService;
this.reservations=reservations;this.execution=execution;this.customers=customers;this.orders=orders;this.json=json;this.clock=clock;
}
/** 平台需求池分页读取客户已下的散板原单。 */
@Transactional(readOnly=true)
public PageResult<com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService.Demand> pendingDemands(FleetCapacityOfferModels.InquiryQuery query) {
writePermission("inquiry-create");return demands.pending(query.page(),query.size(),query.keyword());
}
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public PageResult<FleetCapacityOfferModels.Offer> page(FleetCapacityOfferModels.Query query){
readPermission();
@@ -173,17 +180,20 @@ public class FleetCapacityOfferService {
touch(row);var result=new FleetCapacityOfferModels.OfferResult(project(row,trip),false);
saveCommand(trip,action,number,request.idempotencyKey(),request,result);return result;
}
/** 同一事务登记意向及原单来源,确认前不占位。 */
@Transactional
public FleetCapacityOfferModels.InquiryResult createInquiry(String number,FleetCapacityOfferModels.InquiryWrite request){
writePermission("inquiry-create");var initial=offer(number,false);var trip=trip(initial.getTripNo(),true);var row=offer(number,true);
var replay=replay(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);
if(replay!=null)return replayed(replay);
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip);validateInquiry(trip,request);
var source=demands.prepare(request.customerId().toString(),request.businessType(),request.sourceOrderNo(),request.sourceOrderVersion());
var created=bookingService.create(trip.getTripNo(),operator(),new FleetBookingModels.Create(trip.getRecordVersion(),request.customerId(),request.cargo(),"POOL:"+hash(Arrays.asList(number,request.idempotencyKey())),request.quoteRef()));
if(created.replayed())throw conflict("意向创建记录不完整,请保留原请求联系调度核对");
var booking=bookings.lock(trip.getId(),created.booking().bookingNo());booking.setOfferId(row.getId());booking.setSalesOwnerUserId(operator());apply(booking,request);
bookings.updateById(booking);var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,result);return result;
bookings.updateById(booking);demands.attach(booking.getId(),source);var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,result);return result;
}
/** 编辑登记资料时保持客户及原单来源不可变。 */
@Transactional
public FleetCapacityOfferModels.InquiryResult updateInquiry(String number,FleetCapacityOfferModels.InquiryWrite request){
writePermission("inquiry-edit");var initial=inquiryRow(number);own(initial);var trip=trip(initial.getTripNo(),true);var row=offers.lock(offers.selectById(initial.getOfferId()).getOfferNo());
@@ -191,6 +201,7 @@ public class FleetCapacityOfferService {
var replay=replay(trip,"INQUIRY_EDIT",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);if(replay!=null)return replayed(replay);
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip);validateInquiry(trip,request);
if(!Objects.equals(booking.getCustomerId(),request.customerId().toString()))throw conflict("意向客户不能改写,请取消草稿后重新登记");
demands.validateEdit(booking.getId(),booking.getCustomerId(),request.businessType(),request.sourceOrderNo());
if(request.recordVersion()==null)throw bad("缺少意向版本");
bookingService.updatePool(trip.getTripNo(),number,operator(),new FleetBookingModels.Update(trip.getRecordVersion(),request.recordVersion(),request.cargo(),request.quoteRef()));
booking=bookings.lock(trip.getId(),number);apply(booking,request);bookings.updateById(booking);
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import org.springframework.stereotype.Service;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
/** 运输执行仍由车队应用边界核对车辆主档,不向排车暴露车辆持久化。 */
@Service
public class FleetTransportVehicleGuard {
private final VehicleRepository vehicles;
/** 复用车辆应用端口及其行锁。 */
public FleetTransportVehicleGuard(VehicleRepository vehicles){this.vehicles=vehicles;}
/** 发车核对实时车辆资格,并冻结当次有效容量供业务量复核。 */
public void validate(FleetTripEntity trip){
var vehicle=vehicles.lockById(trip.getVehicleId()).orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"车辆不存在"));
if(!"OVERSEAS".equals(vehicle.region()) || !"IN_SERVICE".equals(vehicle.status()))throw new ResponseStatusException(HttpStatus.CONFLICT,"当前车辆不可出车");
trip.setMaxPalletSpaces(vehicle.maxPalletSpaces());trip.setMaxLoadKg(vehicle.maxLoadKg());trip.setMaxVolumeCbm(vehicle.maxVolumeCbm());
}
}

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