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
171 changed files with 3652 additions and 1687 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}
@@ -429,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 '本趟主档或临时车辆司机的真实资源快照',
@@ -4874,7 +4875,7 @@ CREATE TABLE ll_fleet_pickup_history (
) 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 '真实车辆档案主键,临时外配为空',
@@ -4892,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 '附加运营记录并发版本,独立于原排车版本',
@@ -4917,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 '原命令操作人标识',
@@ -4938,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 '成功写入后的附加记录版本',
@@ -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) {
@@ -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
@@ -24,7 +24,7 @@ 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(f->f.departed() || f.clearanceUploaded()).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
@@ -32,19 +32,24 @@ public class WorkflowTabService {
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));
}
@@ -77,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:
@@ -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);
}
@@ -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 {
@@ -25,12 +25,15 @@ 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);
}
/** 未记录离港时,报关完成即可待清关;上传清关资料后也必须查询精确海外来源。 */
+3 -3
View File
@@ -17,7 +17,7 @@
拼车余位发布查 `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只查部署主契约。
原排车装车后运营视图分别查 `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`。
@@ -57,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` |
@@ -77,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 -4
View File
@@ -85,8 +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/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按冻结DISPATCHED归属展示,包括正式MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五装车照片,无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。
- 自有业务运营:`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不重写。
@@ -95,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` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。
@@ -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) {
@@ -24,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明确拒绝归属或文件格式属于业务错误,不能伪装成服务不可用。 */
@@ -26,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>";
@@ -76,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
}
}
@@ -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,
@@ -63,7 +67,7 @@ public final class DispatchViews {
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) {
@@ -667,7 +739,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark());
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);
@@ -775,13 +848,16 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
// 到仓取货和本土卡车沿运输任务执行,不能进入仓内拣货、扣库存和转派送链。
if (row.getTaskType()!=null && !"DELIVERY".equals(row.getTaskType())) {
var blocked=new EnumMap<Action,Gate>(Action.class);
for(var action:Action.values())blocked.put(action,new Gate(false,"TRANSPORT_TASK","请在排车运输任务中操作"));
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());
@@ -799,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";
@@ -814,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);
@@ -821,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])));
@@ -883,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);
}
@@ -36,7 +36,7 @@ public class TransportTaskProjection {
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);
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);
@@ -7,5 +7,6 @@ public record ContainerArrivalTimeView(
String sourceContainerBookingId,
LocalDateTime estimatedWarehouseArrivalAt,
LocalDateTime actualWarehouseArrivalAt,
int version
int version,
long planCount
) { }
@@ -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);}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.fleet.trip;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.stereotype.Repository;
/** 日历只读访问保留真实主单范围,整批读取原货物快照而不逐车查询。 */
@Repository
public class FleetTripCalendarRepository {
public record Source(String dispatchNo, String sourceSnapshotJson) { }
private final FleetTripMapper mapper;
/** 装配现有车次Mapper,日历服务不直接访问SQL。 */
public FleetTripCalendarRepository(FleetTripMapper mapper) { this.mapper = mapper; }
/** 按真实UTC时间窗口读取有唯一整车主单的运输记录。 */
public List<FleetTripCalendarRow> calendar(LocalDateTime from, LocalDateTime to) {
return mapper.calendar(from, to);
}
/** 一次读取指定主单的全部货物快照,空窗口不发送空IN查询。 */
public List<Source> sourceSnapshots(List<String> dispatchNumbers) {
if (dispatchNumbers.isEmpty()) return List.of();
return mapper.calendarSources(dispatchNumbers).stream()
.map(row -> new Source(row.dispatchNo(), row.sourceSnapshotJson())).toList();
}
}
@@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
@@ -18,27 +19,29 @@ public class FleetTripCalendarService {
String originWarehouseCode, String originWarehouseName, String originCountryCode,
String originTimezone, String originWarehouseStatus, Instant plannedDepartureAt,
Instant expectedDriverEndAt, Instant departedAt, Instant completedAt,
List<FleetTripModels.Stop> stops, List<String> incompleteReasons) { }
List<FleetTripModels.Stop> stops, List<String> containerNumbers, List<String> incompleteReasons) { }
public record Result(List<Entry> records, List<Entry> unresolvedRecords) { }
private static final Set<String> COUNTRIES = Set.of(Locale.getISOCountries());
private final FleetTripMapper trips;
private final FleetTripCalendarRepository trips;
private final FleetWarehouseClient warehouses;
private final ObjectMapper json;
private final String token;
public FleetTripCalendarService(FleetTripMapper trips, FleetWarehouseClient warehouses, ObjectMapper json,
/** 装配日历持久化入口与真实仓库元数据提供者。 */
public FleetTripCalendarService(FleetTripCalendarRepository trips, FleetWarehouseClient warehouses, ObjectMapper json,
@Value("${baiyue.security.internal-service-token:}") String token) {
this.trips=trips; this.warehouses=warehouses; this.json=json; this.token=token;
}
/** 一次读取真实排车及一次仓库资料查询,按业务地点判定日历归属。 */
@Transactional(readOnly=true)
/** 同一快照批读真实主单和货物,仓库资料只查询一次,保留原业务地点时区。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Result calendar(Instant from, Instant to) {
if (from==null || to==null || !to.isAfter(from) || Duration.between(from,to).compareTo(Duration.ofDays(44))>0)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"查询须为不超过44天的UTC时间范围");
var rows=trips.calendar(LocalDateTime.ofInstant(from,ZoneOffset.UTC),LocalDateTime.ofInstant(to,ZoneOffset.UTC));
if (rows.size()>2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"排车过多,请缩小查询日期范围");
if (rows.isEmpty()) return new Result(List.of(),List.of());
var containers=containerNumbers(rows);
var ids=rows.stream().map(FleetTripEntity::getWarehouseId).filter(Objects::nonNull).filter(id->id>0).distinct().sorted().toList();
var metadata=metadata(ids);
var records=new ArrayList<Entry>(); var unresolved=new ArrayList<Entry>();
@@ -64,12 +67,35 @@ public class FleetTripCalendarService {
var entry=new Entry("DISPATCH",row.getDispatchNo(),row.getStatus(),row.getWarehouseId()==null?null:row.getWarehouseId().toString(),
warehouse==null?row.getWarehouseCode():warehouse.warehouseCode(),warehouse==null?row.getWarehouseName():warehouse.warehouseName(),
country,zone,warehouse==null?null:warehouse.status(),instant(row.getPlannedDepartureAt()),instant(row.getExpectedDriverEndAt()),
instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,List.copyOf(reasons));
instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,containers.getOrDefault(row.getDispatchNo(),List.of()),List.copyOf(reasons));
(unresolvedOrigin?unresolved:records).add(entry);
}
return new Result(List.copyOf(records),List.copyOf(unresolved));
}
/** 柜号只取排车建单时正式保存的SourceDisplay.containerNo,不反查或推测其他来源。 */
private Map<String,List<String>> containerNumbers(List<FleetTripCalendarRow> rows) {
var numbers=rows.stream().map(FleetTripCalendarRow::getDispatchNo).distinct().sorted().toList();
var requested=new HashSet<>(numbers);var grouped=new LinkedHashMap<String,LinkedHashSet<String>>();
for(var source:trips.sourceSnapshots(numbers)) {
if(source==null || !requested.contains(source.dispatchNo()) || source.sourceSnapshotJson()==null || source.sourceSnapshotJson().isBlank())
throw sourceUnavailable();
try {
var snapshot=json.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.readTree(source.sourceSnapshotJson());
if(snapshot==null || !snapshot.isObject())throw sourceUnavailable();
var value=snapshot.get("containerNo");
if(value==null || value.isNull())continue;
if(!value.isTextual())throw sourceUnavailable();
var number=value.textValue().strip();
if(!number.isEmpty())grouped.computeIfAbsent(source.dispatchNo(),ignored->new LinkedHashSet<>()).add(number);
} catch(com.fasterxml.jackson.core.JsonProcessingException invalid) { throw sourceUnavailable(); }
}
var result=new LinkedHashMap<String,List<String>>();
grouped.forEach((number,values)->result.put(number,List.copyOf(values)));return Map.copyOf(result);
}
/** 元数据批次必须完整覆盖请求身份,未知地点保持显式未核实。 */
private Map<Long,FleetWarehouseClient.Metadata> metadata(List<Long> ids) {
if (ids.isEmpty()) return Map.of();
try {
@@ -85,6 +111,10 @@ public class FleetTripCalendarService {
return result;
} catch (RuntimeException failure) { throw unavailable(); }
}
/** 持久UTC时间只转换为UTC瞬时,不套用浏览器日期。 */
private static Instant instant(LocalDateTime value) { return value==null?null:value.toInstant(ZoneOffset.UTC); }
/** 仓库服务失败明确报错,不能把未知时区伪装成完整空日历。 */
private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"仓库时区资料暂不可用,请重试"); }
/** 损坏货物快照保留明确错误,不静默丢弃真实排车或柜号。 */
private static ResponseStatusException sourceUnavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"排车货物柜号资料暂不可用,请重试"); }
}
@@ -19,6 +19,18 @@ public interface FleetTripMapper extends BaseMapper<FleetTripEntity> {
ORDER BY COALESCE(t.departed_at,t.planned_departure_at),d.dispatch_no LIMIT 2001
""")
java.util.List<FleetTripCalendarRow> calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to);
record CalendarSource(String dispatchNo, String sourceSnapshotJson) { }
/** 主查询限额之后批量读取原货物快照,不以货物JOIN放大整车记录或截断多柜。 */
@Select("""
<script>
SELECT d.dispatch_no AS dispatchNo,i.source_snapshot_json AS sourceSnapshotJson
FROM ll_dispatch_order d JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id
WHERE d.status='ACTIVE' AND d.dispatch_no IN
<foreach collection='numbers' item='number' open='(' separator=',' close=')'>#{number}</foreach>
ORDER BY d.dispatch_no,i.id
</script>
""")
java.util.List<CalendarSource> calendarSources(@Param("numbers") java.util.List<String> dispatchNumbers);
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE")
FleetTripEntity lockByNo(@Param("tripNo") String tripNo);
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo}")
@@ -4,62 +4,23 @@ import com.baiyue.lastleg.dto.CustomerOrderProgress;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** Batch read of every actual source, never the representative MIN(order_id) on a shared pallet. */
/** 按客户及不可变来源明细读取进度,不使用混托的代表订单。 */
@Mapper
public interface CustomerOrderProgressMapper {
/** 本客户明细决定归属,真实主阶段和实际发车时间决定待排及在途数量。 */
/** 已排车只统计有效待出库单的来源数量,与平台出库单节点一致;在途仍要求实际发车。 */
@Select("""
<script>
WITH scheduled AS (
SELECT line.order_id,SUM(a.planned_package_count) AS packages
FROM ll_dispatch_order d
JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id
JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=i.plan_pallet_id
JOIN ll_inbound_plan_line line ON line.id=a.line_id AND line.customer_id=i.customer_id
SELECT i.source_order_id AS order_id,SUM(i.package_count) AS packages
FROM ll_outbound_order b
JOIN ll_outbound_pallet_item i ON i.outbound_order_id=b.id AND i.customer_id=b.customer_id
<if test="orderIds != null">
JOIN ll_order planned_source ON planned_source.id=line.order_id AND planned_source.customer_id=#{customerId}
AND planned_source.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
JOIN ll_order outbound_source ON outbound_source.id=i.source_order_id AND outbound_source.customer_id=#{customerId}
AND outbound_source.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</if>
WHERE i.customer_id=#{customerId} AND d.status='ACTIVE'
AND d.stage IN ('BEFORE_LOADING','LOADING','AFTER_LOADING')
AND i.warehouse_pallet_id IS NULL
GROUP BY line.order_id
UNION ALL
SELECT b.inbound_order_id AS order_id,SUM(p.available_packages+COALESCE(pk.packages,0)) AS packages
FROM ll_dispatch_order d
JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id
JOIN ll_inventory_position p ON p.customer_id=i.customer_id AND p.pallet_id=i.warehouse_pallet_id
AND p.overseas_warehouse_id=i.overseas_warehouse_id
JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=i.customer_id
<if test="orderIds != null">
JOIN ll_order stock_source ON stock_source.id=b.inbound_order_id AND stock_source.customer_id=#{customerId}
AND stock_source.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</if>
LEFT JOIN (SELECT pick.dispatch_item_id,pick.inventory_position_id,SUM(pick.packages) AS packages
FROM ll_dispatch_pda_pick pick
<if test="orderIds != null">
JOIN ll_inventory_position scoped_position ON scoped_position.id=pick.inventory_position_id
AND scoped_position.customer_id=pick.customer_id
JOIN ll_inventory_balance scoped_balance ON scoped_balance.id=scoped_position.balance_id
AND scoped_balance.customer_id=pick.customer_id
JOIN ll_order scoped_order ON scoped_order.id=scoped_balance.inbound_order_id
AND scoped_order.customer_id=pick.customer_id
AND scoped_order.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</if>
WHERE pick.customer_id=#{customerId}
GROUP BY pick.dispatch_item_id,pick.inventory_position_id) pk
ON pk.dispatch_item_id=i.id AND pk.inventory_position_id=p.id
WHERE i.customer_id=#{customerId} AND d.status='ACTIVE'
AND d.stage IN ('BEFORE_LOADING','LOADING','AFTER_LOADING')
AND (i.plan_pallet_id IS NULL OR EXISTS (
SELECT 1 FROM ll_inbound_plan_pallet_allocation a
JOIN ll_inbound_plan_line line ON line.id=a.line_id AND line.customer_id=i.customer_id
<if test="orderIds != null">
JOIN ll_order planned_source ON planned_source.id=line.order_id AND planned_source.customer_id=#{customerId}
AND planned_source.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</if>
WHERE a.pallet_id=i.plan_pallet_id AND line.order_id=b.inbound_order_id))
GROUP BY b.inbound_order_id
WHERE i.customer_id=#{customerId}
AND b.status NOT IN ('CANCELLED','CANCELED','OUTBOUNDED')
GROUP BY i.source_order_id
), departed AS (
SELECT a.order_id,SUM(a.packages) AS packages
FROM ll_dispatch_pda_pick_order a
@@ -210,12 +210,19 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
@Select("SELECT * FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE")
List<LastlegOrder> lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId);
/** 人工时间及计划冻结时间优先;返回计划数量供各页面统一判断默认日期。 */
@Select("<script>SELECT o.source_container_booking_id AS sourceContainerBookingId," +
"CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) AND COUNT(DISTINCT o.estimated_warehouse_at)=1 " +
"AND (MAX(o.arrival_time_version)&gt;0 OR EXISTS(SELECT 1 FROM ll_inbound_plan p " +
"JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id " +
"WHERE po.source_container_booking_id=o.source_container_booking_id)) " +
"THEN MAX(o.estimated_warehouse_at) ELSE NULL END AS estimatedWarehouseArrivalAt," +
"(SELECT MIN(s.receiving_at) FROM ll_receiving_container_session s " +
"WHERE s.source_container_booking_id=o.source_container_booking_id AND s.receiving_at IS NOT NULL) AS actualWarehouseArrivalAt," +
"CASE WHEN COUNT(DISTINCT o.arrival_time_version)=1 THEN MAX(o.arrival_time_version) ELSE -1 END AS version " +
"CASE WHEN COUNT(DISTINCT o.arrival_time_version)=1 THEN MAX(o.arrival_time_version) ELSE -1 END AS version," +
"(SELECT COUNT(*) FROM ll_inbound_plan p JOIN ll_order po " +
"ON po.id=p.source_order_id AND po.customer_id=p.customer_id " +
"WHERE po.source_container_booking_id=o.source_container_booking_id) AS planCount " +
"FROM ll_order o WHERE o.source_container_booking_id IN " +
"<foreach collection='bookingIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"GROUP BY o.source_container_booking_id</script>")
@@ -1907,6 +1907,7 @@ CREATE TABLE IF NOT EXISTS 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 '本趟主档或临时车辆司机的真实资源快照',
@@ -0,0 +1,379 @@
{
"openapi": "3.0.3",
"info": {
"title": "排车车辆资料",
"version": "1.0.0",
"description": "仅平台有效排车DELIVERING/DELIVERED可用;兼容有无拼车货物,沿装车归属证据校验。共享原车队照片、版本、命令与历史,不新建附件存储。派送文件仍使用delivery-orders documents;不把车队RECEIPT照片当签收。列表权限只读,排车edit独立写;恢复还需list。"
},
"security": [
{
"bearerAuth": []
}
],
"paths": {
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents": {
"get": {
"operationId": "getDispatchVehicleDocuments",
"summary": "读取本车油耗图片;复用车队附加记录",
"x-permission-code": "platform:overseas-warehouse:dispatch:list",
"parameters": [
{
"$ref": "#/components/parameters/dispatchNo"
}
],
"responses": {
"200": {
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/Documents"
}
}
}
}
}
}
}
}
},
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/uploads": {
"post": {
"operationId": "uploadDispatchVehiclePhoto",
"summary": "准备本车油耗图片;尚未绑定保存",
"x-permission-code": "platform:overseas-warehouse:dispatch:edit",
"parameters": [
{
"$ref": "#/components/parameters/dispatchNo"
},
{
"$ref": "#/components/parameters/slot"
}
],
"responses": {
"200": {
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/Photo"
}
}
}
}
}
}
},
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": [
"file",
"idempotencyKey"
],
"properties": {
"file": {
"type": "string",
"format": "binary",
"description": "非空JPG/PNG,最大10MiB"
},
"idempotencyKey": {
"type": "string",
"minLength": 8,
"maxLength": 128
}
}
}
}
}
}
}
},
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}": {
"put": {
"operationId": "bindDispatchVehiclePhoto",
"summary": "按附加版本绑定原上传文件,不改变排车或签收状态",
"x-permission-code": "platform:overseas-warehouse:dispatch:edit",
"parameters": [
{
"$ref": "#/components/parameters/dispatchNo"
},
{
"$ref": "#/components/parameters/slot"
}
],
"responses": {
"200": {
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/Documents"
}
}
}
}
}
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BindPhoto"
}
}
}
}
}
},
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/files/{fileId}/content": {
"get": {
"operationId": "readDispatchVehiclePhoto",
"summary": "只读取本车当前绑定图片",
"x-permission-code": "platform:overseas-warehouse:dispatch:list",
"parameters": [
{
"$ref": "#/components/parameters/dispatchNo"
},
{
"$ref": "#/components/parameters/slot"
},
{
"$ref": "#/components/parameters/fileId"
}
],
"responses": {
"200": {
"description": "no-store图片内容",
"content": {
"image/jpeg": {
"schema": {
"type": "string",
"format": "binary"
}
},
"image/png": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
},
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/uploads/{idempotencyKey}": {
"get": {
"operationId": "recoverDispatchVehiclePhoto",
"summary": "只恢复当前操作者原油耗上传成功结果,未知结果拒绝重传",
"x-permission-code": "platform:overseas-warehouse:dispatch:edit",
"parameters": [
{
"$ref": "#/components/parameters/dispatchNo"
},
{
"$ref": "#/components/parameters/idempotencyKey"
}
],
"responses": {
"200": {
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/Photo"
}
}
}
}
}
}
}
}
}
},
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
},
"parameters": {
"dispatchNo": {
"name": "dispatchNo",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
"slot": {
"name": "slot",
"in": "path",
"required": true,
"schema": {
"type": "string",
"enum": [
"FUEL_START",
"FUEL_END"
]
}
},
"fileId": {
"name": "fileId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
"idempotencyKey": {
"name": "idempotencyKey",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
},
"schemas": {
"Photo": {
"type": "object",
"required": [
"slot",
"fileId",
"fileName",
"contentType",
"sizeBytes",
"uploadedAt"
],
"properties": {
"slot": {
"type": "string",
"enum": [
"FUEL_START",
"FUEL_END"
]
},
"category": {
"type": "string",
"enum": [
"FUEL"
]
},
"fileId": {
"type": "string"
},
"fileName": {
"type": "string"
},
"contentType": {
"type": "string"
},
"sizeBytes": {
"type": "string"
},
"uploadedAt": {
"type": "string"
},
"savedAt": {
"type": "string",
"nullable": true
}
}
},
"Documents": {
"type": "object",
"required": [
"dispatchNo",
"recordVersion",
"editable",
"photos"
],
"properties": {
"dispatchNo": {
"type": "string"
},
"recordVersion": {
"type": "string"
},
"editable": {
"type": "boolean"
},
"photos": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Photo"
}
}
}
},
"BindPhoto": {
"type": "object",
"required": [
"recordVersion",
"idempotencyKey",
"fileId"
],
"properties": {
"recordVersion": {
"type": "string",
"pattern": "^(0|[1-9][0-9]*)$"
},
"idempotencyKey": {
"type": "string",
"minLength": 8,
"maxLength": 128
},
"fileId": {
"type": "string"
}
}
}
}
}
}
@@ -1,8 +1,8 @@
openapi: "3.0.3"
info:
title: "原排车外配车辆运营视图"
title: "原排车拼车任务运营视图"
version: "1.0.0"
description: "仅ACTIVE且装车后/派送中/已完成。按原装车归属DISPATCHED及MASTER/EXTERNAL资源快照读取;无快照不回填。复用四张附加表,不推进排车、不触发库存/财务。承运金额CNY/USD显式登记;单价除以实际物理托数并HALF_UP两位。无里程、油耗及油耗照片。响应遵循NON_NULL:可空属性可省略,省略与null均表示空值,0仍是明确登记值;请求必填项仍以required及DTO校验为准。"
description: "仅ACTIVE且装车后/派送中/已完成。按需求池已实收绑定及本车真实货物组成判定实际拼车。车辆归属允许OWNED/DISPATCHED,MASTER/EXTERNAL只表示资源身份;无快照不回填。复用四张附加表,不推进排车、不触发库存/财务。承运金额CNY/USD显式登记;单价除以实际物理托数并HALF_UP两位。无里程、油耗及油耗照片。响应遵循NON_NULL:可空属性可省略,省略与null均表示空值,0仍是明确登记值;请求必填项仍以required及DTO校验为准。"
security:
- bearerAuth: []
paths:
@@ -182,7 +182,7 @@ components:
properties:
vehicleId: { type: string, nullable: true, description: "原临时EXTERNAL车辆合法为空,档案MASTER核对原捕获ID" }
plate: { type: string, nullable: true }
ownership: { type: string, enum: [DISPATCHED] }
ownership: { type: string, enum: [OWNED, DISPATCHED] }
carrierName: { type: string, nullable: true, description: "只取原车辆快照,缺失显示—;不查当前档案补值" }
PhotoSlot:
type: string
@@ -1,8 +1,8 @@
openapi: "3.0.3"
info:
title: "原排车自有车辆运营视图"
title: "原排车未拼车业务运营视图"
version: "1.0.0"
description: "装车完成时冻结车辆OWNED/DISPATCHED;所有主单事实来自原dispatch。新增数据仅附加运营记录、命令、历史及归属证据。油耗L/100km手工登记,金额CNY/USD不默认、不换汇;零与未登记不同。响应遵循NON_NULL:可空属性可省略,省略与null均表示空值,0仍是明确登记值;请求必填项仍以required及DTO校验为准。"
description: "未实际接入拼车货物的装车后排车;按需求池已实收绑定及本车真实货物组成判定。车辆OWNED/DISPATCHED只表示装车时归属,不决定业务分类;所有主单事实来自原dispatch。新增数据仅附加运营记录、命令、历史及归属证据。油耗L/100km手工登记,金额CNY/USD不默认、不换汇;零与未登记不同。响应遵循NON_NULL:可空属性可省略,省略与null均表示空值,0仍是明确登记值;请求必填项仍以required及DTO校验为准。"
security:
-
bearerAuth: []
@@ -10,7 +10,7 @@ paths:
"/api/platform/v1/last-leg/fleet/owned-business":
get:
operationId: "listOwnedBusiness"
summary: "自有车辆装车后视图及同筛选四项统计"
summary: "未接入拼车货物的装车后视图及同筛选四项统计"
description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:list"
responses:
@@ -540,18 +540,19 @@ components:
type: "object"
x-java-record: "OwnedBusinessModels.Vehicle"
required:
- "vehicleId"
- "plate"
- "ownership"
properties:
vehicleId:
type: "string"
nullable: true
plate:
type: "string"
nullable: true
ownership:
type: "string"
enum:
- "OWNED"
- "DISPATCHED"
Driver:
type: "object"
x-java-record: "OwnedBusinessModels.Driver"
@@ -359,7 +359,7 @@ components:
schemas:
TripCalendarEntry:
type: object
required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, incompleteReasons]
required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, containerNumbers, incompleteReasons]
properties:
sourceType: {type: string, enum: [DISPATCH]}
dispatchNo: {type: string, description: 真实整车主单排车号,用于排车详情路由}
@@ -375,6 +375,11 @@ components:
departedAt: {type: string, format: date-time, nullable: true}
completedAt: {type: string, format: date-time, nullable: true}
stops: {type: array, items: {$ref: '#/components/schemas/Stop'}}
containerNumbers:
type: array
description: 本车全部货物正式来源快照中的真实柜号,去空去重;多客户、多柜保留完整数组,无柜号为空数组,不推测或补造。
items: {type: string}
uniqueItems: true
incompleteReasons:
type: array
items: {type: string, enum: [ORIGIN_WAREHOUSE_UNKNOWN, ORIGIN_COUNTRY_UNKNOWN, ORIGIN_TIMEZONE_UNKNOWN, ROUTE_INCOMPLETE]}
@@ -8,9 +8,10 @@ paths:
summary: Order按认证客户读取排车和实际派送进度
description: >-
只读内部接口,无公网路由。customerId由Order认证上下文提供,所有业务表在Lastleg自身Schema内按客户读取。
正式排车单PENDING_DEPARTURE/PICKING/READY_TO_DEPART按计划或库存来源计算,预排草稿和取消单不计入。
派送必须已实际DEPARTED、出库OUTBOUNDED且对应目的地派送单仍WAIT_DELIVERY/DELIVERING。
混托使用所有库存及不可变拣货来源,不使用代表orderId;数量覆盖不完整或异常时标记partial,不签发派送完成事实。
已排车scheduled按有效待出库单的不可变来源明细及箱数计算,与平台出库单节点一致;仅生成排车单号、审核中、审核通过但未生成出库单均不计入。
取消的出库单及已实际出库的单据不计入scheduled;排车审核、真实拣货核平、装车确认及安排派送仍按原流程执行。
派送必须排车单ACTIVE且处于DELIVERING阶段、具有实际发车时间、出库OUTBOUNDED且对应目的地派送单仍WAIT_DELIVERY/DELIVERING。
混托使用全部不可变出库及拣货来源,不使用代表orderId;数量覆盖不完整或异常时标记partial,不签发派送完成事实。
security: [{internalServiceToken: []}]
requestBody:
required: true
@@ -920,7 +920,8 @@ paths:
parameters:
- name: warehouseId
in: query
required: true
description: 可选真实起运仓;未指定时按 warehouseTab 跨仓分页浏览,草稿及建单仍要求同一起运仓。
required: false
schema:
type: string
- name: planIds
@@ -1345,49 +1346,6 @@ paths:
required: true
schema:
type: string
/api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery:
post:
summary: 原子安排本车全部客户派送及费用
description: 权限 platform:overseas-warehouse:dispatch:transfer-delivery。
security:
- bearerAuth: []
responses:
'200':
description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
requestId:
type: string
nullable: true
traceId:
type: string
nullable: true
timestamp:
type: string
format: date-time
data:
allOf:
- $ref: '#/components/schemas/DispatchCommandResult'
nullable: true
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DispatchArrangeDelivery'
parameters:
- name: dispatchNo
in: path
required: true
schema:
type: string
/api/platform/v1/last-leg/dispatches/{dispatchNo}/items:
post:
summary: 按真实来源版本向整车加货
@@ -1725,10 +1683,53 @@ paths:
required: true
schema:
type: string
/api/platform/v1/last-leg/dispatches/{dispatchNo}/exceptions:
post:
summary: 登记派送异常
description: 权限 platform:overseas-warehouse:dispatch:edit;仅有效仓库派送单DELIVERING可登记,保存后阶段不变,不产生退货、签收或库存变化。原键重试恢复原结果。
security:
- bearerAuth: []
responses:
'200':
description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
requestId:
type: string
nullable: true
traceId:
type: string
nullable: true
timestamp:
type: string
format: date-time
data:
allOf:
- $ref: '#/components/schemas/DispatchCommandResult'
nullable: true
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DispatchMarkException'
parameters:
- name: dispatchNo
in: path
required: true
schema:
type: string
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
post:
summary: PDA核平后确认装车完成
description: 权限 platform:overseas-warehouse:dispatch:loading-complete。
summary: 确认装车完成并自动进入派送中
description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 原请求在同一事务完成实载确认、全车派送单生成及发车;成功action为LOADING_COMPLETE、stage为DELIVERING,result保留真实Loaded装车证据。任一步失败整体回滚,重试原键恢复最终结果,不另行安排派送或确认发车。
security:
- bearerAuth: []
responses:
@@ -1878,43 +1879,6 @@ paths:
required: true
schema:
type: string
/api/platform/v1/last-leg/dispatches/{dispatchNo}/delivery-arrangement:
get:
summary: 读取全车派送分组与费用依据
description: 权限 platform:overseas-warehouse:dispatch:list。
security:
- bearerAuth: []
responses:
'200':
description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
requestId:
type: string
nullable: true
traceId:
type: string
nullable: true
timestamp:
type: string
format: date-time
data:
allOf:
- $ref: '#/components/schemas/DispatchArrangementPreview'
nullable: true
parameters:
- name: dispatchNo
in: path
required: true
schema:
type: string
/api/platform/v1/last-leg/dispatches/{dispatchNo}/commands/{idempotencyKey}:
get:
summary: 恢复本人原动作不可变结果
@@ -1960,7 +1924,7 @@ paths:
/api/platform/v1/last-leg/dispatches/{dispatchNo}/evidence/{kind}:
post:
summary: 上传本趟车辆或司机证据
description: 权限 platform:overseas-warehouse:dispatch:edit。PDF/JPG/PNG,非空且不超过10MiB;平台、本车、类型复合归属。上传后通过版本化EDIT保存引用。
description: 客服阶段使用 platform:overseas-warehouse:dispatch:edit;调度待审/理货待审分别使用 platform:fleet:trip:dispatch-review / platform:fleet:trip:loading-review,无需维护车辆权限。VEHICLE和DRIVER支持PDF/JPG/PNG,DRIVER_PHOTO和EXCEPTION仅JPG/PNG;EXCEPTION仅派送中使用排车edit权限,引用随异常登记保存。非空且不超过10MiB;平台、本车、类型复合归属。审核补充附件随ReviewCommand保存,不修改资源,不重启审核。
security:
- bearerAuth: []
responses:
@@ -2014,6 +1978,8 @@ paths:
enum:
- VEHICLE
- DRIVER
- DRIVER_PHOTO
- EXCEPTION
/api/platform/v1/last-leg/dispatches/{dispatchNo}/evidence/{kind}/{fileId}:
get:
summary: 读取本车证据元数据
@@ -2059,6 +2025,8 @@ paths:
enum:
- VEHICLE
- DRIVER
- DRIVER_PHOTO
- EXCEPTION
- name: fileId
in: path
required: true
@@ -2101,55 +2069,14 @@ paths:
enum:
- VEHICLE
- DRIVER
- DRIVER_PHOTO
- EXCEPTION
- name: fileId
in: path
required: true
schema:
type: string
pattern: ^[1-9][0-9]*$
/api/platform/v1/last-leg/dispatches/{dispatchNo}/depart:
post:
summary: 确认实际整车发车
description: 权限 platform:overseas-warehouse:dispatch:depart。
security:
- bearerAuth: []
responses:
'200':
description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
requestId:
type: string
nullable: true
traceId:
type: string
nullable: true
timestamp:
type: string
format: date-time
data:
allOf:
- $ref: '#/components/schemas/DispatchCommandResult'
nullable: true
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DispatchDepart'
parameters:
- name: dispatchNo
in: path
required: true
schema:
type: string
/api/platform/v1/last-leg/outbound-orders:
get:
summary: 分页查询出库单
@@ -3623,12 +3550,13 @@ components:
remark: { type: string, maxLength: 500, nullable: true }
ContainerArrivalTimeView:
type: object
required: [sourceContainerBookingId, version]
required: [sourceContainerBookingId, version, planCount]
properties:
sourceContainerBookingId: { type: string }
estimatedWarehouseArrivalAt: { type: string, format: date-time, nullable: true }
estimatedWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 人工维护或入库计划冻结的整柜时间;未维护未建计划时为空,由页面按到港当地日期加两个自然日展示默认日期 }
actualWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 海外 PDA 首次开始收货时间 }
version: { type: integer, minimum: 0 }
version: { type: integer, minimum: -1, description: -1表示柜内时间版本不一致 }
planCount: { type: integer, format: int64, minimum: 0, description: 整柜已有入库计划数量;非零时禁止默认推算及改期 }
ContainerPlanProgressDetail:
type: object
required: [summary, orders]
@@ -4274,7 +4202,10 @@ components:
invalidatedReason:
type: string
nullable: true
attachments:
$ref: '#/components/schemas/DispatchReviewAttachments'
required:
- attachments
- status
- reason
- reviewerId
@@ -5259,6 +5190,7 @@ components:
- LOADING_COMPLETE
- ARRANGE_DELIVERY
- DEPART
- MARK_EXCEPTION
DispatchGate:
type: object
x-java-record: DispatchContracts.Gate
@@ -5943,6 +5875,47 @@ components:
required:
- recordVersion
- idempotencyKey
DispatchReviewAttachments:
type: object
description: 本次审核补充证据;省略或null为三组空数组。每类最多20个且不得重复;逐个验证本车和类别归属,不接受MASTER档案证照冒充本车审核上传。
x-java-record: DispatchContracts.ReviewAttachments
properties:
vehicleFileIds:
type: array
maxItems: 20
uniqueItems: true
description: 本车VEHICLE证据文件ID。
items:
oneOf:
- type: string
pattern: ^[1-9][0-9]*$
- type: integer
format: int64
minimum: 1
driverPhotoFileIds:
type: array
maxItems: 20
uniqueItems: true
description: 本车DRIVER_PHOTO证据文件ID。
items:
oneOf:
- type: string
pattern: ^[1-9][0-9]*$
- type: integer
format: int64
minimum: 1
licenseFileIds:
type: array
maxItems: 20
uniqueItems: true
description: 本车DRIVER证据文件ID。
items:
oneOf:
- type: string
pattern: ^[1-9][0-9]*$
- type: integer
format: int64
minimum: 1
DispatchReviewCommand:
type: object
x-java-record: DispatchCommands.ReviewCommand
@@ -5963,6 +5936,10 @@ components:
reason:
type: string
nullable: true
attachments:
allOf:
- $ref: '#/components/schemas/DispatchReviewAttachments'
nullable: true
required:
- recordVersion
- idempotencyKey
@@ -6386,6 +6363,10 @@ components:
items:
$ref: '#/components/schemas/DispatchBatchReference'
nullable: true
exceptions:
type: array
items:
$ref: '#/components/schemas/DispatchExceptionRecord'
required:
- route
- plannedDepartureAt
@@ -6393,6 +6374,7 @@ components:
- estimatedArrivalAt
- fbaAppointments
- appointmentBatches
- exceptions
DispatchDocuments:
type: object
x-java-record: DispatchViews.Documents
@@ -6586,6 +6568,10 @@ components:
allOf:
- $ref: '#/components/schemas/DispatchReviews'
nullable: true
resources:
allOf:
- $ref: '#/components/schemas/DispatchResources'
nullable: true
counts:
allOf:
- $ref: '#/components/schemas/DispatchCounts'
@@ -6610,6 +6596,7 @@ components:
- stage
- recordVersion
- reviews
- resources
- counts
- pickingReconciled
- items
@@ -8007,5 +7994,66 @@ components:
- sourceInboundOrderNo
- sourceInboundCode
- productQuantity
DispatchExceptionType:
type: string
enum: [REFUSED, PARTIAL_RECEIPT, VEHICLE_EXCEPTION]
description: 拒收、部分签收、车辆异常。
DispatchReturnDirection:
type: string
enum: [OVERSEAS_WAREHOUSE, OTHER]
description: 退回海外仓、其他;仅登记去向,不自动退货入库。
DispatchMarkException:
type: object
x-java-record: DispatchCommands.MarkException
required: [recordVersion, idempotencyKey, exceptionType]
properties:
recordVersion:
oneOf:
- type: string
pattern: ^[0-9]+$
- type: integer
minimum: 0
idempotencyKey:
type: string
exceptionType:
$ref: '#/components/schemas/DispatchExceptionType'
returnDirection:
allOf:
- $ref: '#/components/schemas/DispatchReturnDirection'
nullable: true
description:
type: string
maxLength: 1000
nullable: true
imageFileIds:
type: array
maxItems: 20
uniqueItems: true
items:
type: string
pattern: ^[1-9][0-9]*$
DispatchExceptionRecord:
type: object
x-java-record: DispatchViews.ExceptionRecord
required: [exceptionType, returnDirection, description, imageFileIds, occurredAt]
properties:
exceptionType:
$ref: '#/components/schemas/DispatchExceptionType'
returnDirection:
allOf:
- $ref: '#/components/schemas/DispatchReturnDirection'
nullable: true
description:
type: string
nullable: true
imageFileIds:
type: array
items:
type: string
occurredAt:
type: string
format: date-time
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
@@ -9,7 +9,7 @@ paths:
/api/pda/v1/last-leg/dispatches:
get:
summary: 读取Token绑定仓五阶段整车任务
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。不接收客户或仓库范围参数;真实客户归属保留在items,整车公共版本及Gate由服务端返回。
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。不接收客户或仓库范围参数;真实客户归属保留在items,整车公共版本及Gate由服务端返回。未指定阶段时返回待理货审核及装车、装车后、派送中的任务;车辆与司机依据读取resources。
security:
- bearerAuth: []
responses:
@@ -208,10 +208,53 @@ paths:
required: true
schema:
type: string
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-review:
post:
summary: 理货员审核整车,后台与PDA共用审核轮次
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。仅绑定仓且调度审核通过的本轮整车可审核;REJECTED 必填 reason 并退回客服处理,APPROVED 才进入 LOADING;公共版本拒绝双端重复审核,原键恢复仍核验本人、绑定仓和理货权限。
security:
- bearerAuth: []
responses:
'200':
description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
requestId:
type: string
nullable: true
traceId:
type: string
nullable: true
timestamp:
type: string
format: date-time
data:
allOf:
- $ref: overseas-business-v1.yaml#/components/schemas/DispatchCommandResult
nullable: true
requestBody:
required: true
content:
application/json:
schema:
$ref: overseas-business-v1.yaml#/components/schemas/DispatchReviewCommand
parameters:
- name: dispatchNo
in: path
required: true
schema:
type: string
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
post:
summary: 真实拣货核平后显式确认装车完成
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。
summary: 确认装车完成并自动进入派送中
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 原请求在同一事务完成实载确认、全车派送单生成及发车;成功action为LOADING_COMPLETE、stage为DELIVERING,result保留真实Loaded装车证据。任一步失败整体回滚,重试原键恢复最终结果,不另行安排派送或确认发车。
security:
- bearerAuth: []
responses:
@@ -251,49 +294,6 @@ paths:
required: true
schema:
type: string
/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:
post:
summary: 完整安排派送后确认实际发车
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。
security:
- bearerAuth: []
responses:
'200':
description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
requestId:
type: string
nullable: true
traceId:
type: string
nullable: true
timestamp:
type: string
format: date-time
data:
allOf:
- $ref: overseas-business-v1.yaml#/components/schemas/DispatchCommandResult
nullable: true
requestBody:
required: true
content:
application/json:
schema:
$ref: overseas-business-v1.yaml#/components/schemas/DispatchDepart
parameters:
- name: dispatchNo
in: path
required: true
schema:
type: string
/api/pda/v1/last-leg/outbound-tasks:
get:
summary: 查询Token绑定仓的一件代发出库任务
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.calendar;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
@@ -28,12 +29,23 @@ class WarehouseCalendarMapperTest {
}
var config=new MybatisConfiguration(); config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));
config.addMapper(WarehouseCalendarMapper.class);
config.addMapper(LastlegOrderMapper.class);
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var rows=session.getMapper(WarehouseCalendarMapper.class).calendar(LocalDateTime.parse("2026-11-01T00:00:00"),LocalDateTime.parse("2026-12-01T00:00:00"),List.of("1"));
assertEquals(3,rows.size());
assertEquals("1",rows.get(0).sourceContainerBookingId()); assertNull(rows.get(0).plannedAt());
assertEquals(2,rows.get(1).version()); assertEquals("2026-11-10T09:00",rows.get(1).plannedAt().toString());
assertEquals(1,rows.get(2).planCount()); assertNotNull(rows.get(2).plannedAt());
// 清关提柜与日历必须读取相同的人工/冻结事实,默认日期由前端统一推算。
var customs=session.getMapper(LastlegOrderMapper.class).findContainerArrivalTimes(List.of("1","2","3"));
assertEquals(3,customs.size());
for(var calendar:rows) {
var arrival=customs.stream().filter(row -> row.sourceContainerBookingId().equals(calendar.sourceContainerBookingId())).findFirst().orElseThrow();
assertEquals(calendar.plannedAt(),arrival.estimatedWarehouseArrivalAt());
assertEquals(calendar.actualAt(),arrival.actualWarehouseArrivalAt());
assertEquals(calendar.version(),arrival.version());
assertEquals(calendar.planCount(),arrival.planCount());
}
}
}
}
@@ -37,6 +37,7 @@ import static org.mockito.Mockito.*;
/** 真实生产Mapper及事务验证任何客户或主单CAS失败时全车原子回滚。 */
class DispatchDeliveryArrangementPersistenceTest {
private JdbcTemplate db;
private org.springframework.transaction.support.TransactionTemplate tx;
private DispatchDeliveryService service;
private final DispatchExecutionPort execution=mock(DispatchExecutionPort.class);
private final DispatchDeliveryFees fees=mock(DispatchDeliveryFees.class);
@@ -49,6 +50,7 @@ class DispatchDeliveryArrangementPersistenceTest {
@BeforeEach void prepare() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:arrangement-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
db=new JdbcTemplate(ds);
tx=new org.springframework.transaction.support.TransactionTemplate(new DataSourceTransactionManager(ds));
for (var type:List.of(OutboundOrder.class,OutboundPalletItem.class,DeliveryOrder.class,LastlegOrder.class,LastlegOrderStatusHistory.class)) {
String columns=Arrays.stream(type.getDeclaredFields()).filter(f->!Modifier.isStatic(f.getModifiers())).map(f->{
String name=f.getName().replaceAll("([A-Z])","_$1").toLowerCase();
@@ -173,6 +175,32 @@ class DispatchDeliveryArrangementPersistenceTest {
assertEquals(6L,frozen.stream().filter(c->c.attributionId()==4).findFirst().orElseThrow().sourceOrigin().productQuantity());
}
/** 自动装车加入父事务,最终主单失败时全部客户单据与来源绑定均回滚。 */
@Test void automaticPreparationSharesLoadingParentTransactionAndRollback() {
assertThrows(IllegalStateException.class,()->tx.execute(status->{
service.prepareForDeparture(context,actor,"LOADING-FAIL");
throw new IllegalStateException("装车最终保存失败");
}));
for(var table:List.of("ll_outbound_order","ll_outbound_pallet_item","ll_delivery_order","ll_dispatch_delivery_arrangement","ll_loading_order_status_history"))
assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM "+table,Integer.class));
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_order WHERE outbound_order_no IS NULL AND version=0",Integer.class));
var fact=tx.execute(status->service.prepareForDeparture(context,actor,"LOADING-OK"));
assertEquals(2,fact.result().deliveries().size());
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order WHERE planned_delivery_at IS NULL AND delivery_method IS NULL AND pickup_method IS NULL AND loading_type IS NULL",Integer.class));
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order WHERE fee_coverage_json IS NULL",Integer.class));
clearInvocations(fees);
assertEquals(fact,tx.execute(status->service.prepareForDeparture(context,actor,"LOADING-OK")));
verifyNoInteractions(fees);
}
/** 外接费用发布失败同样回滚自动生成的全部客户单据,不能留下半车。 */
@Test void automaticExternalFreightFailureRollsBackEveryCustomer() {
doThrow(new IllegalStateException("外接费用发布失败")).when(fees).publish(any(),argThat(g->g.customerId().equals("102")),any(),any(),any(),any(),anyString(),anyString());
assertThrows(IllegalStateException.class,()->tx.execute(status->service.prepareForDeparture(context,actor,"LOADING-FAIL")));
assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Integer.class));
assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Integer.class));
}
/** 按真实共享记录构造完整客户目的地请求。 */
private ArrangeDelivery request() { return new ArrangeDelivery(3L,"ARRANGE-1","cargo-hash","loading-hash",grouping.groups(context).stream().map(g->
new ArrangeGroup(g.key(),g.cargoHash(),DEPARTED,"TRUCK",null,null,"WAREHOUSE_PICKUP","PALLET",null,null,null,null,null,
@@ -72,6 +72,38 @@ class DispatchDeliveryServiceTest {
verify(fees,times(3)).publish(any(),any(),any(),any(),isNull(),any(),eq("OP-A"),anyString());
}
/** PDA装车自动生成完整客户目的地,不猜派送计划、不要求人工费用或原费覆盖。 */
@Test void loadingAutomaticallyCreatesDocumentsWithoutInventedInputsOrFees() {
var pda=new Actor("PDA-1",ActorMode.PDA,"1001");
var fact=service.prepareForDeparture(context,pda,"LOADING-1");
assertEquals(2,fact.result().outbounds().size());assertEquals(3,fact.result().deliveries().size());
assertEquals(4,fact.result().recordVersion());
assertTrue(deliveries.stream().allMatch(d->d.getPlannedDeliveryAt()==null && d.getDeliveryMethod()==null
&& d.getPickupMethod()==null && d.getLoadingType()==null));
assertTrue(outbounds.stream().allMatch(o->o.getFeeCoverageJson()==null));
assertTrue(calls.indexOf("SOURCE")>calls.lastIndexOf("DELIVERY"));
verify(fees,never()).validate(any(),any(),any(),any());verify(fees,never()).coverage(any(),any(),any(),any());
var inputs=org.mockito.ArgumentCaptor.forClass(ArrangeGroup.class);
verify(fees,times(3)).publish(eq(context),any(),inputs.capture(),any(),isNull(),any(),eq("PDA-1"),anyString());
assertTrue(inputs.getAllValues().stream().allMatch(i->i.fees().isEmpty() && i.feeCoverage().isEmpty()));
clearInvocations(fees,repository);
assertEquals(fact,service.prepareForDeparture(context,pda,"LOADING-1"));
verifyNoInteractions(fees);verify(repository,never()).create(any(OutboundOrder.class));
assertThrows(org.springframework.web.server.ResponseStatusException.class,
()->service.prepareForDeparture(context,pda,"DIFFERENT-KEY"));
}
/** 自动生成仍拒绝越仓与孤立子单,不为异常半车补造第二套事实。 */
@Test void automaticPreparationRejectsWrongWarehouseAndOrphanDocuments() {
assertThrows(org.springframework.web.server.ResponseStatusException.class,
()->service.prepareForDeparture(context,new Actor("PDA-1",ActorMode.PDA,"OTHER"),"LOADING-1"));
verifyNoInteractions(repository,fees);
outbounds.add(new OutboundOrder());
assertThrows(org.springframework.web.server.ResponseStatusException.class,
()->service.prepareForDeparture(context,actor,"LOADING-1"));
verifyNoInteractions(fees);
}
/** 同实托的两申请分别冻结原货源,出库引用逐次归属且真实客户目的地与履约订单不被替换。 */
@Test void relabelOriginsStayFrozenBehindOutboundAttributionReferences() throws Exception {
var first=new SourceOrigin(10001L,201L,301L,401L,701L,"IN-701","CODE-701",null);
@@ -97,8 +97,6 @@ class PlatformDispatchOutboundContractTest {
String.class, DispatchCommands.Update.class);
Method cancel = DispatchAggregateController.class.getMethod("cancel", AuthenticatedUser.class,
String.class, DispatchCommands.ReasonCommand.class);
Method transfer = DeliveryTransferController.class.getMethod("transfer", AuthenticatedUser.class,
String.class, com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.ArrangeDelivery.class);
Method externalFreight = DeliveryTransferController.class.getMethod("externalFreight", AuthenticatedUser.class, String.class);
assertPermission(externalFreight, OverseasPermissions.DISPATCH_LIST);
Method outboundDetail = DeliveryTransferController.class.getMethod("outboundDetail",
@@ -113,11 +111,12 @@ class PlatformDispatchOutboundContractTest {
assertEquals(Set.of("/{dispatchNo}/cancel"), Set.of(cancelRoute.value()));
assertFalse(cancel.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class));
assertTrue(cancel.getParameters()[2].isAnnotationPresent(org.springframework.web.bind.annotation.RequestBody.class));
assertPermission(transfer, OverseasPermissions.DISPATCH_TRANSFER_DELIVERY);
assertTrue(Arrays.stream(DeliveryTransferController.class.getDeclaredMethods())
.noneMatch(method -> Set.of("transfer", "arrangement").contains(method.getName())));
assertPermission(outboundDetail, OverseasPermissions.OUTBOUND_LIST);
assertEquals(2, outboundDetail.getParameterCount());
assertPermission(outboundCancel, OverseasPermissions.OUTBOUND_CANCEL);
for (Method method : Set.of(update, cancel, transfer, externalFreight, outboundDetail, outboundCancel)) {
for (Method method : Set.of(update, cancel, externalFreight, outboundDetail, outboundCancel)) {
assertTrue(Arrays.stream(method.getParameters())
.noneMatch(parameter -> parameter.isAnnotationPresent(RequestParam.class)));
}
@@ -202,16 +201,13 @@ class PlatformDispatchOutboundContractTest {
assertFalse(schema.contains("uk_ll_outbound_no (customer_id,outbound_no)"));
}
/** 当前整车请求按公共版本一次提交全部组,响应保留多客户出库及多目的地派送数组。 */
/** 公开协议撤销人工安排入口,内部完整派送事实仍保留多客户和多目的地数组。 */
@Test
void openApiPublishesOnlyTheFrozenPlatformProtocol() throws Exception {
String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"),
StandardCharsets.UTF_8);
String dispatchAction = slice(yaml, " /api/platform/v1/last-leg/dispatches/{dispatchNo}:",
" /api/platform/v1/last-leg/dispatches/candidates:");
String transfer = slice(yaml,
" /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery:",
" /api/platform/v1/last-leg/outbound-orders:");
String outboundDetail = slice(yaml,
" /api/platform/v1/last-leg/outbound-orders/{outboundNo}:",
" /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:");
@@ -219,7 +215,7 @@ class PlatformDispatchOutboundContractTest {
" /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:",
" /api/platform/v1/last-leg/overseas-procurements:");
for (String action : Set.of(dispatchAction, transfer, outboundDetail, outboundCancel)) {
for (String action : Set.of(dispatchAction, outboundDetail, outboundCancel)) {
assertFalse(action.contains("name: customerId"));
}
assertTrue(outboundDetail.contains("pallets[].palletNo"));
@@ -228,24 +224,13 @@ class PlatformDispatchOutboundContractTest {
assertTrue(yaml.contains("单次 SQL 返回,不逐行补查"));
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:edit"));
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:cancel"));
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:transfer-delivery"));
assertFalse(yaml.contains(" /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery:"));
assertFalse(yaml.contains(" /api/platform/v1/last-leg/dispatches/{dispatchNo}/delivery-arrangement:"));
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:list"));
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:create"));
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:cancel"));
java.util.Map<?,?> document = new org.yaml.snakeyaml.Yaml().load(yaml);
java.util.Map<?,?> schemas = (java.util.Map<?,?>)((java.util.Map<?,?>)document.get("components")).get("schemas");
java.util.Map<?,?> arrange = (java.util.Map<?,?>)schemas.get("DispatchArrangeDelivery");
assertNotNull(arrange);
assertTrue(((java.util.List<?>)arrange.get("required")).containsAll(java.util.List.of("recordVersion", "idempotencyKey")));
java.util.Map<?,?> fields = (java.util.Map<?,?>)arrange.get("properties");
assertTrue(fields.keySet().containsAll(java.util.Set.of("recordVersion", "idempotencyKey", "cargoHash", "loadingCompletionHash", "groups")));
assertFalse(fields.containsKey("customerId"));
assertFalse(fields.containsKey("expectedVersion"));
java.util.Map<?,?> groups = (java.util.Map<?,?>)fields.get("groups");
assertEquals("array", groups.get("type"));
assertEquals("#/components/schemas/DispatchArrangeGroup", ((java.util.Map<?,?>)groups.get("items")).get("$ref"));
java.util.Map<?,?> groupFields = (java.util.Map<?,?>)((java.util.Map<?,?>)schemas.get("DispatchArrangeGroup")).get("properties");
assertTrue(groupFields.keySet().containsAll(java.util.Set.of("groupKey", "groupCargoHash", "fees", "feeCoverage")));
java.util.Map<?,?> resultFields = (java.util.Map<?,?>)((java.util.Map<?,?>)schemas.get("DispatchArrangementResult")).get("properties");
assertEquals("array", ((java.util.Map<?,?>)resultFields.get("outbounds")).get("type"));
assertEquals("array", ((java.util.Map<?,?>)resultFields.get("deliveries")).get("type"));
@@ -38,6 +38,9 @@ class SmartDispatchOpenApiContractTest {
assertThat(map(paths.get(base+"/drafts"))).containsKey("get");
var candidate=map(map(paths.get(base+"/candidates")).get("get"));
var parameterNames=((List<?>)candidate.get("parameters")).stream().map(SmartDispatchOpenApiContractTest::map).map(p->p.get("name")).toList();
var warehouseParameter=((List<?>)candidate.get("parameters")).stream().map(SmartDispatchOpenApiContractTest::map)
.filter(p->"warehouseId".equals(p.get("name"))).findFirst().orElseThrow();
assertThat(warehouseParameter.get("required")).isEqualTo(false);
assertThat(parameterNames).contains("warehouseId","destinationWarehouseCode","draftToken","page","size","warehouseTab").doesNotContain("customerId","fbaWarehouseCode");
assertThat(map(map(schemas.get("DispatchCandidateList")).get("properties")).keySet())
.containsExactlyInAnyOrder("candidates","snapshotAt","complete","total","page","size","pages");
@@ -45,6 +45,16 @@ class SmartDispatchControllerTest {
.andExpect(status().isOk());
verify(aggregate).candidates(actor,new CandidateQuery("92",null,null,"LAX9",null,null,1,20,"ALL"));verifyNoInteractions(planning);
}
/** 区域入口无需隐藏默认仓,全部与城市均由服务端分页。 */
@Test void candidateHttpQueryAllowsRegionalBrowsingWithoutWarehouse()throws Exception {
var mvc=MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()).build();
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(platform(),null,List.of()));
for(String tab:List.of("ALL","LAX","NYC")) {
mvc.perform(get("/api/platform/v1/last-leg/dispatches/candidates").param("warehouseTab",tab)).andExpect(status().isOk());
verify(aggregate).candidates(actor,new CandidateQuery(null,null,null,null,null,null,1,20,tab));
}
verifyNoInteractions(planning);
}
/** 每个写入都将冻结DTO原样传递,拥有者只能来自认证主体。 */
@Test void allDraftRoutesDelegateFrozenVersionedRequests(){
var create=new CreateDraft("create","92");var container=new CreateContainer(0L,"container","d",15);
@@ -37,6 +37,23 @@ class HttpDispatchEvidenceGatewayTest {
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",own),9,DispatchEvidenceGateway.Kind.VEHICLE,3L)).isInstanceOf(ResponseStatusException.class);
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",own),9,DispatchEvidenceGateway.Kind.DRIVER,4L)).isInstanceOf(ResponseStatusException.class);
}
/** 司机照片使用独立本车类别且禁止PDF,不与驾驶证证据串用。 */
@Test void driverPhotoUsesDistinctKindAndImageOnly() {
var photo=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE","9","driver.png","image/png",128L);
assertThat(HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",photo),9,DispatchEvidenceGateway.Kind.DRIVER_PHOTO,3L).fileId()).isEqualTo(3L);
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",photo),9,DispatchEvidenceGateway.Kind.DRIVER,3L)).isInstanceOf(ResponseStatusException.class);
var pdf=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE","9","driver.pdf","application/pdf",128L);
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",pdf),9,DispatchEvidenceGateway.Kind.DRIVER_PHOTO,3L)).isInstanceOf(ResponseStatusException.class);
}
/** 异常图片独立归属本车,审核照片及PDF均不能用作异常图片。 */
@Test void exceptionImagesRejectOtherKindsCarsAndPdf() {
var photo=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE","9","exception.png","image/png",128L);
assertThat(HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",photo),9,DispatchEvidenceGateway.Kind.EXCEPTION,3L).fileId()).isEqualTo(3L);
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",photo),10,DispatchEvidenceGateway.Kind.EXCEPTION,3L)).isInstanceOf(ResponseStatusException.class);
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",photo),9,DispatchEvidenceGateway.Kind.DRIVER_PHOTO,3L)).isInstanceOf(ResponseStatusException.class);
var pdf=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE","9","exception.pdf","application/pdf",128L);
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",pdf),9,DispatchEvidenceGateway.Kind.EXCEPTION,3L)).isInstanceOf(ResponseStatusException.class);
}
/** 客户、旧主档或缺失媒体类型均拒绝,不因为文件本身存在而放行。 */
@Test void rejectsForeignTenantMasterScopeAndMalformedMetadata() {
for(var value:java.util.List.of(
@@ -340,12 +340,12 @@ public class DispatchAggregatePersistenceTest {
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DRAFT,true);
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submission-states-1",null)));
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_PENDING,false);
var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"submission-states-dispatch-rejected",Decision.REJECTED,"核对调度")));
var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"submission-states-dispatch-rejected",Decision.REJECTED,"核对调度",null)));
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_REJECTED,true);
var resubmitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(rejected.recordVersion(),"submission-states-2",null)));
var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(resubmitted.recordVersion(),"submission-states-dispatch-approved",Decision.APPROVED,null)));
var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(resubmitted.recordVersion(),"submission-states-dispatch-approved",Decision.APPROVED,null,null)));
assertSubmissionGate(f,created.dispatchNo(),ReviewState.LOADING_PENDING,false);
var loadingRejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"submission-states-loading-rejected",Decision.REJECTED,"核对装载")));
var loadingRejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"submission-states-loading-rejected",Decision.REJECTED,"核对装载",null)));
assertSubmissionGate(f,created.dispatchNo(),ReviewState.LOADING_REJECTED,true);
f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(loadingRejected.recordVersion(),"submission-states-3",null)));
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_PENDING,false);
@@ -363,13 +363,13 @@ public class DispatchAggregatePersistenceTest {
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("review-create",List.of(source))));
var submit=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit-1",null)));
assertThatThrownBy(()->f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(submit.recordVersion(),"wrong-order",Decision.APPROVED,null)))).isInstanceOf(BizException.class);
var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submit.recordVersion(),"reject-1",Decision.REJECTED,"补核装载计划")));
assertThatThrownBy(()->f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(submit.recordVersion(),"wrong-order",Decision.APPROVED,null,null)))).isInstanceOf(BizException.class);
var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submit.recordVersion(),"reject-1",Decision.REJECTED,"补核装载计划",null)));
var resubmitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(rejected.recordVersion(),"submit-2",null)));
var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(resubmitted.recordVersion(),"dispatch-approved",Decision.APPROVED,null)));
var loading=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"loading-approved",Decision.APPROVED,null)));
var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(resubmitted.recordVersion(),"dispatch-approved",Decision.APPROVED,null,null)));
var loading=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"loading-approved",Decision.APPROVED,null,null)));
assertThat(loading.stage()).isEqualTo(Stage.LOADING);
var replay=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submit.recordVersion(),"reject-1",Decision.REJECTED,"补核装载计划")));
var replay=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submit.recordVersion(),"reject-1",Decision.REJECTED,"补核装载计划",null)));
assertThat(replay.replayed()).isTrue();assertThat(replay.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(replay.recordVersion()).isEqualTo(rejected.recordVersion());
var returned=f.tx.execute(s->f.service.returnToReview(f.actor,created.dispatchNo(),new DispatchCommands.ReturnToReview(loading.recordVersion(),"return-zero","重新核实资源")));
assertThat(returned.stage()).isEqualTo(Stage.BEFORE_LOADING);
@@ -419,12 +419,13 @@ public class DispatchAggregatePersistenceTest {
assertThat(f.db.queryForList("SELECT DISTINCT customer_id FROM ll_dispatch_item ORDER BY customer_id",String.class)).containsExactly("101","202");
assertThat(f.db.queryForObject("SELECT COUNT(DISTINCT dispatch_order_id) FROM ll_dispatch_item",Long.class)).isOne();
assertThat(f.db.queryForObject("SELECT package_count FROM ll_fleet_trip_dispatch",Integer.class)).isEqualTo(8);
var query=new DispatchViews.Query(1,20,null,null,null,"101",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,BeforeLoadingSection.DISPATCH_REVIEW,ReviewStatus.NOT_SUBMITTED);
var query=new DispatchViews.Query(1,20,null,null,null,"101",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,BeforeLoadingSection.VEHICLE_INFO,null);
var page=f.tx.execute(s->f.service.page(f.actor,query));
assertThat(page.total()).isOne(); assertThat(page.records()).hasSize(1);
assertThat(page.records().get(0).counts().packageCount()).isEqualTo(8);
assertThat(page.statistics().activeTotal()).isOne(); assertThat(page.statistics().stageCounts().get(Stage.BEFORE_LOADING)).isOne();
assertThat(page.statistics().beforeLoadingReviewCounts().dispatchReview().notSubmitted()).isOne();
assertThat(page.statistics().beforeLoadingReviewCounts().vehicleInfoTotal()).isOne();
assertThat(page.statistics().beforeLoadingReviewCounts().dispatchReview().notSubmitted()).isZero();
long dispatch=f.db.queryForObject("SELECT id FROM ll_dispatch_order",Long.class);
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE customer_id='101'",Long.class);
@@ -597,8 +598,8 @@ public class DispatchAggregatePersistenceTest {
/** 通过实际两级流程进入装车,测试不直接伪造通过状态。 */
public DispatchContracts.CommandResult approve(DispatchContracts.CommandResult created) {
var submitted=tx.execute(s->service.submitReview(actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),created.dispatchNo()+"-submit",null)));
var reviewed=tx.execute(s->service.review(actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),created.dispatchNo()+"-dispatch",Decision.APPROVED,null)));
return tx.execute(s->service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),created.dispatchNo()+"-loading",Decision.APPROVED,null)));
var reviewed=tx.execute(s->service.review(actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),created.dispatchNo()+"-dispatch",Decision.APPROVED,null,null)));
return tx.execute(s->service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),created.dispatchNo()+"-loading",Decision.APPROVED,null,null)));
}
/** 只为实载端口夹具准备真实扫码/归属记录,不声称运行库存命令。 */
public void picked(long source,String customer,long pallet,int packages) {
@@ -0,0 +1,89 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 通过真实Mapper和事务验证异常登记,不把登记事实解释成签收或退货。 */
class DispatchExceptionPersistenceTest {
/** 登记使用排车独立维护权限。 */
@BeforeEach void authorize() { SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator-test","","platform:overseas-warehouse:dispatch:edit")); }
/** 清除线程身份,避免影响其他权限回归。 */
@AfterEach void clear() { SecurityContextHolder.clearContext(); }
/** 每种原型类型均可保存,原键重试不追加,最终完成后仍恢复原结果。 */
@Test void recordsAllTypesAndReplaysWithoutChangingDeliveryFacts() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();var no=create(f);long version=0;
var before=f.db.queryForMap("SELECT stage,shipped_package_count,departed_at,review_state FROM ll_dispatch_order");
for(var type:ExceptionType.values()) {
var body=new DispatchCommands.MarkException(version,"exception-"+type,type,ReturnDirection.OVERSEAS_WAREHOUSE,"真实异常",List.of());
var saved=f.tx.execute(t->f.service.markException(f.actor,no,body));
var replay=f.tx.execute(t->f.service.markException(f.actor,no,body));
assertThat(saved.stage()).isEqualTo(Stage.DELIVERING);assertThat(replay.replayed()).isTrue();
assertThat(replay.recordVersion()).isEqualTo(saved.recordVersion());version=saved.recordVersion();
}
var records=f.service.detail(f.actor,no).dispatchInfo().exceptions();assertThat(records).hasSize(3);
assertThat(records).allSatisfy(r->{assertThat(r.description()).isEqualTo("真实异常");assertThat(r.occurredAt()).isNotNull();});
assertThat(f.db.queryForMap("SELECT stage,shipped_package_count,departed_at,review_state FROM ll_dispatch_order")).isEqualTo(before);
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero();
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_history WHERE action='MARK_EXCEPTION'",Long.class)).isEqualTo(3);
var first=new DispatchCommands.MarkException(0L,"exception-REFUSED",ExceptionType.REFUSED,ReturnDirection.OVERSEAS_WAREHOUSE,"真实异常",List.of());
f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERED'");
assertThat(f.tx.execute(t->f.service.markException(f.actor,no,first)).replayed()).isTrue();
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,new DispatchCommands.MarkException(0L,"exception-REFUSED",ExceptionType.REFUSED,null,"改了内容",List.of())))).isInstanceOf(RuntimeException.class);
assertThat(f.service.detail(f.actor,no).dispatchInfo().exceptions()).hasSize(3);
}
/** 非派送阶段、过期版本、只读身份、PDA及运输任务不能登记。 */
@Test void rejectsWrongStageVersionPermissionAndTask() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();var no=create(f);var body=request(0,"blocked",List.of());
for(var stage:List.of("BEFORE_LOADING","LOADING","AFTER_LOADING","DELIVERED")) {
f.db.update("UPDATE ll_dispatch_order SET stage=?",stage);
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,body))).hasMessageContaining("仅派送中");
}
f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING'");
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,request(9,"stale",List.of())))).hasMessageContaining("版本");
SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator-test","","platform:overseas-warehouse:dispatch:list"));
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,body))).hasMessageContaining("权限");
authorize();f.db.update("UPDATE ll_dispatch_order SET task_type='LOCAL_TRUCK'");
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,body))).hasMessageContaining("仅派送中");
f.db.update("UPDATE ll_dispatch_order SET task_type='DELIVERY',status='CANCELLED'");
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,body))).hasMessageContaining("仅派送中");
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(new DispatchContracts.Actor("pda",ActorMode.PDA,"9"),no,body))).hasMessageContaining("仅平台");
assertThat(f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isZero();
}
/** 图片严格核对当前车和类别;附件失败或缺类型不保存任何业务记录。 */
@Test void verifiesImageOwnershipAndKeepsFailedRequestUnchanged() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();var no=create(f);var id=f.repository.find(no).getId();
var files=mock(DispatchEvidenceGateway.class);ReflectionTestUtils.setField(f.service,"evidence",files);
when(files.metadata(id,DispatchEvidenceGateway.Kind.EXCEPTION,91L)).thenThrow(new IllegalArgumentException("文件不属于当前车"));
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,request(0,"files",List.of(91L))))).hasMessageContaining("不属于");
assertThat(f.service.detail(f.actor,no).dispatchInfo().exceptions()).isEmpty();
var missingType=new DispatchCommands.MarkException(0L,"no-type",null,null,null,List.of());
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,missingType))).hasMessageContaining("请选择异常类型");
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,request(0,"duplicates",List.of(91L,91L))))).hasMessageContaining("图片引用");
doReturn(new DispatchEvidenceGateway.File(91L,"现场.png","image/png",10L)).when(files).metadata(id,DispatchEvidenceGateway.Kind.EXCEPTION,91L);
f.tx.execute(t->f.service.markException(f.actor,no,request(0,"files",List.of(91L))));
assertThat(f.service.detail(f.actor,no).dispatchInfo().exceptions().get(0).imageFileIds()).containsExactly(91L);
verify(files,times(2)).metadata(id,DispatchEvidenceGateway.Kind.EXCEPTION,91L);
}
/** 创建真实排车后仅以SQL模拟已发车前置,不声称此用例执行装车流程。 */
private String create(DispatchAggregatePersistenceTest.Fixture f) {
var created=f.tx.execute(t->f.service.create(f.actor,f.configured("create",List.of())));
f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',shipped_package_count=3,departed_at=CURRENT_TIMESTAMP");return created.dispatchNo();
}
/** 构造类型必填、其余选填的最小登记请求。 */
private DispatchCommands.MarkException request(long version,String key,List<Long> images) { return new DispatchCommands.MarkException(version,key,ExceptionType.VEHICLE_EXCEPTION,null,null,images); }
}
@@ -0,0 +1,144 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.mapper.*;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.repository.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.AfterEach;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
/** 装完即发车的共同事务、原键最终结果及跨端竞争使用真实根单据和版本。 */
class DispatchLoadingDeparturePersistenceTest {
/** 每例恢复隔离身份,不影响其他服务权限测试。 */
@AfterEach void clearSecurity(){SecurityContextHolder.clearContext();}
/** 一次确认完成全部客户发运;原键重放返回派送中,不再生成子单或执行来源。 */
@Test void loadingCompletesAllCustomersAndReplaysOnlyFinalResult() throws Exception {
authorize();var f=new Fixture();
var result=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
assertThat(result.action()).isEqualTo(Action.LOADING_COMPLETE);assertThat(result.stage()).isEqualTo(Stage.DELIVERING);
assertThat(result.counts().shippedPackageCount()).isEqualTo(8);assertThat(result.result()).isInstanceOf(DispatchResults.Loaded.class);
assertThat(f.f.db.queryForList("SELECT status FROM ll_outbound_order",String.class)).containsExactly("OUTBOUNDED","OUTBOUNDED");
assertThat(f.f.db.queryForList("SELECT status FROM ll_delivery_order",String.class)).containsExactly("DELIVERING","DELIVERING");
assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("IN_TRANSIT");
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2);
assertThat(f.f.db.queryForObject("SELECT result_json FROM ll_dispatch_command WHERE idempotency_key='complete'",String.class)).contains("DELIVERING");
var replay=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
assertThat(replay.replayed()).isTrue();assertThat(replay.recordVersion()).isEqualTo(result.recordVersion());assertThat(replay.result()).isEqualTo(result.result());
assertThat(f.f.tx.execute(s->f.f.service.command(f.f.actor,f.number,"complete")).stage()).isEqualTo(Stage.DELIVERING);
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
assertThat(f.f.service.actions(f.f.actor,f.f.repository.find(f.number))).doesNotContainKeys(Action.ARRANGE_DELIVERY,Action.DEPART);
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("stale-new")))).hasMessageContaining("版本或审核阶段");
}
/** 任一后续步骤失败都恢复装车中,已提交的真实拣货保持,不留下中间装车或子单。 */
@Test void preparationAndDepartureFailuresRollBackTheEntireLoadingCommand() throws Exception {
authorize();
for(boolean failPreparation:List.of(true,false)) {
var f=new Fixture();
if(failPreparation)doThrow(new IllegalStateException("模拟单据准备失败")).when(f.delivery).prepareForDeparture(any(),any(),anyString());
else doThrow(new IllegalStateException("模拟来源发运失败")).when(f.sourceWorkflow).departWholeSources(any(),any(),any(),any());
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("failed")))).isInstanceOf(IllegalStateException.class);
var row=f.f.repository.find(f.number);assertThat(row.getStage()).isEqualTo("LOADING");assertThat(row.getRecordVersion()).isEqualTo(f.version);
assertThat(row.getLoadingCompletionHash()).isNull();assertThat(row.getArrangementKey()).isNull();assertThat(row.getDepartedAt()).isNull();
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Long.class)).isZero();
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero();
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action IN ('LOADING_COMPLETE','ARRANGE_DELIVERY','DEPART')",Long.class)).isZero();
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2);
assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
}
}
/** PDA保持本仓权限、原键权限复验;不借自动流程绕开真实装载清单。 */
@Test void pdaUsesOnePermissionButRejectsOtherWarehouseAndInvalidEvidence() throws Exception {
authorize();var f=new Fixture();var pda=new Actor("picker",ActorMode.PDA,"9");
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(new Actor("picker",ActorMode.PDA,"99"),f.number,f.request("foreign")))).hasMessageContaining("当前绑定仓");
var bad=new DispatchCommands.LoadingComplete(f.version,"bad-manifest","changed",f.measurements,null);
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,bad))).hasMessageContaining("真实装载清单已变化");
verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());
var result=f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")));assertThat(result.stage()).isEqualTo(Stage.DELIVERING);
SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("picker","","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")))).hasMessageContaining("装车完成权限");
}
/** 同一版本后台/PDA同时确认只有一条完整链提交,不会产生半车或重复派送。 */
@Test void concurrentAdminAndPdaCompleteOnlyOneDeparture() throws Exception {
authorize();var f=new Fixture();var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2);
try {
var a=workers.submit(()->f.tryComplete(start,f.f.actor,"admin"));
var b=workers.submit(()->f.tryComplete(start,new Actor("picker",ActorMode.PDA,"9"),"pda"));
start.countDown();assertThat(List.of(a.get(25,TimeUnit.SECONDS),b.get(25,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false);
} finally {workers.shutdownNow();}
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("DELIVERING");
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
}
/** 仅配置装车完成及PDA既有角色权限,不授予人工派送、发车权限。 */
private static void authorize(){SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator","",
"platform:overseas-warehouse:dispatch:loading-complete","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));}
/** 主流程与发车使用真实SQL,派送提供者受控写入真实子单来验证共同回滚。 */
private static final class Fixture {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
final DispatchDeliveryPort delivery=mock(DispatchDeliveryPort.class);
final DispatchPdaWorkflowService sourceWorkflow=mock(DispatchPdaWorkflowService.class);
final DispatchLoadingDepartureService service;final String number;final long version;final String manifest;
final List<LoadingMeasurement> measurements;
/** 创建两客户货物并走真实两级审核、真实拣货归属,不伪造已装车状态。 */
Fixture() throws Exception {
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("create",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"202",502,"LAX9",5)))));
number=created.dispatchNo();version=f.approve(created).recordVersion();
f.picked(1,"101",501,3);f.picked(2,"202",502,5);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8");
f.db.execute("ALTER TABLE ll_order ADD detained INT DEFAULT 0");
var preview=f.tx.execute(s->f.service.loading(f.actor,number));manifest=preview.manifestHash();
measurements=preview.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1,BigDecimal.ONE,BigDecimal.ONE,p.deliveryIndex())).toList();
when(delivery.prepareForDeparture(any(),any(),anyString())).thenAnswer(call->{
Context c=call.getArgument(0);assertThat(c.stage()).isEqualTo(Stage.AFTER_LOADING);
var os=outbounds("WAIT_OUTBOUND");var ds=deliveries(c,"WAIT_DELIVERY");
for(int n=0;n<os.size();n++) {
var o=os.get(n);var d=ds.get(n);
f.db.update("INSERT INTO ll_outbound_order(id,customer_id,dispatch_order_id,outbound_no,status,package_count)VALUES(?,?,?,?,?,?)",6001+n,o.customerId(),c.dispatchId(),o.outboundNo(),o.status(),o.packageCount());
f.db.update("INSERT INTO ll_delivery_order(id,customer_id,dispatch_order_id,outbound_order_id,outbound_no,delivery_no,status,package_count)VALUES(?,?,?,?,?,?,?,?)",7001+n,o.customerId(),c.dispatchId(),6001+n,o.outboundNo(),d.deliveryNo(),d.status(),d.packageCount());
}
return new ArrangementFact("AUTO-ARR","business-hash",c.cargoHash(),c.loadingCompletionHash(),new ArrangementResult(number,c.recordVersion()+1,"AUTO-ARR",c.cargoHash(),Instant.now(),os,ds));
});
when(delivery.departureFacts(any(),eq(true))).thenAnswer(call->{
Context c=call.getArgument(0);f.db.queryForList("SELECT id FROM ll_outbound_order ORDER BY id FOR UPDATE");f.db.queryForList("SELECT id FROM ll_delivery_order ORDER BY id FOR UPDATE");
return new DepartureFacts(number,"AUTO-ARR",c.cargoHash(),c.loadingCompletionHash(),"business-hash",outbounds("WAIT_OUTBOUND"),deliveries(c,"WAIT_DELIVERY"),c.cargo(),new Gate(true,null,null));
});
when(delivery.markDeparted(any(),any(),any(),anyString())).thenAnswer(call->{
Context c=call.getArgument(0);f.db.update("UPDATE ll_outbound_order SET status='OUTBOUNDED'");f.db.update("UPDATE ll_delivery_order SET status='DELIVERING'");
return new DepartureResult(outbounds("OUTBOUNDED"),deliveries(c,"DELIVERING"),8);
});
var ledger=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json));
var hashes=new DispatchCommandHasher(f.json);
var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class));
var departure=new DispatchDepartureService(f.service,f.repository,sources,delivery,sourceWorkflow,f.fleet,ledger,hashes,f.json);
service=new DispatchLoadingDepartureService(f.service,delivery,departure,ledger,hashes);
}
/** 每次请求沿用最初真实测量和版本,便于验证竞争及原键恢复。 */
DispatchCommands.LoadingComplete request(String key){return new DispatchCommands.LoadingComplete(version,key,manifest,measurements,"实装发车");}
/** 两客户子出库保持独立业务号和箱数。 */
List<OutboundReference> outbounds(String status){return List.of(new OutboundReference("101","客户101","OUT-A",status,3,1),new OutboundReference("202","客户202","OUT-B",status,5,1));}
/** 两目的地仍对应各自真实客户,不把整车合并为单客户。 */
List<DeliveryReference> deliveries(Context c,String status){return List.of(new DeliveryReference("GA","101","客户101","OUT-A","DL-A",c.cargo().get(0).destination(),status,3,1),new DeliveryReference("GB","202","客户202","OUT-B","DL-B",c.cargo().get(1).destination(),status,5,1));}
/** 各线程独立身份和数据库事务竞争同一整车父锁。 */
boolean tryComplete(CountDownLatch start,Actor actor,String key) throws Exception {
authorize();try {start.await(5,TimeUnit.SECONDS);f.tx.execute(s->service.complete(actor,number,request(key)));return true;}
catch(com.baiyue.common.core.exception.BizException expected){return false;}
finally {SecurityContextHolder.clearContext();}
}
}
}
@@ -13,6 +13,32 @@ import static org.assertj.core.api.Assertions.*;
/** 统一PDA读取实际多客户主单和逐项扫码,仓限制在SQL完成。 */
class DispatchPdaReadPersistenceTest {
/** 默认PDA待办包含已通过调度的待理货整车,资源取同一冻结快照。 */
@Test void pendingTallyReviewIsVisibleWithResourcesButDraftAndRejectedAreNot() throws Exception {
var security=org.springframework.security.core.context.SecurityContextHolder.getContext();var previous=security.getAuthentication();
security.setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("picker","","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));
try {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("pending-read",List.of(f.source(1,"101",501,"ONT8",3)))));
f.db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),balance_id BIGINT,pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),available_packages BIGINT,hold_packages BIGINT,allocated_packages BIGINT,picked_packages BIGINT)");
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchWholePdaReadMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(f.db.getDataSource());factory.setConfiguration(config);factory.afterPropertiesSet();
var queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class)),f.service);
var actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
assertThat(f.tx.<List<DispatchViews.PdaTask>>execute(s->queries.list(actor,null))).isEmpty();
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null)));
assertThat(f.tx.<List<DispatchViews.PdaTask>>execute(s->queries.list(actor,null))).isEmpty();
var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch",Decision.APPROVED,null,null)));
var list=f.tx.execute(s->queries.list(actor,null));assertThat(list).hasSize(1);
var task=list.get(0);assertThat(task.stage()).isEqualTo(Stage.BEFORE_LOADING);
assertThat(task.actions().get(Action.LOADING_REVIEW).allowed()).isTrue();assertThat(task.actions().get(Action.PICK).allowed()).isFalse();
assertThat(task.resources().vehicle().plate()).isEqualTo("TEST-TRUCK");assertThat(task.resources().driver().name()).isEqualTo("测试司机");
f.tx.execute(s->f.service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"tally-reject",Decision.REJECTED,"车辆不符",null)));
assertThat(f.tx.<List<DispatchViews.PdaTask>>execute(s->queries.list(actor,null))).isEmpty();
assertThat(f.tx.execute(s->queries.task(actor,created.dispatchNo())).reviews().loadingReview().reason()).isEqualTo("车辆不符");
} finally {security.setAuthentication(previous);}
}
/** 同仓两个客户、两个目的地只返回一个整车作业,别仓不能借单号读取。 */
@Test void readsOneTruckAcrossCustomersAndRejectsOtherWarehouseNumber() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5);
@@ -0,0 +1,47 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidatePageMapper;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor;
import com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 区域候选的读取范围与单仓写入职责分离,不依赖测试环境数据。 */
class DispatchRegionalCandidatesTest {
/** 真实服务接受不指定仓的三种区域,保留服务端分页总数;非法仓和区域仍拒绝。 */
@Test void serviceAcceptsRegionalQueriesAndRejectsMalformedScopes() {
var sources=mock(DispatchService.class);var appointments=mock(DispatchAppointmentAllocations.class);
var service=new DispatchAggregateService(null,null,sources,null,null,null,new com.fasterxml.jackson.databind.ObjectMapper());
ReflectionTestUtils.setField(service,"appointments",appointments);
var actor=new Actor("7",ActorMode.PLATFORM,null);
when(sources.candidatePage(eq("7"),any())).thenReturn(new DispatchService.CandidatePage(List.of(),41));
when(sources.selectionGroups(List.of())).thenReturn(Map.of());when(appointments.candidates(List.of())).thenReturn(Map.of());
for(String tab:List.of("ALL","LAX","NYC")) {
var query=new CandidateQuery(null,null,null,null,null,null,2,20,tab);
var result=service.candidates(actor,query);
assertThat(result.total()).isEqualTo(41);assertThat(result.page()).isEqualTo(2);assertThat(result.pages()).isEqualTo(3);
verify(sources).candidatePage("7",query);
}
assertThatThrownBy(()->service.candidates(actor,new CandidateQuery("bad",null,null,null,null,null,1,20,"ALL"))).hasMessageContaining("起运仓筛选无效");
assertThatThrownBy(()->service.candidates(actor,new CandidateQuery(null,null,null,null,null,null,1,20,"bad"))).hasMessageContaining("仓库页签不合法");
}
/** 解析实际Mapper动态SQL,统计和分页采用相同区域条件,单仓消费者继续绑定真实仓。 */
@Test void countAndPageUseOptionalWarehouseAndTheSameRegionPredicate() {
var config=new Configuration();config.addMapper(DispatchCandidatePageMapper.class);
for(String method:List.of("count","page")) for(String tab:List.of("ALL","LAX","NYC")) for(String warehouse:List.of("ALL_WAREHOUSES","321")) {
var query=new CandidateQuery(warehouse.equals("321")?warehouse:null,null,null,null,null,null,1,20,tab);
var sql=config.getMappedStatement(DispatchCandidatePageMapper.class.getName()+"."+method).getBoundSql(Map.of("query",query,"offset",0)).getSql();
if(query.warehouseId()==null)assertThat(sql).doesNotContain("overseasWarehouseId=?");
else assertThat(sql).contains("overseasWarehouseId=?");
if(tab.equals("LAX"))assertThat(sql).contains("REGEXP 'lax|洛杉矶|los angeles'");
if(tab.equals("NYC"))assertThat(sql).contains("REGEXP 'nyc|jfk|");
if(tab.equals("ALL"))assertThat(sql).doesNotContain("REGEXP 'lax|", "REGEXP 'nyc|");
if(method.equals("page"))assertThat(sql).contains("LIMIT ? OFFSET ?");
}
}
}
@@ -0,0 +1,104 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
import com.baiyue.lastleg.dispatch.port.*;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** 审核附件以真实Mapper保存并随审核轮次流转,不能改写车辆和司机资源。 */
class DispatchReviewAttachmentsPersistenceTest {
/** 两级附件随决定保存,理货驳回与客服改货保留证据,重提清当前但历史不丢。 */
@Test void attachmentsSurviveRejectionAndEditsUntilResubmission() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("review-evidence",List.of(f.source(1,"101",501,"ONT8",3)))));
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null)));
var before=f.repository.find(created.dispatchNo()).getResourcesJson();
var first=new DispatchContracts.ReviewAttachments(List.of(91L),List.of(92L),List.of(93L));
var request=new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch",Decision.APPROVED,null,first);
var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,request));
assertThat(f.service.detail(f.actor,created.dispatchNo()).reviews().dispatchReview().attachments()).isEqualTo(first);
assertThat(f.repository.find(created.dispatchNo()).getResourcesJson()).isEqualTo(before);
var second=new DispatchContracts.ReviewAttachments(List.of(94L),List.of(95L),List.of(96L));
var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"reject",Decision.REJECTED,"照片不清晰",second)));
var reviews=f.service.detail(f.actor,created.dispatchNo()).reviews();
assertThat(reviews.dispatchReview().status()).isEqualTo(ReviewStatus.NOT_SUBMITTED);
assertThat(reviews.dispatchReview().attachments()).isEqualTo(first);
assertThat(reviews.loadingReview().attachments()).isEqualTo(second);
var changed=f.tx.execute(s->f.service.addCargo(f.actor,created.dispatchNo(),new DispatchCommands.AddCargo(rejected.recordVersion(),"cargo",List.of(f.source(2,"101",502,"ONT8",2)),List.of())));
reviews=f.service.detail(f.actor,created.dispatchNo()).reviews();
assertThat(reviews.dispatchReview().attachments()).isEqualTo(first);
assertThat(reviews.loadingReview().attachments()).isEqualTo(second);
assertThat(reviews.loadingReview().reason()).isEqualTo("照片不清晰");
f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(changed.recordVersion(),"resubmit",null)));
reviews=f.service.detail(f.actor,created.dispatchNo()).reviews();
assertThat(reviews.dispatchReview().attachments()).isEqualTo(DispatchContracts.ReviewAttachments.empty());
assertThat(reviews.loadingReview().attachments()).isEqualTo(DispatchContracts.ReviewAttachments.empty());
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_history WHERE snapshot_json LIKE '%driverPhotoFileIds%92%'",Long.class)).isPositive();
assertThat(f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,request)).replayed()).isTrue();
assertThatThrownBy(()->f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch",Decision.APPROVED,null,second)))).hasMessageContaining("原请求标识");
}
/** 拒绝也逐文件核验本车分类;错误归属不提交状态和版本。 */
@Test void rejectsForeignKindOrMasterLicenseAndKeepsOriginalVersion() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("verify-evidence",List.of(f.source(1,"101",501,"ONT8",3)))));
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null)));
var gateway=(DispatchEvidenceGateway)ReflectionTestUtils.getField(f.service,"evidence");
long id=f.repository.find(created.dispatchNo()).getId();
var attachments=new DispatchContracts.ReviewAttachments(List.of(91L),List.of(92L),List.of(93L));
doThrow(new IllegalArgumentException("本车分类归属不匹配")).when(gateway).metadata(id,DispatchEvidenceGateway.Kind.DRIVER,93L);
assertThatThrownBy(()->f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"bad",Decision.REJECTED,"需补资料",attachments)))).hasMessageContaining("归属不匹配");
verify(gateway).metadata(id,DispatchEvidenceGateway.Kind.VEHICLE,91L);
verify(gateway).metadata(id,DispatchEvidenceGateway.Kind.DRIVER_PHOTO,92L);
verify(gateway).metadata(id,DispatchEvidenceGateway.Kind.DRIVER,93L);
assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(submitted.recordVersion());
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='bad'",Long.class)).isZero();
}
/** 只有当前待审角色能上传;维护权限不能替代审核资格,上传不变更版本和依据。 */
@Test void uploadUsesCurrentReviewAuthorityWithoutEditAndDoesNotMutateAggregate() throws Exception {
var security=SecurityContextHolder.getContext();var prior=security.getAuthentication();
try {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("upload-evidence",List.of(f.source(1,"101",501,"ONT8",3)))));
var file=new MockMultipartFile("file","driver.png","image/png",new byte[]{1});
var kind=DispatchEvidenceGateway.Kind.DRIVER_PHOTO;
security.setAuthentication(new TestingAuthenticationToken("operator","","platform:fleet:trip:dispatch-review"));
assertThatThrownBy(()->f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file)).hasMessageContaining("无当前阶段");
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null)));
var before=f.repository.find(created.dispatchNo());
f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file);
security.setAuthentication(new TestingAuthenticationToken("operator","","platform:overseas-warehouse:dispatch:edit"));
assertThatThrownBy(()->f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file)).hasMessageContaining("无当前阶段");
var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"approve",Decision.APPROVED,null,null)));
security.setAuthentication(new TestingAuthenticationToken("operator","","platform:fleet:trip:dispatch-review"));
assertThatThrownBy(()->f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file)).hasMessageContaining("无当前阶段");
security.setAuthentication(new TestingAuthenticationToken("operator","","platform:fleet:trip:loading-review"));
f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file);
var after=f.repository.find(created.dispatchNo());
assertThat(after.getRecordVersion()).isEqualTo(approved.recordVersion());
assertThat(after.getReviewBasisHash()).isEqualTo(before.getReviewBasisHash());
assertThat(after.getResourcesJson()).isEqualTo(before.getResourcesJson());
var loaded=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"load",Decision.APPROVED,null,null)));
assertThatThrownBy(()->f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file)).hasMessageContaining("装车前");
} finally {security.setAuthentication(prior);}
}
/** 省略附件保持PDA原输入语义,非法或过多引用在进入业务前拒绝。 */
@Test void optionalAttachmentShapeIsBoundedAndNormalized() throws Exception {
var json=new com.fasterxml.jackson.databind.ObjectMapper();
var request=json.readValue("{\"recordVersion\":1,\"idempotencyKey\":\"pda\",\"decision\":\"APPROVED\"}",DispatchCommands.ReviewCommand.class);
assertThat(request.attachments()).isEqualTo(DispatchContracts.ReviewAttachments.empty());
assertThatThrownBy(()->new DispatchContracts.ReviewAttachments(List.of(1L,1L),null,null)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(()->new DispatchContracts.ReviewAttachments(null,List.of(-1L),null)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(()->new DispatchContracts.ReviewAttachments(null,null,java.util.stream.LongStream.rangeClosed(1,21).boxed().toList())).isInstanceOf(IllegalArgumentException.class);
}
}
@@ -0,0 +1,126 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.port.*;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import static org.assertj.core.api.Assertions.*;
/** 审核两端共用真实持久主单,驳回回客服且不能复用上一轮批准。 */
class DispatchReviewWorkflowPersistenceTest {
/** 客服、调度、理货队列互斥,任一级驳回都只回客服队列。 */
@Test void eachReviewRejectionReturnsOnlyToCustomerServiceQueue() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("queues",List.of(f.source(1,"101",501,"ONT8",3)))));
assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO);
var submitted=submit(f,created,"submit-1");assertQueue(f,created.dispatchNo(),BeforeLoadingSection.DISPATCH_REVIEW);
var rejected=review(f,f.actor,submitted,false,"dispatch-reject",Decision.REJECTED," 车辆图片不清楚 ");
assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO);
assertThat(f.service.detail(f.actor,created.dispatchNo()).reviews().dispatchReview().reason()).isEqualTo("车辆图片不清楚");
var resubmitted=submit(f,rejected,"submit-2");
var approved=review(f,f.actor,resubmitted,false,"dispatch-approve",Decision.APPROVED,null);
assertQueue(f,created.dispatchNo(),BeforeLoadingSection.LOADING_REVIEW);
var tallyRejected=review(f,f.actor,approved,true,"loading-reject",Decision.REJECTED,"司机不符");
assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO);
var detail=f.service.detail(f.actor,created.dispatchNo());
assertThat(detail.reviews().loadingReview().reason()).isEqualTo("司机不符");
assertThat(detail.reviews().dispatchReview().status()).isEqualTo(ReviewStatus.NOT_SUBMITTED);
assertThatThrownBy(()->review(f,f.actor,tallyRejected,true,"cannot-skip-dispatch",Decision.APPROVED,null)).hasMessageContaining("状态已变化");
var third=submit(f,tallyRejected,"submit-3");assertQueue(f,created.dispatchNo(),BeforeLoadingSection.DISPATCH_REVIEW);
detail=f.service.detail(f.actor,created.dispatchNo());
assertThat(detail.reviews().dispatchReview().reviewRound()).isEqualTo(3);
assertThat(detail.reviews().loadingReview().reason()).isNull();
assertThatThrownBy(()->review(f,f.actor,third,true,"cannot-skip-again",Decision.APPROVED,null)).hasMessageContaining("状态已变化");
}
/** 两级拒绝都须真实原因;失败不写版本、审核历史或命令结果。 */
@Test void blankRejectionReasonIsRejectedAtBothLevelsWithoutWrites() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("reason",List.of(f.source(1,"101",501,"ONT8",3)))));
var submitted=submit(f,created,"submit");
assertThatThrownBy(()->review(f,f.actor,submitted,false,"blank-dispatch",Decision.REJECTED," \n ")).hasMessageContaining("驳回原因不能为空");
assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(submitted.recordVersion());
var approved=review(f,f.actor,submitted,false,"approve",Decision.APPROVED,null);
assertThatThrownBy(()->review(f,f.actor,approved,true,"blank-loading",Decision.REJECTED,null)).hasMessageContaining("驳回原因不能为空");
assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(approved.recordVersion());
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key LIKE 'blank-%'",Long.class)).isZero();
}
/** 客服多次改车货保留本轮驳回原因,重新提交才开始全新审核轮次。 */
@Test void cargoChangesKeepRejectionReasonUntilNextSubmission() throws Exception {
for(boolean loading:List.of(false,true)) {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("retain",List.of(f.source(1,"101",501,"ONT8",3)))));
var current=submit(f,created,"submit");
if(loading)current=review(f,f.actor,current,false,"approve",Decision.APPROVED,null);
var rejected=review(f,f.actor,current,loading,"reject",Decision.REJECTED,"请重新核对货物");
var added=f.tx.execute(s->f.service.addCargo(f.actor,created.dispatchNo(),new DispatchCommands.AddCargo(rejected.recordVersion(),"add",List.of(f.source(2,"101",502,"ONT8",2)),List.of())));
var addedAgain=f.tx.execute(s->f.service.addCargo(f.actor,created.dispatchNo(),new DispatchCommands.AddCargo(added.recordVersion(),"add-again",List.of(f.source(3,"101",503,"ONT8",1)),List.of())));
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=3",Long.class);
var removed=f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,new DispatchCommands.ReasonCommand(addedAgain.recordVersion(),"remove","调整目的仓")));
var resources=f.configured("updated-resources",List.of());
var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正")));
var detail=f.service.detail(f.actor,created.dispatchNo());
var retained=loading?detail.reviews().loadingReview():detail.reviews().dispatchReview();
assertThat(detail.reviewState()).isEqualTo(ReviewState.DRAFT);
assertThat(retained.status()).isEqualTo(ReviewStatus.REJECTED);assertThat(retained.reason()).isEqualTo("请重新核对货物");
assertThat(retained.basisHash()).isNull();assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO);
submit(f,updated,"resubmit");detail=f.service.detail(f.actor,created.dispatchNo());
assertThat(detail.reviews().dispatchReview().status()).isEqualTo(ReviewStatus.PENDING);
assertThat(detail.reviews().dispatchReview().reason()).isNull();assertThat(detail.reviews().loadingReview().reason()).isNull();
}
}
/** PDA理货共享后台版本;成功恢复不重复写,越仓、失权和替换身份均拒绝。 */
@Test void pdaReviewSharesVersionAndOriginalRecoveryWithAdmin() throws Exception {
var security=SecurityContextHolder.getContext();var previous=security.getAuthentication();
try {
security.setAuthentication(new TestingAuthenticationToken("picker","","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));
var f=new DispatchAggregatePersistenceTest.Fixture();
var pda=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("pda-review",List.of(f.source(1,"101",501,"ONT8",3)))));
var submitted=submit(f,created,"submit");
assertThat(f.service.actions(pda,f.repository.find(created.dispatchNo())).get(Action.LOADING_REVIEW).allowed()).isFalse();
assertThatThrownBy(()->review(f,pda,submitted,false,"pda-dispatch",Decision.APPROVED,null)).hasMessageContaining("仅当前绑定仓");
var approved=review(f,f.actor,submitted,false,"dispatch",Decision.APPROVED,null);
assertThat(f.service.actions(pda,f.repository.find(created.dispatchNo())).get(Action.LOADING_REVIEW).allowed()).isTrue();
assertThatThrownBy(()->review(f,new DispatchContracts.Actor("picker",ActorMode.PDA,"99"),approved,true,"wrong-warehouse",Decision.APPROVED,null)).hasMessageContaining("当前绑定仓");
var loaded=review(f,pda,approved,true,"pda-approve",Decision.APPROVED,null);
assertThat(loaded.stage()).isEqualTo(Stage.LOADING);
assertThatThrownBy(()->review(f,f.actor,approved,true,"admin-stale",Decision.APPROVED,null)).hasMessageContaining("版本已变化");
assertThat(review(f,pda,approved,true,"pda-approve",Decision.APPROVED,null).replayed()).isTrue();
assertThat(f.tx.execute(s->f.service.command(pda,created.dispatchNo(),"pda-approve")).replayed()).isTrue();
assertThatThrownBy(()->f.tx.execute(s->f.service.command(new DispatchContracts.Actor("picker",ActorMode.PDA,"99"),created.dispatchNo(),"pda-approve"))).hasMessageContaining("当前绑定仓");
assertThatThrownBy(()->f.tx.execute(s->f.service.command(new DispatchContracts.Actor("other",ActorMode.PDA,"9"),created.dispatchNo(),"pda-approve"))).hasMessageContaining("无权");
security.setAuthentication(new TestingAuthenticationToken("picker","","lastleg:pda:operate"));
assertThatThrownBy(()->review(f,pda,approved,true,"pda-approve",Decision.APPROVED,null)).hasMessageContaining("仅当前绑定仓");
assertThatThrownBy(()->f.tx.execute(s->f.service.command(pda,created.dispatchNo(),"pda-approve"))).hasMessageContaining("原动作权限");
assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(loaded.recordVersion());
} finally {security.setAuthentication(previous);}
}
/** 使用服务真实SQL分页和全量统计验证当前队列,不以返回动作猜测队列。 */
private static void assertQueue(DispatchAggregatePersistenceTest.Fixture f,String number,BeforeLoadingSection expected) {
for(var section:BeforeLoadingSection.values()) {
var page=f.tx.execute(s->f.service.page(f.actor,new DispatchViews.Query(1,20,number,null,null,null,null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,section,null)));
assertThat(page.total()).isEqualTo(section==expected?1:0);
var counts=page.statistics().beforeLoadingReviewCounts();
assertThat(counts.vehicleInfoTotal()).isEqualTo(expected==BeforeLoadingSection.VEHICLE_INFO?1:0);
assertThat(counts.dispatchReview().pending()).isEqualTo(expected==BeforeLoadingSection.DISPATCH_REVIEW?1:0);
assertThat(counts.loadingReview().pending()).isEqualTo(expected==BeforeLoadingSection.LOADING_REVIEW?1:0);
}
}
/** 每次提交以真实返回版本开始下一轮,不人工造审核状态。 */
private static DispatchContracts.CommandResult submit(DispatchAggregatePersistenceTest.Fixture f,DispatchContracts.CommandResult prior,String key) {
return f.tx.execute(s->f.service.submitReview(f.actor,prior.dispatchNo(),new DispatchCommands.SubmitReview(prior.recordVersion(),key,null)));
}
/** 通过同一审核服务分别调用两端,版本和幂等键始终由请求明确提供。 */
private static DispatchContracts.CommandResult review(DispatchAggregatePersistenceTest.Fixture f,DispatchContracts.Actor actor,DispatchContracts.CommandResult prior,boolean loading,String key,Decision decision,String reason) {
return f.tx.execute(s->f.service.review(actor,prior.dispatchNo(),loading,new DispatchCommands.ReviewCommand(prior.recordVersion(),key,decision,reason,null)));
}
}
@@ -20,6 +20,7 @@ final class OwnedBusinessFixtures {
/** 装车完成但未发车,证明不依赖DEPART历史。 */
static Source source() {
var row=new Source();row.setId(1L);row.setDispatchNo("DS-OWN-ONE");row.setStatus("ACTIVE");row.setStage("AFTER_LOADING");
row.setPooledCargo(false);
row.setRecordVersion(8L);row.setOverseasWarehouseId("9");row.setOverseasWarehouseCode("WH-9");row.setOverseasWarehouseName("测试仓");
row.setPlannedDepartureAt(LocalDateTime.parse("2026-10-20T08:00:00"));row.setLoadingCompletedAt(LocalDateTime.parse("2026-10-20T07:30:00"));
row.setLoadingCompletionHash("actual-load");row.setOwnedLoadingCompletionHash("actual-load");row.setOwnedLoadingCompletedAt(row.getLoadingCompletedAt());

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