Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f9e027dfc | ||
|
|
aa195cef66 | ||
|
|
fe1b2a6a46 | ||
|
|
e8f6bb32dc | ||
|
|
45fbd6eff8 | ||
|
|
2c330bf9df | ||
|
|
4bef8577fa | ||
|
|
db4354bda8 | ||
|
|
aa33514ec6 | ||
|
|
66a0beb653 | ||
|
|
5cd108b39a | ||
|
|
b0acbfbb34 | ||
|
|
662dead053 | ||
|
|
2971d6f7f0 | ||
|
|
81e4123b10 | ||
|
|
732cf99007 | ||
|
|
5fd74e9c8c | ||
|
|
af97cd17d0 | ||
|
|
5f5133bd68 | ||
|
|
339c70bb56 | ||
|
|
e4bf573146 | ||
|
|
4348296daf | ||
|
|
9ea03eb540 | ||
|
|
c9186c37a5 | ||
|
|
6211d4ae37 | ||
|
|
fee0268391 | ||
|
|
38a1f1079a | ||
|
|
58685945b1 | ||
|
|
ecded9e58b | ||
|
|
488c0318ff | ||
|
|
000125c3ec | ||
|
|
0708bbad2e | ||
|
|
30f48bd926 | ||
|
|
358feb5567 | ||
|
|
5855529e97 | ||
|
|
4e2ea89403 | ||
|
|
a3a5862f89 | ||
|
|
b8739eebf0 | ||
|
|
607aaf74dc | ||
|
|
4277601999 | ||
|
|
ae33780dae | ||
|
|
097bec33b1 | ||
|
|
abf4e45e39 | ||
|
|
2631bd3af4 | ||
|
|
cda5f3dc32 | ||
|
|
caff693ad8 | ||
|
|
204ae10737 | ||
|
|
23ce486539 | ||
|
|
994215d981 | ||
|
|
42679d4d54 | ||
|
|
14b8cc625b |
@@ -106,6 +106,19 @@
|
|||||||
- 装柜、船节点、卸柜、暂扣、派送、费用与资金按统一通信契约的逐事件表直接投递。保留本地 Outbox、消费幂等、重试与失败处置,不携带用户 JWT;旧 Bridge 源码已退出聚合和发布清单,退役待清理;实际停机仍须替代链验证与授权,不得据源码状态认定已部署。
|
- 装柜、船节点、卸柜、暂扣、派送、费用与资金按统一通信契约的逐事件表直接投递。保留本地 Outbox、消费幂等、重试与失败处置,不携带用户 JWT;旧 Bridge 源码已退出聚合和发布清单,退役待清理;实际停机仍须替代链验证与授权,不得据源码状态认定已部署。
|
||||||
- `lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。共用测试 MySQL 实例不改变服务数据归属。准确路由、ACK、撤销矩阵和切换门槛只维护在新的两份主契约中。
|
- `lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。共用测试 MySQL 实例不改变服务数据归属。准确路由、ACK、撤销矩阵和切换门槛只维护在新的两份主契约中。
|
||||||
|
|
||||||
|
## 数据库访问与中文代码注释(所有服务及公共模块强制)
|
||||||
|
|
||||||
|
- 本节适用于全部后端服务和公共模块;新增、修改的方法必须执行,触及的存量代码同步整改。登记本约束不代表存量代码已全部完成迁移和注释补齐。
|
||||||
|
- 生产代码的关系型数据库读写统一经 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。覆盖方法同样须有简短中文说明,实现中的关键规则仍须就地注释;自动生成的方法不手工逐个补注释。
|
||||||
|
- 核心业务、复杂 SQL、状态流转、资金与库存变更、权限隔离、事务、锁、幂等、并发、重试和补偿逻辑,须在关键代码前增加适量中文注释,解释业务规则、处理原因、约束边界及失败影响;不逐行翻译代码,不添加无信息量或与实现不符的注释。
|
||||||
|
- 新增和修改的代码说明注释统一使用中文;必要的技术标识、协议名、类名可保留原文,第三方许可证和生成标记保持原样。修改方法时同步更新相关存量英文或过期说明;审查时同时核对 Controller → Service → Repository → Mapper 分层及中文方法、核心逻辑注释。
|
||||||
|
|
||||||
## 契约、数据库与验证入口
|
## 契约、数据库与验证入口
|
||||||
|
|
||||||
- 对外 Controller/API 新增、改名或删除时,同时核对调用端、统一 Gateway、OpenAPI 和权限;提交前运行 `node --test scripts/gateway-route-contract.test.mjs`,自动发现生产 Controller 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。
|
- 对外 Controller/API 新增、改名或删除时,同时核对调用端、统一 Gateway、OpenAPI 和权限;提交前运行 `node --test scripts/gateway-route-contract.test.mjs`,自动发现生产 Controller 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Audit Service 模块指引
|
# Audit Service 模块指引
|
||||||
|
|
||||||
|
## 数据库访问与中文注释约束
|
||||||
|
|
||||||
|
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||||
|
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||||
|
|
||||||
## 职责边界
|
## 职责边界
|
||||||
|
|
||||||
- Mapper 只由 `BaiyueAuditApplication.java` 对 `com.baiyue.audit` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
|
- Mapper 只由 `BaiyueAuditApplication.java` 对 `com.baiyue.audit` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Auth Service 模块指引
|
# Auth Service 模块指引
|
||||||
|
|
||||||
|
## 数据库访问与中文注释约束
|
||||||
|
|
||||||
|
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||||
|
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||||
|
|
||||||
## 职责与边界
|
## 职责与边界
|
||||||
|
|
||||||
- Mapper 只由 `BaiyueAuthApplication.java` 对 `com.baiyue.auth` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
|
- Mapper 只由 `BaiyueAuthApplication.java` 对 `com.baiyue.auth` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Common Audit 模块指引
|
# Common Audit 模块指引
|
||||||
|
|
||||||
|
## 数据库访问与中文注释约束
|
||||||
|
|
||||||
|
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||||
|
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||||
|
|
||||||
## 职责边界
|
## 职责边界
|
||||||
|
|
||||||
- 负责业务审计事件采集、注解切面、敏感字段脱敏以及日志/HTTP 输出。
|
- 负责业务审计事件采集、注解切面、敏感字段脱敏以及日志/HTTP 输出。
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Common Core 模块指引
|
# Common Core 模块指引
|
||||||
|
|
||||||
|
## 数据库访问与中文注释约束
|
||||||
|
|
||||||
|
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||||
|
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||||
|
|
||||||
## 职责边界
|
## 职责边界
|
||||||
|
|
||||||
- 提供不依赖具体业务域的返回模型、异常、基础实体和 MyBatis-Plus 通用配置。
|
- 提供不依赖具体业务域的返回模型、异常、基础实体和 MyBatis-Plus 通用配置。
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Common Event Contract 模块指引
|
# Common Event Contract 模块指引
|
||||||
|
|
||||||
|
## 数据库访问与中文注释约束
|
||||||
|
|
||||||
|
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||||
|
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||||
|
|
||||||
## 职责边界
|
## 职责边界
|
||||||
|
|
||||||
- 维护 Firstleg、Lastleg、Finance 之间稳定、可版本化的业务事件契约。
|
- 维护 Firstleg、Lastleg、Finance 之间稳定、可版本化的业务事件契约。
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Common OpenAPI 模块指引
|
# Common OpenAPI 模块指引
|
||||||
|
|
||||||
|
## 数据库访问与中文注释约束
|
||||||
|
|
||||||
|
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||||
|
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||||
|
|
||||||
## 职责边界
|
## 职责边界
|
||||||
|
|
||||||
- 为 Spring MVC 服务提供 SpringDoc/OpenAPI 自动配置和统一文档元数据。
|
- 为 Spring MVC 服务提供 SpringDoc/OpenAPI 自动配置和统一文档元数据。
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Common Security 模块指引
|
# Common Security 模块指引
|
||||||
|
|
||||||
|
## 数据库访问与中文注释约束
|
||||||
|
|
||||||
|
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||||
|
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||||
|
|
||||||
## 职责边界
|
## 职责边界
|
||||||
|
|
||||||
- 提供业务服务通用的 JWT 解码、认证过滤、Redis Access Token 会话校验、认证用户上下文及 401/403 JSON 响应。
|
- 提供业务服务通用的 JWT 解码、认证过滤、Redis Access Token 会话校验、认证用户上下文及 401/403 JSON 响应。
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Common Web 模块指引
|
# Common Web 模块指引
|
||||||
|
|
||||||
|
## 数据库访问与中文注释约束
|
||||||
|
|
||||||
|
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||||
|
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||||
|
|
||||||
## 职责边界
|
## 职责边界
|
||||||
|
|
||||||
- 提供 Servlet Web 通用能力:请求链路标识、上下文和统一异常响应。
|
- 提供 Servlet Web 通用能力:请求链路标识、上下文和统一异常响应。
|
||||||
|
|||||||
@@ -285,9 +285,14 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg
|
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg
|
||||||
expose: ["9303"]
|
expose: ["9303"]
|
||||||
|
# 仅限制尾程服务,避免 JVM 异常时拖垮测试服务器.
|
||||||
|
mem_limit: 3g
|
||||||
|
memswap_limit: 3g
|
||||||
|
cpus: "2.0"
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9303
|
SERVER_PORT: 9303
|
||||||
|
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1536m -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
|
||||||
SELFOP_EXECUTION_ENABLED: ${SELFOP_EXECUTION_ENABLED:-false}
|
SELFOP_EXECUTION_ENABLED: ${SELFOP_EXECUTION_ENABLED:-false}
|
||||||
SELFOP_COLLECTION_MODE: ${SELFOP_COLLECTION_MODE:-OFF}
|
SELFOP_COLLECTION_MODE: ${SELFOP_COLLECTION_MODE:-OFF}
|
||||||
SELFOP_SOURCE_ACCOUNT: ${SELFOP_SOURCE_ACCOUNT:-lingxing-primary}
|
SELFOP_SOURCE_ACCOUNT: ${SELFOP_SOURCE_ACCOUNT:-lingxing-primary}
|
||||||
@@ -333,3 +338,5 @@ networks:
|
|||||||
infra-network:
|
infra-network:
|
||||||
external: true
|
external: true
|
||||||
name: ${INFRA_NETWORK:-firstleg-${DEPLOY_ENV:-test}-infra-network}
|
name: ${INFRA_NETWORK:-firstleg-${DEPLOY_ENV:-test}-infra-network}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 2026-10-16 拉取合并数据库变更汇总
|
||||||
|
|
||||||
|
范围:IDEA 更新前暂存基点 `cda5f3dc3` 到远程 `test` 的 `4e2ea8940`,加上本次恢复的本地改动。不是任意历史版本的一键全量升级脚本。没有连接或执行实际数据库。
|
||||||
|
|
||||||
|
## 数据库对应关系
|
||||||
|
|
||||||
|
| 服务 | 数据库 | 本次处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| lastleg-service | `baiyue_lastleg` | 执行下列 V28 增量,新增表、字段、索引并放宽四个字段的可空性 |
|
||||||
|
| warehouse-overseas-service | `baiyue_lastlag_warehouse` | 仅业务代码变化,无新增表或字段,无需 SQL;数据库名称保留正式的 lastlag 拼写 |
|
||||||
|
| 其他服务 | 各服务原数据库 | 本次比较范围内无 SQL 结构新增,无需 SQL |
|
||||||
|
|
||||||
|
已有尾程库使用:[V28 可重复执行 SQL](migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql)。文件显式指定 `USE baiyue_lastleg`,不访问其他服务数据库。
|
||||||
|
|
||||||
|
## 新表与字段
|
||||||
|
|
||||||
|
| 表 | 字段 | 定义/用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| `ll_compact_number_clock`(新表) | `number_key` | VARCHAR(16),主键,业务前缀及客户名称缩写 |
|
||||||
|
| 同上 | `last_second` | BIGINT NOT NULL,短单号分配逻辑秒,防止同秒及多实例重号 |
|
||||||
|
| `ll_warehouse_transfer_item` | `declared_quantity` | BIGINT NULL,SKU 申报件数,独立于箱数 |
|
||||||
|
| `ll_warehouse_transfer_carton` | `length_cm`、`width_cm`、`height_cm`、`gross_weight_kg` | DECIMAL(12,2) NULL,逐箱实测尺寸、重量 |
|
||||||
|
| 同上 | `counted_quantity` | BIGINT NULL,实点 SKU 件数 |
|
||||||
|
| 同上 | `count_idempotency_key` | VARCHAR(255) NULL,清点幂等键 |
|
||||||
|
| 同上 | `count_request_hash` | VARCHAR(64) NULL,清点请求摘要 |
|
||||||
|
| 同上 | `counted_by` | VARCHAR(128) NULL,清点操作人 |
|
||||||
|
| 同上 | `counted_at` | DATETIME(3) NULL,清点时间 |
|
||||||
|
| 同上 | `discrepancy_reason` | VARCHAR(1000) NULL,实收差异原因 |
|
||||||
|
| 同上 | `transfer_pallet_id` | BIGINT NULL,所属转运托盘事实 ID |
|
||||||
|
| 同上 | `sku_items_json` | JSON NULL,一箱多 SKU 清点明细 |
|
||||||
|
| 同上 | `customer_carton_mark` | VARCHAR(255) NULL,客户原箱唛及现场换标绑定 |
|
||||||
|
|
||||||
|
合计:1 张新表(2 列)、既有表补齐 14 列。逐箱清点等字段在旧版独立增量中已有,本次合并统一纳入 V28,避免漏执行;已存在的列会跳过。
|
||||||
|
|
||||||
|
## 索引及既有字段调整
|
||||||
|
|
||||||
|
| 表 | 调整 |
|
||||||
|
|---|---|
|
||||||
|
| `ll_cargo_discard_item` | 缺失时增加普通索引 `idx_ll_cargo_discard_return_carton(customer_id,return_carton_id)` |
|
||||||
|
| `ll_warehouse_transfer_order` | `estimated_arrival_at` 改为 DATETIME(3) NULL,库存转运无需填写预计入仓时间 |
|
||||||
|
| `ll_delivery_order` | `total_volume_cbm` 改为 DECIMAL(18,4) NULL,未知体积保持空值 |
|
||||||
|
| `ll_outbound_order`、`ll_outbound_pallet_item` | `volume_cbm` 改为 DECIMAL(18,4) NULL,未知体积保持空值 |
|
||||||
|
|
||||||
|
远程同时完善了 `ll_relabel_application.delivery_method` 的业务说明及照片、预约表的中文注释,没有新增对应字段,不需要因此另建表。照片回显和箱子订单归属校验复用已有结构。
|
||||||
|
|
||||||
|
## 执行与重跑
|
||||||
|
|
||||||
|
1. 确认连接为需要升级的 MySQL 8 实例,已有 `baiyue_lastleg` 及之前版本的业务表。通过数据库客户端打开并完整执行 V28,失败时停止,不启用忽略错误继续执行。
|
||||||
|
2. 新表采用 `CREATE TABLE IF NOT EXISTS`;每个新增字段和索引单独检查 `information_schema`,部分完成后可重跑。四个可空字段仅在当前为 NOT NULL 时调整。不会回填、删除或重算历史业务数据。
|
||||||
|
3. DDL 在 MySQL 中会自动提交,不能整体事务回滚;脚本不会修复同名但定义不一致的已有列、索引或新表,执行前应核对这些对象的结构。脚本末尾提供字段结构查询。
|
||||||
|
4. 此前 V1–V27 和其他已发布增量仍按原版本顺序管理,V28 不替代它们;缺失整个业务表的旧库应先完成对应历史升级。
|
||||||
|
|
||||||
|
空库使用原有海外统一初始化入口,已同步最终建表结构与 209 张表计数。名称含“增量”的海外统一文件仍仅用于空库,不应向已有业务库执行。
|
||||||
|
|
||||||
|
## 本地合并记录
|
||||||
|
|
||||||
|
远程分支已拉取,当前本地 HEAD 与 `origin/test` 一致。本次异常来自 IDEA 恢复更新前暂存补丁不完整;48 个文件按当前文件、原始基点、暂存内容做三方合并。唯一文本冲突保留远程 `bindingGuard` 与本地 `compactNumbers` 两项测试依赖初始化。
|
||||||
|
|
||||||
|
恢复前文件及三方版本保存在 `D:/project/.codex-temp/pull-resolution-20261016`;IDEA 原 shelf 保留。没有自动提交或推送代码。
|
||||||
|
|
||||||
|
本地验证:相关业务 Maven 定向测试通过;初始化基线、入库费用/扣货及网关路由合同共 27 项通过;Git 无未解决索引、无残留冲突标记,差异格式检查通过。SQL 仅完成离线核对,未进行 MySQL 实库执行或二次重跑验证。
|
||||||
|
|
||||||
|
指引索引检查 `scripts/agents-index-contract.ps1` 因工作区缺少 `D:/project/docs/业务快速索引.md` 而中止,此项未通过;未为消除检查错误而创建替代业务文档。
|
||||||
+10
-3
@@ -34,14 +34,19 @@
|
|||||||
|
|
||||||
预约工作事项新增结构由 `lastleg-service/src/main/resources/db/appointment-work.sql` 提供五张表:完整范围协调锁、工作关系、不可变历史、承诺影响版本与逐票批次实物分配,已同步 Lastleg 和海外空库初始化入口;对已有 TEST 仅审阅并授权这五表 CREATE 及协调锁初始行。直送目的仓实际卸货独立表由 `firstleg/src/main/resources/db/phase3-direct-delivery-schema.sql` 提供并同步国内空库入口,不产生海外库存。两项独立写权限见 `iam/src/main/resources/db/appointment-work-permissions.sql`,先校验精确权限身份再写入;默认只授权平台管理员。旧预约表的有损重建只允许单独审阅 `reset-test-fba-appointments.sql`;若历史、核实或工作/分配表已存在,前置检查会在 DROP 前拒绝,不能重放或擅自扩大重置范围。以上只维护脚本,不代表已执行或已部署。
|
预约工作事项新增结构由 `lastleg-service/src/main/resources/db/appointment-work.sql` 提供五张表:完整范围协调锁、工作关系、不可变历史、承诺影响版本与逐票批次实物分配,已同步 Lastleg 和海外空库初始化入口;对已有 TEST 仅审阅并授权这五表 CREATE 及协调锁初始行。直送目的仓实际卸货独立表由 `firstleg/src/main/resources/db/phase3-direct-delivery-schema.sql` 提供并同步国内空库入口,不产生海外库存。两项独立写权限见 `iam/src/main/resources/db/appointment-work-permissions.sql`,先校验精确权限身份再写入;默认只授权平台管理员。旧预约表的有损重建只允许单独审阅 `reset-test-fba-appointments.sql`;若历史、核实或工作/分配表已存在,前置检查会在 DROP 前拒绝,不能重放或擅自扩大重置范围。以上只维护脚本,不代表已执行或已部署。
|
||||||
|
|
||||||
|
换标直接出库的实物体积快照在 `ll_outbound_order.volume_cbm`、`ll_outbound_pallet_item.volume_cbm`、`ll_delivery_order.total_volume_cbm` 三列均允许 NULL,表示来源尚未确认;已有实盘尺寸按箱计算,不能用默认 0 代替未知,也不按 SKU 件数重复累计。两份初始化基线同步此定义,已有 TEST 只能在核对结构和授权后精确调整这三列,不重放空库文件或回填历史行。
|
||||||
|
|
||||||
## 唯一入口与区域边界
|
## 唯一入口与区域边界
|
||||||
|
|
||||||
|
V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving.sql` 提供四表:独立收货预约、当前/历史客户成员、待确认变化和不可变覆盖版本;同步 Lastleg 与海外空库基线。有效外部约号唯一性归收货预约的目的仓与约号,批次头表不再独立占用约号。部署前必须核对四表及旧头表唯一索引;本批只准备基线,不提供测试存量回填或自动重置。旧 `reset-test-fba-appointments.sql` 已拒绝存在这些新表的环境,不能用于重放 V13 初始化。实际 SQL 与部署须逐项授权。
|
||||||
|
|
||||||
两个统一 SQL 文件仍只用于空库初始化,名称中的“增量”不表示可向已有业务库重放。已有生产库的受控结构补齐单独放在 `migrations/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。
|
两个统一 SQL 文件仍只用于空库初始化,名称中的“增量”不表示可向已有业务库重放。已有生产库的受控结构补齐单独放在 `migrations/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。
|
||||||
|
|
||||||
| 区域 | 完整入口 | 服务 schema | 表数 |
|
| 区域 | 完整入口 | 服务 schema | 表数 |
|
||||||
|---|---|---|---:|
|
|---|---|---|---:|
|
||||||
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 |
|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 227 |
|
||||||
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 202 |
|
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 220 |
|
||||||
|
|
||||||
|
|
||||||
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
|
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
|
||||||
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
|
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
|
||||||
@@ -63,7 +68,7 @@
|
|||||||
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
|
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
|
||||||
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
|
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
|
||||||
|
|
||||||
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/199`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
|
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/215`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
|
||||||
|
|
||||||
## 平台管理员密码
|
## 平台管理员密码
|
||||||
|
|
||||||
@@ -157,3 +162,5 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
|
|||||||
自营库存周转后端导出权限见 `selfop-turnover-export-permission.sql`,仅新增 `platform:self-operated-analytics:export` 并授予平台管理员;同步 IAM 自营权限源及国内统一基线。配套部署 Lastleg、File、Admin,File 注册 `SELFOP_INVENTORY_TURNOVER`(Nacos同名配置同步);无业务建表及采集配置变更。执行前须逐项授权。
|
自营库存周转后端导出权限见 `selfop-turnover-export-permission.sql`,仅新增 `platform:self-operated-analytics:export` 并授予平台管理员;同步 IAM 自营权限源及国内统一基线。配套部署 Lastleg、File、Admin,File 注册 `SELFOP_INVENTORY_TURNOVER`(Nacos同名配置同步);无业务建表及采集配置变更。执行前须逐项授权。
|
||||||
|
|
||||||
货物废弃通用库存与散箱字段迁移:`migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql`。新增库存仓位关联及箱唛快照,须先迁移再部署 Lastleg;不回填历史废弃单。
|
货物废弃通用库存与散箱字段迁移:`migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql`。新增库存仓位关联及箱唛快照,须先迁移再部署 Lastleg;不回填历史废弃单。
|
||||||
|
|
||||||
|
2026-10-16 拉取合并配套结构清单及执行边界见 [数据库变更汇总](20261016-数据库变更汇总.md),已有尾程库使用 `migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql`;空库初始化海外表数同步为 209。该增量可重复执行,尚未执行到任何实际数据库。
|
||||||
|
|||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
-- 2026-10-16 拉取合并及本地恢复配套增量,MySQL 8。
|
||||||
|
-- 目标:lastleg-service / baiyue_lastleg;前置:已有业务表及此前迁移。
|
||||||
|
-- 只调整结构,不回填、删除或重算业务数据;执行失败应停止,修复后可重跑。
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_compact_number_clock (
|
||||||
|
number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写',
|
||||||
|
last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒',
|
||||||
|
PRIMARY KEY (number_key)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟';
|
||||||
|
|
||||||
|
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='width_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN width_cm DECIMAL(12,2) NULL COMMENT ''箱子实测宽度厘米'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='height_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN height_cm DECIMAL(12,2) NULL COMMENT ''箱子实测高度厘米'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN gross_weight_kg DECIMAL(12,2) NULL COMMENT ''箱子实测重量千克'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_quantity BIGINT NULL COMMENT ''箱内SKU实点件数'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_idempotency_key VARCHAR(255) NULL COMMENT ''箱内清点幂等键'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_request_hash VARCHAR(64) NULL COMMENT ''箱内清点请求摘要'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_by'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点操作人'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点完成时间'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='discrepancy_reason'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN discrepancy_reason VARCHAR(1000) NULL COMMENT ''实收与预报箱数差异原因'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='transfer_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN transfer_pallet_id BIGINT NULL COMMENT ''签收箱所属转运托盘事实ID'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
|
||||||
|
-- SKU 申报件数独立于预计箱数;历史未申报件数保持 NULL,实收以 PDA 清点为准。
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='declared_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_item ADD COLUMN declared_quantity BIGINT NULL COMMENT ''SKU申报件数,非预计或实收箱数'''));
|
||||||
|
PREPARE transfer_quantity_stmt FROM @ddl; EXECUTE transfer_quantity_stmt; DEALLOCATE PREPARE transfer_quantity_stmt;
|
||||||
|
|
||||||
|
-- 仓储转运一箱多SKU清点;仅增加结构,不回填历史业务数据。
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='sku_items_json'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN sku_items_json JSON NULL COMMENT ''实收箱内SKU及逐项件数明细'''));
|
||||||
|
PREPARE transfer_sku_stmt FROM @ddl; EXECUTE transfer_sku_stmt; DEALLOCATE PREPARE transfer_sku_stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='customer_carton_mark'), 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN customer_carton_mark VARCHAR(255) NULL COMMENT ''客户原箱唛,用于换标现场绑定''', 'SELECT 1');
|
||||||
|
PREPARE merge_schema_stmt FROM @ddl; EXECUTE merge_schema_stmt; DEALLOCATE PREPARE merge_schema_stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='idx_ll_cargo_discard_return_carton'), 'ALTER TABLE ll_cargo_discard_item ADD KEY idx_ll_cargo_discard_return_carton(customer_id,return_carton_id)', 'SELECT 1');
|
||||||
|
PREPARE merge_schema_stmt FROM @ddl; EXECUTE merge_schema_stmt; DEALLOCATE PREPARE merge_schema_stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='estimated_arrival_at' AND is_nullable='NO'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT ''预计入仓时间,库存转运不填写''', 'SELECT 1');
|
||||||
|
PREPARE merge_schema_stmt FROM @ddl; EXECUTE merge_schema_stmt; DEALLOCATE PREPARE merge_schema_stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='total_volume_cbm' AND is_nullable='NO'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN total_volume_cbm DECIMAL(18,4) NULL COMMENT ''体积(立方米),来源未确认时为空''', 'SELECT 1');
|
||||||
|
PREPARE merge_schema_stmt FROM @ddl; EXECUTE merge_schema_stmt; DEALLOCATE PREPARE merge_schema_stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='volume_cbm' AND is_nullable='NO'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN volume_cbm DECIMAL(18,4) NULL COMMENT ''体积(立方米),来源未确认时为空''', 'SELECT 1');
|
||||||
|
PREPARE merge_schema_stmt FROM @ddl; EXECUTE merge_schema_stmt; DEALLOCATE PREPARE merge_schema_stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='volume_cbm' AND is_nullable='NO'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN volume_cbm DECIMAL(18,4) NULL COMMENT ''体积(立方米),来源未确认时为空''', 'SELECT 1');
|
||||||
|
PREPARE merge_schema_stmt FROM @ddl; EXECUTE merge_schema_stmt; DEALLOCATE PREPARE merge_schema_stmt;
|
||||||
|
|
||||||
|
-- 执行后核对本次结构;这不是应用功能验收。
|
||||||
|
SELECT table_name,column_name,column_type,is_nullable,column_comment FROM information_schema.columns
|
||||||
|
WHERE table_schema=DATABASE() AND table_name IN ('ll_compact_number_clock','ll_warehouse_transfer_carton','ll_warehouse_transfer_item','ll_warehouse_transfer_order','ll_delivery_order','ll_outbound_order','ll_outbound_pallet_item')
|
||||||
|
ORDER BY table_name,ordinal_position;
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
-- 对应数据库:baiyue_lastleg;可重复执行,不修改原箱号、SKU预报件数和实测数据。
|
||||||
|
ALTER TABLE ll_warehouse_transfer_carton MODIFY COLUMN transfer_item_id BIGINT NULL COMMENT '实际箱内首项SKU明细ID,预报补箱可为空';
|
||||||
@@ -10,7 +10,8 @@ CREATE PROCEDURE assert_test_appointment_reset_preconditions()
|
|||||||
BEGIN
|
BEGIN
|
||||||
IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE()
|
IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE()
|
||||||
AND table_name IN ('ll_fba_appointment_command','ll_appointment_cargo_verification',
|
AND table_name IN ('ll_fba_appointment_command','ll_appointment_cargo_verification',
|
||||||
'll_appointment_work','ll_dispatch_appointment_allocation')) THEN
|
'll_appointment_work','ll_dispatch_appointment_allocation','ll_fba_receiving_agreement',
|
||||||
|
'll_fba_receiving_member','ll_fba_receiving_change','ll_fba_receiving_revision','ll_fba_receiving_plan')) THEN
|
||||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Stop: appointment history or work/allocation schema already exists; inspect before any reset';
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Stop: appointment history or work/allocation schema already exists; inspect before any reset';
|
||||||
END IF;
|
END IF;
|
||||||
IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE()
|
IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE()
|
||||||
@@ -49,7 +50,6 @@ CREATE TABLE ll_inbound_fba_appointment (
|
|||||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '更新UTC时间',
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '更新UTC时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_ll_fba_appointment_no (appointment_no),
|
UNIQUE KEY uk_ll_fba_appointment_no (appointment_no),
|
||||||
UNIQUE KEY uk_ll_fba_external_confirmation (fba_warehouse_code,confirmation_no),
|
|
||||||
KEY idx_ll_fba_appointment_list (appointment_at,appointment_status,fba_warehouse_code),
|
KEY idx_ll_fba_appointment_list (appointment_at,appointment_status,fba_warehouse_code),
|
||||||
KEY idx_ll_fba_appointment_planned (planned_at,appointment_status)
|
KEY idx_ll_fba_appointment_planned (planned_at,appointment_status)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA预约安排与正式确认唯一主记录';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA预约安排与正式确认唯一主记录';
|
||||||
@@ -66,7 +66,7 @@ CREATE TABLE ll_inbound_fba_appointment_item (
|
|||||||
package_count INT NULL COMMENT '本批分配箱数,未知保持空;同一原来源总量守恒',
|
package_count INT NULL COMMENT '本批分配箱数,未知保持空;同一原来源总量守恒',
|
||||||
cargo_verification_version INT NULL COMMENT '采用的预约货物核实版本,未核实时为空',
|
cargo_verification_version INT NULL COMMENT '采用的预约货物核实版本,未核实时为空',
|
||||||
shipment_references_json LONGTEXT NULL COMMENT '本次采用的货件或追踪号核实快照',
|
shipment_references_json LONGTEXT NULL COMMENT '本次采用的货件或追踪号核实快照',
|
||||||
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '取消或失败后释放货源占用',
|
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前批次数量分配有效;取消、未约成和完成均保留,仅替代时释放',
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间',
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_ll_fba_item_source (appointment_id,source_key),
|
UNIQUE KEY uk_ll_fba_item_source (appointment_id,source_key),
|
||||||
|
|||||||
+208
-86
@@ -271,7 +271,7 @@ CREATE TABLE ll_delivery_order (
|
|||||||
package_count int NOT NULL DEFAULT '0' COMMENT '派送箱数',
|
package_count int NOT NULL DEFAULT '0' COMMENT '派送箱数',
|
||||||
pallet_count int NOT NULL DEFAULT '0' COMMENT '派送托盘数',
|
pallet_count int NOT NULL DEFAULT '0' COMMENT '派送托盘数',
|
||||||
total_weight_kg DECIMAL(18,4) NULL COMMENT '总重量(千克),任一来源未确认时为空',
|
total_weight_kg DECIMAL(18,4) NULL COMMENT '总重量(千克),任一来源未确认时为空',
|
||||||
total_volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '总体积(立方米)',
|
total_volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米),来源未确认时为空',
|
||||||
planned_delivery_at datetime(3) DEFAULT NULL COMMENT '预计派送时间',
|
planned_delivery_at datetime(3) DEFAULT NULL COMMENT '预计派送时间',
|
||||||
dispatched_at datetime(3) DEFAULT NULL COMMENT '实际派送时间',
|
dispatched_at datetime(3) DEFAULT NULL COMMENT '实际派送时间',
|
||||||
expected_arrival_at datetime(3) DEFAULT NULL COMMENT '预计到达时间',
|
expected_arrival_at datetime(3) DEFAULT NULL COMMENT '预计到达时间',
|
||||||
@@ -754,13 +754,17 @@ CREATE TABLE ll_exception_return_item (
|
|||||||
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键',
|
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键',
|
||||||
exception_order_id bigint NOT NULL COMMENT '异常订单ID',
|
exception_order_id bigint NOT NULL COMMENT '异常订单ID',
|
||||||
outbound_pallet_item_id bigint NOT NULL COMMENT '原出库托盘明细ID',
|
outbound_pallet_item_id bigint NOT NULL COMMENT '原出库托盘明细ID',
|
||||||
inventory_position_id bigint NOT NULL COMMENT '原出库库存位置ID',
|
inventory_position_id bigint DEFAULT NULL COMMENT '普通原出库库存位置;与退货原箱互斥',
|
||||||
|
return_carton_id bigint DEFAULT NULL COMMENT '原生退货箱标识;与普通库存位置互斥',
|
||||||
|
return_carton_mark varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原生退货箱业务箱码快照',
|
||||||
pallet_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原出库托盘码快照',
|
pallet_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原出库托盘码快照',
|
||||||
sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原出库SKU快照',
|
sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原出库SKU快照',
|
||||||
max_return_packages bigint NOT NULL COMMENT '原出库可退箱数上限',
|
max_return_packages bigint NOT NULL COMMENT '原出库可退箱数上限',
|
||||||
approved_return_packages bigint NOT NULL COMMENT '批准退回箱数',
|
approved_return_packages bigint NOT NULL COMMENT '批准退回箱数',
|
||||||
created_at datetime(3) NOT NULL COMMENT '创建时间',
|
created_at datetime(3) NOT NULL COMMENT '创建时间',
|
||||||
received_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已实际退回入仓的累计箱数',
|
received_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已实际退回入仓的累计箱数',
|
||||||
|
CONSTRAINT chk_exception_return_source CHECK((inventory_position_id IS NOT NULL AND return_carton_id IS NULL) OR (inventory_position_id IS NULL AND return_carton_id IS NOT NULL)),
|
||||||
|
UNIQUE KEY uk_ll_exception_return_carton(customer_id,exception_order_id,outbound_pallet_item_id,return_carton_id),
|
||||||
CONSTRAINT chk_exception_return_received CHECK(received_packages>=0 AND received_packages<=approved_return_packages),
|
CONSTRAINT chk_exception_return_received CHECK(received_packages>=0 AND received_packages<=approved_return_packages),
|
||||||
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
@@ -768,6 +772,38 @@ CREATE TABLE ll_exception_return_item (
|
|||||||
KEY idx_ll_exception_return_order (customer_id,exception_order_id)
|
KEY idx_ll_exception_return_order (customer_id,exception_order_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常退回原出库库存冻结明细';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常退回原出库库存冻结明细';
|
||||||
|
|
||||||
|
CREATE TABLE ll_return_carton_stock_receipt (
|
||||||
|
id BIGINT NOT NULL COMMENT '原箱回仓凭据明细标识',
|
||||||
|
customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键',
|
||||||
|
receipt_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原箱回仓实收业务号',
|
||||||
|
exception_order_id BIGINT NOT NULL COMMENT '本次异常退回订单',
|
||||||
|
exception_return_item_id BIGINT NOT NULL COMMENT '本次冻结的原箱来源明细',
|
||||||
|
delivery_order_id BIGINT NOT NULL COMMENT '原运输派送单',
|
||||||
|
source_pick_id BIGINT NOT NULL COMMENT '原箱实际出库拣货凭据',
|
||||||
|
source_outbound_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原出库单业务号',
|
||||||
|
source_dispatch_item_id BIGINT NOT NULL COMMENT '原排车货物明细',
|
||||||
|
source_outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细',
|
||||||
|
return_carton_id BIGINT NOT NULL COMMENT '真实退货原箱标识',
|
||||||
|
carton_mark VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原箱业务箱码快照',
|
||||||
|
overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际回仓海外仓标识',
|
||||||
|
target_pallet_id BIGINT NOT NULL COMMENT '实际回仓目标实物托盘',
|
||||||
|
target_pallet_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际目标托盘业务码',
|
||||||
|
target_location_id BIGINT NOT NULL COMMENT '实际回仓目标库位',
|
||||||
|
target_location_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际目标库位业务码',
|
||||||
|
sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原出库快照中的商品编码',
|
||||||
|
product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '原出库快照中的中文品名',
|
||||||
|
quantity BIGINT NOT NULL COMMENT '本次原SKU实际恢复件数',
|
||||||
|
packages BIGINT NOT NULL COMMENT '本箱仅首条SKU记录计一箱其余为零',
|
||||||
|
evidence_file_ids_json JSON NOT NULL COMMENT '本次实收证据文件引用',
|
||||||
|
received_by VARCHAR(128) NOT NULL COMMENT '本次原箱实收确认操作人',
|
||||||
|
received_at DATETIME(3) NOT NULL COMMENT '实际原箱回仓入账时间',
|
||||||
|
PRIMARY KEY(id),
|
||||||
|
UNIQUE KEY uk_return_carton_receipt_source(customer_id,source_pick_id,sku_code),
|
||||||
|
KEY idx_return_carton_receipt_exception(customer_id,exception_order_id,received_at),
|
||||||
|
KEY idx_return_carton_receipt_delivery(customer_id,delivery_order_id),
|
||||||
|
CONSTRAINT chk_return_carton_receipt_quantity CHECK(quantity>0 AND packages IN (0,1))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='原生退货箱按原出库SKU实际回仓的不可变凭据';
|
||||||
|
|
||||||
CREATE TABLE ll_finance_fee_command (
|
CREATE TABLE ll_finance_fee_command (
|
||||||
id bigint NOT NULL COMMENT '幂等命令ID',
|
id bigint NOT NULL COMMENT '幂等命令ID',
|
||||||
operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID',
|
operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID',
|
||||||
@@ -849,6 +885,57 @@ CREATE TABLE ll_finance_fee_source (
|
|||||||
CONSTRAINT chk_ll_finance_fee_status CHECK ((source_status in (_utf8mb4'ACTIVE',_utf8mb4'SUPERSEDED',_utf8mb4'VOIDED')))
|
CONSTRAINT chk_ll_finance_fee_status CHECK ((source_status in (_utf8mb4'ACTIVE',_utf8mb4'SUPERSEDED',_utf8mb4'VOIDED')))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance';
|
||||||
|
|
||||||
|
-- V13 receiving coverage and pending changes; baseline only, no test data migration.
|
||||||
|
CREATE TABLE ll_fba_receiving_agreement (
|
||||||
|
agreement_key VARCHAR(64) NOT NULL COMMENT '收货预约独立业务键',
|
||||||
|
version INT NOT NULL COMMENT '预约覆盖范围与确认资料版本',
|
||||||
|
status VARCHAR(24) NOT NULL COMMENT '有效、取消或已替代状态',
|
||||||
|
destination_key VARCHAR(128) NOT NULL COMMENT '权威目的仓关联键',
|
||||||
|
confirmation_no VARCHAR(128) NULL COMMENT '当前有效外部约号,失效后清空以释放覆盖',
|
||||||
|
snapshot_json LONGTEXT NOT NULL COMMENT '客户逐来源覆盖、确认时区及仓库依据快照',
|
||||||
|
PRIMARY KEY (agreement_key),
|
||||||
|
UNIQUE KEY uk_fba_receiving_confirmation (destination_key, confirmation_no)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='独立于送货批次的收货预约';
|
||||||
|
|
||||||
|
CREATE TABLE ll_fba_receiving_member (
|
||||||
|
agreement_key VARCHAR(64) NOT NULL COMMENT '所属收货预约业务键',
|
||||||
|
batch_key VARCHAR(128) NOT NULL COMMENT '稳定送货批次键',
|
||||||
|
customer_id VARCHAR(128) NOT NULL COMMENT '货物所属客户标识',
|
||||||
|
active_ref VARCHAR(260) NULL COMMENT '有效批次与客户唯一引用,退出后清空且历史保留',
|
||||||
|
PRIMARY KEY (agreement_key,batch_key,customer_id),
|
||||||
|
UNIQUE KEY uk_fba_receiving_active_member (active_ref),
|
||||||
|
KEY idx_fba_receiving_batch (batch_key,customer_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收货预约当前覆盖关联及退出历史';
|
||||||
|
|
||||||
|
CREATE TABLE ll_fba_receiving_change (
|
||||||
|
change_key VARCHAR(64) NOT NULL COMMENT '客户预约变化业务键',
|
||||||
|
version INT NOT NULL COMMENT '变化续办版本',
|
||||||
|
status VARCHAR(24) NOT NULL COMMENT '待仓库确认、已应用或已撤回状态',
|
||||||
|
batch_key VARCHAR(128) NOT NULL COMMENT '受影响原送货批次键',
|
||||||
|
customer_id VARCHAR(128) NOT NULL COMMENT '本次变化所属客户',
|
||||||
|
snapshot_json LONGTEXT NOT NULL COMMENT '原覆盖快照、逐来源拟保留量、负责人及跟进资料',
|
||||||
|
result_json LONGTEXT NULL COMMENT '确认后的实际批次去向及逐步骤结果',
|
||||||
|
active_ref VARCHAR(260) NULL COMMENT '同批同客户唯一未解决变化引用',
|
||||||
|
PRIMARY KEY (change_key),
|
||||||
|
UNIQUE KEY uk_fba_receiving_pending (active_ref),
|
||||||
|
KEY idx_fba_receiving_pending_batch (batch_key,status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='保留原安排且阻止误发车的客户变化';
|
||||||
|
|
||||||
|
CREATE TABLE ll_fba_receiving_revision (
|
||||||
|
agreement_key VARCHAR(64) NOT NULL COMMENT '收货预约业务键',
|
||||||
|
version INT NOT NULL COMMENT '不可变覆盖与确认版本',
|
||||||
|
snapshot_json LONGTEXT NOT NULL COMMENT '该版本完整客户覆盖、确认时段及回复依据',
|
||||||
|
PRIMARY KEY (agreement_key,version)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收货预约覆盖与确认不可变历史';
|
||||||
|
|
||||||
|
CREATE TABLE ll_fba_receiving_plan (
|
||||||
|
batch_key VARCHAR(128) NOT NULL COMMENT '稳定送货批次键',
|
||||||
|
customer_id VARCHAR(128) NOT NULL COMMENT '计划所属客户标识',
|
||||||
|
snapshot_json LONGTEXT NOT NULL COMMENT '本客户未确认计划、目标窗口及业务时区,不含有效预约确认',
|
||||||
|
PRIMARY KEY (batch_key,customer_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='按批次及客户保存的未确认收货计划';
|
||||||
|
|
||||||
|
|
||||||
CREATE TABLE ll_inbound_fba_appointment (
|
CREATE TABLE ll_inbound_fba_appointment (
|
||||||
id BIGINT NOT NULL COMMENT '主键ID',
|
id BIGINT NOT NULL COMMENT '主键ID',
|
||||||
appointment_no VARCHAR(64) NOT NULL COMMENT '系统预约业务单号,不是外部约号',
|
appointment_no VARCHAR(64) NOT NULL COMMENT '系统预约业务单号,不是外部约号',
|
||||||
@@ -875,7 +962,6 @@ CREATE TABLE ll_inbound_fba_appointment (
|
|||||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '更新UTC时间',
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '更新UTC时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_ll_fba_appointment_no (appointment_no),
|
UNIQUE KEY uk_ll_fba_appointment_no (appointment_no),
|
||||||
UNIQUE KEY uk_ll_fba_external_confirmation (fba_warehouse_code,confirmation_no),
|
|
||||||
KEY idx_ll_fba_appointment_list (appointment_at,appointment_status,fba_warehouse_code),
|
KEY idx_ll_fba_appointment_list (appointment_at,appointment_status,fba_warehouse_code),
|
||||||
KEY idx_ll_fba_appointment_planned (planned_at,appointment_status)
|
KEY idx_ll_fba_appointment_planned (planned_at,appointment_status)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA预约安排与正式确认唯一主记录';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA预约安排与正式确认唯一主记录';
|
||||||
@@ -892,7 +978,7 @@ CREATE TABLE ll_inbound_fba_appointment_item (
|
|||||||
package_count INT NULL COMMENT '本批分配箱数,未知保持空;同一原来源总量守恒',
|
package_count INT NULL COMMENT '本批分配箱数,未知保持空;同一原来源总量守恒',
|
||||||
cargo_verification_version INT NULL COMMENT '采用的预约货物核实版本,未核实时为空',
|
cargo_verification_version INT NULL COMMENT '采用的预约货物核实版本,未核实时为空',
|
||||||
shipment_references_json LONGTEXT NULL COMMENT '本次采用的货件或追踪号核实快照',
|
shipment_references_json LONGTEXT NULL COMMENT '本次采用的货件或追踪号核实快照',
|
||||||
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '取消或失败后释放货源占用',
|
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前批次数量分配有效;取消、未约成和完成均保留,仅替代时释放',
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间',
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_ll_fba_item_source (appointment_id,source_key),
|
UNIQUE KEY uk_ll_fba_item_source (appointment_id,source_key),
|
||||||
@@ -1725,7 +1811,7 @@ CREATE TABLE ll_outbound_order (
|
|||||||
pallet_count int NOT NULL DEFAULT '0' COMMENT '托盘数',
|
pallet_count int NOT NULL DEFAULT '0' COMMENT '托盘数',
|
||||||
gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空',
|
gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空',
|
||||||
volume_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)',
|
volume_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)',
|
||||||
volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)',
|
volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米),来源未确认时为空',
|
||||||
fee_coverage_json JSON NULL COMMENT '本次发运引用的原费用版本、实际货物及明确服务覆盖确认快照',
|
fee_coverage_json JSON NULL COMMENT '本次发运引用的原费用版本、实际货物及明确服务覆盖确认快照',
|
||||||
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键',
|
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键',
|
||||||
created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID',
|
created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID',
|
||||||
@@ -1754,7 +1840,7 @@ CREATE TABLE ll_outbound_pallet_item (
|
|||||||
package_count int NOT NULL DEFAULT '0' COMMENT '箱数',
|
package_count int NOT NULL DEFAULT '0' COMMENT '箱数',
|
||||||
gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空',
|
gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空',
|
||||||
volume_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)',
|
volume_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)',
|
||||||
volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)',
|
volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米),来源未确认时为空',
|
||||||
picked_package_count int NOT NULL DEFAULT '0' COMMENT '已拣箱数',
|
picked_package_count int NOT NULL DEFAULT '0' COMMENT '已拣箱数',
|
||||||
status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态',
|
status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态',
|
||||||
sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发SKU',
|
sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发SKU',
|
||||||
@@ -2215,7 +2301,7 @@ CREATE TABLE ll_relabel_application (
|
|||||||
label_deadline datetime(3) NOT NULL COMMENT '换标截止时间',
|
label_deadline datetime(3) NOT NULL COMMENT '换标截止时间',
|
||||||
contact_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人姓名',
|
contact_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人姓名',
|
||||||
contact_phone varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人电话',
|
contact_phone varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人电话',
|
||||||
delivery_method varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填',
|
delivery_method varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送方式:TRUCK平台卡车、EXPRESS快递、PICKUP自提、AMAZON_PICKUP亚马逊自提、CUSTOMER_PICKUP客户自提;仅平台卡车进入预排车',
|
||||||
print_shipping_label tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否需要打印面单',
|
print_shipping_label tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否需要打印面单',
|
||||||
shipping_label_file_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户上传面单文件ID',
|
shipping_label_file_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户上传面单文件ID',
|
||||||
shipping_label_file_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户上传面单文件名',
|
shipping_label_file_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户上传面单文件名',
|
||||||
@@ -2842,6 +2928,7 @@ CREATE TABLE ll_fleet_vehicle_record_history (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录不可变历史';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录不可变历史';
|
||||||
|
|
||||||
CREATE TABLE ll_fleet_trip (
|
CREATE TABLE ll_fleet_trip (
|
||||||
|
trip_purpose VARCHAR(16) NOT NULL DEFAULT 'DELIVERY' COMMENT 'DELIVERY出库派送 PICKUP上门取货入仓',
|
||||||
id BIGINT NOT NULL COMMENT '内部主键',
|
id BIGINT NOT NULL COMMENT '内部主键',
|
||||||
trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号',
|
trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号',
|
||||||
idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键',
|
idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键',
|
||||||
@@ -3206,9 +3293,21 @@ CREATE TABLE ll_wallet_event_inbox (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='跨区客户资金结果幂等收件箱';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='跨区客户资金结果幂等收件箱';
|
||||||
|
|
||||||
CREATE TABLE ll_warehouse_transfer_carton (
|
CREATE TABLE ll_warehouse_transfer_carton (
|
||||||
|
length_cm DECIMAL(12,2) NULL COMMENT '箱子实测长度厘米',
|
||||||
|
width_cm DECIMAL(12,2) NULL COMMENT '箱子实测宽度厘米',
|
||||||
|
height_cm DECIMAL(12,2) NULL COMMENT '箱子实测高度厘米',
|
||||||
|
gross_weight_kg DECIMAL(12,2) NULL COMMENT '箱子实测重量千克',
|
||||||
|
counted_quantity BIGINT NULL COMMENT '箱内SKU实点件数',
|
||||||
|
count_idempotency_key VARCHAR(255) NULL COMMENT '箱内清点幂等键',
|
||||||
|
count_request_hash VARCHAR(64) NULL COMMENT '箱内清点请求摘要',
|
||||||
|
counted_by VARCHAR(128) NULL COMMENT '箱内清点操作人',
|
||||||
|
counted_at DATETIME(3) NULL COMMENT '箱内清点完成时间',
|
||||||
|
discrepancy_reason VARCHAR(1000) NULL COMMENT '实收与预报箱数差异原因',
|
||||||
|
transfer_pallet_id BIGINT NULL COMMENT '签收箱所属转运托盘事实ID',
|
||||||
|
sku_items_json JSON NULL COMMENT '实收箱内SKU及逐项件数明细',
|
||||||
id bigint NOT NULL COMMENT '预报箱主键',
|
id bigint NOT NULL COMMENT '预报箱主键',
|
||||||
transfer_order_id bigint NOT NULL COMMENT '转运单ID',
|
transfer_order_id bigint NOT NULL COMMENT '转运单ID',
|
||||||
transfer_item_id bigint NOT NULL COMMENT '转运申报明细ID',
|
transfer_item_id bigint DEFAULT NULL COMMENT '转运申报明细ID',
|
||||||
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
|
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
|
||||||
carton_mark varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '系统生成的一箱一码箱唛',
|
carton_mark varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '系统生成的一箱一码箱唛',
|
||||||
customer_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户原箱唛,用于换标现场绑定',
|
customer_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户原箱唛,用于换标现场绑定',
|
||||||
@@ -3249,6 +3348,7 @@ CREATE TABLE ll_warehouse_transfer_inventory_allocation (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转仓单来源库存分配记录';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转仓单来源库存分配记录';
|
||||||
|
|
||||||
CREATE TABLE ll_warehouse_transfer_item (
|
CREATE TABLE ll_warehouse_transfer_item (
|
||||||
|
declared_quantity BIGINT NULL COMMENT 'SKU申报件数,非预计或实收箱数',
|
||||||
id bigint NOT NULL COMMENT '主键ID',
|
id bigint NOT NULL COMMENT '主键ID',
|
||||||
transfer_order_id bigint NOT NULL COMMENT '转仓单ID',
|
transfer_order_id bigint NOT NULL COMMENT '转仓单ID',
|
||||||
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
|
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
|
||||||
@@ -3285,7 +3385,7 @@ CREATE TABLE ll_warehouse_transfer_order (
|
|||||||
pickup_contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '提货联系电话',
|
pickup_contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '提货联系电话',
|
||||||
package_count int NOT NULL COMMENT '箱数',
|
package_count int NOT NULL COMMENT '箱数',
|
||||||
delivery_method varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓方式',
|
delivery_method varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓方式',
|
||||||
estimated_arrival_at datetime(3) NOT NULL COMMENT '预计入仓时间',
|
estimated_arrival_at datetime(3) NULL COMMENT '预计入仓时间,库存转运不填写',
|
||||||
estimated_ship_at datetime(3) DEFAULT NULL COMMENT '预计发货时间',
|
estimated_ship_at datetime(3) DEFAULT NULL COMMENT '预计发货时间',
|
||||||
fba_shipment_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '可选FBA货件编号',
|
fba_shipment_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '可选FBA货件编号',
|
||||||
tracking_no varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '物流跟踪号',
|
tracking_no varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '物流跟踪号',
|
||||||
@@ -4268,7 +4368,7 @@ CREATE TABLE ll_selfop_sku_profile (
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_appointment_impact (
|
CREATE TABLE ll_appointment_impact (
|
||||||
container_booking_id VARCHAR(64) NOT NULL COMMENT '稳定柜次关联键,不作业务编号展示',
|
container_booking_id VARCHAR(64) NOT NULL COMMENT '稳定柜次关联键,不作业务编号展示',
|
||||||
impact_no VARCHAR(40) NOT NULL COMMENT '船期核对事项业务号',
|
impact_no VARCHAR(40) NOT NULL COMMENT '船期核对事项业务号',
|
||||||
baseline_json LONGTEXT NOT NULL COMMENT '已核对基准或首次变化前观察,原来源时间口径',
|
baseline_json LONGTEXT NOT NULL COMMENT '已核对基准或首次变化前观察,原来源时间口径',
|
||||||
@@ -4286,7 +4386,7 @@ CREATE TABLE IF NOT EXISTS ll_appointment_impact (
|
|||||||
CONSTRAINT chk_appointment_impact_status CHECK (status IN ('MONITORING','OPEN','RESOLVED'))
|
CONSTRAINT chk_appointment_impact_status CHECK (status IN ('MONITORING','OPEN','RESOLVED'))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='一柜一个稳定船期变化核对事项,不改写预约或运输';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='一柜一个稳定船期变化核对事项,不改写预约或运输';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_appointment_impact_history (
|
CREATE TABLE ll_appointment_impact_history (
|
||||||
id BIGINT NOT NULL COMMENT '历史记录标识',
|
id BIGINT NOT NULL COMMENT '历史记录标识',
|
||||||
container_booking_id VARCHAR(64) NOT NULL COMMENT '关联柜次标识',
|
container_booking_id VARCHAR(64) NOT NULL COMMENT '关联柜次标识',
|
||||||
version INT NOT NULL COMMENT '记录版本',
|
version INT NOT NULL COMMENT '记录版本',
|
||||||
@@ -4335,47 +4435,29 @@ CREATE TABLE ll_return_sku_conversion (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物SKU换标库存转换和冲回记录';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物SKU换标库存转换和冲回记录';
|
||||||
|
|
||||||
-- PDA cargo discard photo evidence
|
-- PDA cargo discard photo evidence
|
||||||
CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo (
|
CREATE TABLE ll_cargo_discard_photo (
|
||||||
id BIGINT NOT NULL,
|
id BIGINT NOT NULL COMMENT '照片记录标识',
|
||||||
customer_id VARCHAR(64) NOT NULL,
|
customer_id VARCHAR(64) NOT NULL COMMENT '所属客户标识',
|
||||||
application_id BIGINT NOT NULL,
|
application_id BIGINT NOT NULL COMMENT '废弃申请标识',
|
||||||
pallet_no VARCHAR(128) NOT NULL,
|
pallet_no VARCHAR(128) NOT NULL COMMENT '托盘业务码',
|
||||||
file_id BIGINT NOT NULL,
|
file_id BIGINT NOT NULL COMMENT '照片文件标识',
|
||||||
file_name VARCHAR(255) NOT NULL,
|
file_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
|
||||||
content_type VARCHAR(128) NOT NULL,
|
content_type VARCHAR(128) NOT NULL COMMENT '文件媒体类型',
|
||||||
size_bytes BIGINT NOT NULL,
|
size_bytes BIGINT NOT NULL COMMENT '文件字节数',
|
||||||
operator_id VARCHAR(64) NOT NULL,
|
operator_id VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
|
||||||
created_at DATETIME(6) NOT NULL,
|
created_at DATETIME(6) NOT NULL COMMENT '照片创建时间',
|
||||||
submitted_at DATETIME(6) NULL,
|
submitted_at DATETIME(6) NULL COMMENT '照片提交时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_cargo_discard_photo_file (file_id),
|
UNIQUE KEY uk_cargo_discard_photo_file (file_id),
|
||||||
KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no)
|
KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA货物废弃照片凭证';
|
||||||
|
|
||||||
-- 货物废弃支持所有入库来源及散箱全量作业;不回填或修改历史单据。
|
-- 货物废弃支持所有入库来源及散箱全量作业;不回填或修改历史单据。
|
||||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='inventory_position_id'),
|
|
||||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN inventory_position_id BIGINT NULL COMMENT ''废弃来源库存仓位ID''', 'SELECT 1');
|
|
||||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
|
||||||
|
|
||||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_marks_json'),
|
|
||||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_marks_json TEXT NULL COMMENT ''本次废弃所选真实箱唛快照JSON数组''', 'SELECT 1');
|
|
||||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
|
||||||
|
|
||||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='uk_ll_discard_position_sku'),
|
|
||||||
'ALTER TABLE ll_cargo_discard_item ADD UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code)', 'SELECT 1');
|
|
||||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
|
||||||
|
|
||||||
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY';
|
|
||||||
|
|
||||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_scan_required'),
|
|
||||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要逐箱核对本次所选箱子''', 'SELECT 1');
|
|
||||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
|
||||||
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组';
|
|
||||||
ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '箱唛状态:未绑定UNASSIGNED、已绑托ON_PALLET、整票收货BULK_RECEIVED、可用AVAILABLE、待废弃DISCARD_PENDING、已废弃DISCARDED';
|
|
||||||
|
|
||||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='source_type'),
|
|
||||||
'ALTER TABLE ll_cargo_discard_carton ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER''', 'SELECT 1');
|
|
||||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
|
||||||
|
|
||||||
CREATE TABLE ll_selfop_owner (
|
CREATE TABLE ll_selfop_owner (
|
||||||
id BIGINT NOT NULL COMMENT '主键',
|
id BIGINT NOT NULL COMMENT '主键',
|
||||||
@@ -4414,39 +4496,15 @@ CREATE TABLE ll_selfop_product (
|
|||||||
|
|
||||||
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
|
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
|
||||||
USE baiyue_lastleg;
|
USE baiyue_lastleg;
|
||||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米'''));
|
|
||||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
|
||||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='width_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN width_cm DECIMAL(12,2) NULL COMMENT ''箱子实测宽度厘米'''));
|
|
||||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
|
||||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='height_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN height_cm DECIMAL(12,2) NULL COMMENT ''箱子实测高度厘米'''));
|
|
||||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
|
||||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN gross_weight_kg DECIMAL(12,2) NULL COMMENT ''箱子实测重量千克'''));
|
|
||||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
|
||||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_quantity BIGINT NULL COMMENT ''箱内SKU实点件数'''));
|
|
||||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
|
||||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_idempotency_key VARCHAR(255) NULL COMMENT ''箱内清点幂等键'''));
|
|
||||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
|
||||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_request_hash VARCHAR(64) NULL COMMENT ''箱内清点请求摘要'''));
|
|
||||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
|
||||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_by'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点操作人'''));
|
|
||||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
|
||||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点完成时间'''));
|
|
||||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
|
||||||
|
|
||||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='discrepancy_reason'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN discrepancy_reason VARCHAR(1000) NULL COMMENT ''实收与预报箱数差异原因'''));
|
|
||||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
|
||||||
|
|
||||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='transfer_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN transfer_pallet_id BIGINT NULL COMMENT ''签收箱所属转运托盘事实ID'''));
|
|
||||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
|
||||||
|
|
||||||
-- SKU 申报件数独立于预计箱数;历史未申报件数保持 NULL,实收以 PDA 清点为准。
|
-- SKU 申报件数独立于预计箱数;历史未申报件数保持 NULL,实收以 PDA 清点为准。
|
||||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='declared_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_item ADD COLUMN declared_quantity BIGINT NULL COMMENT ''SKU申报件数,非预计或实收箱数'''));
|
|
||||||
PREPARE transfer_quantity_stmt FROM @ddl; EXECUTE transfer_quantity_stmt; DEALLOCATE PREPARE transfer_quantity_stmt;
|
|
||||||
|
|
||||||
USE baiyue_lastleg;
|
USE baiyue_lastleg;
|
||||||
|
|
||||||
-- 历史库存基线;执行前必须取得指定环境的建表授权。
|
-- 历史库存基线;执行前必须取得指定环境的建表授权。
|
||||||
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_flow (
|
CREATE TABLE ll_selfop_inventory_flow (
|
||||||
id BIGINT NOT NULL COMMENT '应用生成的流水记录标识',
|
id BIGINT NOT NULL COMMENT '应用生成的流水记录标识',
|
||||||
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号',
|
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号',
|
||||||
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码',
|
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码',
|
||||||
@@ -4482,7 +4540,7 @@ CREATE TABLE IF NOT EXISTS ll_selfop_inventory_flow (
|
|||||||
CONSTRAINT chk_selfop_flow_revision CHECK (revision_no > 0)
|
CONSTRAINT chk_selfop_flow_revision CHECK (revision_no > 0)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='自营历史库存流水最新事实,旧版本保留于暂存证据';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='自营历史库存流水最新事实,旧版本保留于暂存证据';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_day_version (
|
CREATE TABLE ll_selfop_inventory_day_version (
|
||||||
id BIGINT NOT NULL COMMENT '不可变历史日版本标识',
|
id BIGINT NOT NULL COMMENT '不可变历史日版本标识',
|
||||||
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号',
|
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号',
|
||||||
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码',
|
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码',
|
||||||
@@ -4512,7 +4570,7 @@ CREATE TABLE IF NOT EXISTS ll_selfop_inventory_day_version (
|
|||||||
CONSTRAINT chk_selfop_history_head CHECK (head_slot IS NULL OR (head_slot=1 AND status='PUBLISHED'))
|
CONSTRAINT chk_selfop_history_head CHECK (head_slot IS NULL OR (head_slot=1 AND status='PUBLISHED'))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日库存不可变版本及原子发布日头';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日库存不可变版本及原子发布日头';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_daily (
|
CREATE TABLE ll_selfop_inventory_daily (
|
||||||
day_version_id BIGINT NOT NULL COMMENT '所属历史日版本',
|
day_version_id BIGINT NOT NULL COMMENT '所属历史日版本',
|
||||||
customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码',
|
customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码',
|
||||||
sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码',
|
sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码',
|
||||||
@@ -4528,29 +4586,32 @@ CREATE TABLE IF NOT EXISTS ll_selfop_inventory_daily (
|
|||||||
CONSTRAINT chk_selfop_daily_quantity CHECK ((quality_status='VALID' AND physical_quantity IS NOT NULL AND physical_quantity>=0) OR (quality_status='UNKNOWN' AND physical_quantity IS NULL))
|
CONSTRAINT chk_selfop_daily_quantity CHECK ((quality_status='VALID' AND physical_quantity IS NOT NULL AND physical_quantity>=0) OR (quality_status='UNKNOWN' AND physical_quantity IS NULL))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日末分货主商品品质实物库存';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日末分货主商品品质实物库存';
|
||||||
|
|
||||||
|
-- 仓储转运一箱多SKU清点;仅增加结构,不回填历史业务数据。
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
|
||||||
|
|
||||||
-- Appointment work scope, audit and physical batch allocation.
|
-- Appointment work scope, audit and physical batch allocation.
|
||||||
USE baiyue_lastleg;
|
USE baiyue_lastleg;
|
||||||
CREATE TABLE IF NOT EXISTS ll_dispatch_appointment_allocation (
|
CREATE TABLE ll_dispatch_appointment_allocation (
|
||||||
dispatch_id BIGINT NOT NULL,
|
dispatch_id BIGINT NOT NULL COMMENT '排车单标识',
|
||||||
dispatch_item_id BIGINT NOT NULL,
|
dispatch_item_id BIGINT NOT NULL COMMENT '排车明细标识',
|
||||||
plan_pallet_id BIGINT NOT NULL,
|
plan_pallet_id BIGINT NOT NULL COMMENT '入仓计划托盘标识',
|
||||||
batch_key VARCHAR(96) NOT NULL,
|
batch_key VARCHAR(96) NOT NULL COMMENT '预约稳定批次键',
|
||||||
customer_id VARCHAR(64) NOT NULL,
|
customer_id VARCHAR(64) NOT NULL COMMENT '所属客户标识',
|
||||||
source_key VARCHAR(160) NOT NULL,
|
source_key VARCHAR(160) NOT NULL COMMENT '货物来源稳定键',
|
||||||
destination_key VARCHAR(160) NOT NULL,
|
destination_key VARCHAR(160) NOT NULL COMMENT '目的地稳定键',
|
||||||
package_count INT NOT NULL,
|
package_count INT NOT NULL COMMENT '本票分配实物箱数',
|
||||||
PRIMARY KEY(dispatch_item_id,batch_key,source_key,destination_key),
|
PRIMARY KEY(dispatch_item_id,batch_key,source_key,destination_key),
|
||||||
KEY idx_dispatch_appointment_batch(batch_key,customer_id),
|
KEY idx_dispatch_appointment_batch(batch_key,customer_id),
|
||||||
KEY idx_dispatch_appointment_dispatch(dispatch_id)
|
KEY idx_dispatch_appointment_dispatch(dispatch_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车实物与预约批次逐票分配,原执行域事实';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车实物与预约批次逐票分配,原执行域事实';
|
||||||
CREATE TABLE IF NOT EXISTS ll_appointment_work_commitment (
|
CREATE TABLE ll_appointment_work_commitment (
|
||||||
entity_key VARCHAR(160) NOT NULL,
|
entity_key VARCHAR(160) NOT NULL COMMENT '原业务实体稳定键',
|
||||||
state_json LONGTEXT NOT NULL COMMENT '原业务命令产生的只读语义影响基线,不是第二份可编辑计划',
|
state_json LONGTEXT NOT NULL COMMENT '原业务命令产生的只读语义影响基线,不是第二份可编辑计划',
|
||||||
impact_version BIGINT NOT NULL DEFAULT 0,
|
impact_version BIGINT NOT NULL DEFAULT 0 COMMENT '承诺影响单调递增版本',
|
||||||
PRIMARY KEY (entity_key)
|
PRIMARY KEY (entity_key)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工作事项承诺影响审计基线';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工作事项承诺影响审计基线';
|
||||||
CREATE TABLE IF NOT EXISTS ll_appointment_work_guard (
|
CREATE TABLE ll_appointment_work_guard (
|
||||||
id INT NOT NULL COMMENT '工作事项协调锁,固定1',
|
id INT NOT NULL COMMENT '工作事项协调锁,固定1',
|
||||||
scan_cursor VARCHAR(128) NULL COMMENT '已开船来源同步游标',
|
scan_cursor VARCHAR(128) NULL COMMENT '已开船来源同步游标',
|
||||||
initialized BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否完成首次完整来源扫描',
|
initialized BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否完成首次完整来源扫描',
|
||||||
@@ -4562,9 +4623,9 @@ CREATE TABLE IF NOT EXISTS ll_appointment_work_guard (
|
|||||||
scan_requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '当前扫描需求版本,防跨页漂移',
|
scan_requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '当前扫描需求版本,防跨页漂移',
|
||||||
PRIMARY KEY (id)
|
PRIMARY KEY (id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项同步与命令串行守卫';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项同步与命令串行守卫';
|
||||||
INSERT INTO ll_appointment_work_guard(id,initialized) SELECT 1,FALSE WHERE NOT EXISTS(SELECT 1 FROM ll_appointment_work_guard WHERE id=1);
|
INSERT INTO ll_appointment_work_guard (id,initialized) VALUES (1,FALSE);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_appointment_work (
|
CREATE TABLE ll_appointment_work (
|
||||||
work_item_key VARCHAR(96) NOT NULL COMMENT '不可变事项键,业务身份确定性生成',
|
work_item_key VARCHAR(96) NOT NULL COMMENT '不可变事项键,业务身份确定性生成',
|
||||||
container_booking_id VARCHAR(64) NOT NULL COMMENT '来源柜次内部关联标识,不作展示编号',
|
container_booking_id VARCHAR(64) NOT NULL COMMENT '来源柜次内部关联标识,不作展示编号',
|
||||||
version INT NOT NULL DEFAULT 0 COMMENT '工作命令乐观锁版本',
|
version INT NOT NULL DEFAULT 0 COMMENT '工作命令乐观锁版本',
|
||||||
@@ -4574,7 +4635,7 @@ CREATE TABLE IF NOT EXISTS ll_appointment_work (
|
|||||||
KEY idx_appointment_work_container (container_booking_id,retired)
|
KEY idx_appointment_work_container (container_booking_id,retired)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项需求与关系';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项需求与关系';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_appointment_work_history (
|
CREATE TABLE ll_appointment_work_history (
|
||||||
id VARCHAR(36) NOT NULL COMMENT '历史记录标识',
|
id VARCHAR(36) NOT NULL COMMENT '历史记录标识',
|
||||||
work_item_key VARCHAR(96) NOT NULL COMMENT '事项稳定键',
|
work_item_key VARCHAR(96) NOT NULL COMMENT '事项稳定键',
|
||||||
operator_id VARCHAR(64) NOT NULL COMMENT '真实操作人员标识',
|
operator_id VARCHAR(64) NOT NULL COMMENT '真实操作人员标识',
|
||||||
@@ -4590,4 +4651,65 @@ CREATE TABLE IF NOT EXISTS ll_appointment_work_history (
|
|||||||
KEY idx_appointment_work_history (work_item_key,occurred_at)
|
KEY idx_appointment_work_history (work_item_key,occurred_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令';
|
||||||
|
|
||||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 208 AS initialized_tables;
|
-- 海外业务短单号分配时钟。
|
||||||
|
CREATE TABLE ll_compact_number_clock (
|
||||||
|
number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写',
|
||||||
|
last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒',
|
||||||
|
PRIMARY KEY (number_key)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟';
|
||||||
|
|
||||||
|
-- 仓储转运上门取货表归尾程服务,仅用于空库初始化。
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
CREATE TABLE ll_fleet_pickup_plan (
|
||||||
|
trip_no VARCHAR(64) NOT NULL COMMENT '现有车辆车次号',
|
||||||
|
request_hash VARCHAR(64) NOT NULL COMMENT '创建来源与计划摘要',
|
||||||
|
PRIMARY KEY(trip_no)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运取货计划';
|
||||||
|
|
||||||
|
CREATE TABLE ll_fleet_pickup_task (
|
||||||
|
transfer_order_id BIGINT NOT NULL COMMENT '原仓储转运单ID,一票唯一',
|
||||||
|
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键',
|
||||||
|
transfer_no VARCHAR(64) NOT NULL COMMENT '原仓储转运单号',
|
||||||
|
trip_no VARCHAR(64) NULL COMMENT '当前取货车次,解除后为空',
|
||||||
|
status VARCHAR(32) NOT NULL COMMENT 'WAITING待安排 ASSIGNED已安排 PICKED已取货 ARRIVED已送仓',
|
||||||
|
actual_packages INT NULL COMMENT '司机实提箱数,非仓库实收',
|
||||||
|
picked_at DATETIME NULL COMMENT '实际确认取货UTC时间',
|
||||||
|
arrived_at DATETIME NULL COMMENT '实际确认送仓UTC时间',
|
||||||
|
evidence_json LONGTEXT NULL COMMENT '本票已确认凭证元数据',
|
||||||
|
remark VARCHAR(1000) NULL COMMENT '取货备注',
|
||||||
|
updated_at DATETIME NOT NULL COMMENT '最近修改UTC时间',
|
||||||
|
PRIMARY KEY(transfer_order_id),
|
||||||
|
UNIQUE KEY uk_pickup_source(customer_id,transfer_no),
|
||||||
|
KEY idx_pickup_trip(trip_no,status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运逐票取货执行';
|
||||||
|
|
||||||
|
CREATE TABLE ll_fleet_pickup_command (
|
||||||
|
trip_no VARCHAR(64) NOT NULL COMMENT '取货车次号',
|
||||||
|
request_key VARCHAR(128) NOT NULL COMMENT '成功命令幂等键',
|
||||||
|
request_hash VARCHAR(64) NOT NULL COMMENT '命令动作与内容摘要',
|
||||||
|
PRIMARY KEY(trip_no,request_key)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货命令幂等记录';
|
||||||
|
|
||||||
|
CREATE TABLE ll_fleet_pickup_upload (
|
||||||
|
transfer_order_id BIGINT NOT NULL COMMENT '凭证所属原单',
|
||||||
|
trip_no VARCHAR(64) NOT NULL COMMENT '凭证所属车次',
|
||||||
|
file_id BIGINT NOT NULL COMMENT '平台归属文件ID',
|
||||||
|
PRIMARY KEY(file_id),
|
||||||
|
KEY idx_pickup_upload_source(transfer_order_id,trip_no)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货上传凭证精确单据归属';
|
||||||
|
|
||||||
|
CREATE TABLE ll_fleet_pickup_history (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '操作流水',
|
||||||
|
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键',
|
||||||
|
transfer_no VARCHAR(64) NOT NULL COMMENT '原转运单号',
|
||||||
|
trip_no VARCHAR(64) NOT NULL COMMENT '当时取货车次号',
|
||||||
|
action VARCHAR(32) NOT NULL COMMENT '安排解除取货到仓动作',
|
||||||
|
operator_id VARCHAR(64) NOT NULL COMMENT '操作人',
|
||||||
|
occurred_at DATETIME NOT NULL COMMENT '操作UTC时间',
|
||||||
|
detail VARCHAR(2000) NULL COMMENT '操作说明',
|
||||||
|
PRIMARY KEY(id),
|
||||||
|
KEY idx_pickup_history_source(customer_id,transfer_no,id),
|
||||||
|
KEY idx_pickup_history_trip(trip_no,id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
|
||||||
|
|
||||||
|
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 220 AS initialized_tables;
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# File Service 模块指引
|
# File Service 模块指引
|
||||||
|
|
||||||
|
## 数据库访问与中文注释约束
|
||||||
|
|
||||||
|
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||||
|
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||||
|
|
||||||
## 职责与边界
|
## 职责与边界
|
||||||
|
|
||||||
- Mapper 只由 `FileServiceApplication.java` 对 `com.baiyue.file` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
|
- Mapper 只由 `FileServiceApplication.java` 对 `com.baiyue.file` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Finance Service Instructions
|
# Finance Service Instructions
|
||||||
|
|
||||||
|
## 数据库访问与中文注释约束
|
||||||
|
|
||||||
|
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||||
|
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||||
|
|
||||||
## 模块定位
|
## 模块定位
|
||||||
|
|
||||||
- 本模块是独立财务服务,拥有客户结算条款、财务费用来源、CNY 账单与明细、客户资金账户及充值申请、应收款月度统计、客户对账确认、收款及分配、成本及分摊、账单修订、单据编号、幂等记录和财务审计状态。
|
- 本模块是独立财务服务,拥有客户结算条款、财务费用来源、CNY 账单与明细、客户资金账户及充值申请、应收款月度统计、客户对账确认、收款及分配、成本及分摊、账单修订、单据编号、幂等记录和财务审计状态。
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Firstleg Service 模块指引
|
# Firstleg Service 模块指引
|
||||||
|
|
||||||
|
## 数据库访问与中文注释约束
|
||||||
|
|
||||||
|
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||||
|
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||||
|
|
||||||
## 职责与边界
|
## 职责与边界
|
||||||
|
|
||||||
- Mapper 统一显式使用 `@Mapper`,仅由 `BaiyueFirstlegApplication.java` 对 `com.baiyue.firstleg` 根包按 `annotationClass = Mapper.class` 扫描;禁止恢复业务子域 Mapper 包白名单。静态门禁见根 `scripts/mapper-scan-contract.mjs`,模块内编译门禁见 `MapperRegistrationArchitectureTest.java`、`BaiyueFirstlegApplicationContractTest.java`。
|
- Mapper 统一显式使用 `@Mapper`,仅由 `BaiyueFirstlegApplication.java` 对 `com.baiyue.firstleg` 根包按 `annotationClass = Mapper.class` 扫描;禁止恢复业务子域 Mapper 包白名单。静态门禁见根 `scripts/mapper-scan-contract.mjs`,模块内编译门禁见 `MapperRegistrationArchitectureTest.java`、`BaiyueFirstlegApplicationContractTest.java`。
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import jakarta.validation.constraints.NotBlank;
|
|||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Pattern;
|
import jakarta.validation.constraints.Pattern;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
|
import jakarta.validation.constraints.DecimalMin;
|
||||||
|
import jakarta.validation.constraints.Digits;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
/** 费用项目请求模型。 */
|
/** 费用项目请求模型。 */
|
||||||
public final class FeeItemDtos {
|
public final class FeeItemDtos {
|
||||||
@@ -21,26 +24,9 @@ public final class FeeItemDtos {
|
|||||||
@NotNull Boolean warehousePayable,
|
@NotNull Boolean warehousePayable,
|
||||||
@NotNull Boolean warehouseReceivable,
|
@NotNull Boolean warehouseReceivable,
|
||||||
@NotNull Boolean allocationFee,
|
@NotNull Boolean allocationFee,
|
||||||
@Size(max = 500) String description
|
@Size(max = 500) String description,
|
||||||
|
@DecimalMin("0.00") @Digits(integer = 10, fraction = 2) BigDecimal defaultPrice
|
||||||
) {
|
) {
|
||||||
public SaveRequest(String category, String tradeScope, String feeCode,
|
|
||||||
String feeTypeName, Boolean pickupPayable,
|
|
||||||
Boolean pickupReceivable, Boolean warehousePayable,
|
|
||||||
Boolean warehouseReceivable, Boolean allocationFee,
|
|
||||||
String description) {
|
|
||||||
this(category, tradeScope, feeCode, null, feeTypeName, pickupPayable,
|
|
||||||
pickupReceivable, warehousePayable, warehouseReceivable,
|
|
||||||
allocationFee, description);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Backward-compatible constructor for callers predating the stable fee code. */
|
|
||||||
public SaveRequest(String category, String tradeScope, String feeTypeName,
|
|
||||||
Boolean pickupPayable, Boolean pickupReceivable,
|
|
||||||
Boolean warehousePayable, Boolean warehouseReceivable,
|
|
||||||
Boolean allocationFee, String description) {
|
|
||||||
this(category, tradeScope, null, null, feeTypeName, pickupPayable, pickupReceivable,
|
|
||||||
warehousePayable, warehouseReceivable, allocationFee, description);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public record StatusRequest(
|
public record StatusRequest(
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ public final class MerchantWorkbenchFilters {
|
|||||||
private MerchantWorkbenchFilters() {}
|
private MerchantWorkbenchFilters() {}
|
||||||
|
|
||||||
public record Request(@NotNull @Positive Long tenantId,
|
public record Request(@NotNull @Positive Long tenantId,
|
||||||
@Pattern(regexp = "WAIT_MEASURE|WAIT_PALLETIZE|WAIT_SPLIT|LOADING|LOADED|DELIVERED") String mainStatus) {}
|
@Pattern(regexp = "WAIT_MEASURE|WAIT_PALLETIZE|WAIT_SPLIT|LOADING|LOADED|DELIVERED") String mainStatus,
|
||||||
|
@jakarta.validation.constraints.Size(min=1,max=200) List<@NotNull @Positive Long> orderIds) {}
|
||||||
|
|
||||||
public record Response(List<Long> matchingStatusOrderIds, List<Long> feeOrderIds,
|
public record Response(List<Long> matchingStatusOrderIds, List<Long> feeOrderIds,
|
||||||
List<Long> customsOrderIds, List<Progress> progress) {}
|
List<Long> customsOrderIds, List<Progress> progress) {}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package com.baiyue.firstleg.entity;
|
package com.baiyue.firstleg.entity;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
import com.baiyue.common.core.model.BaseEntity;
|
import com.baiyue.common.core.model.BaseEntity;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
/** 费用项目主数据。 */
|
/** 费用项目主数据。 */
|
||||||
@Getter
|
@Getter
|
||||||
@@ -20,6 +23,9 @@ public class FeeItemEntity extends BaseEntity {
|
|||||||
private String feeCode;
|
private String feeCode;
|
||||||
private String supplierCategory;
|
private String supplierCategory;
|
||||||
private String feeTypeName;
|
private String feeTypeName;
|
||||||
|
/** 业务选费时带入的默认价格,可清空且不限制实际成交金额。 */
|
||||||
|
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||||
|
private BigDecimal defaultPrice;
|
||||||
private Boolean pickupPayable;
|
private Boolean pickupPayable;
|
||||||
private Boolean pickupReceivable;
|
private Boolean pickupReceivable;
|
||||||
private Boolean warehousePayable;
|
private Boolean warehousePayable;
|
||||||
|
|||||||
+12
-2
@@ -65,6 +65,7 @@ public interface MerchantWorkbenchFilterMapper {
|
|||||||
List<Long> pendingCustoms(@Param("tenantId") long tenantId);
|
List<Long> pendingCustoms(@Param("tenantId") long tenantId);
|
||||||
/** Only physically loaded fragments can expose container milestones to the customer. */
|
/** Only physically loaded fragments can expose container milestones to the customer. */
|
||||||
@Select("""
|
@Select("""
|
||||||
|
<script>
|
||||||
SELECT o.order_id, CASE WHEN owned.container_booking_id IS NULL THEN 0
|
SELECT o.order_id, CASE WHEN owned.container_booking_id IS NULL THEN 0
|
||||||
WHEN EXISTS (SELECT 1 FROM fl_carrier_schedule_node_fact n
|
WHEN EXISTS (SELECT 1 FROM fl_carrier_schedule_node_fact n
|
||||||
WHERE n.container_booking_id=owned.container_booking_id
|
WHERE n.container_booking_id=owned.container_booking_id
|
||||||
@@ -76,6 +77,11 @@ public interface MerchantWorkbenchFilterMapper {
|
|||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT DISTINCT i.inbound_id, i.account_id, p.id AS loading_plan_id, p.container_booking_id
|
SELECT DISTINCT i.inbound_id, i.account_id, p.id AS loading_plan_id, p.container_booking_id
|
||||||
FROM fl_loading_plan_item i
|
FROM fl_loading_plan_item i
|
||||||
|
<if test="orderIds != null">
|
||||||
|
JOIN fl_inbound_order selected ON selected.id=i.inbound_id AND selected.tenant_id=i.account_id
|
||||||
|
AND selected.deleted=0 AND selected.order_id IN
|
||||||
|
<foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||||
|
</if>
|
||||||
JOIN fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status='COMPLETED'
|
JOIN fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status='COMPLETED'
|
||||||
JOIN fl_loading_pick_task t ON t.loading_plan_id=p.id AND t.task_status='COMPLETED'
|
JOIN fl_loading_pick_task t ON t.loading_plan_id=p.id AND t.task_status='COMPLETED'
|
||||||
JOIN fl_loading_pick_item picked ON picked.pick_task_id=t.id AND picked.loading_plan_item_id=i.id
|
JOIN fl_loading_pick_item picked ON picked.pick_task_id=t.id AND picked.loading_plan_item_id=i.id
|
||||||
@@ -84,8 +90,12 @@ public interface MerchantWorkbenchFilterMapper {
|
|||||||
AND picked.item_status IN ('PICKED','PARTIAL') AND picked.picked_package_count>0
|
AND picked.item_status IN ('PICKED','PARTIAL') AND picked.picked_package_count>0
|
||||||
) owned ON owned.inbound_id=o.id AND owned.account_id=o.tenant_id
|
) owned ON owned.inbound_id=o.id AND owned.account_id=o.tenant_id
|
||||||
WHERE o.tenant_id=#{tenantId} AND o.deleted=0 AND o.order_id IS NOT NULL
|
WHERE o.tenant_id=#{tenantId} AND o.deleted=0 AND o.order_id IS NOT NULL
|
||||||
AND o.process_status <> 'CANCELLED'
|
AND o.process_status <> 'CANCELLED'
|
||||||
|
<if test="orderIds != null">
|
||||||
|
AND o.order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||||
|
</if>
|
||||||
ORDER BY o.order_id
|
ORDER BY o.order_id
|
||||||
|
</script>
|
||||||
""")
|
""")
|
||||||
List<com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Fragment> progressFragments(@Param("tenantId") long tenantId);
|
List<com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Fragment> progressFragments(@Param("tenantId") long tenantId, @Param("orderIds") List<Long> orderIds);
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-7
@@ -10,8 +10,12 @@ public interface AppointmentWorkSourceMapper {
|
|||||||
String destinationWarehouseName, LocalDateTime departedLocalAt, LocalDateTime sailedEvidenceLocalAt, int version, long factVersion, long deliveryVersion) { }
|
String destinationWarehouseName, LocalDateTime departedLocalAt, LocalDateTime sailedEvidenceLocalAt, int version, long factVersion, long deliveryVersion) { }
|
||||||
record Cargo(long inboundId, long customerId, long orderId, String orderNo, String inboundCode,
|
record Cargo(long inboundId, long customerId, long orderId, String orderNo, String inboundCode,
|
||||||
String destinationKind, String destinationCode, String destinationName, int packageCount) { }
|
String destinationKind, String destinationCode, String destinationName, int packageCount) { }
|
||||||
|
record ContainerCargo(long containerBookingId,long inboundId,long customerId,long orderId,String orderNo,String inboundCode,
|
||||||
|
String destinationKind,String destinationCode,String destinationName,int packageCount) {
|
||||||
|
public Cargo cargo() { return new Cargo(inboundId,customerId,orderId,orderNo,inboundCode,destinationKind,destinationCode,destinationName,packageCount); }
|
||||||
|
}
|
||||||
|
|
||||||
String SOURCES = """
|
String SOURCE_ROWS = """
|
||||||
SELECT b.id,b.cabinet_no,b.container_no,b.departure_port_name,b.destination_warehouse_name,
|
SELECT b.id,b.cabinet_no,b.container_no,b.departure_port_name,b.destination_warehouse_name,
|
||||||
MIN(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT' THEN f.actual_at END) departed_local_at,
|
MIN(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT' THEN f.actual_at END) departed_local_at,
|
||||||
MIN(f.actual_at) sailed_evidence_local_at,
|
MIN(f.actual_at) sailed_evidence_local_at,
|
||||||
@@ -21,23 +25,40 @@ public interface AppointmentWorkSourceMapper {
|
|||||||
WHERE b.record_status='ACTIVE' AND f.actual_at IS NOT NULL
|
WHERE b.record_status='ACTIVE' AND f.actual_at IS NOT NULL
|
||||||
AND f.standard_node IN ('DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED_DESTINATION_PORT',
|
AND f.standard_node IN ('DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED_DESTINATION_PORT',
|
||||||
'DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')
|
'DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')
|
||||||
|
""";
|
||||||
|
String SOURCE_ORDER = """
|
||||||
GROUP BY b.id,b.cabinet_no,b.container_no,b.departure_port_name,b.destination_warehouse_name,b.version
|
GROUP BY b.id,b.cabinet_no,b.container_no,b.departure_port_name,b.destination_warehouse_name,b.version
|
||||||
ORDER BY b.id
|
ORDER BY b.id
|
||||||
""";
|
""";
|
||||||
// A complete identity/version manifest also detects deletions or edits between cursor pages.
|
// A complete identity/version manifest also detects deletions or edits between cursor pages.
|
||||||
@Select(SOURCES)
|
@Select(SOURCE_ROWS + SOURCE_ORDER)
|
||||||
List<Source> sources();
|
List<Source> sources();
|
||||||
|
|
||||||
|
/** Same live eligibility and aggregate facts as the manifest, restricted before aggregation. */
|
||||||
|
@Select("<script>" + SOURCE_ROWS + """
|
||||||
|
<choose>
|
||||||
|
<when test="ids != null and !ids.isEmpty()">
|
||||||
|
AND b.id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
|
||||||
|
</when>
|
||||||
|
<otherwise>AND 1=0</otherwise>
|
||||||
|
</choose>
|
||||||
|
""" + SOURCE_ORDER + "</script>")
|
||||||
|
List<Source> sourcesByIds(@Param("ids") List<Long> ids);
|
||||||
|
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT i.inbound_id, i.account_id customer_id, i.order_id, i.order_no,i.inbound_code,
|
<script>
|
||||||
|
SELECT p.container_booking_id,i.inbound_id, i.account_id customer_id, i.order_id, i.order_no,i.inbound_code,
|
||||||
o.destination_type destination_kind,i.destination_warehouse_code destination_code,
|
o.destination_type destination_kind,i.destination_warehouse_code destination_code,
|
||||||
i.destination_warehouse_name destination_name,SUM(i.package_count) package_count
|
i.destination_warehouse_name destination_name,SUM(i.package_count) package_count
|
||||||
FROM fl_loading_plan p JOIN fl_loading_plan_item i ON i.loading_plan_id=p.id AND i.deleted=0
|
FROM fl_loading_plan p JOIN fl_loading_plan_item i ON i.loading_plan_id=p.id AND i.deleted=0
|
||||||
JOIN fl_inbound_order o ON o.id=i.inbound_id
|
JOIN fl_inbound_order o ON o.id=i.inbound_id
|
||||||
WHERE p.container_booking_id=#{id} AND p.plan_status='COMPLETED'
|
WHERE p.container_booking_id IN
|
||||||
GROUP BY i.inbound_id,i.account_id,i.order_id,i.order_no,i.inbound_code,o.destination_type,
|
<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
|
||||||
|
AND p.plan_status='COMPLETED'
|
||||||
|
GROUP BY p.container_booking_id,i.inbound_id,i.account_id,i.order_id,i.order_no,i.inbound_code,o.destination_type,
|
||||||
i.destination_warehouse_code,i.destination_warehouse_name
|
i.destination_warehouse_code,i.destination_warehouse_name
|
||||||
ORDER BY i.inbound_id
|
ORDER BY p.container_booking_id,i.inbound_id
|
||||||
|
</script>
|
||||||
""")
|
""")
|
||||||
List<Cargo> cargo(@Param("id") long id);
|
List<ContainerCargo> cargo(@Param("ids") List<Long> ids);
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-12
@@ -13,8 +13,8 @@ import java.util.*;
|
|||||||
@Service
|
@Service
|
||||||
public class AppointmentWorkSourceService {
|
public class AppointmentWorkSourceService {
|
||||||
public interface OriginalFacts {
|
public interface OriginalFacts {
|
||||||
String establishedFlow(long containerBookingId);
|
Map<Long,String> establishedFlows(List<Long> containerBookingIds);
|
||||||
Fact read(FactRequest request, ContainerSource source);
|
List<Fact> read(List<FactRequest> requests, Map<Long,ContainerSource> sources);
|
||||||
}
|
}
|
||||||
private final AppointmentWorkSourceMapper mapper;
|
private final AppointmentWorkSourceMapper mapper;
|
||||||
private final OriginalFacts original;
|
private final OriginalFacts original;
|
||||||
@@ -37,14 +37,23 @@ public class AppointmentWorkSourceService {
|
|||||||
var page=rows.stream().filter(row->row.id()>cursor).limit(size+1L).toList();
|
var page=rows.stream().filter(row->row.id()>cursor).limit(size+1L).toList();
|
||||||
boolean complete=page.size()<=size;
|
boolean complete=page.size()<=size;
|
||||||
var selected=page.stream().limit(size).toList();
|
var selected=page.stream().limit(size).toList();
|
||||||
return new SourcePage(selected.stream().map(this::source).toList(),complete?null:scope+":"+selected.get(selected.size()-1).id(),complete,scope);
|
return new SourcePage(sources(selected),complete?null:scope+":"+selected.get(selected.size()-1).id(),complete,scope);
|
||||||
}
|
}
|
||||||
public ContainerSource container(long id) {
|
public ContainerSource container(long id) {
|
||||||
return mapper.sources().stream().filter(row->row.id()==id).findFirst().map(this::source)
|
var row=mapper.sources().stream().filter(candidate->candidate.id()==id).findFirst()
|
||||||
.orElseThrow(()->new ResponseStatusException(HttpStatus.NOT_FOUND,"未找到有效已开船柜次"));
|
.orElseThrow(()->new ResponseStatusException(HttpStatus.NOT_FOUND,"未找到有效已开船柜次"));
|
||||||
|
return sources(List.of(row)).get(0);
|
||||||
}
|
}
|
||||||
private ContainerSource source(AppointmentWorkSourceMapper.Source row) {
|
private List<ContainerSource> sources(List<AppointmentWorkSourceMapper.Source> rows) {
|
||||||
var cargo=mapper.cargo(row.id());
|
if(rows.isEmpty()) return List.of();
|
||||||
|
var ids=rows.stream().map(AppointmentWorkSourceMapper.Source::id).toList();
|
||||||
|
var cargo=new HashMap<Long,List<AppointmentWorkSourceMapper.Cargo>>();
|
||||||
|
for(var item:mapper.cargo(ids)) cargo.computeIfAbsent(item.containerBookingId(),ignored->new ArrayList<>()).add(item.cargo());
|
||||||
|
var flows=original.establishedFlows(ids);
|
||||||
|
return rows.stream().map(row->source(row,cargo.getOrDefault(row.id(),List.of()),flows.get(row.id()))).toList();
|
||||||
|
}
|
||||||
|
private ContainerSource source(AppointmentWorkSourceMapper.Source row,List<AppointmentWorkSourceMapper.Cargo> cargo,String flow) {
|
||||||
|
if(flow==null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"柜次路线事实读取不完整");
|
||||||
var requirements=cargo.stream().map(c->new Requirement("INBOUND:"+c.inboundId(),null,null,
|
var requirements=cargo.stream().map(c->new Requirement("INBOUND:"+c.inboundId(),null,null,
|
||||||
c.destinationKind(),row.departurePortName(),c.destinationName(),null,
|
c.destinationKind(),row.departurePortName(),c.destinationName(),null,
|
||||||
List.of(new Reference("CUSTOMER",Long.toString(c.customerId()),null,"所属客户"),
|
List.of(new Reference("CUSTOMER",Long.toString(c.customerId()),null,"所属客户"),
|
||||||
@@ -52,18 +61,22 @@ public class AppointmentWorkSourceService {
|
|||||||
new Reference("INBOUND",Long.toString(c.inboundId()),c.inboundCode(),"入仓单"),
|
new Reference("INBOUND",Long.toString(c.inboundId()),c.inboundCode(),"入仓单"),
|
||||||
new Reference("CONTAINER",Long.toString(row.id()),row.cabinetNo(),"柜次")),List.of(),List.of())).toList();
|
new Reference("CONTAINER",Long.toString(row.id()),row.cabinetNo(),"柜次")),List.of(),List.of())).toList();
|
||||||
return new ContainerSource(Long.toString(row.id()),row.containerNo(),null,row.departedLocalAt(),row.sailedEvidenceLocalAt(),
|
return new ContainerSource(Long.toString(row.id()),row.containerNo(),null,row.departedLocalAt(),row.sailedEvidenceLocalAt(),
|
||||||
hash(List.of(row,cargo)),original.establishedFlow(row.id()),null,requirements);
|
hash(List.of(row,cargo)),flow,null,requirements);
|
||||||
}
|
}
|
||||||
public List<Fact> facts(List<FactRequest> requests) {
|
public List<Fact> facts(List<FactRequest> requests) {
|
||||||
if(requests==null || requests.isEmpty() || requests.size()>200) throw bad("事实请求须包含1至200项");
|
if(requests==null || requests.isEmpty() || requests.size()>200) throw bad("事实请求须包含1至200项");
|
||||||
Set<String> keys=new HashSet<>(); Map<Long,ContainerSource> sources=new HashMap<>();
|
Set<String> keys=new HashSet<>(); var ids=new LinkedHashSet<Long>();
|
||||||
return requests.stream().map(request->{
|
for(var request:requests) {
|
||||||
if(request==null || request.workItemKey()==null || request.workItemKey().isBlank() || !keys.add(request.workItemKey())
|
if(request==null || request.workItemKey()==null || request.workItemKey().isBlank() || !keys.add(request.workItemKey())
|
||||||
|| !("PICKUP".equals(request.type()) || "EMPTY_RETURN".equals(request.type()) || "DELIVERY".equals(request.type()) && "DIRECT".equals(request.flow())))
|
|| !("PICKUP".equals(request.type()) || "EMPTY_RETURN".equals(request.type()) || "DELIVERY".equals(request.type()) && "DIRECT".equals(request.flow())))
|
||||||
throw bad("事项身份或事实类型无效");
|
throw bad("事项身份或事实类型无效");
|
||||||
long id=id(request.containerBookingId());
|
ids.add(id(request.containerBookingId()));
|
||||||
return original.read(request,sources.computeIfAbsent(id,this::container));
|
}
|
||||||
}).toList();
|
var selected=mapper.sourcesByIds(List.copyOf(ids));
|
||||||
|
if(selected.size()!=ids.size()) throw new ResponseStatusException(HttpStatus.NOT_FOUND,"未找到有效已开船柜次");
|
||||||
|
var sources=new LinkedHashMap<Long,ContainerSource>();
|
||||||
|
sources(selected).forEach(source->sources.put(Long.parseLong(source.containerBookingId()),source));
|
||||||
|
return original.read(requests,sources);
|
||||||
}
|
}
|
||||||
public String hash(Object value) {
|
public String hash(Object value) {
|
||||||
try {return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value)));}
|
try {return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value)));}
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ public class FeeItemService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 保存项目属性及可清空的默认价格,实际费用仍以业务录入金额为准。 */
|
||||||
private static void copy(FeeItemEntity value, SaveRequest request) {
|
private static void copy(FeeItemEntity value, SaveRequest request) {
|
||||||
boolean payable = Boolean.TRUE.equals(request.pickupPayable())
|
boolean payable = Boolean.TRUE.equals(request.pickupPayable())
|
||||||
|| Boolean.TRUE.equals(request.warehousePayable());
|
|| Boolean.TRUE.equals(request.warehousePayable());
|
||||||
@@ -111,6 +112,7 @@ public class FeeItemService {
|
|||||||
value.setSupplierCategory(payable
|
value.setSupplierCategory(payable
|
||||||
? FeeItemSupplierCategory.normalize(request.supplierCategory()) : null);
|
? FeeItemSupplierCategory.normalize(request.supplierCategory()) : null);
|
||||||
value.setFeeTypeName(request.feeTypeName().trim());
|
value.setFeeTypeName(request.feeTypeName().trim());
|
||||||
|
value.setDefaultPrice(request.defaultPrice());
|
||||||
value.setPickupPayable(request.pickupPayable());
|
value.setPickupPayable(request.pickupPayable());
|
||||||
value.setPickupReceivable(request.pickupReceivable());
|
value.setPickupReceivable(request.pickupReceivable());
|
||||||
value.setWarehousePayable(request.warehousePayable());
|
value.setWarehousePayable(request.warehousePayable());
|
||||||
|
|||||||
+5
-1
@@ -12,11 +12,15 @@ public class MerchantWorkbenchFilterService {
|
|||||||
|
|
||||||
@org.springframework.transaction.annotation.Transactional(readOnly=true)
|
@org.springframework.transaction.annotation.Transactional(readOnly=true)
|
||||||
public MerchantWorkbenchFilters.Response read(MerchantWorkbenchFilters.Request request) {
|
public MerchantWorkbenchFilters.Response read(MerchantWorkbenchFilters.Request request) {
|
||||||
|
if (request.orderIds() != null) {
|
||||||
|
return new MerchantWorkbenchFilters.Response(null, java.util.List.of(), java.util.List.of(),
|
||||||
|
progress(mapper.progressFragments(request.tenantId(), request.orderIds())));
|
||||||
|
}
|
||||||
return new MerchantWorkbenchFilters.Response(
|
return new MerchantWorkbenchFilters.Response(
|
||||||
request.mainStatus() == null ? null : mapper.byProcessStatuses(request.tenantId(),
|
request.mainStatus() == null ? null : mapper.byProcessStatuses(request.tenantId(),
|
||||||
unified.internalStatuses(request.mainStatus())),
|
unified.internalStatuses(request.mainStatus())),
|
||||||
mapper.pendingFees(request.tenantId()), mapper.pendingCustoms(request.tenantId()),
|
mapper.pendingFees(request.tenantId()), mapper.pendingCustoms(request.tenantId()),
|
||||||
progress(mapper.progressFragments(request.tenantId())));
|
progress(mapper.progressFragments(request.tenantId(), null)));
|
||||||
}
|
}
|
||||||
|
|
||||||
static java.util.List<MerchantWorkbenchFilters.Progress> progress(java.util.List<MerchantWorkbenchFilters.Fragment> facts) {
|
static java.util.List<MerchantWorkbenchFilters.Progress> progress(java.util.List<MerchantWorkbenchFilters.Fragment> facts) {
|
||||||
|
|||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
package com.baiyue.firstleg.terminaldrayage.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||||
|
import com.baiyue.firstleg.terminaldrayage.service.ContainerCalendarSourceService;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/platform/v1/terminal-drayages")
|
||||||
|
public class ContainerCalendarSourceController {
|
||||||
|
private final ContainerCalendarSourceService service;
|
||||||
|
/** 装配只读日历业务服务。 */
|
||||||
|
public ContainerCalendarSourceController(ContainerCalendarSourceService service) { this.service = service; }
|
||||||
|
/** 预约列表可读基础柜信息,还柜事实继续核验原业务读取权限。 */
|
||||||
|
@GetMapping("/calendar-sources")
|
||||||
|
@PreAuthorize("hasAuthority('platform:appointment:list')")
|
||||||
|
public ApiResponse<ContainerCalendarSourceService.Result> calendar(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
|
||||||
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
|
||||||
|
FirstlegScopeGuard.platform(user);
|
||||||
|
return ApiResponse.success(service.calendar(from, to, user.permissions()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package com.baiyue.firstleg.terminaldrayage.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 四类日历使用的柜级来源,日期均保留原业务当地值。 */
|
||||||
|
public record ContainerCalendarSource(Long containerBookingId, String cabinetNo, String containerNo,
|
||||||
|
String vesselName, String voyageNo, String destinationPortName, String destinationWarehouseCode,
|
||||||
|
LocalDateTime arrivalAt, String arrivalType) { }
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
package com.baiyue.firstleg.terminaldrayage.mapper;
|
||||||
|
|
||||||
|
import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface ContainerCalendarSourceMapper {
|
||||||
|
/** 同时选出到港、缺日期和可能还柜的有效出仓柜,最终日期由业务服务复核。 */
|
||||||
|
@Select("""
|
||||||
|
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
|
||||||
|
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName,
|
||||||
|
c.destination_warehouse_code destinationWarehouseCode,
|
||||||
|
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
|
||||||
|
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
|
||||||
|
FROM fl_container_booking c
|
||||||
|
LEFT JOIN fl_terminal_drayage t ON t.container_booking_id=c.id
|
||||||
|
LEFT JOIN fl_carrier_schedule_node_fact n ON n.id=(
|
||||||
|
SELECT f.id FROM fl_carrier_schedule_node_fact f WHERE f.container_booking_id=c.id
|
||||||
|
AND f.standard_node='ARRIVED_DESTINATION_PORT' AND f.actual_at IS NOT NULL
|
||||||
|
ORDER BY f.observed_at DESC,f.id DESC LIMIT 1)
|
||||||
|
WHERE c.record_status='ACTIVE'
|
||||||
|
AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d WHERE d.container_booking_id=c.id
|
||||||
|
AND d.confirmed_at IS NOT NULL AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING')
|
||||||
|
AND (
|
||||||
|
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) IS NULL
|
||||||
|
OR (COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at)>=#{arrivalFrom}
|
||||||
|
AND COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at)<#{to})
|
||||||
|
OR (t.actual_empty_return_at>=#{from} AND t.actual_empty_return_at<#{to})
|
||||||
|
OR (c.unloading_completed_at>=#{returnFrom} AND c.unloading_completed_at<#{to})
|
||||||
|
OR EXISTS(SELECT 1 FROM fl_carrier_schedule_node_fact r WHERE r.container_booking_id=c.id
|
||||||
|
AND r.standard_node='EMPTY_RETURNED' AND r.actual_at>=#{from} AND r.actual_at<#{to})
|
||||||
|
OR EXISTS(SELECT 1 FROM fl_direct_delivery_unloading u WHERE u.container_booking_id=c.id
|
||||||
|
AND u.actual_at>=#{returnFrom} AND u.actual_at<#{to})
|
||||||
|
)
|
||||||
|
ORDER BY c.id LIMIT 2001
|
||||||
|
""")
|
||||||
|
List<ContainerCalendarSource> sources(@Param("from") LocalDateTime from,
|
||||||
|
@Param("arrivalFrom") LocalDateTime arrivalFrom, @Param("returnFrom") LocalDateTime returnFrom,
|
||||||
|
@Param("to") LocalDateTime to);
|
||||||
|
}
|
||||||
+35
@@ -10,6 +10,41 @@ public interface DirectDeliveryMapper {
|
|||||||
int version,LocalDateTime actualAt,String timezone,String receivedBy,String evidenceNo,String remark,
|
int version,LocalDateTime actualAt,String timezone,String receivedBy,String evidenceNo,String remark,
|
||||||
String idempotencyKey,String requestHash) { }
|
String idempotencyKey,String requestHash) { }
|
||||||
record Container(long id,String recordStatus,LocalDateTime warehouseUnloadedAt) { }
|
record Container(long id,String recordStatus,LocalDateTime warehouseUnloadedAt) { }
|
||||||
|
record FlowEvidence(long id,LocalDateTime warehouseUnloadedAt,long evidenceCount) { }
|
||||||
|
record ContainerDelivery(long containerBookingId,long inboundId,String inboundCode,String orderNo,String destinationName,int packageCount,
|
||||||
|
int version,LocalDateTime actualAt,String timezone,String receivedBy,String evidenceNo,String remark,
|
||||||
|
String idempotencyKey,String requestHash) {
|
||||||
|
public Delivery delivery(){return new Delivery(inboundId,inboundCode,orderNo,destinationName,packageCount,version,
|
||||||
|
actualAt,timezone,receivedBy,evidenceNo,remark,idempotencyKey,requestHash);}
|
||||||
|
}
|
||||||
|
@Select("""
|
||||||
|
<script>
|
||||||
|
SELECT c.id,c.unloading_completed_at warehouse_unloaded_at,COUNT(d.inbound_id) evidence_count
|
||||||
|
FROM fl_container_booking c LEFT JOIN fl_direct_delivery_unloading d ON d.container_booking_id=c.id
|
||||||
|
WHERE c.id IN <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||||
|
GROUP BY c.id,c.unloading_completed_at
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
List<FlowEvidence> flowEvidence(@Param("ids") List<Long> ids);
|
||||||
|
@Select("""
|
||||||
|
<script>SELECT id,record_status,unloading_completed_at warehouse_unloaded_at FROM fl_container_booking
|
||||||
|
WHERE id IN <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach></script>
|
||||||
|
""")
|
||||||
|
List<Container> containers(@Param("ids") List<Long> ids);
|
||||||
|
@Select("""
|
||||||
|
<script>
|
||||||
|
SELECT p.container_booking_id,i.inbound_id,MAX(i.inbound_code) inbound_code,MAX(i.order_no) order_no,
|
||||||
|
MAX(i.destination_warehouse_name) destination_name,SUM(i.package_count) package_count,
|
||||||
|
COALESCE(d.version,0) version,d.actual_at,d.timezone,d.received_by,d.evidence_no,d.remark,d.idempotency_key,d.request_hash
|
||||||
|
FROM fl_loading_plan p JOIN fl_loading_plan_item i ON i.loading_plan_id=p.id AND i.deleted=0
|
||||||
|
LEFT JOIN fl_direct_delivery_unloading d ON d.container_booking_id=p.container_booking_id AND d.inbound_id=i.inbound_id
|
||||||
|
WHERE p.container_booking_id IN <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||||
|
AND p.plan_status='COMPLETED'
|
||||||
|
GROUP BY p.container_booking_id,i.inbound_id,d.version,d.actual_at,d.timezone,d.received_by,d.evidence_no,d.remark,d.idempotency_key,d.request_hash
|
||||||
|
ORDER BY p.container_booking_id,i.inbound_id
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
List<ContainerDelivery> deliveriesBatch(@Param("ids") List<Long> ids);
|
||||||
@Select("SELECT id,record_status,unloading_completed_at warehouse_unloaded_at FROM fl_container_booking WHERE id=#{id} FOR UPDATE")
|
@Select("SELECT id,record_status,unloading_completed_at warehouse_unloaded_at FROM fl_container_booking WHERE id=#{id} FOR UPDATE")
|
||||||
@Options(flushCache=Options.FlushCachePolicy.TRUE,useCache=false)
|
@Options(flushCache=Options.FlushCachePolicy.TRUE,useCache=false)
|
||||||
Container lockContainer(@Param("id") long id);
|
Container lockContainer(@Param("id") long id);
|
||||||
|
|||||||
+10
@@ -114,6 +114,16 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
|
|||||||
ORDER BY created_at DESC,id DESC
|
ORDER BY created_at DESC,id DESC
|
||||||
""")
|
""")
|
||||||
List<History> histories(@Param("containerBookingId") long containerBookingId);
|
List<History> histories(@Param("containerBookingId") long containerBookingId);
|
||||||
|
record PickupChanges(long containerBookingId,long schedules,long fleets) { }
|
||||||
|
@Select("""
|
||||||
|
<script>SELECT container_booking_id,
|
||||||
|
SUM(CASE WHEN action='SCHEDULE_PICKUP' THEN 1 ELSE 0 END) schedules,
|
||||||
|
SUM(CASE WHEN action='SELECT_FLEET' THEN 1 ELSE 0 END) fleets
|
||||||
|
FROM fl_terminal_drayage_history WHERE container_booking_id IN
|
||||||
|
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||||
|
GROUP BY container_booking_id</script>
|
||||||
|
""")
|
||||||
|
List<PickupChanges> pickupChanges(@Param("ids") List<Long> ids);
|
||||||
|
|
||||||
@Insert("""
|
@Insert("""
|
||||||
INSERT INTO fl_terminal_drayage_history(id,drayage_id,container_booking_id,action,from_status,to_status,
|
INSERT INTO fl_terminal_drayage_history(id,drayage_id,container_booking_id,action,from_status,to_status,
|
||||||
|
|||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package com.baiyue.firstleg.terminaldrayage.repository;
|
||||||
|
|
||||||
|
import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource;
|
||||||
|
import com.baiyue.firstleg.terminaldrayage.mapper.ContainerCalendarSourceMapper;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class ContainerCalendarSourceRepository {
|
||||||
|
private final ContainerCalendarSourceMapper mapper;
|
||||||
|
/** 使用本服务 Mapper 读取日历所需柜级来源。 */
|
||||||
|
public ContainerCalendarSourceRepository(ContainerCalendarSourceMapper mapper) { this.mapper = mapper; }
|
||||||
|
/** 到港前移两天覆盖默认到仓,直送还柜候选额外留一天覆盖夏令时。 */
|
||||||
|
public List<ContainerCalendarSource> sources(LocalDate from, LocalDate to) {
|
||||||
|
return mapper.sources(from.atStartOfDay(), from.minusDays(2).atStartOfDay(),
|
||||||
|
from.minusDays(4).atStartOfDay(), to.atStartOfDay());
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
-15
@@ -3,6 +3,8 @@ package com.baiyue.firstleg.terminaldrayage.service;
|
|||||||
import com.baiyue.firstleg.portschedule.dto.AppointmentWorkSourceDtos.*;
|
import com.baiyue.firstleg.portschedule.dto.AppointmentWorkSourceDtos.*;
|
||||||
import com.baiyue.firstleg.portschedule.service.AppointmentWorkSourceService.OriginalFacts;
|
import com.baiyue.firstleg.portschedule.service.AppointmentWorkSourceService.OriginalFacts;
|
||||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||||
|
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||||
|
import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity;
|
||||||
import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper;
|
import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper;
|
||||||
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper;
|
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -26,17 +28,43 @@ public class AppointmentWorkTerminalFacts implements OriginalFacts {
|
|||||||
CarrierScheduleQueryService nodes,UnloadingEvidenceGuard unloading,ObjectMapper json) {
|
CarrierScheduleQueryService nodes,UnloadingEvidenceGuard unloading,ObjectMapper json) {
|
||||||
this.terminal=terminal;this.direct=direct;this.nodes=nodes;this.unloading=unloading;this.json=json;
|
this.terminal=terminal;this.direct=direct;this.nodes=nodes;this.unloading=unloading;this.json=json;
|
||||||
}
|
}
|
||||||
public String establishedFlow(long id) {
|
public Map<Long,String> establishedFlows(List<Long> ids) {
|
||||||
var container=direct.container(id);
|
var result=new HashMap<Long,String>();
|
||||||
boolean transfer=container!=null && container.warehouseUnloadedAt()!=null;
|
if(ids.isEmpty()) return result;
|
||||||
boolean delivery=direct.evidenceCount(id)>0;
|
for(var row:direct.flowEvidence(ids)) {
|
||||||
if(transfer && delivery) throw new ResponseStatusException(HttpStatus.CONFLICT,"货柜存在冲突的中转和直送执行事实");
|
boolean transfer=row.warehouseUnloadedAt()!=null;
|
||||||
return transfer?"TRANSFER":delivery?"DIRECT":"UNKNOWN";
|
boolean delivery=row.evidenceCount()>0;
|
||||||
|
if(transfer && delivery) throw new ResponseStatusException(HttpStatus.CONFLICT,"货柜存在冲突的中转和直送执行事实");
|
||||||
|
result.put(row.id(),transfer?"TRANSFER":delivery?"DIRECT":"UNKNOWN");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
public Fact read(FactRequest request,ContainerSource source) {
|
public List<Fact> read(List<FactRequest> requests,Map<Long,ContainerSource> sources) {
|
||||||
long id=Long.parseLong(request.containerBookingId());
|
var ids=requests.stream().map(r->Long.parseLong(r.containerBookingId())).distinct().toList();
|
||||||
var node=nodes.containerActualFacts(List.of(id)).stream().findFirst().orElse(null);
|
if(ids.isEmpty()) return List.of();
|
||||||
var plan=terminal.find(id);
|
var actuals=new HashMap<Long,ContainerActualFacts>();
|
||||||
|
for(var row:nodes.containerActualFacts(ids)) actuals.put(row.containerBookingId(),row);
|
||||||
|
var plans=new HashMap<Long,TerminalDrayageEntity>();
|
||||||
|
for(var row:terminal.findAll(ids)) plans.put(row.getContainerBookingId(),row);
|
||||||
|
var pickupIds=requests.stream().filter(r->"PICKUP".equals(r.type())).map(r->Long.parseLong(r.containerBookingId())).distinct().toList();
|
||||||
|
var changes=new HashMap<Long,TerminalDrayageMapper.PickupChanges>();
|
||||||
|
if(!pickupIds.isEmpty()) for(var row:terminal.pickupChanges(pickupIds)) changes.put(row.containerBookingId(),row);
|
||||||
|
var deliveryIds=requests.stream().filter(r->"DELIVERY".equals(r.type())).map(r->Long.parseLong(r.containerBookingId())).distinct().toList();
|
||||||
|
var deliveries=new HashMap<Long,List<DirectDeliveryMapper.Delivery>>();
|
||||||
|
if(!deliveryIds.isEmpty()) for(var row:direct.deliveriesBatch(deliveryIds))
|
||||||
|
deliveries.computeIfAbsent(row.containerBookingId(),ignored->new ArrayList<>()).add(row.delivery());
|
||||||
|
var returnIds=requests.stream().filter(r->"EMPTY_RETURN".equals(r.type())).map(r->Long.parseLong(r.containerBookingId())).distinct()
|
||||||
|
.filter(id->(actuals.get(id)==null || actuals.get(id).emptyReturnedAt()==null)
|
||||||
|
&& (plans.get(id)==null || plans.get(id).getActualEmptyReturnAt()==null)).toList();
|
||||||
|
var completed=returnIds.isEmpty()?Map.<Long,LocalDateTime>of():unloading.completedUnloading(returnIds);
|
||||||
|
return requests.stream().map(request->{
|
||||||
|
long id=Long.parseLong(request.containerBookingId());
|
||||||
|
return read(request,sources.get(id),actuals.get(id),plans.get(id),changes.get(id),
|
||||||
|
deliveries.getOrDefault(id,List.of()),completed.get(id));
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
private Fact read(FactRequest request,ContainerSource source,ContainerActualFacts node,TerminalDrayageEntity plan,
|
||||||
|
TerminalDrayageMapper.PickupChanges changes,List<DirectDeliveryMapper.Delivery> deliveries,LocalDateTime completedUnloading) {
|
||||||
var references=new ArrayList<Reference>();
|
var references=new ArrayList<Reference>();
|
||||||
source.requirements().forEach(r->r.references().forEach(ref->{if(!references.contains(ref))references.add(ref);}));
|
source.requirements().forEach(r->r.references().forEach(ref->{if(!references.contains(ref))references.add(ref);}));
|
||||||
var blocks=new ArrayList<String>(); var missing=new ArrayList<String>();
|
var blocks=new ArrayList<String>(); var missing=new ArrayList<String>();
|
||||||
@@ -53,9 +81,8 @@ public class AppointmentWorkTerminalFacts implements OriginalFacts {
|
|||||||
if(plan!=null && plan.getFleetSupplierId()!=null)
|
if(plan!=null && plan.getFleetSupplierId()!=null)
|
||||||
responsibility=new Responsibility("SUPPLIER",plan.getFleetSupplierId().toString(),plan.getFleetSupplierName());
|
responsibility=new Responsibility("SUPPLIER",plan.getFleetSupplierId().toString(),plan.getFleetSupplierName());
|
||||||
// The initial arrangement/assignment establishes a baseline. Only later changes invalidate dependants.
|
// The initial arrangement/assignment establishes a baseline. Only later changes invalidate dependants.
|
||||||
var history=terminal.histories(id);
|
long reschedules=Math.max(0,(changes==null?0:changes.schedules())-1);
|
||||||
long reschedules=Math.max(0,history.stream().filter(h->"SCHEDULE_PICKUP".equals(h.action())).count()-1);
|
long reassignments=Math.max(0,(changes==null?0:changes.fleets())-1);
|
||||||
long reassignments=Math.max(0,history.stream().filter(h->"SELECT_FLEET".equals(h.action())).count()-1);
|
|
||||||
impact=reschedules+":"+reassignments;
|
impact=reschedules+":"+reassignments;
|
||||||
if(at==null) {
|
if(at==null) {
|
||||||
if(node==null || node.arrivedDestinationPortAt()==null) blocks.add("实际到港后才能安排提柜");
|
if(node==null || node.arrivedDestinationPortAt()==null) blocks.add("实际到港后才能安排提柜");
|
||||||
@@ -67,7 +94,7 @@ public class AppointmentWorkTerminalFacts implements OriginalFacts {
|
|||||||
case "EMPTY_RETURN" -> {
|
case "EMPTY_RETURN" -> {
|
||||||
at=node==null?null:node.emptyReturnedAt();
|
at=node==null?null:node.emptyReturnedAt();
|
||||||
if(at==null && plan!=null) at=plan.getActualEmptyReturnAt();
|
if(at==null && plan!=null) at=plan.getActualEmptyReturnAt();
|
||||||
if(at==null && unloading.completedUnloading(id)==null) blocks.add("海外仓卸柜或全部直送目的仓实际卸货后才能还空");
|
if(at==null && completedUnloading==null) blocks.add("海外仓卸柜或全部直送目的仓实际卸货后才能还空");
|
||||||
else if(at==null) {code="RECORD_EXECUTION";label="确认实际还空";permission="platform:firstleg:terminal-drayage:empty-return:confirm";}
|
else if(at==null) {code="RECORD_EXECUTION";label="确认实际还空";permission="platform:firstleg:terminal-drayage:empty-return:confirm";}
|
||||||
// Return responsibility belongs to the work handoff, not the historical pickup carrier.
|
// Return responsibility belongs to the work handoff, not the historical pickup carrier.
|
||||||
}
|
}
|
||||||
@@ -75,7 +102,7 @@ public class AppointmentWorkTerminalFacts implements OriginalFacts {
|
|||||||
var requirement=source.requirements().stream().filter(r->Objects.equals(r.requirementKey(),request.requirementKey())).findFirst()
|
var requirement=source.requirements().stream().filter(r->Objects.equals(r.requirementKey(),request.requirementKey())).findFirst()
|
||||||
.orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"直送需求与原柜来源不一致"));
|
.orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"直送需求与原柜来源不一致"));
|
||||||
references.clear(); references.addAll(requirement.references());
|
references.clear(); references.addAll(requirement.references());
|
||||||
var row=direct.deliveries(id).stream().filter(d->("INBOUND:"+d.inboundId()).equals(request.requirementKey())).findFirst()
|
var row=deliveries.stream().filter(d->("INBOUND:"+d.inboundId()).equals(request.requirementKey())).findFirst()
|
||||||
.orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"直送需求与实际装柜来源不一致"));
|
.orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"直送需求与实际装柜来源不一致"));
|
||||||
at=row.actualAt();zone=row.timezone();businessNo=row.inboundCode();
|
at=row.actualAt();zone=row.timezone();businessNo=row.inboundCode();
|
||||||
if(at==null && (node==null || node.pickedUpAt()==null)) blocks.add("实际提柜后才能记录直送卸货");
|
if(at==null && (node==null || node.pickedUpAt()==null)) blocks.add("实际提柜后才能记录直送卸货");
|
||||||
|
|||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
package com.baiyue.firstleg.terminaldrayage.service;
|
||||||
|
|
||||||
|
import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource;
|
||||||
|
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View;
|
||||||
|
import com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ContainerCalendarSourceService {
|
||||||
|
public record Result(List<ContainerCalendarSource> containers, List<View> emptyReturns,
|
||||||
|
boolean emptyReturnsReadable) { }
|
||||||
|
private final ContainerCalendarSourceRepository repository;
|
||||||
|
private final TerminalDrayageService drayages;
|
||||||
|
/** 复用真实还柜业务的截止时间和完成事实,不在日历另算还柜状态。 */
|
||||||
|
public ContainerCalendarSourceService(ContainerCalendarSourceRepository repository, TerminalDrayageService drayages) {
|
||||||
|
this.repository = repository; this.drayages = drayages;
|
||||||
|
}
|
||||||
|
/** 按日期范围读取到港、默认到仓来源及可见的还柜记录。 */
|
||||||
|
public Result calendar(LocalDate from, LocalDate to, Set<String> permissions) {
|
||||||
|
if (from == null || to == null || !to.isAfter(from) || ChronoUnit.DAYS.between(from, to) > 42)
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历查询范围必须为1至42天");
|
||||||
|
var sources = repository.sources(from, to);
|
||||||
|
if (sources.size() > 2000)
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历来源超过2000柜,请缩小范围或补齐未定日期");
|
||||||
|
boolean readable = permissions != null && permissions.contains("platform:firstleg:terminal-drayage:list");
|
||||||
|
var returns = new ArrayList<View>();
|
||||||
|
if (readable) {
|
||||||
|
var ids = sources.stream().map(ContainerCalendarSource::containerBookingId).toList();
|
||||||
|
for (int start = 0; start < ids.size(); start += 200) {
|
||||||
|
for (var row : drayages.authorize(drayages.views(ids.subList(start, Math.min(start + 200, ids.size()))), permissions)) {
|
||||||
|
var at = row.actualEmptyReturnAt() != null ? row.actualEmptyReturnAt() : row.emptyReturnWarning().deadlineAt();
|
||||||
|
if (at != null && !at.toLocalDate().isBefore(from) && at.toLocalDate().isBefore(to)) returns.add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 未授权还柜读取时,也不能通过仅因还柜日期命中的旧柜候选推断受保护事实。
|
||||||
|
var visibleSources = readable ? sources : sources.stream().filter(row -> row.arrivalAt() == null
|
||||||
|
|| !row.arrivalAt().toLocalDate().isBefore(from.minusDays(2)) && row.arrivalAt().toLocalDate().isBefore(to)).toList();
|
||||||
|
return new Result(List.copyOf(visibleSources), List.copyOf(returns), readable);
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-1
@@ -6,6 +6,7 @@ import com.baiyue.common.core.api.ResultCode;
|
|||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import java.time.*;
|
import java.time.*;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class UnloadingEvidenceGuard implements EmptyReturnPrerequisite {
|
public class UnloadingEvidenceGuard implements EmptyReturnPrerequisite {
|
||||||
@@ -16,11 +17,32 @@ public class UnloadingEvidenceGuard implements EmptyReturnPrerequisite {
|
|||||||
public LocalDateTime deadline(int hours){return instant().plus(Duration.ofHours(hours)).atZone(ZoneId.of(timezone)).toLocalDateTime();}
|
public LocalDateTime deadline(int hours){return instant().plus(Duration.ofHours(hours)).atZone(ZoneId.of(timezone)).toLocalDateTime();}
|
||||||
}
|
}
|
||||||
public DirectCompletion directCompletion(long id) {
|
public DirectCompletion directCompletion(long id) {
|
||||||
var rows=mapper.deliveries(id);
|
return directCompletion(mapper.deliveries(id));
|
||||||
|
}
|
||||||
|
private DirectCompletion directCompletion(List<DirectDeliveryMapper.Delivery> rows) {
|
||||||
if(rows.isEmpty() || rows.stream().anyMatch(row->row.actualAt()==null)) return null;
|
if(rows.isEmpty() || rows.stream().anyMatch(row->row.actualAt()==null)) return null;
|
||||||
return rows.stream().map(row->new DirectCompletion(row.actualAt(),row.timezone()))
|
return rows.stream().map(row->new DirectCompletion(row.actualAt(),row.timezone()))
|
||||||
.max(java.util.Comparator.comparing(DirectCompletion::instant)).orElse(null);
|
.max(java.util.Comparator.comparing(DirectCompletion::instant)).orElse(null);
|
||||||
}
|
}
|
||||||
|
public Map<Long,LocalDateTime> completedUnloading(List<Long> ids) {
|
||||||
|
var result=new HashMap<Long,LocalDateTime>();
|
||||||
|
if(ids.isEmpty()) return result;
|
||||||
|
var pending=new ArrayList<Long>();
|
||||||
|
for(var container:mapper.containers(ids)) {
|
||||||
|
if(!"ACTIVE".equals(container.recordStatus())) continue;
|
||||||
|
if(container.warehouseUnloadedAt()!=null) result.put(container.id(),container.warehouseUnloadedAt());
|
||||||
|
else pending.add(container.id());
|
||||||
|
}
|
||||||
|
if(!pending.isEmpty()) {
|
||||||
|
var deliveries=new HashMap<Long,List<DirectDeliveryMapper.Delivery>>();
|
||||||
|
for(var row:mapper.deliveriesBatch(pending)) deliveries.computeIfAbsent(row.containerBookingId(),ignored->new ArrayList<>()).add(row.delivery());
|
||||||
|
for(long id:pending) {
|
||||||
|
var completed=directCompletion(deliveries.getOrDefault(id,List.of()));
|
||||||
|
if(completed!=null) result.put(id,completed.localAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
public LocalDateTime completedUnloading(long id) {
|
public LocalDateTime completedUnloading(long id) {
|
||||||
var container=mapper.container(id);
|
var container=mapper.container(id);
|
||||||
if(container==null || !"ACTIVE".equals(container.recordStatus())) return null;
|
if(container==null || !"ACTIVE".equals(container.recordStatus())) return null;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS fl_fee_item (
|
|||||||
fee_code VARCHAR(50) NOT NULL COMMENT '稳定费用编码;平台公共主数据固定编码',
|
fee_code VARCHAR(50) NOT NULL COMMENT '稳定费用编码;平台公共主数据固定编码',
|
||||||
supplier_category VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别约束;为空时可选任意ACTIVE供应商',
|
supplier_category VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别约束;为空时可选任意ACTIVE供应商',
|
||||||
fee_type_name VARCHAR(128) NOT NULL COMMENT '费用类型名称',
|
fee_type_name VARCHAR(128) NOT NULL COMMENT '费用类型名称',
|
||||||
|
default_price DECIMAL(12,2) NULL COMMENT '默认价格CNY;仅选费带入,实际费用可修改',
|
||||||
pickup_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应付是否参与',
|
pickup_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应付是否参与',
|
||||||
pickup_receivable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应收是否参与',
|
pickup_receivable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应收是否参与',
|
||||||
warehouse_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '仓储应付是否参与',
|
warehouse_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '仓储应付是否参与',
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info: {title: 四类日历柜级来源, version: 1.0.0}
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
paths:
|
||||||
|
/api/platform/v1/terminal-drayages/calendar-sources:
|
||||||
|
get:
|
||||||
|
summary: 查询日历到港、默认到仓来源和还柜事实
|
||||||
|
description: >-
|
||||||
|
仅平台,platform:appointment:list。返回已确认国内出仓、未撤销且有效的柜次。
|
||||||
|
到港取最新已观测实际到港节点、柜次实际到港、预计到港,按此顺序选一个日期。
|
||||||
|
到港候选前移两天覆盖计划到仓默认日期;缺到港日期的柜次同时返回,不能伪造日期。
|
||||||
|
还柜候选含原截止日期与真实完成日期,最终由原码头拖车业务复核,完成后只按实际还柜日期归日历。
|
||||||
|
没有platform:firstleg:terminal-drayage:list时emptyReturns为空且emptyReturnsReadable=false;不把缺权限当没有安排。
|
||||||
|
最多2000个候选,超出返回400;来源读取失败返回错误,不以空列表代替。
|
||||||
|
x-permissions: [platform:appointment:list]
|
||||||
|
parameters:
|
||||||
|
- {name: from, in: query, required: true, schema: {type: string, format: date}, description: 包含的原业务当地日期}
|
||||||
|
- {name: to, in: query, required: true, schema: {type: string, format: date}, description: 不包含的原业务当地日期,范围最多42天}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 成功业务响应,code为字符串200
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
code: {type: string}
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
required: [containers, emptyReturns, emptyReturnsReadable]
|
||||||
|
properties:
|
||||||
|
emptyReturnsReadable: {type: boolean}
|
||||||
|
containers:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
containerBookingId: {type: string}
|
||||||
|
cabinetNo: {type: string, nullable: true}
|
||||||
|
containerNo: {type: string, nullable: true}
|
||||||
|
vesselName: {type: string, nullable: true}
|
||||||
|
voyageNo: {type: string, nullable: true}
|
||||||
|
destinationPortName: {type: string, nullable: true}
|
||||||
|
destinationWarehouseCode: {type: string, nullable: true}
|
||||||
|
arrivalAt: {type: string, format: date-time, nullable: true}
|
||||||
|
arrivalType: {type: string, enum: [ACTUAL, ESTIMATED]}
|
||||||
|
emptyReturns:
|
||||||
|
type: array
|
||||||
|
description: 沿terminal-drayage-v1的View结构,allowedActions按当前权限过滤;含actualEmptyReturnAt及emptyReturnWarning.deadlineAt
|
||||||
|
items: {$ref: './terminal-drayage-v1.yaml#/components/schemas/TerminalDrayageView'}
|
||||||
|
'400': {description: 范围无效或候选过多}
|
||||||
|
'403': {description: 非平台或没有预约读取权限}
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
|
||||||
@@ -102,6 +102,7 @@ components:
|
|||||||
feeCode: {type: string, maxLength: 50, pattern: '^[A-Za-z0-9_-]+$'}
|
feeCode: {type: string, maxLength: 50, pattern: '^[A-Za-z0-9_-]+$'}
|
||||||
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时可选任意ACTIVE Warehouse Supplier}
|
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时可选任意ACTIVE Warehouse Supplier}
|
||||||
feeTypeName: {type: string, maxLength: 128}
|
feeTypeName: {type: string, maxLength: 128}
|
||||||
|
defaultPrice: {type: number, nullable: true, minimum: 0, maximum: 9999999999.99, multipleOf: 0.01, description: 默认价格CNY;为空不自动带价,业务录入时可修改,不追改已有费用}
|
||||||
pickupPayable: {type: boolean}
|
pickupPayable: {type: boolean}
|
||||||
pickupReceivable: {type: boolean}
|
pickupReceivable: {type: boolean}
|
||||||
warehousePayable: {type: boolean}
|
warehousePayable: {type: boolean}
|
||||||
@@ -118,6 +119,7 @@ components:
|
|||||||
feeCode: {type: string}
|
feeCode: {type: string}
|
||||||
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时可选任意ACTIVE Warehouse Supplier}
|
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时可选任意ACTIVE Warehouse Supplier}
|
||||||
feeTypeName: {type: string}
|
feeTypeName: {type: string}
|
||||||
|
defaultPrice: {type: number, nullable: true, minimum: 0, maximum: 9999999999.99, multipleOf: 0.01, description: 默认价格CNY;仅用于业务选择费用项目时带入}
|
||||||
pickupPayable: {type: boolean}
|
pickupPayable: {type: boolean}
|
||||||
pickupReceivable: {type: boolean}
|
pickupReceivable: {type: boolean}
|
||||||
warehousePayable: {type: boolean}
|
warehousePayable: {type: boolean}
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ paths:
|
|||||||
/internal/firstleg/v1/inbounds/unified-orders/workbench-filters:
|
/internal/firstleg/v1/inbounds/unified-orders/workbench-filters:
|
||||||
post:
|
post:
|
||||||
summary: 查询单一客户工作台履约筛选标识(Order内部)
|
summary: 查询单一客户工作台履约筛选标识(Order内部)
|
||||||
description: 只读返回命中状态、最新待确认费用及待上传资料的订单标识。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。
|
description: 只读返回命中状态、最新待确认费用及待上传资料的订单标识。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。
|
||||||
security: [{internalServiceToken: []}]
|
security: [{internalServiceToken: []}]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -367,6 +367,13 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
tenantId: {type: integer, format: int64, minimum: 1}
|
tenantId: {type: integer, format: int64, minimum: 1}
|
||||||
mainStatus: {type: string, nullable: true, enum: [WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED]}
|
mainStatus: {type: string, nullable: true, enum: [WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED]}
|
||||||
|
orderIds:
|
||||||
|
type: array
|
||||||
|
nullable: true
|
||||||
|
minItems: 1
|
||||||
|
maxItems: 200
|
||||||
|
items: {type: integer, format: int64, minimum: 1}
|
||||||
|
description: 省略或null查询当前客户全部进度;传入时仅在SQL内查询这些来源Order订单,空数组拒绝,不允许扩大到全客户。
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: 按响应code判断,标识只供Order在自身客户范围内过滤、计数和分页
|
description: 按响应code判断,标识只供Order在自身客户范围内过滤、计数和分页
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ class FeeManagementContractTest {
|
|||||||
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
|
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
|
||||||
assertThat(schemas).containsKeys("FeeItemSaveRequest", "FeeItemResource", "FeeItemPage",
|
assertThat(schemas).containsKeys("FeeItemSaveRequest", "FeeItemResource", "FeeItemPage",
|
||||||
"FeeItemApiResponse", "FeeItemPageApiResponse", "VoidApiResponse");
|
"FeeItemApiResponse", "FeeItemPageApiResponse", "VoidApiResponse");
|
||||||
|
for (String schema : List.of("FeeItemSaveRequest", "FeeItemResource")) {
|
||||||
|
assertThat(stringMap(properties(schemas, schema).get("defaultPrice")))
|
||||||
|
.containsEntry("nullable", true).containsEntry("minimum", 0);
|
||||||
|
}
|
||||||
for (String response : List.of("FeeItemApiResponse", "FeeItemPageApiResponse", "VoidApiResponse")) {
|
for (String response : List.of("FeeItemApiResponse", "FeeItemPageApiResponse", "VoidApiResponse")) {
|
||||||
assertThat(list(stringMap(schemas.get(response)).get("required"))).contains("timestamp");
|
assertThat(list(stringMap(schemas.get(response)).get("required"))).contains("timestamp");
|
||||||
assertThat(properties(schemas, response)).containsKeys(
|
assertThat(properties(schemas, response)).containsKeys(
|
||||||
|
|||||||
+15
-1
@@ -45,7 +45,7 @@ class InternalInboundOrderReferenceControllerTest {
|
|||||||
private InternalInboundOrderController controller;
|
private InternalInboundOrderController controller;
|
||||||
|
|
||||||
@Test void workbenchFiltersRejectMissingAndWrongInternalIdentity() {
|
@Test void workbenchFiltersRejectMissingAndWrongInternalIdentity() {
|
||||||
var request = new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L, "LOADED");
|
var request = new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L, "LOADED", null);
|
||||||
assertThatThrownBy(() -> controller.workbenchFilters(null, request)).isInstanceOf(BizException.class);
|
assertThatThrownBy(() -> controller.workbenchFilters(null, request)).isInstanceOf(BizException.class);
|
||||||
assertThatThrownBy(() -> controller.workbenchFilters("wrong", request)).isInstanceOf(BizException.class);
|
assertThatThrownBy(() -> controller.workbenchFilters("wrong", request)).isInstanceOf(BizException.class);
|
||||||
org.mockito.Mockito.verifyNoInteractions(workbenchFilters);
|
org.mockito.Mockito.verifyNoInteractions(workbenchFilters);
|
||||||
@@ -143,4 +143,18 @@ class InternalInboundOrderReferenceControllerTest {
|
|||||||
carrierId == null ? null : "承运商", null, null, null, null,
|
carrierId == null ? null : "承运商", null, null, null, null,
|
||||||
null, null, null, null, null, null);
|
null, null, null, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test void optionalExactScopeRejectsEmptyOversizeNullOrNonPositiveItems() {
|
||||||
|
try(var factory=jakarta.validation.Validation.buildDefaultValidatorFactory()) {
|
||||||
|
var validator=factory.getValidator();
|
||||||
|
for(java.util.List<Long> ids: java.util.List.of(java.util.List.<Long>of(),java.util.List.of(0L),
|
||||||
|
java.util.Arrays.asList(1L,null),java.util.stream.LongStream.rangeClosed(1,201).boxed().toList())) {
|
||||||
|
assertThat(validator.validate(new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L,null,ids))).isNotEmpty();
|
||||||
|
}
|
||||||
|
for(java.util.List<Long> ids: java.util.Arrays.asList(null,java.util.List.of(1L),java.util.stream.LongStream.rangeClosed(1,200).boxed().toList())) {
|
||||||
|
assertThat(validator.validate(new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L,null,ids))).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -74,4 +74,16 @@ class UnifiedInboundOpenApiContractTest {
|
|||||||
.containsOnlyKeys("standardNode","nodeSequence","providerCode","estimatedAt","actualAt","locationCode","locationName");
|
.containsOnlyKeys("standardNode","nodeSequence","providerCode","estimatedAt","actualAt","locationCode","locationName");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Test @SuppressWarnings("unchecked") void documentsBoundedOptionalExactOrderScope() throws Exception {
|
||||||
|
var document=(java.util.Map<String,Object>)new org.yaml.snakeyaml.Yaml().load(
|
||||||
|
new org.springframework.core.io.ClassPathResource("openapi/firstleg-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
|
var paths=(java.util.Map<String,Object>)document.get("paths");
|
||||||
|
var operation=(java.util.Map<String,Object>)((java.util.Map<String,Object>)paths.get("/internal/firstleg/v1/inbounds/unified-orders/workbench-filters")).get("post");
|
||||||
|
var content=(java.util.Map<String,Object>)((java.util.Map<String,Object>)operation.get("requestBody")).get("content");
|
||||||
|
var schema=(java.util.Map<String,Object>)((java.util.Map<String,Object>)content.get("application/json")).get("schema");
|
||||||
|
var ids=(java.util.Map<String,Object>)((java.util.Map<String,Object>)schema.get("properties")).get("orderIds");
|
||||||
|
assertThat(ids).containsEntry("minItems",1).containsEntry("maxItems",200).containsEntry("nullable",true);
|
||||||
|
assertThat((java.util.List<String>)schema.get("required")).doesNotContain("orderIds");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-3
@@ -18,15 +18,25 @@ class AppointmentWorkSourceMapperTest {
|
|||||||
s.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT)");
|
s.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT)");
|
||||||
s.execute("INSERT INTO fl_container_booking VALUES(1,'D1',NULL,'上海',NULL,1,'ACTIVE'),(2,'D2','C2','上海','私人仓',1,'ACTIVE'),(3,'D3','C3','上海',NULL,1,'ACTIVE'),(4,'D4','C4','上海',NULL,1,'MERGED')");
|
s.execute("INSERT INTO fl_container_booking VALUES(1,'D1',NULL,'上海',NULL,1,'ACTIVE'),(2,'D2','C2','上海','私人仓',1,'ACTIVE'),(3,'D3','C3','上海',NULL,1,'ACTIVE'),(4,'D4','C4','上海',NULL,1,'MERGED')");
|
||||||
s.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(10,1,'DEPARTED_ORIGIN_PORT','2026-10-01'),(20,2,'EMPTY_RETURNED','2026-10-03'),(30,3,'LOADED_ON_VESSEL','2026-10-04'),(40,4,'DEPARTED_ORIGIN_PORT','2026-10-01'),(50,3,'DEPARTED_ORIGIN_PORT',NULL)");
|
s.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(10,1,'DEPARTED_ORIGIN_PORT','2026-10-01'),(20,2,'EMPTY_RETURNED','2026-10-03'),(30,3,'LOADED_ON_VESSEL','2026-10-04'),(40,4,'DEPARTED_ORIGIN_PORT','2026-10-01'),(50,3,'DEPARTED_ORIGIN_PORT',NULL)");
|
||||||
s.execute("INSERT INTO fl_direct_delivery_unloading VALUES(2)");
|
s.execute("INSERT INTO fl_direct_delivery_unloading VALUES(2),(2)");
|
||||||
|
s.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(60,1,'IN_TRANSIT','2026-10-02'),(70,1,'ARRIVED_DESTINATION_PORT','2026-10-03'),(80,2,'EMPTY_RETURNED',NULL)");
|
||||||
}
|
}
|
||||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
|
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
|
||||||
config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(AppointmentWorkSourceMapper.class);
|
config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(AppointmentWorkSourceMapper.class);
|
||||||
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
|
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
|
||||||
var rows=session.getMapper(AppointmentWorkSourceMapper.class).sources();
|
var mapper=session.getMapper(AppointmentWorkSourceMapper.class);
|
||||||
|
var rows=mapper.sources();
|
||||||
assertEquals(2,rows.size());assertEquals(1,rows.get(0).id());assertEquals(2,rows.get(1).id());
|
assertEquals(2,rows.size());assertEquals(1,rows.get(0).id());assertEquals(2,rows.get(1).id());
|
||||||
assertNull(rows.get(0).destinationWarehouseName());assertEquals(1,rows.get(1).deliveryVersion());
|
assertNull(rows.get(0).destinationWarehouseName());assertEquals(2,rows.get(1).deliveryVersion());
|
||||||
assertNull(rows.get(1).departedLocalAt());assertNotNull(rows.get(1).sailedEvidenceLocalAt());
|
assertNull(rows.get(1).departedLocalAt());assertNotNull(rows.get(1).sailedEvidenceLocalAt());
|
||||||
|
assertEquals(70,rows.get(0).factVersion());assertEquals(20,rows.get(1).factVersion());
|
||||||
|
// The optimized path must equal the former full-manifest + Java filter, including
|
||||||
|
// dates, versions, null names, unloading counts, duplicate IDs and ineligible sources.
|
||||||
|
for(var ids:java.util.List.of(java.util.List.of(2L,1L),java.util.List.of(1L,1L),
|
||||||
|
java.util.List.of(2L,3L,4L,999L),java.util.List.of(3L,4L),java.util.List.<Long>of())) {
|
||||||
|
assertEquals(rows.stream().filter(row->ids.contains(row.id())).toList(),mapper.sourcesByIds(ids));
|
||||||
|
}
|
||||||
|
assertTrue(mapper.sourcesByIds(null).isEmpty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-3
@@ -7,6 +7,10 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.IntStream;
|
||||||
|
import com.baiyue.firstleg.portschedule.dto.AppointmentWorkSourceDtos.*;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
@@ -19,7 +23,7 @@ class AppointmentWorkSourceServiceTest {
|
|||||||
}
|
}
|
||||||
@Test void completeCursorManifestIncludesUnboundSourcesAndRejectsScopeDrift() {
|
@Test void completeCursorManifestIncludesUnboundSourcesAndRejectsScopeDrift() {
|
||||||
when(mapper.sources()).thenReturn(List.of(source(1,1),source(2,1)));
|
when(mapper.sources()).thenReturn(List.of(source(1,1),source(2,1)));
|
||||||
when(mapper.cargo(anyLong())).thenReturn(List.of()); when(facts.establishedFlow(anyLong())).thenReturn("UNKNOWN");
|
when(mapper.cargo(anyList())).thenReturn(List.of()); stubFlows("UNKNOWN");
|
||||||
var first=service.discover(null,1);
|
var first=service.discover(null,1);
|
||||||
assertFalse(first.complete()); assertNotNull(first.nextCursor());
|
assertFalse(first.complete()); assertNotNull(first.nextCursor());
|
||||||
assertNull(first.containers().get(0).departedAt());
|
assertNull(first.containers().get(0).departedAt());
|
||||||
@@ -31,9 +35,11 @@ class AppointmentWorkSourceServiceTest {
|
|||||||
}
|
}
|
||||||
@Test void sourceKeepsCustomerAndPhysicalInboundLineageWithoutDisplayingIdsAsNumbers() {
|
@Test void sourceKeepsCustomerAndPhysicalInboundLineageWithoutDisplayingIdsAsNumbers() {
|
||||||
when(mapper.sources()).thenReturn(List.of(source(1,1)));
|
when(mapper.sources()).thenReturn(List.of(source(1,1)));
|
||||||
when(mapper.cargo(1)).thenReturn(List.of(new AppointmentWorkSourceMapper.Cargo(8,9,10,"ORDER-10","IN-8","PRIVATE","P","客户仓",12)));
|
when(mapper.cargo(List.of(1L))).thenReturn(List.of(new AppointmentWorkSourceMapper.ContainerCargo(1,8,9,10,"ORDER-10","IN-8","PRIVATE","P","客户仓",12)));
|
||||||
when(facts.establishedFlow(1)).thenReturn("DIRECT");
|
stubFlows("DIRECT");
|
||||||
var source=service.container(1);var requirement=source.requirements().get(0);
|
var source=service.container(1);var requirement=source.requirements().get(0);
|
||||||
|
assertEquals(service.hash(List.of(source(1,1),List.of(new AppointmentWorkSourceMapper.Cargo(8,9,10,"ORDER-10","IN-8","PRIVATE","P","客户仓",12)))),source.sourceVersion());
|
||||||
|
assertEquals(source,service.discover(null,200).containers().get(0));
|
||||||
assertEquals("INBOUND:8",requirement.requirementKey());assertEquals("PRIVATE",requirement.destinationKind());
|
assertEquals("INBOUND:8",requirement.requirementKey());assertEquals("PRIVATE",requirement.destinationKind());
|
||||||
assertTrue(requirement.references().stream().anyMatch(r->r.kind().equals("CUSTOMER") && r.key().equals("9") && r.businessNo()==null));
|
assertTrue(requirement.references().stream().anyMatch(r->r.kind().equals("CUSTOMER") && r.key().equals("9") && r.businessNo()==null));
|
||||||
}
|
}
|
||||||
@@ -42,4 +48,45 @@ class AppointmentWorkSourceServiceTest {
|
|||||||
assertEquals(401,assertThrows(ResponseStatusException.class,()->guarded.discover(null,null,20)).getStatusCode().value());
|
assertEquals(401,assertThrows(ResponseStatusException.class,()->guarded.discover(null,null,20)).getStatusCode().value());
|
||||||
verifyNoInteractions(mapper);
|
verifyNoInteractions(mapper);
|
||||||
}
|
}
|
||||||
|
@Test void missingFlowEvidenceIsNotSilentlyPresentedAsUnknown() {
|
||||||
|
when(mapper.sources()).thenReturn(List.of(source(1,1)));
|
||||||
|
assertEquals(503,assertThrows(ResponseStatusException.class,()->service.container(1)).getStatusCode().value());
|
||||||
|
}
|
||||||
|
@Test void factBatchReadsOnlyRequestedSourcesAndPreservesEachContainerIdentity() {
|
||||||
|
when(mapper.sourcesByIds(anyList())).thenReturn(IntStream.rangeClosed(1,100).mapToObj(id->source(id,1)).toList());
|
||||||
|
when(mapper.cargo(anyList())).thenReturn(List.of());
|
||||||
|
stubFlows("UNKNOWN");
|
||||||
|
var fact=mock(Fact.class);
|
||||||
|
when(facts.read(any(),any())).thenAnswer(invocation->{
|
||||||
|
List<FactRequest> requests=invocation.getArgument(0); Map<Long,ContainerSource> sources=invocation.getArgument(1);
|
||||||
|
assertEquals(100,sources.size());
|
||||||
|
requests.forEach(request->assertEquals(request.containerBookingId(),sources.get(Long.parseLong(request.containerBookingId())).containerBookingId()));
|
||||||
|
return requests.stream().map(request->fact).toList();
|
||||||
|
});
|
||||||
|
var requests=IntStream.rangeClosed(1,100).boxed().flatMap(id->List.of(
|
||||||
|
request("PICKUP-"+id,"PICKUP",id),request("RETURN-"+id,"EMPTY_RETURN",id)).stream()).toList();
|
||||||
|
assertEquals(200,service.facts(requests).size());
|
||||||
|
verify(mapper,times(1)).sourcesByIds(IntStream.rangeClosed(1,100).mapToObj(Long::valueOf).toList());
|
||||||
|
verify(mapper,never()).sources();
|
||||||
|
verify(mapper,times(1)).cargo(IntStream.rangeClosed(1,100).mapToObj(Long::valueOf).toList());
|
||||||
|
verify(facts,times(1)).establishedFlows(anyList());
|
||||||
|
verify(facts,times(1)).read(any(),any());
|
||||||
|
}
|
||||||
|
@Test void nextFactBatchReadsFreshSourcesAndRejectsRemovedContainer() {
|
||||||
|
when(mapper.sourcesByIds(List.of(1L))).thenReturn(List.of(source(1,1)),List.of());
|
||||||
|
when(mapper.cargo(List.of(1L))).thenReturn(List.of());
|
||||||
|
stubFlows("UNKNOWN");
|
||||||
|
var request=request("PICKUP-1","PICKUP",1);
|
||||||
|
service.facts(List.of(request));
|
||||||
|
assertEquals(404,assertThrows(ResponseStatusException.class,()->service.facts(List.of(request))).getStatusCode().value());
|
||||||
|
verify(mapper,times(2)).sourcesByIds(List.of(1L));
|
||||||
|
verify(mapper,never()).sources();
|
||||||
|
verify(facts,times(1)).read(any(),any());
|
||||||
|
}
|
||||||
|
private void stubFlows(String flow) {
|
||||||
|
when(facts.establishedFlows(anyList())).thenAnswer(invocation->((List<Long>)invocation.getArgument(0)).stream().collect(Collectors.toMap(id->id,id->flow)));
|
||||||
|
}
|
||||||
|
private FactRequest request(String key,String type,long id) {
|
||||||
|
return new FactRequest(key,type,"TRANSIT",Long.toString(id),null,null,null,List.of(),List.of(),List.of());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-7
@@ -83,19 +83,28 @@ class MerchantWorkbenchFilterMapperTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test void onlyActualLoadedOwnedFragmentsAndActualClearanceAdvanceProgress() {
|
@Test void onlyActualLoadedOwnedFragmentsAndActualClearanceAdvanceProgress() {
|
||||||
assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==0);
|
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==0);
|
||||||
jdbc.update("INSERT INTO fl_loading_pick_task VALUES(900,100,'COMPLETED')");
|
jdbc.update("INSERT INTO fl_loading_pick_task VALUES(900,100,'COMPLETED')");
|
||||||
jdbc.update("INSERT INTO fl_loading_pick_item SELECT 900,id,'PICKED',2 FROM fl_loading_plan_item");
|
jdbc.update("INSERT INTO fl_loading_pick_item SELECT 900,id,'PICKED',2 FROM fl_loading_plan_item");
|
||||||
assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==1).noneMatch(f->f.orderId()==103||f.orderId()==104);
|
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==1).noneMatch(f->f.orderId()==103||f.orderId()==104);
|
||||||
jdbc.update("INSERT INTO fl_customs_container VALUES(200,100,'DECLARING')");
|
jdbc.update("INSERT INTO fl_customs_container VALUES(200,100,'DECLARING')");
|
||||||
assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==1);
|
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==1);
|
||||||
jdbc.update("UPDATE fl_customs_container SET status='COMPLETED'");
|
jdbc.update("UPDATE fl_customs_container SET status='COMPLETED'");
|
||||||
assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==2);
|
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==2);
|
||||||
jdbc.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(100,'PICKUP_READY',NULL)");
|
jdbc.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(100,'PICKUP_READY',NULL)");
|
||||||
assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==2);
|
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==2);
|
||||||
jdbc.update("UPDATE fl_carrier_schedule_node_fact SET actual_at=CURRENT_TIMESTAMP");
|
jdbc.update("UPDATE fl_carrier_schedule_node_fact SET actual_at=CURRENT_TIMESTAMP");
|
||||||
assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==3);
|
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==3);
|
||||||
|
assertThat(mapper.progressFragments(11,List.of(101L))).singleElement().satisfies(f->{assertThat(f.orderId()).isEqualTo(101);assertThat(f.stage()).isEqualTo(3);});
|
||||||
jdbc.update("UPDATE fl_container_booking SET record_status='CANCELLED'");
|
jdbc.update("UPDATE fl_container_booking SET record_status='CANCELLED'");
|
||||||
assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==0);
|
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test void exactOrderScopeKeepsAllItsFragmentsAndCannotReadForeignOrDeletedOrders() {
|
||||||
|
jdbc.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,deleted,tax_mode,process_status) VALUES(50,11,101,0,'TAX_REFUND','LOADED')");
|
||||||
|
assertThat(mapper.progressFragments(11,List.of(101L,103L,104L))).extracting(f->f.orderId()).containsExactly(101L,101L);
|
||||||
|
assertThat(mapper.progressFragments(11,List.of(999L))).isEmpty();
|
||||||
|
assertThat(mapper.progressFragments(22,List.of(101L))).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ class FeeManagementServiceTest {
|
|||||||
|
|
||||||
assertThat(created.getId()).isEqualTo(11L);
|
assertThat(created.getId()).isEqualTo(11L);
|
||||||
assertThat(created.getAllocationFee()).isTrue();
|
assertThat(created.getAllocationFee()).isTrue();
|
||||||
|
assertThat(created.getDefaultPrice()).isEqualByComparingTo("60.00");
|
||||||
assertThat(created.getWarehouseReceivable()).isTrue();
|
assertThat(created.getWarehouseReceivable()).isTrue();
|
||||||
assertThat(created.getWarehousePayable()).isTrue();
|
assertThat(created.getWarehousePayable()).isTrue();
|
||||||
assertThat(created.getSupplierCategory()).isNull();
|
assertThat(created.getSupplierCategory()).isNull();
|
||||||
@@ -122,7 +123,7 @@ class FeeManagementServiceTest {
|
|||||||
@DisplayName("自定义费用编码统一转为大写并检查客户内唯一性")
|
@DisplayName("自定义费用编码统一转为大写并检查客户内唯一性")
|
||||||
void normalizesCustomFeeCode() {
|
void normalizesCustomFeeCode() {
|
||||||
FeeItemDtos.SaveRequest request = new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL",
|
FeeItemDtos.SaveRequest request = new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL",
|
||||||
"storage_fee", "仓库租赁费", false, false, true, true, true, "应付仓库租赁费");
|
"storage_fee", null, "仓库租赁费", false, false, true, true, true, "应付仓库租赁费", null);
|
||||||
|
|
||||||
FeeItemEntity created = feeItems.create(9L, "admin", request);
|
FeeItemEntity created = feeItems.create(9L, "admin", request);
|
||||||
|
|
||||||
@@ -134,7 +135,7 @@ class FeeManagementServiceTest {
|
|||||||
@DisplayName("并发创建相同费用编码返回业务冲突")
|
@DisplayName("并发创建相同费用编码返回业务冲突")
|
||||||
void rejectsConcurrentDuplicateFeeCode() {
|
void rejectsConcurrentDuplicateFeeCode() {
|
||||||
FeeItemDtos.SaveRequest request = new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL",
|
FeeItemDtos.SaveRequest request = new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL",
|
||||||
"STORAGE_FEE", "仓库租赁费", false, false, true, true, true, null);
|
"STORAGE_FEE", null, "仓库租赁费", false, false, true, true, true, null, null);
|
||||||
doThrow(new DuplicateKeyException("duplicate fee code")).when(repository).insertItem(any());
|
doThrow(new DuplicateKeyException("duplicate fee code")).when(repository).insertItem(any());
|
||||||
|
|
||||||
assertThatThrownBy(() -> feeItems.create(9L, "admin", request))
|
assertThatThrownBy(() -> feeItems.create(9L, "admin", request))
|
||||||
@@ -156,6 +157,38 @@ class FeeManagementServiceTest {
|
|||||||
verify(repository, never()).itemCodeExists("STORAGE_FEE", 11L);
|
verify(repository, never()).itemCodeExists("STORAGE_FEE", 11L);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 清空默认价只修改公共项目,不触碰已生成的费用。 */
|
||||||
|
@Test
|
||||||
|
void clearsDefaultPrice() {
|
||||||
|
FeeItemEntity existing = item(true, true);
|
||||||
|
existing.setDefaultPrice(new BigDecimal("60.00"));
|
||||||
|
when(repository.findItem(11L)).thenReturn(existing);
|
||||||
|
when(repository.updateItem(any())).thenReturn(1);
|
||||||
|
FeeItemDtos.SaveRequest request = new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL",
|
||||||
|
null, null, "仓库租赁费", false, false, true, true, true, null, null);
|
||||||
|
|
||||||
|
assertThat(feeItems.update(11L, 9L, "admin", request).getDefaultPrice()).isNull();
|
||||||
|
verify(repository).updateItem(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 请求价格允许空值和零元,拒绝负数、过多小数和超出字段范围的金额。 */
|
||||||
|
@Test
|
||||||
|
void validatesDefaultPrice() {
|
||||||
|
try (var factory = jakarta.validation.Validation.buildDefaultValidatorFactory()) {
|
||||||
|
var validator = factory.getValidator();
|
||||||
|
for (String price : List.of("0", "60.25", "9999999999.99")) {
|
||||||
|
var request = new FeeItemDtos.SaveRequest("仓储成本", "DOMESTIC", null, null,
|
||||||
|
"操作费", false, true, false, false, false, null, new BigDecimal(price));
|
||||||
|
assertThat(validator.validate(request)).isEmpty();
|
||||||
|
}
|
||||||
|
for (String price : List.of("-1", "60.255", "10000000000")) {
|
||||||
|
var request = new FeeItemDtos.SaveRequest("仓储成本", "DOMESTIC", null, null,
|
||||||
|
"操作费", false, true, false, false, false, null, new BigDecimal(price));
|
||||||
|
assertThat(validator.validate(request)).isNotEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("任一客户引用公共费用项目后都禁止删除")
|
@DisplayName("任一客户引用公共费用项目后都禁止删除")
|
||||||
void rejectsDeletingItemBoundByAnyCustomer() {
|
void rejectsDeletingItemBoundByAnyCustomer() {
|
||||||
@@ -478,9 +511,10 @@ class FeeManagementServiceTest {
|
|||||||
eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class));
|
eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 构造带默认价格的公共费用项目。 */
|
||||||
private static FeeItemDtos.SaveRequest itemRequest() {
|
private static FeeItemDtos.SaveRequest itemRequest() {
|
||||||
return new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL", "仓库租赁费",
|
return new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL", null, null, "仓库租赁费",
|
||||||
false, false, true, true, true, "应付仓库租赁费");
|
false, false, true, true, true, "应付仓库租赁费", new BigDecimal("60.00"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static InboundFeeDetailDtos.SaveRequest detailRequest(String direction) {
|
private static InboundFeeDetailDtos.SaveRequest detailRequest(String direction) {
|
||||||
|
|||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
package com.baiyue.firstleg.service;
|
||||||
|
|
||||||
|
import com.baiyue.firstleg.dto.MerchantWorkbenchFilters;
|
||||||
|
import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import java.util.List;
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class MerchantWorkbenchFilterServiceTest {
|
||||||
|
@Test void exactDetailProgressSkipsUnrelatedCustomerQueues() {
|
||||||
|
var mapper=mock(MerchantWorkbenchFilterMapper.class);
|
||||||
|
var unified=mock(UnifiedInboundQueryService.class);
|
||||||
|
var service=new MerchantWorkbenchFilterService();
|
||||||
|
ReflectionTestUtils.setField(service,"mapper",mapper);
|
||||||
|
ReflectionTestUtils.setField(service,"unified",unified);
|
||||||
|
when(mapper.progressFragments(11,List.of(101L))).thenReturn(List.of(
|
||||||
|
new MerchantWorkbenchFilters.Fragment(101,3),new MerchantWorkbenchFilters.Fragment(101,1)));
|
||||||
|
var response=service.read(new MerchantWorkbenchFilters.Request(11L,null,List.of(101L)));
|
||||||
|
assertThat(response.progress()).containsExactly(new MerchantWorkbenchFilters.Progress(101,"cleared",true));
|
||||||
|
assertThat(response.feeOrderIds()).isEmpty();assertThat(response.customsOrderIds()).isEmpty();
|
||||||
|
assertThat(response.matchingStatusOrderIds()).isNull();
|
||||||
|
verify(mapper).progressFragments(11,List.of(101L));verifyNoMoreInteractions(mapper);verifyNoInteractions(unified);
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
package com.baiyue.firstleg.terminaldrayage.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
|
||||||
|
import org.apache.ibatis.mapping.Environment;
|
||||||
|
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||||
|
import org.h2.jdbcx.JdbcDataSource;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class ContainerCalendarSourceMapperTest {
|
||||||
|
/** 使用实际 Mapper 核对跨月默认日期候选、缺日期与真实出仓边界。 */
|
||||||
|
@Test
|
||||||
|
void selectsShiftedArrivalUndatedAndOlderReturnCandidatesWithoutInactiveOrUndepartedContainers() throws Exception {
|
||||||
|
var ds = new JdbcDataSource();
|
||||||
|
ds.setURL("jdbc:h2:mem:four-type-calendar;MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||||
|
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
|
||||||
|
sql.execute("CREATE TABLE fl_container_booking(id BIGINT,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_node_fact(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-30 12:00:00','ACTIVE'),(2,'D2','2026-11-10 12:00:00','ACTIVE')," +
|
||||||
|
"(3,'D3',NULL,'ACTIVE'),(4,'D4','2026-01-01 12:00:00','ACTIVE')," +
|
||||||
|
"(5,'D5','2026-11-10 12:00:00','CANCELLED'),(6,'D6','2026-11-10 12:00:00','ACTIVE')");
|
||||||
|
sql.execute("INSERT INTO fl_outbound_dispatch SELECT id,'2026-01-01 00:00:00',NULL,'WAITING_FOR_LOADING' FROM fl_container_booking WHERE id<>6");
|
||||||
|
sql.execute("INSERT INTO fl_terminal_drayage VALUES(4,'2026-11-05 12:00:00')");
|
||||||
|
sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(1,2,'ARRIVED_DESTINATION_PORT','2026-10-25 12:00:00','2026-10-26 12:00:00')");
|
||||||
|
}
|
||||||
|
var config = new MybatisConfiguration();
|
||||||
|
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds)); config.addMapper(ContainerCalendarSourceMapper.class);
|
||||||
|
try (var session = new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
|
||||||
|
var from = LocalDateTime.parse("2026-11-01T00:00:00");
|
||||||
|
var rows = session.getMapper(ContainerCalendarSourceMapper.class).sources(from, from.minusDays(2), from.minusDays(4), from.plusDays(30));
|
||||||
|
assertEquals(java.util.List.of(1L, 3L, 4L), rows.stream().map(row -> row.containerBookingId()).toList());
|
||||||
|
assertEquals("2026-10-30T12:00", rows.get(0).arrivalAt().toString());
|
||||||
|
assertNull(rows.get(1).arrivalAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -8,6 +8,8 @@ import org.h2.jdbcx.JdbcDataSource;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.List;
|
||||||
|
import com.baiyue.firstleg.portschedule.mapper.AppointmentWorkSourceMapper;
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
class DirectDeliveryMapperTest {
|
class DirectDeliveryMapperTest {
|
||||||
@@ -20,8 +22,19 @@ class DirectDeliveryMapperTest {
|
|||||||
s.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT,inbound_id BIGINT,actual_at TIMESTAMP,timezone VARCHAR,received_by VARCHAR,evidence_no VARCHAR,remark VARCHAR,version INT,idempotency_key VARCHAR,request_hash VARCHAR,created_by BIGINT,created_by_name VARCHAR,PRIMARY KEY(container_booking_id,inbound_id),UNIQUE(container_booking_id,idempotency_key))");
|
s.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT,inbound_id BIGINT,actual_at TIMESTAMP,timezone VARCHAR,received_by VARCHAR,evidence_no VARCHAR,remark VARCHAR,version INT,idempotency_key VARCHAR,request_hash VARCHAR,created_by BIGINT,created_by_name VARCHAR,PRIMARY KEY(container_booking_id,inbound_id),UNIQUE(container_booking_id,idempotency_key))");
|
||||||
s.execute("INSERT INTO fl_container_booking VALUES(1,'ACTIVE',NULL)");s.execute("INSERT INTO fl_loading_plan VALUES(10,1,'COMPLETED')");
|
s.execute("INSERT INTO fl_container_booking VALUES(1,'ACTIVE',NULL)");s.execute("INSERT INTO fl_loading_plan VALUES(10,1,'COMPLETED')");
|
||||||
s.execute("INSERT INTO fl_loading_plan_item VALUES(10,20,'I20','O20','D',3,0),(10,20,'I20','O20','D',4,0),(10,21,'I21','O21','D',5,0),(10,22,'I22','O22','D',100,1)");
|
s.execute("INSERT INTO fl_loading_plan_item VALUES(10,20,'I20','O20','D',3,0),(10,20,'I20','O20','D',4,0),(10,21,'I21','O21','D',5,0),(10,22,'I22','O22','D',100,1)");
|
||||||
|
s.execute("INSERT INTO fl_container_booking VALUES(2,'ACTIVE','2026-01-02 12:00:00'),(3,'ACTIVE',NULL)");
|
||||||
|
s.execute("INSERT INTO fl_loading_plan VALUES(11,2,'COMPLETED'),(12,3,'DRAFT')");
|
||||||
|
s.execute("INSERT INTO fl_loading_plan_item VALUES(11,20,'I20','O20','D',2,0),(12,20,'I20','O20','D',100,0)");
|
||||||
|
s.execute("ALTER TABLE fl_loading_plan_item ADD account_id BIGINT DEFAULT 7");
|
||||||
|
s.execute("ALTER TABLE fl_loading_plan_item ADD order_id BIGINT DEFAULT 8");
|
||||||
|
s.execute("ALTER TABLE fl_loading_plan_item ADD destination_warehouse_code VARCHAR DEFAULT 'D'");
|
||||||
|
s.execute("CREATE TABLE fl_inbound_order(id BIGINT,destination_type VARCHAR)");
|
||||||
|
s.execute("INSERT INTO fl_inbound_order VALUES(20,'PRIVATE'),(21,'AMAZON'),(22,'PRIVATE')");
|
||||||
|
s.execute("CREATE TABLE fl_terminal_drayage_history(container_booking_id BIGINT,action VARCHAR)");
|
||||||
|
s.execute("INSERT INTO fl_terminal_drayage_history VALUES(1,'SELECT_FLEET'),(1,'SCHEDULE_PICKUP'),(1,'SCHEDULE_PICKUP'),(1,'OTHER'),(2,'SELECT_FLEET')");
|
||||||
}
|
}
|
||||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(DirectDeliveryMapper.class);
|
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(DirectDeliveryMapper.class);
|
||||||
|
config.addMapper(AppointmentWorkSourceMapper.class);config.addMapper(TerminalDrayageMapper.class);
|
||||||
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
|
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
|
||||||
var mapper=session.getMapper(DirectDeliveryMapper.class);assertEquals(1,mapper.lockContainer(1).id());
|
var mapper=session.getMapper(DirectDeliveryMapper.class);assertEquals(1,mapper.lockContainer(1).id());
|
||||||
assertEquals(2,mapper.deliveries(1).size());assertEquals(7,mapper.deliveries(1).get(0).packageCount());
|
assertEquals(2,mapper.deliveries(1).size());assertEquals(7,mapper.deliveries(1).get(0).packageCount());
|
||||||
@@ -29,6 +42,18 @@ class DirectDeliveryMapperTest {
|
|||||||
mapper.insert(1,20,at,"America/Los_Angeles","签收人","POD20",null,"key","hash",7,"admin");
|
mapper.insert(1,20,at,"America/Los_Angeles","签收人","POD20",null,"key","hash",7,"admin");
|
||||||
assertEquals(1,mapper.evidenceCount(1));assertEquals(1,mapper.keyCount(1,"key"));
|
assertEquals(1,mapper.evidenceCount(1));assertEquals(1,mapper.keyCount(1,"key"));
|
||||||
assertEquals(at,mapper.deliveries(1).get(0).actualAt());assertNull(mapper.deliveries(1).get(1).actualAt());
|
assertEquals(at,mapper.deliveries(1).get(0).actualAt());assertNull(mapper.deliveries(1).get(1).actualAt());
|
||||||
|
var batch=mapper.deliveriesBatch(List.of(1L,2L,3L));
|
||||||
|
assertEquals(3,batch.size());assertEquals(mapper.deliveries(1),batch.stream().filter(r->r.containerBookingId()==1).map(DirectDeliveryMapper.ContainerDelivery::delivery).toList());
|
||||||
|
assertEquals(2,batch.get(2).packageCount());assertNull(batch.get(2).actualAt());
|
||||||
|
assertEquals(3,mapper.containers(List.of(1L,2L,3L)).size());
|
||||||
|
var flows=mapper.flowEvidence(List.of(1L,2L,3L));
|
||||||
|
assertEquals(1,flows.stream().filter(r->r.id()==1).findFirst().orElseThrow().evidenceCount());
|
||||||
|
assertNotNull(flows.stream().filter(r->r.id()==2).findFirst().orElseThrow().warehouseUnloadedAt());
|
||||||
|
var cargo=session.getMapper(AppointmentWorkSourceMapper.class).cargo(List.of(1L,2L,3L));
|
||||||
|
assertEquals(3,cargo.size());assertEquals(7,cargo.get(0).packageCount());assertEquals(1,cargo.get(0).containerBookingId());
|
||||||
|
assertEquals(2,cargo.get(2).containerBookingId());assertEquals(2,cargo.get(2).packageCount());
|
||||||
|
var changes=session.getMapper(TerminalDrayageMapper.class).pickupChanges(List.of(1L,2L,3L));
|
||||||
|
assertEquals(new TerminalDrayageMapper.PickupChanges(1,2,1),changes.stream().filter(r->r.containerBookingId()==1).findFirst().orElseThrow());
|
||||||
assertThrows(RuntimeException.class,()->mapper.insert(1,20,at,"America/Los_Angeles","签收人","POD20",null,"another","hash",7,"admin"));
|
assertThrows(RuntimeException.class,()->mapper.insert(1,20,at,"America/Los_Angeles","签收人","POD20",null,"another","hash",7,"admin"));
|
||||||
session.rollback();
|
session.rollback();
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-13
@@ -5,11 +5,11 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerAc
|
|||||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||||
import com.baiyue.firstleg.terminaldrayage.mapper.*;
|
import com.baiyue.firstleg.terminaldrayage.mapper.*;
|
||||||
import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity;
|
import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity;
|
||||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.History;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
@@ -22,22 +22,43 @@ class AppointmentWorkTerminalFactsTest {
|
|||||||
private final LocalDateTime at=LocalDateTime.parse("2026-01-01T12:30:00");
|
private final LocalDateTime at=LocalDateTime.parse("2026-01-01T12:30:00");
|
||||||
private ContainerSource source(){return new ContainerSource("1","C1",null,at,at,"v","UNKNOWN",null,List.of());}
|
private ContainerSource source(){return new ContainerSource("1","C1",null,at,at,"v","UNKNOWN",null,List.of());}
|
||||||
private FactRequest request(String type){return new FactRequest("W1",type,"DIRECT","1",null,null,null,List.of(),List.of(),List.of());}
|
private FactRequest request(String type){return new FactRequest("W1",type,"DIRECT","1",null,null,null,List.of(),List.of(),List.of());}
|
||||||
private History history(String action){return new History(action,"A","B",1,2,null,7L,"admin",at);}
|
private Fact read(FactRequest request,ContainerSource source) {return adapter.read(List.of(request),Map.of(1L,source)).get(0);}
|
||||||
|
@Test void twoHundredRequestsReadEachFactSetOnceAndKeepMissingFactsVisible() {
|
||||||
|
var ids=java.util.stream.LongStream.rangeClosed(1,100).boxed().toList();
|
||||||
|
var sources=new java.util.HashMap<Long,ContainerSource>();
|
||||||
|
var requests=new java.util.ArrayList<FactRequest>();
|
||||||
|
for(long id:ids) {
|
||||||
|
sources.put(id,new ContainerSource(Long.toString(id),"C"+id,null,at,at,"v","UNKNOWN",null,List.of()));
|
||||||
|
requests.add(new FactRequest("P"+id,"PICKUP","TRANSIT",Long.toString(id),null,null,null,List.of(),List.of(),List.of()));
|
||||||
|
requests.add(new FactRequest("R"+id,"EMPTY_RETURN","TRANSIT",Long.toString(id),null,null,null,List.of(),List.of(),List.of()));
|
||||||
|
}
|
||||||
|
var results=adapter.read(requests,sources);
|
||||||
|
assertEquals(200,results.size());
|
||||||
|
for(int i=0;i<results.size();i++) {assertEquals(requests.get(i).workItemKey(),results.get(i).workItemKey());assertFalse(results.get(i).blockReasons().isEmpty());}
|
||||||
|
verify(nodes).containerActualFacts(ids);verify(terminal).findAll(ids);verify(terminal).pickupChanges(ids);
|
||||||
|
verify(guard).completedUnloading(ids);verifyNoMoreInteractions(nodes,terminal,guard);verifyNoInteractions(direct);
|
||||||
|
}
|
||||||
|
@Test void batchedFlowConflictsStillFailClosed() {
|
||||||
|
when(direct.flowEvidence(List.of(1L,2L))).thenReturn(List.of(new DirectDeliveryMapper.FlowEvidence(1,null,1),new DirectDeliveryMapper.FlowEvidence(2,at,1)));
|
||||||
|
assertEquals(409,assertThrows(org.springframework.web.server.ResponseStatusException.class,()->adapter.establishedFlows(List.of(1L,2L))).getStatusCode().value());
|
||||||
|
}
|
||||||
@Test void initialPickupPlanRetainsLocalDateAndDoesNotInventZoneOrReview() {
|
@Test void initialPickupPlanRetainsLocalDateAndDoesNotInventZoneOrReview() {
|
||||||
var plan=new TerminalDrayageEntity();plan.setPickupAppointmentAt(at);plan.setFleetSupplierId(4L);plan.setFleetSupplierName("车队");
|
var plan=new TerminalDrayageEntity();plan.setContainerBookingId(1L);plan.setPickupAppointmentAt(at);plan.setFleetSupplierId(4L);plan.setFleetSupplierName("车队");
|
||||||
when(terminal.find(1)).thenReturn(plan);
|
when(terminal.findAll(List.of(1L))).thenReturn(List.of(plan));
|
||||||
when(terminal.histories(1)).thenReturn(List.of(history("SELECT_FLEET"),history("SCHEDULE_PICKUP")));
|
when(terminal.pickupChanges(List.of(1L))).thenReturn(List.of(new TerminalDrayageMapper.PickupChanges(1,1,1)));
|
||||||
var fact=adapter.read(request("PICKUP"),source());
|
var fact=read(request("PICKUP"),source());
|
||||||
assertEquals("0:0",fact.impactToken());assertEquals(at.toLocalDate(),fact.confirmed().date());
|
assertEquals("0:0",fact.impactToken());assertEquals(at.toLocalDate(),fact.confirmed().date());
|
||||||
assertNull(fact.timezone());assertTrue(fact.missingFields().contains("SOURCE_TIMEZONE"));
|
assertNull(fact.timezone());assertTrue(fact.missingFields().contains("SOURCE_TIMEZONE"));
|
||||||
when(terminal.histories(1)).thenReturn(List.of(history("SELECT_FLEET"),history("SCHEDULE_PICKUP"),history("SCHEDULE_PICKUP")));
|
var returnRequest=new FactRequest("W2","EMPTY_RETURN","DIRECT","1",null,null,null,List.of(),List.of(),List.of());
|
||||||
assertEquals("1:0",adapter.read(request("PICKUP"),source()).impactToken());
|
assertEquals(fact,adapter.read(List.of(request("PICKUP"),returnRequest),Map.of(1L,source())).get(0));
|
||||||
|
when(terminal.pickupChanges(List.of(1L))).thenReturn(List.of(new TerminalDrayageMapper.PickupChanges(1,2,1)));
|
||||||
|
assertEquals("1:0",read(request("PICKUP"),source()).impactToken());
|
||||||
}
|
}
|
||||||
@Test void emptyReturnActualIsCompleteEvenWhenOriginalTimezoneIsUnknownAndDoesNotCopyPickupResponsibility() {
|
@Test void emptyReturnActualIsCompleteEvenWhenOriginalTimezoneIsUnknownAndDoesNotCopyPickupResponsibility() {
|
||||||
var plan=new TerminalDrayageEntity();plan.setFleetSupplierId(4L);plan.setFleetSupplierName("提柜承运人");
|
var plan=new TerminalDrayageEntity();plan.setContainerBookingId(1L);plan.setFleetSupplierId(4L);plan.setFleetSupplierName("提柜承运人");
|
||||||
when(terminal.find(1)).thenReturn(plan);
|
when(terminal.findAll(List.of(1L))).thenReturn(List.of(plan));
|
||||||
when(nodes.containerActualFacts(List.of(1L))).thenReturn(List.of(new ContainerActualFacts(1L,2L,3,"STOPPED","EMPTY_RETURNED",at,null,null,null,null,at.minusDays(1),at)));
|
when(nodes.containerActualFacts(List.of(1L))).thenReturn(List.of(new ContainerActualFacts(1L,2L,3,"STOPPED","EMPTY_RETURNED",at,null,null,null,null,at.minusDays(1),at)));
|
||||||
var fact=adapter.read(request("EMPTY_RETURN"),source());
|
var fact=read(request("EMPTY_RETURN"),source());
|
||||||
assertEquals("COMPLETED",fact.executionStatus());assertNull(fact.actual().occurredAt());assertEquals(at,fact.actual().localOccurredAt());
|
assertEquals("COMPLETED",fact.executionStatus());assertNull(fact.actual().occurredAt());assertEquals(at,fact.actual().localOccurredAt());
|
||||||
assertNull(fact.responsibility());assertNull(fact.action());assertNull(fact.businessNo());
|
assertNull(fact.responsibility());assertNull(fact.action());assertNull(fact.businessNo());
|
||||||
}
|
}
|
||||||
@@ -47,9 +68,10 @@ class AppointmentWorkTerminalFactsTest {
|
|||||||
var source=new ContainerSource("1","C1",null,at,at,"v","DIRECT",null,List.of(
|
var source=new ContainerSource("1","C1",null,at,at,"v","DIRECT",null,List.of(
|
||||||
new Requirement("INBOUND:2",null,null,"PRIVATE","港","A",null,own,List.of(),List.of()),
|
new Requirement("INBOUND:2",null,null,"PRIVATE","港","A",null,own,List.of(),List.of()),
|
||||||
new Requirement("INBOUND:3",null,null,"PRIVATE","港","B",null,other,List.of(),List.of())));
|
new Requirement("INBOUND:3",null,null,"PRIVATE","港","B",null,other,List.of(),List.of())));
|
||||||
when(direct.deliveries(1)).thenReturn(List.of(new DirectDeliveryMapper.Delivery(2,"IN2","O2","A",4,1,at,"America/Los_Angeles","a","P2",null,"k","h")));
|
when(direct.deliveriesBatch(List.of(1L))).thenReturn(List.of(new DirectDeliveryMapper.ContainerDelivery(1,2,"IN2","O2","A",4,1,at,"America/Los_Angeles","a","P2",null,"k","h")));
|
||||||
var request=new FactRequest("W1","DELIVERY","DIRECT","1","INBOUND:2",null,null,List.of(),List.of(),List.of());
|
var request=new FactRequest("W1","DELIVERY","DIRECT","1","INBOUND:2",null,null,List.of(),List.of(),List.of());
|
||||||
var fact=adapter.read(request,source);
|
var fact=read(request,source);
|
||||||
assertEquals(own,fact.references());assertEquals("IN2",fact.businessNo());assertEquals("COMPLETED",fact.executionStatus());
|
assertEquals(own,fact.references());assertEquals("IN2",fact.businessNo());assertEquals("COMPLETED",fact.executionStatus());
|
||||||
|
assertEquals(fact,adapter.read(List.of(request,new FactRequest("W2","PICKUP","DIRECT","1",null,null,null,List.of(),List.of(),List.of())),Map.of(1L,source)).get(0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
package com.baiyue.firstleg.terminaldrayage.service;
|
||||||
|
|
||||||
|
import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource;
|
||||||
|
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.*;
|
||||||
|
import com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import java.time.*;
|
||||||
|
import java.util.*;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class ContainerCalendarSourceServiceTest {
|
||||||
|
private final ContainerCalendarSourceRepository repository = mock(ContainerCalendarSourceRepository.class);
|
||||||
|
private final TerminalDrayageService drayages = mock(TerminalDrayageService.class);
|
||||||
|
private final ContainerCalendarSourceService service = new ContainerCalendarSourceService(repository, drayages);
|
||||||
|
private final LocalDate from = LocalDate.of(2026, 11, 1), to = from.plusDays(30);
|
||||||
|
/** 缺还柜读取权限时保留范围说明,不读取受保护事实。 */
|
||||||
|
@Test void keepsMissingReturnPermissionExplicitWithoutReadingProtectedFacts() {
|
||||||
|
when(repository.sources(from, to)).thenReturn(List.of(
|
||||||
|
new ContainerCalendarSource(1L,"D1",null,null,null,null,null,null,"ESTIMATED"),
|
||||||
|
new ContainerCalendarSource(2L,"D2",null,null,null,null,null,from.minusMonths(2).atStartOfDay(),"ACTUAL")));
|
||||||
|
var result = service.calendar(from, to, Set.of("platform:appointment:list"));
|
||||||
|
assertEquals(1, result.containers().size()); assertFalse(result.emptyReturnsReadable());
|
||||||
|
verifyNoInteractions(drayages);
|
||||||
|
}
|
||||||
|
/** 实际还柜日期优先于截止日期,范围外完成事实不回落到原提醒日。 */
|
||||||
|
@Test void usesOriginalDeadlineButActualReturnSupersedesTheDeadlineDate() {
|
||||||
|
when(repository.sources(from, to)).thenReturn(List.of(new ContainerCalendarSource(1L,"D1",null,null,null,null,null,null,"ESTIMATED")));
|
||||||
|
var row = mock(View.class);
|
||||||
|
when(row.actualEmptyReturnAt()).thenReturn(to.atStartOfDay());
|
||||||
|
when(row.emptyReturnWarning()).thenReturn(new Warning("RESOLVED", from.atStartOfDay(),0,0));
|
||||||
|
when(drayages.views(List.of(1L))).thenReturn(List.of(row));
|
||||||
|
when(drayages.authorize(anyList(), anySet())).thenAnswer(call -> call.getArgument(0));
|
||||||
|
assertTrue(service.calendar(from,to,Set.of("platform:firstleg:terminal-drayage:list")).emptyReturns().isEmpty());
|
||||||
|
when(row.actualEmptyReturnAt()).thenReturn(null);
|
||||||
|
assertEquals(List.of(row), service.calendar(from,to,Set.of("platform:firstleg:terminal-drayage:list")).emptyReturns());
|
||||||
|
}
|
||||||
|
/** 非法日期范围在访问持久化层前拒绝。 */
|
||||||
|
@Test void rejectsInvalidRangesBeforeDatabaseAccess() {
|
||||||
|
assertThrows(ResponseStatusException.class, () -> service.calendar(from,from,Set.of()));
|
||||||
|
assertThrows(ResponseStatusException.class, () -> service.calendar(from,from.plusDays(43),Set.of()));
|
||||||
|
verifyNoInteractions(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -9,6 +9,21 @@ import static org.mockito.Mockito.*;
|
|||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
class UnloadingEvidenceGuardTest {
|
class UnloadingEvidenceGuardTest {
|
||||||
|
@Test void batchRetainsInactiveMissingPartialAndZonedCompletionRules() {
|
||||||
|
var mapper=mock(DirectDeliveryMapper.class);var guard=new UnloadingEvidenceGuard(mapper);
|
||||||
|
var at=LocalDateTime.parse("2026-10-10T12:00:00");
|
||||||
|
var ids=List.of(1L,2L,3L,4L,5L,6L);
|
||||||
|
when(mapper.containers(ids)).thenReturn(List.of(new DirectDeliveryMapper.Container(1,"ACTIVE",at),
|
||||||
|
new DirectDeliveryMapper.Container(2,"ACTIVE",null),new DirectDeliveryMapper.Container(3,"ACTIVE",null),
|
||||||
|
new DirectDeliveryMapper.Container(4,"CANCELLED",at),new DirectDeliveryMapper.Container(5,"ACTIVE",null)));
|
||||||
|
when(mapper.deliveriesBatch(List.of(2L,3L,5L))).thenReturn(List.of(
|
||||||
|
new DirectDeliveryMapper.ContainerDelivery(2,20,"I20","O20","D",1,1,at,"America/New_York",null,null,null,null,null),
|
||||||
|
new DirectDeliveryMapper.ContainerDelivery(2,21,"I21","O21","D",1,1,at.minusHours(2),"America/Los_Angeles",null,null,null,null,null),
|
||||||
|
new DirectDeliveryMapper.ContainerDelivery(3,30,"I30","O30","D",1,1,at,"America/New_York",null,null,null,null,null),
|
||||||
|
new DirectDeliveryMapper.ContainerDelivery(3,31,"I31","O31","D",1,0,null,null,null,null,null,null,null)));
|
||||||
|
assertEquals(java.util.Map.of(1L,at,2L,at.minusHours(2)),guard.completedUnloading(ids));
|
||||||
|
verify(mapper).containers(ids);verify(mapper).deliveriesBatch(List.of(2L,3L,5L));verifyNoMoreInteractions(mapper);
|
||||||
|
}
|
||||||
@Test void requiresEveryPhysicalInboundSourceAndAcceptsLatestActualEvidence() {
|
@Test void requiresEveryPhysicalInboundSourceAndAcceptsLatestActualEvidence() {
|
||||||
var mapper=mock(DirectDeliveryMapper.class);var guard=new UnloadingEvidenceGuard(mapper);
|
var mapper=mock(DirectDeliveryMapper.class);var guard=new UnloadingEvidenceGuard(mapper);
|
||||||
var container=new DirectDeliveryMapper.Container(1,"ACTIVE",null);
|
var container=new DirectDeliveryMapper.Container(1,"ACTIVE",null);
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Gateway 模块指引
|
# Gateway 模块指引
|
||||||
|
|
||||||
|
## 数据库访问与中文注释约束
|
||||||
|
|
||||||
|
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||||
|
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||||
|
|
||||||
## 职责与边界
|
## 职责与边界
|
||||||
|
|
||||||
- 负责路由、CORS、JWT/Redis 会话校验、平台/客户访问域隔离、请求标识和 Swagger 聚合。
|
- 负责路由、CORS、JWT/Redis 会话校验、平台/客户访问域隔离、请求标识和 Swagger 聚合。
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# IAM Service 模块指引
|
# IAM Service 模块指引
|
||||||
|
|
||||||
|
## 数据库访问与中文注释约束
|
||||||
|
|
||||||
|
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||||
|
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||||
|
|
||||||
## 职责与边界
|
## 职责与边界
|
||||||
|
|
||||||
- Mapper 只由 `BaiyueIamApplication.java` 对 `com.baiyue.iam` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
|
- Mapper 只由 `BaiyueIamApplication.java` 对 `com.baiyue.iam` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Integration Service 模块指引
|
# Integration Service 模块指引
|
||||||
|
|
||||||
|
## 数据库访问与中文注释约束
|
||||||
|
|
||||||
|
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||||
|
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||||
|
|
||||||
## 职责与边界
|
## 职责与边界
|
||||||
|
|
||||||
- 负责百度 OCR、Amazon Logistics/SP-API、海外仓业务船司/船舶轨迹查询等第三方适配和能力开关;不保存业务主数据。
|
- 负责百度 OCR、Amazon Logistics/SP-API、海外仓业务船司/船舶轨迹查询等第三方适配和能力开关;不保存业务主数据。
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Lastleg Service 模块指引
|
# Lastleg Service 模块指引
|
||||||
|
|
||||||
|
## 数据库访问与中文注释约束
|
||||||
|
|
||||||
|
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||||
|
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||||
|
|
||||||
## 职责与边界
|
## 职责与边界
|
||||||
|
|
||||||
- Mapper 必须标注 `@Mapper`,仅由 `BaiyueLastlegApplication.java` 对 `com.baiyue.lastleg` 根包按 `annotationClass = Mapper.class` 扫描;禁止局部 `*MapperConfiguration` 和包白名单。验证:`scripts/mapper-scan-contract.mjs`、`MapperRegistrationArchitectureTest.java`。
|
- Mapper 必须标注 `@Mapper`,仅由 `BaiyueLastlegApplication.java` 对 `com.baiyue.lastleg` 根包按 `annotationClass = Mapper.class` 扫描;禁止局部 `*MapperConfiguration` 和包白名单。验证:`scripts/mapper-scan-contract.mjs`、`MapperRegistrationArchitectureTest.java`。
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
| 派送完成回传 | `delivery/service/DeliveryCompletedOutboxService.java`;签收资料保存并自动完成时在业务事务内按关联 `sourceInboundId` 分别写 `OVERSEAS_DELIVERY_COMPLETED` Outbox,同车不同 FBA 派送单互不代完成;不保留人工完成入口 |
|
| 派送完成回传 | `delivery/service/DeliveryCompletedOutboxService.java`;签收资料保存并自动完成时在业务事务内按关联 `sourceInboundId` 分别写 `OVERSEAS_DELIVERY_COMPLETED` Outbox,同车不同 FBA 派送单互不代完成;不保留人工完成入口 |
|
||||||
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
|
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
|
||||||
| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,排车和出库使用独立 PDA Controller |
|
| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,排车和出库使用独立 PDA Controller |
|
||||||
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;涉及箱唛换标时整张申请只能有一个 FBA 目的仓,服务端拒绝明细目的仓不一致的请求。普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核后等待客户查看照片确认。对外换标状态只投影为 `PENDING_PROCESSING/COMPLETED`(待处理/换标完成),`PROCESSING`、`CUSTOMER_CONFIRM_PENDING`、`READY_FOR_OUTBOUND` 只属于内部作业节点且不得直接出现在业务列表;客户确认后仅卡车派送进入 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 |
|
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;涉及箱唛换标时整张申请只能有一个 FBA 目的仓,服务端拒绝明细目的仓不一致的请求。普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/PICKUP/AMAZON_PICKUP/CUSTOMER_PICKUP`,仅快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核后等待客户查看照片确认。对外换标状态只投影为 `PENDING_PROCESSING/COMPLETED`(待处理/换标完成),`PROCESSING`、`CUSTOMER_CONFIRM_PENDING`、`READY_FOR_OUTBOUND` 只属于内部作业节点且不得直接出现在业务列表;客户确认后仅平台卡车 `TRUCK` 进入 `WAIT_PRE_DISPATCH`;快递及普通自提、亚马逊自提、客户自提均直接创建换标出库单,普通库存仍复验实际托盘绑定,已分配仓储转运保留原转运出库链。客户确认前不得进入出库 |
|
||||||
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token |
|
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token |
|
||||||
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;按 `containerBookingId` 柜级接收,信封使用 `tenantId=0` 且门禁不存客户;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 |
|
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;按 `containerBookingId` 柜级接收,信封使用 `tenantId=0` 且门禁不存客户;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 |
|
||||||
| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,路线由当前已排托盘的 FBA 仓编码去重后按稳定顺序以英文逗号连接,预排与普通编辑请求均不接收路线;查询、预排与执行边界见“海外仓全流程开发路由” |
|
| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,路线由当前已排托盘的 FBA 仓编码去重后按稳定顺序以英文逗号连接,预排与普通编辑请求均不接收路线;查询、预排与执行边界见“海外仓全流程开发路由” |
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 仓储转运上门取货与车辆排车
|
||||||
|
|
||||||
|
客户选择仓库上门取货并提交,平台审核通过后,原转运单进入车辆排车的“待取货”。一个取货车次可取多名客户的多票货,目的海外仓必须相同。客户自送、未审核、库存转运和测量已完成的订单不进入候选。
|
||||||
|
|
||||||
|
业务入口为 `fleet/pickup/PlatformFleetPickupController`,按 Controller → Service → Repository → Mapper 持久化,复用原车次编号、车辆司机分配和资源占用。`tripPurpose=PICKUP` 区别于原 `DELIVERY`,取货车次不关联出库排车、不发布拼车余位、不执行原出库装载和扣库存。
|
||||||
|
|
||||||
|
车次按 `PLANNED → IN_TRANSIT → COMPLETED` 执行。逐票取货任务为 `ASSIGNED → PICKED → ARRIVED`:发车复验车辆司机资格和实际占用;取货必须填写实提箱数并上传本票凭证;到仓后才开放原 PDA 清点测量。实提箱数独立保存,不覆盖客户预报箱数、SKU件数和箱唛。全车送仓可结束运输,车辆与司机任务分别沿原入口释放,不需要等仓内测量。
|
||||||
|
|
||||||
|
计划阶段取消车次会保留操作历史、释放资源和原单关联,使原单重新可安排;发车后禁止取消。原车次分配入口支持发车前改派。所有取货命令在车次行锁、版本校验和持久幂等摘要下执行;来源安排同时锁原单,避免一单多车。前端遇到结果未知保留原请求和请求键,阻止刷新卸载或改派后重新生成命令。
|
||||||
|
|
||||||
|
平台待取货使用服务端分页;客户详情、平台尾程详情按原转运单查询同一运输进度。凭证只允许当前单已确认引用,本车其他客户文件不能借用或下载。文件数据归 File 服务,Lastleg仅持有文件归属和引用。
|
||||||
|
|
||||||
|
公开接口与请求字段见 `src/main/resources/openapi/fleet-transfer-pickup-v1.yaml`。数据库增量为 `../../scripts/db/fleet-transfer-pickup-20261017.sql`,目标 `baiyue_lastleg`,可重复执行,无业务 DML。
|
||||||
|
|
||||||
|
本机验收入口 `../../scripts/local-transfer-pickup-acceptance.mjs` 创建带 TEST 标记的新业务记录并保存报告。覆盖待取货、未审核、自送、异仓拒绝、共车、取消改派、资源冲突、同键重放与异载荷拒绝、跨客户文件隔离、未送仓禁止PDA和送仓后完整测量。合成图片仅验证上传与文件归属,不证明物理取货。本机构建、单测和HTTP验收与远端部署、真实页面视觉验收分别记录。
|
||||||
@@ -25,4 +25,29 @@
|
|||||||
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
|
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
|
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
|
||||||
|
<profiles>
|
||||||
|
<profile>
|
||||||
|
<id>ci-bounded</id>
|
||||||
|
<build><plugins><plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>default-test</id>
|
||||||
|
<configuration><excludes><exclude>**/*SqlSemanticTest.java</exclude></excludes></configuration>
|
||||||
|
</execution>
|
||||||
|
<!-- H2复杂查询规划与普通单测分进程,仍沿用父级1GB上限并执行全部断言。 -->
|
||||||
|
<execution>
|
||||||
|
<id>sql-semantic-tests</id>
|
||||||
|
<phase>test</phase>
|
||||||
|
<goals><goal>test</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<includes><include>**/*SqlSemanticTest.java</include></includes>
|
||||||
|
<reuseForks>false</reuseForks>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin></plugins></build>
|
||||||
|
</profile>
|
||||||
|
</profiles>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
+22
@@ -16,6 +16,17 @@ public interface AppointmentCargoVerificationMapper extends BaseMapper<Appointme
|
|||||||
@Select("SELECT * FROM ll_appointment_cargo_verification WHERE customer_id=#{customerId} AND source_key=#{sourceKey} ORDER BY version DESC LIMIT 1")
|
@Select("SELECT * FROM ll_appointment_cargo_verification WHERE customer_id=#{customerId} AND source_key=#{sourceKey} ORDER BY version DESC LIMIT 1")
|
||||||
AppointmentCargoVerification latest(@Param("customerId") String customerId,@Param("sourceKey") String sourceKey);
|
AppointmentCargoVerification latest(@Param("customerId") String customerId,@Param("sourceKey") String sourceKey);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
<script>
|
||||||
|
SELECT ranked.* FROM (
|
||||||
|
SELECT v.*,ROW_NUMBER() OVER(PARTITION BY customer_id,source_key ORDER BY version DESC) work_row
|
||||||
|
FROM ll_appointment_cargo_verification v WHERE
|
||||||
|
<foreach collection='refs' item='ref' separator=' OR '>(customer_id=#{ref.customerId} AND source_key=#{ref.sourceKey})</foreach>
|
||||||
|
) ranked WHERE work_row=1
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
java.util.List<AppointmentCargoVerification> latestBatch(@Param("refs") java.util.List<AppointmentCoverageService.SourceRef> refs);
|
||||||
|
|
||||||
@Select("SELECT * FROM ll_appointment_cargo_verification WHERE customer_id=#{customerId} AND source_key=#{sourceKey} AND command_key=#{key}")
|
@Select("SELECT * FROM ll_appointment_cargo_verification WHERE customer_id=#{customerId} AND source_key=#{sourceKey} AND command_key=#{key}")
|
||||||
AppointmentCargoVerification command(@Param("customerId") String customerId,@Param("sourceKey") String sourceKey,@Param("key") String key);
|
AppointmentCargoVerification command(@Param("customerId") String customerId,@Param("sourceKey") String sourceKey,@Param("key") String key);
|
||||||
|
|
||||||
@@ -27,4 +38,15 @@ public interface AppointmentCargoVerificationMapper extends BaseMapper<Appointme
|
|||||||
WHERE customer_id=#{customerId} AND source_key=#{sourceKey} AND active=1
|
WHERE customer_id=#{customerId} AND source_key=#{sourceKey} AND active=1
|
||||||
""")
|
""")
|
||||||
Allocation allocated(@Param("customerId") String customerId,@Param("sourceKey") String sourceKey);
|
Allocation allocated(@Param("customerId") String customerId,@Param("sourceKey") String sourceKey);
|
||||||
|
|
||||||
|
record SourceAllocation(String customerId,String sourceKey,long packages,long unknownItems) { }
|
||||||
|
@Select("""
|
||||||
|
<script>
|
||||||
|
SELECT customer_id,source_key,COALESCE(SUM(package_count),0) packages,COUNT(*)-COUNT(package_count) unknown_items
|
||||||
|
FROM ll_inbound_fba_appointment_item WHERE active=1 AND (
|
||||||
|
<foreach collection='refs' item='ref' separator=' OR '>(customer_id=#{ref.customerId} AND source_key=#{ref.sourceKey})</foreach>
|
||||||
|
) GROUP BY customer_id,source_key
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
java.util.List<SourceAllocation> allocatedBatch(@Param("refs") java.util.List<AppointmentCoverageService.SourceRef> refs);
|
||||||
}
|
}
|
||||||
|
|||||||
+30
@@ -40,13 +40,43 @@ public class AppointmentCargoVerificationService {
|
|||||||
|
|
||||||
@Transactional(readOnly=true)
|
@Transactional(readOnly=true)
|
||||||
public Context get(String sourceKey,String customerId,String destinationKey) {
|
public Context get(String sourceKey,String customerId,String destinationKey) {
|
||||||
|
return inspect(sourceKey,customerId,destinationKey);
|
||||||
|
}
|
||||||
|
/** Caller-owned transaction: a handled read prerequisite must not mark the whole work scan rollback-only. */
|
||||||
|
public Context inspect(String sourceKey,String customerId,String destinationKey) {
|
||||||
var source=sources.resolve(sourceKey,customerId,destinationKey);
|
var source=sources.resolve(sourceKey,customerId,destinationKey);
|
||||||
return inspect(source);
|
return inspect(source);
|
||||||
}
|
}
|
||||||
|
public record Inspection(AppointmentCoverageService.SourceRef reference,Context context,ResponseStatusException error) {
|
||||||
|
public Context requireContext() { if(error!=null) throw error; return context; }
|
||||||
|
}
|
||||||
|
/** Shares source and warehouse reads within this call only; never caches mutable business facts across requests. */
|
||||||
|
public List<Inspection> inspectBatch(List<AppointmentCoverageService.SourceRef> refs) {
|
||||||
|
var result=new ArrayList<Inspection>();
|
||||||
|
for(int start=0;start<refs.size();start+=100) {
|
||||||
|
var batch=refs.subList(start,Math.min(start+100,refs.size()));
|
||||||
|
var resolved=sources.inspectBatch(batch);
|
||||||
|
var valid=resolved.stream().filter(r->r.error()==null).map(AppointmentSourceService.Resolution::reference).toList();
|
||||||
|
var latest=new HashMap<List<String>,AppointmentCargoVerification>();
|
||||||
|
var allocations=new HashMap<List<String>,AppointmentCargoVerificationMapper.Allocation>();
|
||||||
|
if(!valid.isEmpty()) {
|
||||||
|
mapper.latestBatch(valid).forEach(r->latest.put(List.of(r.getCustomerId(),r.getSourceKey()),r));
|
||||||
|
mapper.allocatedBatch(valid).forEach(r->allocations.put(List.of(r.customerId(),r.sourceKey()),new AppointmentCargoVerificationMapper.Allocation(r.packages(),r.unknownItems())));
|
||||||
|
}
|
||||||
|
for(var resolution:resolved) {
|
||||||
|
var key=List.of(resolution.reference().customerId(),resolution.reference().sourceKey());
|
||||||
|
result.add(new Inspection(resolution.reference(),resolution.error()==null?inspect(resolution.source(),latest.get(key),allocations.getOrDefault(key,new AppointmentCargoVerificationMapper.Allocation(0,0))):null,resolution.error()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.copyOf(result);
|
||||||
|
}
|
||||||
/** Uses an already-authorized batched source resolution; never repeats warehouse HTTP lookups per row. */
|
/** Uses an already-authorized batched source resolution; never repeats warehouse HTTP lookups per row. */
|
||||||
public Context inspect(AppointmentSourceService.Source source) {
|
public Context inspect(AppointmentSourceService.Source source) {
|
||||||
var row=mapper.latest(source.customerId(),source.sourceKey());
|
var row=mapper.latest(source.customerId(),source.sourceKey());
|
||||||
var allocation=mapper.allocated(source.customerId(),source.sourceKey());
|
var allocation=mapper.allocated(source.customerId(),source.sourceKey());
|
||||||
|
return inspect(source,row,allocation);
|
||||||
|
}
|
||||||
|
private Context inspect(AppointmentSourceService.Source source,AppointmentCargoVerification row,AppointmentCargoVerificationMapper.Allocation allocation) {
|
||||||
boolean changed=row!=null && changed(row,source);
|
boolean changed=row!=null && changed(row,source);
|
||||||
Integer total=row==null?source.packages():row.getPackageCount();
|
Integer total=row==null?source.packages():row.getPackageCount();
|
||||||
var reasons=new ArrayList<>(source.blockReasons());
|
var reasons=new ArrayList<>(source.blockReasons());
|
||||||
|
|||||||
+46
-7
@@ -58,14 +58,42 @@ public class AppointmentSourceService {
|
|||||||
return response.data().warehouse().timezone();
|
return response.data().warehouse().timezone();
|
||||||
} catch(RuntimeException error) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"目的仓时区读取失败,请稍后核对",error); }
|
} catch(RuntimeException error) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"目的仓时区读取失败,请稍后核对",error); }
|
||||||
}
|
}
|
||||||
/** One catalog request and batched source reads, including mixed first-leg and external bookings. */
|
/** 普通批量读取创建独立目录作用域,同时读取头程和外接登记来源。 */
|
||||||
public List<Source> resolveBatch(List<AppointmentCoverageService.SourceRef> requested) {
|
public List<Source> resolveBatch(List<AppointmentCoverageService.SourceRef> requested) {
|
||||||
|
return openReadScope().resolveBatch(requested);
|
||||||
|
}
|
||||||
|
/** 创建显式单次读取作用域;仅成功的目录结果可在本次生成中复用。 */
|
||||||
|
public ReadScope openReadScope() { return new ReadScope(); }
|
||||||
|
public final class ReadScope {
|
||||||
|
private List<AppointmentWarehouseClient.Warehouse> directory;
|
||||||
|
/** 只能由所属来源服务创建,不允许传入未经校验的目录。 */
|
||||||
|
private ReadScope() { }
|
||||||
|
/** 每次仍读取原订单和权限事实,仅目录共享;不保存来源解析结果。 */
|
||||||
|
public List<Source> resolveBatch(List<AppointmentCoverageService.SourceRef> requested) {
|
||||||
|
return AppointmentSourceService.this.resolveBatch(requested,true,this).stream().map(Resolution::requireSource).toList();
|
||||||
|
}
|
||||||
|
/** 读取失败不赋值,下次显式调用仍按原目录接口重读。 */
|
||||||
|
private List<AppointmentWarehouseClient.Warehouse> catalog() {
|
||||||
|
if(directory==null) directory=AppointmentSourceService.this.catalog();
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Read-only inspection keeps each source's prerequisite failure separate; infrastructure errors still escape. */
|
||||||
|
public record Resolution(AppointmentCoverageService.SourceRef reference,Source source,ResponseStatusException error) {
|
||||||
|
public Source requireSource() { if(error!=null) throw error; return source; }
|
||||||
|
}
|
||||||
|
/** 每次检查独立读取目录,保留逐来源前置条件失败与基础设施异常边界。 */
|
||||||
|
public List<Resolution> inspectBatch(List<AppointmentCoverageService.SourceRef> requested) {
|
||||||
|
return resolveBatch(requested,false,openReadScope());
|
||||||
|
}
|
||||||
|
/** 保留原批量校验与错误边界,目录只从调用方的局部作用域读取。 */
|
||||||
|
private List<Resolution> resolveBatch(List<AppointmentCoverageService.SourceRef> requested,boolean strict,ReadScope scope) {
|
||||||
if(requested==null || requested.isEmpty() || requested.size()>100) throw bad("每批请选择1至100票真实货源");
|
if(requested==null || requested.isEmpty() || requested.size()>100) throw bad("每批请选择1至100票真实货源");
|
||||||
var keys=new LinkedHashSet<String>(); var orderIds=new LinkedHashSet<Long>();
|
var keys=new LinkedHashSet<String>(); var orderIds=new LinkedHashSet<Long>();
|
||||||
var bookingRefs=new java.util.ArrayList<AppointmentExternalSourceGateway.SourceRef>();
|
var bookingRefs=new java.util.ArrayList<AppointmentExternalSourceGateway.SourceRef>();
|
||||||
for(var ref:requested) {
|
for(var ref:requested) {
|
||||||
if(ref==null || blank(ref.sourceKey()) || ref.sourceKey().length()>160 || blank(ref.customerId()) || ref.customerId().length()>128) throw bad("请选择完整的真实货源");
|
if(ref==null || blank(ref.sourceKey()) || ref.sourceKey().length()>160 || blank(ref.customerId()) || ref.customerId().length()>128) throw bad("请选择完整的真实货源");
|
||||||
if(!keys.add(ref.sourceKey())) throw bad("同一来源货物不能重复选择");
|
if(!keys.add(ref.sourceKey()) && strict) throw bad("同一来源货物不能重复选择");
|
||||||
if(ref.sourceKey().startsWith("ORDER:")) orderIds.add(orderId(ref.sourceKey()));
|
if(ref.sourceKey().startsWith("ORDER:")) orderIds.add(orderId(ref.sourceKey()));
|
||||||
else if(ref.sourceKey().startsWith("BOOKING:") && !blank(ref.sourceKey().substring(8))) bookingRefs.add(new AppointmentExternalSourceGateway.SourceRef(ref.customerId(),ref.sourceKey().substring(8)));
|
else if(ref.sourceKey().startsWith("BOOKING:") && !blank(ref.sourceKey().substring(8))) bookingRefs.add(new AppointmentExternalSourceGateway.SourceRef(ref.customerId(),ref.sourceKey().substring(8)));
|
||||||
else throw bad("不支持的货源类型");
|
else throw bad("不支持的货源类型");
|
||||||
@@ -77,17 +105,28 @@ public class AppointmentSourceService {
|
|||||||
if(booking.orderId()!=null) orderIds.add(booking.orderId());
|
if(booking.orderId()!=null) orderIds.add(booking.orderId());
|
||||||
}
|
}
|
||||||
var orderRows=orderIds.isEmpty()?List.<com.baiyue.lastleg.entity.LastlegOrder>of():orders.selectBatchIds(orderIds);
|
var orderRows=orderIds.isEmpty()?List.<com.baiyue.lastleg.entity.LastlegOrder>of():orders.selectBatchIds(orderIds);
|
||||||
var directOrders=requested.stream().filter(ref->ref.sourceKey().startsWith("ORDER:")).map(ref->new AppointmentExternalSourceGateway.OrderRef(ref.customerId(),orderId(ref.sourceKey()))).toList();
|
var directOrders=requested.stream().filter(ref->ref.sourceKey().startsWith("ORDER:")).map(ref->new AppointmentExternalSourceGateway.OrderRef(ref.customerId(),orderId(ref.sourceKey()))).distinct().toList();
|
||||||
if(!directOrders.isEmpty() && !external.resolveOrders(directOrders).isEmpty()) throw bad("该货物来自外接登记,请使用原登记来源继续预约");
|
var externalOrders=directOrders.isEmpty()?Set.<AppointmentExternalSourceGateway.OrderRef>of():external.resolveOrders(directOrders).stream()
|
||||||
|
.map(s->new AppointmentExternalSourceGateway.OrderRef(s.customerId(),s.orderId())).collect(Collectors.toSet());
|
||||||
|
if(strict && !externalOrders.isEmpty()) throw bad("该货物来自外接登记,请使用原登记来源继续预约");
|
||||||
var orderMap=orderRows.stream().collect(Collectors.toMap(com.baiyue.lastleg.entity.LastlegOrder::getId,Function.identity()));
|
var orderMap=orderRows.stream().collect(Collectors.toMap(com.baiyue.lastleg.entity.LastlegOrder::getId,Function.identity()));
|
||||||
var transferRefs=orderRows.stream().filter(o->"WAREHOUSE_TRANSFER".equals(o.getOrderType())).map(o->new AppointmentCoverageMapper.OrderRef(o.getCustomerId(),o.getId())).toList();
|
var transferRefs=orderRows.stream().filter(o->"WAREHOUSE_TRANSFER".equals(o.getOrderType())).map(o->new AppointmentCoverageMapper.OrderRef(o.getCustomerId(),o.getId())).toList();
|
||||||
var destinations=transferRefs.isEmpty()?Map.<Long,AppointmentCoverageMapper.Source>of():destinationFacts.sources(transferRefs).stream().collect(Collectors.toMap(AppointmentCoverageMapper.Source::orderId,Function.identity()));
|
var destinations=transferRefs.isEmpty()?Map.<Long,AppointmentCoverageMapper.Source>of():destinationFacts.sources(transferRefs).stream().collect(Collectors.toMap(AppointmentCoverageMapper.Source::orderId,Function.identity()));
|
||||||
var containerIds=orderRows.stream().filter(o->"FIRST_LEG".equals(o.getOrderType())).map(com.baiyue.lastleg.entity.LastlegOrder::getSourceContainerBookingId).filter(Objects::nonNull).distinct().toList();
|
var containerIds=orderRows.stream().filter(o->"FIRST_LEG".equals(o.getOrderType())).map(com.baiyue.lastleg.entity.LastlegOrder::getSourceContainerBookingId).filter(Objects::nonNull).distinct().toList();
|
||||||
var gates=containerIds.isEmpty()?Map.<String,Boolean>of():shipping.selectBatchIds(containerIds).stream().collect(Collectors.toMap(g->g.getContainerBookingId(),g->g.getNodeSequence()!=null && g.getNodeSequence()>=40));
|
var gates=containerIds.isEmpty()?Map.<String,Boolean>of():shipping.selectBatchIds(containerIds).stream().collect(Collectors.toMap(g->g.getContainerBookingId(),g->g.getNodeSequence()!=null && g.getNodeSequence()>=40));
|
||||||
var catalog=catalog();
|
var catalog=scope.catalog();
|
||||||
var result=requested.stream().map(ref->resolve(ref.sourceKey(),ref.customerId(),ref.destinationKey(),orderMap,bookingMap,gates,catalog,destinations)).toList();
|
var result=requested.stream().map(ref->{
|
||||||
|
try {
|
||||||
|
if(ref.sourceKey().startsWith("ORDER:") && externalOrders.contains(new AppointmentExternalSourceGateway.OrderRef(ref.customerId(),orderId(ref.sourceKey()))))
|
||||||
|
throw bad("该货物来自外接登记,请使用原登记来源继续预约");
|
||||||
|
return new Resolution(ref,resolve(ref.sourceKey(),ref.customerId(),ref.destinationKey(),orderMap,bookingMap,gates,catalog,destinations),null);
|
||||||
|
} catch(ResponseStatusException error) {
|
||||||
|
if(strict || error.getStatusCode().value()>=500) throw error;
|
||||||
|
return new Resolution(ref,null,error);
|
||||||
|
}
|
||||||
|
}).toList();
|
||||||
var materialized=new java.util.HashSet<Long>();
|
var materialized=new java.util.HashSet<Long>();
|
||||||
for(var source:result) if(source.orderId()!=null && !materialized.add(source.orderId())) throw bad("同一货物存在重复来源,请保留原登记来源");
|
if(strict) for(var resolution:result) { var source=resolution.requireSource(); if(source.orderId()!=null && !materialized.add(source.orderId())) throw bad("同一货物存在重复来源,请保留原登记来源"); }
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
private Source resolve(String sourceKey,String customerId,String destinationKey,
|
private Source resolve(String sourceKey,String customerId,String destinationKey,
|
||||||
|
|||||||
+6
-4
@@ -4,13 +4,15 @@ import org.springframework.stereotype.Component;
|
|||||||
import org.springframework.transaction.annotation.Propagation;
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
/** Original business commands invalidate the manifest atomically with their business write. */
|
/** 原业务写入与工作清单失效保持在同一事务,不延后或绕过版本门禁。 */
|
||||||
@Component
|
@Component
|
||||||
public class AppointmentWorkChanges {
|
public class AppointmentWorkChanges {
|
||||||
private final AppointmentWorkMapper mapper;
|
private final AppointmentWorkStore store;
|
||||||
public AppointmentWorkChanges(AppointmentWorkMapper mapper) { this.mapper=mapper; }
|
/** 复用工作事项持久层,统一记录守卫更新耗时。 */
|
||||||
|
public AppointmentWorkChanges(AppointmentWorkStore store) { this.store=store; }
|
||||||
|
/** 只标记需求变化,保留调用方原事务和异常语义。 */
|
||||||
@Transactional(propagation=Propagation.MANDATORY)
|
@Transactional(propagation=Propagation.MANDATORY)
|
||||||
public void requirementsChanged() {
|
public void requirementsChanged() {
|
||||||
if(mapper.invalidateRequirements()!=1) throw AppointmentWorkPolicy.unavailable("工作事项同步守卫未初始化");
|
store.invalidateRequirements();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-1
@@ -63,7 +63,9 @@ public class AppointmentWorkCommandService {
|
|||||||
fields(payload,Set.of("reviewVersion","note"),Set.of("reviewVersion","note"));
|
fields(payload,Set.of("reviewVersion","note"),Set.of("reviewVersion","note"));
|
||||||
int version=integer(payload,"reviewVersion");
|
int version=integer(payload,"reviewVersion");
|
||||||
if(!state.isReviewRequired() || state.getReviewVersion()!=version) throw conflict("影响版本已变化,请重新核对");
|
if(!state.isReviewRequired() || state.getReviewVersion()!=version) throw conflict("影响版本已变化,请重新核对");
|
||||||
if(changed(state,fact)) throw conflict("原业务已有更新,须先同步最新影响");
|
if(changed(state,fact) || fact!=null && state.getObservedImpactToken()!=null
|
||||||
|
&& !Objects.equals(state.getObservedImpactToken(),fact.impactToken())) throw conflict("原业务已有更新,须先同步最新影响");
|
||||||
|
requireCurrentImpacts(state);
|
||||||
if(fact!=null && !fact.blockReasons().isEmpty()) throw conflict("原业务仍有阻断,请先处理原安排");
|
if(fact!=null && !fact.blockReasons().isEmpty()) throw conflict("原业务仍有阻断,请先处理原安排");
|
||||||
summary=text(payload,"note",true,1000); state.setReviewRequired(false); state.setReviewReason(null);
|
summary=text(payload,"note",true,1000); state.setReviewRequired(false); state.setReviewReason(null);
|
||||||
}
|
}
|
||||||
@@ -114,6 +116,20 @@ public class AppointmentWorkCommandService {
|
|||||||
store.history(state,actor,request.command().name(),summary,request.idempotencyKey(),hash,result);
|
store.history(state,actor,request.command().name(),summary,request.idempotencyKey(),hash,result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
private void requireCurrentImpacts(AppointmentWorkState state) {
|
||||||
|
store.requireInitialized();
|
||||||
|
var prerequisites=AppointmentWorkSyncService.impactPrerequisites(state,store.all());
|
||||||
|
if(prerequisites.isEmpty()) return;
|
||||||
|
var current=gateway.facts(prerequisites);
|
||||||
|
for(var prior:prerequisites) {
|
||||||
|
if(prior.getType()==Type.ROUTE) continue;
|
||||||
|
var fact=current.get(prior.getWorkItemKey());
|
||||||
|
if(fact==null) throw unavailable("关联安排权威事实缺失,不能完成复核");
|
||||||
|
if(prior.isObservedArranged() && prior.getObservedDependentImpactToken()!=null
|
||||||
|
&& !Objects.equals(prior.getObservedDependentImpactToken(),fact.dependentImpactToken()))
|
||||||
|
throw conflict("关联安排已有新变化,须先同步最新影响再复核");
|
||||||
|
}
|
||||||
|
}
|
||||||
private List<String> markDownstream(AppointmentWorkState source,String reason,Actor actor) {
|
private List<String> markDownstream(AppointmentWorkState source,String reason,Actor actor) {
|
||||||
var states=store.all(); var keys=new LinkedHashSet<String>(); AppointmentWorkSyncService.expandDependents(source.getWorkItemKey(),states,keys); keys.remove(source.getWorkItemKey());
|
var states=store.all(); var keys=new LinkedHashSet<String>(); AppointmentWorkSyncService.expandDependents(source.getWorkItemKey(),states,keys); keys.remove(source.getWorkItemKey());
|
||||||
var candidates=states.stream().filter(s->keys.contains(s.getWorkItemKey()) && !s.isRetired()).toList(); var facts=gateway.facts(candidates); var affected=new ArrayList<String>();
|
var candidates=states.stream().filter(s->keys.contains(s.getWorkItemKey()) && !s.isRetired()).toList(); var facts=gateway.facts(candidates); var affected=new ArrayList<String>();
|
||||||
|
|||||||
+6
@@ -29,6 +29,12 @@ public class AppointmentWorkCommitmentService {
|
|||||||
var row=mapper.commitment(key);
|
var row=mapper.commitment(key);
|
||||||
return key+":"+(row==null?0:row.getImpactVersion());
|
return key+":"+(row==null?0:row.getImpactVersion());
|
||||||
}
|
}
|
||||||
|
public java.util.Map<String,String> impactTokens(java.util.List<String> keys) {
|
||||||
|
var tokens=new java.util.HashMap<String,String>();
|
||||||
|
keys.forEach(key->tokens.put(key,key+":0"));
|
||||||
|
if(!keys.isEmpty()) mapper.commitments(keys).forEach(row->tokens.put(row.getEntityKey(),row.getEntityKey()+":"+row.getImpactVersion()));
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
public static boolean impacts(Commitment old,Commitment now) {
|
public static boolean impacts(Commitment old,Commitment now) {
|
||||||
if(old.cancelled()!=now.cancelled()) return true;
|
if(old.cancelled()!=now.cancelled()) return true;
|
||||||
boolean firstSameDayConfirmation=old.confirmed()==null && now.confirmed()!=null && old.target()!=null && old.target().date().equals(now.confirmed().date());
|
boolean firstSameDayConfirmation=old.confirmed()==null && now.confirmed()!=null && old.target()!=null && old.target().date().equals(now.confirmed().date());
|
||||||
|
|||||||
+4
@@ -33,6 +33,10 @@ public class AppointmentWorkController {
|
|||||||
@RequestParam(required=false) String containerBookingId,@RequestParam(required=false) Bucket bucket,@RequestParam(required=false) String keyword) {
|
@RequestParam(required=false) String containerBookingId,@RequestParam(required=false) Bucket bucket,@RequestParam(required=false) String keyword) {
|
||||||
return ApiResponse.success(queries.items(view,page,size,date,flow,types,containerBookingId,bucket,keyword,actor(user)));
|
return ApiResponse.success(queries.items(view,page,size,date,flow,types,containerBookingId,bucket,keyword,actor(user)));
|
||||||
}
|
}
|
||||||
|
@GetMapping("/items/resolve")
|
||||||
|
public ApiResponse<BatchWorkResolution> resolve(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String batchKey,@RequestParam(required=false) String customerId) {
|
||||||
|
return ApiResponse.success(queries.resolveBatch(batchKey,customerId,actor(user)));
|
||||||
|
}
|
||||||
@GetMapping("/items/{workItemKey}")
|
@GetMapping("/items/{workItemKey}")
|
||||||
public ApiResponse<WorkDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String workItemKey) {
|
public ApiResponse<WorkDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String workItemKey) {
|
||||||
return ApiResponse.success(queries.detail(workItemKey,actor(user)));
|
return ApiResponse.success(queries.detail(workItemKey,actor(user)));
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ public class AppointmentWorkDirectTransportProvider implements BusinessProvider
|
|||||||
done?ExecutionStatus.COMPLETED:carrier.executionStatus()==ExecutionStatus.COMPLETED?ExecutionStatus.IN_PROGRESS:ExecutionStatus.PENDING,
|
done?ExecutionStatus.COMPLETED:carrier.executionStatus()==ExecutionStatus.COMPLETED?ExecutionStatus.IN_PROGRESS:ExecutionStatus.PENDING,
|
||||||
carrier.target(),carrier.confirmed(),done?delivery.actual():null,done?delivery.timezone():carrier.timezone(),delivery.businessNo(),null,
|
carrier.target(),carrier.confirmed(),done?delivery.actual():null,done?delivery.timezone():carrier.timezone(),delivery.businessNo(),null,
|
||||||
List.copyOf(read),action==null?List.of():carrier.executionStatus()==ExecutionStatus.COMPLETED?delivery.actionPermissions():carrier.actionPermissions(),action,
|
List.copyOf(read),action==null?List.of():carrier.executionStatus()==ExecutionStatus.COMPLETED?delivery.actionPermissions():carrier.actionPermissions(),action,
|
||||||
done?List.of():carrier.executionStatus()==ExecutionStatus.COMPLETED?delivery.blockReasons():carrier.blockReasons(),List.copyOf(missing),List.copyOf(refs),carrier.responsibility());
|
done?List.of():carrier.executionStatus()==ExecutionStatus.COMPLETED?delivery.blockReasons():carrier.blockReasons(),List.copyOf(missing),List.copyOf(refs),carrier.responsibility(),null);
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
private FactRequest request(FactRequest r,Type type,String suffix) { return new FactRequest(r.workItemKey()+suffix,type,r.flow(),r.containerBookingId(),r.requirementKey(),r.batchKey(),null,r.batchKeys(),r.containers(),r.sources()); }
|
private FactRequest request(FactRequest r,Type type,String suffix) { return new FactRequest(r.workItemKey()+suffix,type,r.flow(),r.containerBookingId(),r.requirementKey(),r.batchKey(),null,r.batchKeys(),r.containers(),r.sources()); }
|
||||||
|
|||||||
+6
@@ -8,9 +8,15 @@ public interface AppointmentWorkDispatchPort {
|
|||||||
record Allocation(@NotNull Long planPalletId,@NotBlank String batchKey,@NotBlank String customerId,
|
record Allocation(@NotNull Long planPalletId,@NotBlank String batchKey,@NotBlank String customerId,
|
||||||
@NotBlank String sourceKey,@NotBlank String destinationKey,@Positive int packageCount) { }
|
@NotBlank String sourceKey,@NotBlank String destinationKey,@Positive int packageCount) { }
|
||||||
record PhysicalItem(Long itemId,Long planPalletId,String sourceStage,Integer packages) { }
|
record PhysicalItem(Long itemId,Long planPalletId,String sourceStage,Integer packages) { }
|
||||||
|
record BatchSource(String sourceKey,String destinationKey,int packageCount) { }
|
||||||
|
record BatchReference(String batchKey,String batchNo,String customerId,List<BatchSource> sources) { }
|
||||||
|
/** Current original allocations, grouped for authorized dispatches; historical copies are read-only evidence. */
|
||||||
|
java.util.Map<Long,List<BatchReference>> references(List<Long> dispatchIds);
|
||||||
void bind(String customerId,long dispatchId,List<PhysicalItem> items,List<Allocation> allocations);
|
void bind(String customerId,long dispatchId,List<PhysicalItem> items,List<Allocation> allocations);
|
||||||
void requireReplay(String customerId,long dispatchId,List<Allocation> allocations);
|
void requireReplay(String customerId,long dispatchId,List<Allocation> allocations);
|
||||||
void requireDeparture(String dispatchNo);
|
void requireDeparture(String dispatchNo);
|
||||||
|
/** Read-only explanation; departure must repeat the same authority checks while holding batch locks. */
|
||||||
|
String departureBlockReason(String dispatchNo);
|
||||||
void removeItem(long dispatchId,long itemId);
|
void removeItem(long dispatchId,long itemId);
|
||||||
void requireTripBinding(String dispatchNo,long tripId);
|
void requireTripBinding(String dispatchNo,long tripId);
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-12
@@ -4,12 +4,14 @@ import org.springframework.beans.factory.ObjectProvider;
|
|||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
|
||||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
|
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
|
||||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
|
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
|
||||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
|
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway {
|
public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway {
|
||||||
|
private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(AppointmentWorkFactAdapter.class);
|
||||||
private final AppointmentWorkFirstlegClient firstleg;
|
private final AppointmentWorkFirstlegClient firstleg;
|
||||||
private final List<BusinessProvider> providers;
|
private final List<BusinessProvider> providers;
|
||||||
private final ObjectProvider<OwnerDirectory> directory;
|
private final ObjectProvider<OwnerDirectory> directory;
|
||||||
@@ -38,7 +40,10 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway {
|
|||||||
return response.data();
|
return response.data();
|
||||||
} catch(RuntimeException error) { throw unavailable("柜次权威事实不可用",error); }
|
} catch(RuntimeException error) { throw unavailable("柜次权威事实不可用",error); }
|
||||||
}
|
}
|
||||||
|
/** 原顺序读取全部事实;慢调用和失败仅附加无业务内容的局部统计。 */
|
||||||
public Map<String,Fact> facts(List<AppointmentWorkState> states) {
|
public Map<String,Fact> facts(List<AppointmentWorkState> states) {
|
||||||
|
var timing=new FactTiming();boolean success=false;
|
||||||
|
try {
|
||||||
var result=new LinkedHashMap<String,Fact>();
|
var result=new LinkedHashMap<String,Fact>();
|
||||||
var remote=new ArrayList<FactRequest>();
|
var remote=new ArrayList<FactRequest>();
|
||||||
var groups=new LinkedHashMap<BusinessProvider,List<FactRequest>>();
|
var groups=new LinkedHashMap<BusinessProvider,List<FactRequest>>();
|
||||||
@@ -53,36 +58,99 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for(int start=0; start<remote.size(); start+=200) {
|
for(int start=0; start<remote.size(); start+=200) {
|
||||||
var requests=remote.subList(start,Math.min(start+200,remote.size())); requireToken();
|
var requests=remote.subList(start,Math.min(start+200,remote.size())); timing.stage="FIRSTLEG"; requireToken();
|
||||||
try { var response=firstleg.facts(token,requests);
|
try { var response=timing.measure(timing.firstleg,requests.size(),()->firstleg.facts(token,requests));
|
||||||
if(response==null || !response.successful()) throw unavailable("提柜及还空事实读取失败");
|
if(response==null || !response.successful()) throw unavailable("提柜及还空事实读取失败");
|
||||||
append(result,requests,response.data());
|
run(Stage.FACTS_VALIDATE,()->append(result,requests,response.data()));
|
||||||
} catch(RuntimeException error) { throw unavailable("Firstleg工作事实不可用",error); }
|
} catch(RuntimeException error) { throw unavailable("Firstleg工作事实不可用",error); }
|
||||||
}
|
}
|
||||||
groups.forEach((provider,requests)->{
|
groups.forEach((provider,requests)->{
|
||||||
for(int start=0; start<requests.size(); start+=200) {
|
for(int start=0; start<requests.size(); start+=200) {
|
||||||
var batch=requests.subList(start,Math.min(start+200,requests.size()));
|
var batch=requests.subList(start,Math.min(start+200,requests.size()));
|
||||||
try { append(result,batch,provider.read(batch)); }
|
var statistics=timing.provider(provider);
|
||||||
|
try { var response=timing.measure(statistics,batch.size(),()->provider.read(batch));run(Stage.FACTS_VALIDATE,()->append(result,batch,response)); }
|
||||||
catch(RuntimeException error) { throw unavailable("预约/收货/运输事实不可用",error); }
|
catch(RuntimeException error) { throw unavailable("预约/收货/运输事实不可用",error); }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return result;
|
success=true;return result;
|
||||||
|
} finally { timing.finish(success); }
|
||||||
}
|
}
|
||||||
public List<Requirement> requirements(ContainerSource source,Flow effectiveFlow) {
|
/** 一次 facts 调用的计时,不保存原来源或返回值,不跨调用缓存。 */
|
||||||
var provider=requirements.getIfAvailable();
|
private static final class FactTiming {
|
||||||
if(provider==null) throw unavailable("原预约需求与批次清单适配尚未接入,不能生成不完整工作清单");
|
private final Span prepare=span(Stage.FACTS_PREPARE);
|
||||||
var result=provider.requirements(source,effectiveFlow);
|
private final long started=System.nanoTime();
|
||||||
if(result==null || result.isEmpty()) throw unavailable("原预约需求清单缺失,不能以空清单完成路线生成");
|
private final CallStatistics firstleg=new CallStatistics();
|
||||||
return List.copyOf(result);
|
private final Map<String,CallStatistics> providers=new LinkedHashMap<>();
|
||||||
|
private String stage="PREPARE";
|
||||||
|
/** 固定诊断桶不使用代理名、业务参数或异常文本。 */
|
||||||
|
private CallStatistics provider(BusinessProvider provider) {
|
||||||
|
String name=org.springframework.util.ClassUtils.getUserClass(provider).getSimpleName();
|
||||||
|
name=switch(name) {
|
||||||
|
case "AppointmentWorkReceivingProvider","AppointmentWorkDeliveryProvider","AppointmentWorkTransportProvider","AppointmentWorkDirectTransportProvider" -> name;
|
||||||
|
default -> "OTHER";
|
||||||
|
};
|
||||||
|
stage=name;
|
||||||
|
return providers.computeIfAbsent(name,ignored->new CallStatistics());
|
||||||
|
}
|
||||||
|
/** 只统计真正尝试的提供端调用;校验及组装耗时保留在 totalMs 中。 */
|
||||||
|
private <T> T measure(CallStatistics statistics,int requests,java.util.function.Supplier<T> operation) {
|
||||||
|
prepare.close();statistics.calls++;statistics.requests+=requests;long begin=System.nanoTime();
|
||||||
|
try(var span=span(switch(stage){case "FIRSTLEG"->Stage.FACTS_FIRSTLEG;case "AppointmentWorkReceivingProvider"->Stage.FACTS_RECEIVING;
|
||||||
|
case "AppointmentWorkDeliveryProvider"->Stage.FACTS_DELIVERY;case "AppointmentWorkTransportProvider"->Stage.FACTS_TRANSPORT;
|
||||||
|
case "AppointmentWorkDirectTransportProvider"->Stage.FACTS_DIRECT_TRANSPORT;default->Stage.FACTS_OTHER;})){
|
||||||
|
try{return operation.get();}catch(RuntimeException|Error error){span.failed();throw error;}
|
||||||
|
}finally{statistics.elapsed+=System.nanoTime()-begin;}
|
||||||
|
}
|
||||||
|
/** 成功慢调用或任意失败只输出一次汇总,日志故障不得遮蔽原异常。 */
|
||||||
|
private void finish(boolean success) {
|
||||||
|
if(!success)prepare.failed();prepare.close();
|
||||||
|
long totalMs=java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime()-started);
|
||||||
|
if(success && totalMs<250) return;
|
||||||
|
try {
|
||||||
|
var summaries=new LinkedHashMap<String,String>();
|
||||||
|
providers.forEach((name,value)->summaries.put(name,value.summary()));
|
||||||
|
LOG.warn("Appointment work facts diagnostic: totalMs={}, firstlegCalls={}, firstlegRequests={}, firstlegMs={}, providers={}, failedStage={}, requestId={}, traceId={}, scopeId={}",
|
||||||
|
totalMs,firstleg.calls,firstleg.requests,firstleg.millis(),summaries,success?"NONE":stage,org.slf4j.MDC.get("requestId"),org.slf4j.MDC.get("traceId"),currentScopeId());
|
||||||
|
} catch(Throwable diagnosticFailure) { /* 仅忽略诊断故障,原事实错误仍沿原路径抛出。 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** 仅包含数量和单调时钟耗时的累加器。 */
|
||||||
|
private static final class CallStatistics {
|
||||||
|
private int calls,requests;private long elapsed;
|
||||||
|
/** 输出整毫秒,未计入网络之后的事实完整性校验。 */
|
||||||
|
private long millis() { return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(elapsed); }
|
||||||
|
/** 固定字段格式便于日志汇总,不包含任何请求或响应内容。 */
|
||||||
|
private String summary() { return "calls="+calls+",requests="+requests+",ms="+millis(); }
|
||||||
|
}
|
||||||
|
/** 每次生成建立独立读取器;没有实际需求读取时不引入原业务依赖。 */
|
||||||
|
public RequirementReader openRequirements() {
|
||||||
|
return new RequirementReader() {
|
||||||
|
private RequirementReader reader;
|
||||||
|
/** 首次实际读取才取得原域作用域,保留每份需求的完整性校验。 */
|
||||||
|
public List<Requirement> read(ContainerSource source,Flow effectiveFlow) {
|
||||||
|
if(reader==null) {
|
||||||
|
var provider=requirements.getIfAvailable();
|
||||||
|
if(provider==null) throw unavailable("原预约需求与批次清单适配尚未接入,不能生成不完整工作清单");
|
||||||
|
reader=provider.openRequirements();
|
||||||
|
}
|
||||||
|
var result=reader.read(source,effectiveFlow);
|
||||||
|
if(result==null || result.isEmpty()) throw unavailable("原预约需求清单缺失,不能以空清单完成路线生成");
|
||||||
|
return List.copyOf(result);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
static void append(Map<String,Fact> target,List<FactRequest> requests,List<Fact> facts) {
|
static void append(Map<String,Fact> target,List<FactRequest> requests,List<Fact> facts) {
|
||||||
if(facts==null || facts.size()!=requests.size()) throw unavailable("工作事实响应不完整");
|
if(facts==null || facts.size()!=requests.size()) throw unavailable("工作事实响应不完整");
|
||||||
var expected=new HashSet<>(requests.stream().map(FactRequest::workItemKey).toList());
|
var requestByKey=new HashMap<String,FactRequest>();requests.forEach(r->requestByKey.put(r.workItemKey(),r));
|
||||||
|
var expected=new HashSet<>(requestByKey.keySet());
|
||||||
for(var fact:facts) {
|
for(var fact:facts) {
|
||||||
if(fact==null || !expected.remove(fact.workItemKey()) || fact.fingerprint()==null || fact.fingerprint().isBlank() || fact.impactToken()==null || fact.impactToken().isBlank() || fact.planStatus()==null || fact.executionStatus()==null
|
if(fact==null || !expected.remove(fact.workItemKey()) || fact.fingerprint()==null || fact.fingerprint().isBlank() || fact.impactToken()==null || fact.impactToken().isBlank() || fact.planStatus()==null || fact.executionStatus()==null
|
||||||
|| fact.readPermissions()==null || fact.readPermissions().isEmpty() || fact.actionPermissions()==null || fact.blockReasons()==null || fact.missingFields()==null || fact.references()==null)
|
|| fact.readPermissions()==null || fact.readPermissions().isEmpty() || fact.actionPermissions()==null || fact.blockReasons()==null || fact.missingFields()==null || fact.references()==null)
|
||||||
throw unavailable("工作事实缺少身份、版本或资格");
|
throw unavailable("工作事实缺少身份、版本或资格");
|
||||||
if(fact.executionStatus()==ExecutionStatus.COMPLETED && fact.actual()==null) throw unavailable("完成事实缺少实际时间");
|
if(fact.executionStatus()==ExecutionStatus.COMPLETED && fact.actual()==null) throw unavailable("完成事实缺少实际时间");
|
||||||
|
var request=requestByKey.get(fact.workItemKey());
|
||||||
|
if(request.type()==Type.TRANSPORT && request.flow()==Flow.TRANSFER && (fact.transportPlanImpactToken()==null || fact.transportPlanImpactToken().isBlank()))
|
||||||
|
throw unavailable("共享车次事实缺少独立计划影响版本");
|
||||||
if(fact.actual()!=null) validateActual(fact.actual());
|
if(fact.actual()!=null) validateActual(fact.actual());
|
||||||
if(!fact.planExists() && (fact.target()!=null || fact.confirmed()!=null || fact.planStatus()==PlanStatus.CONFIRMED)) throw unavailable("原计划存在性与计划结果冲突");
|
if(!fact.planExists() && (fact.target()!=null || fact.confirmed()!=null || fact.planStatus()==PlanStatus.CONFIRMED)) throw unavailable("原计划存在性与计划结果冲突");
|
||||||
validateSourceSlot(fact.target()); validateSourceSlot(fact.confirmed());
|
validateSourceSlot(fact.target()); validateSourceSlot(fact.confirmed());
|
||||||
|
|||||||
+9
-5
@@ -20,13 +20,15 @@ public interface AppointmentWorkFactGateway {
|
|||||||
ExecutionStatus executionStatus, Slot target, Slot confirmed, Actual actual, String timezone,
|
ExecutionStatus executionStatus, Slot target, Slot confirmed, Actual actual, String timezone,
|
||||||
String businessNo, String tripKey, List<String> readPermissions, List<String> actionPermissions,
|
String businessNo, String tripKey, List<String> readPermissions, List<String> actionPermissions,
|
||||||
Action action, List<String> blockReasons, List<String> missingFields, List<BusinessReference> references,
|
Action action, List<String> blockReasons, List<String> missingFields, List<BusinessReference> references,
|
||||||
Responsibility responsibility) {
|
Responsibility responsibility, String transportPlanImpactToken) {
|
||||||
public boolean arranged() { return planStatus == PlanStatus.CONFIRMED || planStatus == PlanStatus.NOT_REQUIRED || target != null; }
|
/** Shared fleet transport separates its own cargo impact from changes affecting receiving appointments. */
|
||||||
|
public String dependentImpactToken() { return transportPlanImpactToken==null?impactToken:transportPlanImpactToken; }
|
||||||
|
public boolean arranged() { return planStatus == PlanStatus.CONFIRMED || planStatus == PlanStatus.NOT_REQUIRED || target != null || confirmed != null; }
|
||||||
}
|
}
|
||||||
SourcePage discover(String after, int size);
|
SourcePage discover(String after, int size);
|
||||||
ContainerSource container(String containerBookingId);
|
ContainerSource container(String containerBookingId);
|
||||||
/** Original appointment core supplies the complete current obligation/batch manifest, including explicit replacement keys. */
|
/** 建立本次原预约需求读取作用域,保持完整批次及明确替代关系。 */
|
||||||
List<Requirement> requirements(ContainerSource source,Flow effectiveFlow);
|
RequirementReader openRequirements();
|
||||||
Map<String, Fact> facts(List<AppointmentWorkState> states);
|
Map<String, Fact> facts(List<AppointmentWorkState> states);
|
||||||
Owner owner(String id, Actor actor);
|
Owner owner(String id, Actor actor);
|
||||||
Responsibility supplier(String id);
|
Responsibility supplier(String id);
|
||||||
@@ -39,5 +41,7 @@ public interface AppointmentWorkFactGateway {
|
|||||||
/** IAM-backed active platform operators; display names supplied by callers are never trusted. */
|
/** IAM-backed active platform operators; display names supplied by callers are never trusted. */
|
||||||
interface OwnerDirectory { Owner activePlatformOwner(String id); }
|
interface OwnerDirectory { Owner activePlatformOwner(String id); }
|
||||||
interface SupplierDirectory { Responsibility activeSupplier(String id); }
|
interface SupplierDirectory { Responsibility activeSupplier(String id); }
|
||||||
interface RequirementProvider { List<Requirement> requirements(ContainerSource source,Flow effectiveFlow); }
|
/** 单次生成显式复用目录,不跨请求保存来源事实。 */
|
||||||
|
interface RequirementReader { List<Requirement> read(ContainerSource source,Flow effectiveFlow); }
|
||||||
|
interface RequirementProvider { RequirementReader openRequirements(); }
|
||||||
}
|
}
|
||||||
|
|||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
package com.baiyue.lastleg.appointmentwork;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.*;
|
||||||
|
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
|
||||||
|
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
|
||||||
|
|
||||||
|
/** A future work key may receive an audited follow-up seed before source discovery creates its work row. */
|
||||||
|
@Component
|
||||||
|
public class AppointmentWorkFollowupInheritance {
|
||||||
|
public record Member(String batchKey,String customerId) { }
|
||||||
|
private final AppointmentWorkStore store;
|
||||||
|
private final AppointmentWorkFactGateway.OwnerDirectory directory;
|
||||||
|
public AppointmentWorkFollowupInheritance(AppointmentWorkStore store,AppointmentWorkFactGateway.OwnerDirectory directory) {
|
||||||
|
this.store=store;this.directory=directory;
|
||||||
|
}
|
||||||
|
public AppointmentWorkStore.Followup resolve(String ownerId,LocalDate nextFollowupDate) {
|
||||||
|
if(ownerId==null || ownerId.isBlank()) throw bad("请选择有效平台负责人");
|
||||||
|
var owner=directory.activePlatformOwner(ownerId);
|
||||||
|
if(owner==null || !ownerId.equals(owner.id()) || owner.name()==null || owner.name().isBlank()) throw bad("负责人不是有效平台操作人员");
|
||||||
|
return new AppointmentWorkStore.Followup(owner,nextFollowupDate);
|
||||||
|
}
|
||||||
|
@Transactional(propagation=Propagation.MANDATORY)
|
||||||
|
public void seed(Member original,Member later,AppointmentWorkStore.Followup followup,String operatorId) {
|
||||||
|
key(original);key(later);
|
||||||
|
if(!original.customerId().equals(later.customerId()) || original.batchKey().equals(later.batchKey())) throw bad("跟进继承必须来自同一客户的原批次和新批次");
|
||||||
|
if(followup==null || followup.owner()==null || followup.owner().id()==null || followup.owner().id().isBlank() || followup.owner().name()==null || followup.owner().name().isBlank()) throw bad("后送跟进缺少真实负责人");
|
||||||
|
store.lock();write(later,followup,operatorId);
|
||||||
|
}
|
||||||
|
@Transactional(propagation=Propagation.MANDATORY)
|
||||||
|
public void inherit(List<Member> originals,List<Member> successors,String operatorId) {
|
||||||
|
validate(originals);validate(successors);store.lock();
|
||||||
|
var values=new HashMap<String,Set<AppointmentWorkStore.Followup>>();
|
||||||
|
for(var original:originals) {
|
||||||
|
var key=key(original);var state=store.find(key);
|
||||||
|
var current=state==null?store.inheritedFollowup(key):new AppointmentWorkStore.Followup(state.getOwner(),state.getFollowupDate());
|
||||||
|
if(current!=null) values.computeIfAbsent(original.customerId(),ignored->new HashSet<>()).add(current);
|
||||||
|
}
|
||||||
|
// Validate every target before writing; caller transaction still supplies rollback for all original-domain writes.
|
||||||
|
for(var successor:successors) if(values.getOrDefault(successor.customerId(),Set.of()).size()>1)
|
||||||
|
throw conflict("合批来源的负责人或跟进日期不同,请先在原工作事项统一后再合批");
|
||||||
|
for(var successor:successors) {
|
||||||
|
var current=values.getOrDefault(successor.customerId(),Set.of());
|
||||||
|
if(!current.isEmpty()) write(successor,current.iterator().next(),operatorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private void write(Member target,AppointmentWorkStore.Followup followup,String operatorId) {
|
||||||
|
if(operatorId==null || operatorId.isBlank()) throw bad("跟进继承缺少真实操作人");
|
||||||
|
var key=key(target);var state=store.find(key);var previous=store.inheritedFollowup(key);
|
||||||
|
if(state!=null) {
|
||||||
|
if(Objects.equals(state.getOwner(),followup.owner()) && Objects.equals(state.getFollowupDate(),followup.nextFollowupDate())) return;
|
||||||
|
throw conflict("目标工作已存在独立跟进,不能用继承覆盖");
|
||||||
|
}
|
||||||
|
if(previous!=null) {
|
||||||
|
if(previous.equals(followup)) return;
|
||||||
|
throw conflict("目标批次已有不同的跟进继承记录");
|
||||||
|
}
|
||||||
|
var future=new AppointmentWorkState();future.setWorkItemKey(key);
|
||||||
|
store.history(future,new Actor(operatorId,null,Set.of()),"FOLLOWUP_INHERITED","继承原送仓事项的负责人及跟进日期",null,null,followup);
|
||||||
|
}
|
||||||
|
private static String key(Member member) {
|
||||||
|
if(member==null || member.batchKey()==null || !member.batchKey().matches("FBA_APPOINTMENT:[1-9][0-9]*") || member.customerId()==null || member.customerId().isBlank()) throw bad("跟进继承缺少真实批次客户范围");
|
||||||
|
return AppointmentWorkPolicy.key("BATCH:"+member.batchKey(),Type.DELIVERY,customerRequirement(member.batchKey(),member.customerId()));
|
||||||
|
}
|
||||||
|
private static void validate(List<Member> members) {
|
||||||
|
if(members==null || members.isEmpty() || members.size()>2000 || new HashSet<>(members).size()!=members.size()) throw bad("跟进继承范围须为1至2000个不重复客户批次");
|
||||||
|
members.forEach(AppointmentWorkFollowupInheritance::key);
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -44,6 +44,8 @@ public interface AppointmentWorkMapper {
|
|||||||
}
|
}
|
||||||
@Select("SELECT * FROM ll_appointment_work_commitment WHERE entity_key=#{key} FOR UPDATE") CommitmentRow lockCommitment(String key);
|
@Select("SELECT * FROM ll_appointment_work_commitment WHERE entity_key=#{key} FOR UPDATE") CommitmentRow lockCommitment(String key);
|
||||||
@Select("SELECT * FROM ll_appointment_work_commitment WHERE entity_key=#{key}") CommitmentRow commitment(String key);
|
@Select("SELECT * FROM ll_appointment_work_commitment WHERE entity_key=#{key}") CommitmentRow commitment(String key);
|
||||||
|
@Select("<script>SELECT * FROM ll_appointment_work_commitment WHERE entity_key IN <foreach collection='keys' item='key' open='(' separator=',' close=')'>#{key}</foreach></script>")
|
||||||
|
List<CommitmentRow> commitments(@Param("keys") List<String> keys);
|
||||||
@Insert("INSERT INTO ll_appointment_work_commitment(entity_key,state_json,impact_version) VALUES(#{key},#{json},0)")
|
@Insert("INSERT INTO ll_appointment_work_commitment(entity_key,state_json,impact_version) VALUES(#{key},#{json},0)")
|
||||||
int insertCommitment(@Param("key") String key,@Param("json") String json);
|
int insertCommitment(@Param("key") String key,@Param("json") String json);
|
||||||
@Update("UPDATE ll_appointment_work_commitment SET state_json=#{json},impact_version=#{version} WHERE entity_key=#{key}")
|
@Update("UPDATE ll_appointment_work_commitment SET state_json=#{json},impact_version=#{version} WHERE entity_key=#{key}")
|
||||||
@@ -64,4 +66,6 @@ public interface AppointmentWorkMapper {
|
|||||||
VALUES(#{id},#{workItemKey},#{operatorId},#{operatorName},#{action},#{summary},#{commandKey},#{requestHash},#{responseJson},#{occurredAt})
|
VALUES(#{id},#{workItemKey},#{operatorId},#{operatorName},#{action},#{summary},#{commandKey},#{requestHash},#{responseJson},#{occurredAt})
|
||||||
""") int insertHistory(History row);
|
""") int insertHistory(History row);
|
||||||
@Select("SELECT * FROM ll_appointment_work_history WHERE work_item_key=#{key} ORDER BY occurred_at DESC,id DESC LIMIT 1001") List<History> selectHistory(@Param("key") String key);
|
@Select("SELECT * FROM ll_appointment_work_history WHERE work_item_key=#{key} ORDER BY occurred_at DESC,id DESC LIMIT 1001") List<History> selectHistory(@Param("key") String key);
|
||||||
|
@Select("SELECT * FROM ll_appointment_work_history WHERE work_item_key=#{key} AND action='FOLLOWUP_INHERITED' ORDER BY occurred_at DESC,id DESC LIMIT 1")
|
||||||
|
History inheritedFollowup(String key);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -51,6 +51,8 @@ public final class AppointmentWorkModels {
|
|||||||
public record HistoryEntry(Instant occurredAt, String operatorName, String action, String summary) { }
|
public record HistoryEntry(Instant occurredAt, String operatorName, String action, String summary) { }
|
||||||
public record WorkDetail(WorkItem item, List<WorkItem> relatedItems,
|
public record WorkDetail(WorkItem item, List<WorkItem> relatedItems,
|
||||||
List<BusinessReference> references, List<HistoryEntry> history, Review review, RequirementDetails requirement, ReturnHandoff returnHandoff) { }
|
List<BusinessReference> references, List<HistoryEntry> history, Review review, RequirementDetails requirement, ReturnHandoff returnHandoff) { }
|
||||||
|
public record BatchWorkCandidate(String customerId,String workItemKey,String batchNo) { }
|
||||||
|
public record BatchWorkResolution(String batchKey,String customerId,WorkDetail detail,List<BatchWorkCandidate> candidates) { }
|
||||||
public record Review(int version,List<String> reasons) { }
|
public record Review(int version,List<String> reasons) { }
|
||||||
public record RequirementDetails(boolean bookingRequired,String receivingRequirements,String reason) { }
|
public record RequirementDetails(boolean bookingRequired,String receivingRequirements,String reason) { }
|
||||||
public enum ResponsibilityKind { USER, SUPPLIER, EXTERNAL }
|
public enum ResponsibilityKind { USER, SUPPLIER, EXTERNAL }
|
||||||
|
|||||||
+10
@@ -14,6 +14,15 @@ public final class AppointmentWorkPolicy {
|
|||||||
public static final ZoneId REMINDER_ZONE=ZoneId.of("America/Los_Angeles");
|
public static final ZoneId REMINDER_ZONE=ZoneId.of("America/Los_Angeles");
|
||||||
public static final String ORDER_READ="platform:overseas-warehouse:last-mile-order:list";
|
public static final String ORDER_READ="platform:overseas-warehouse:last-mile-order:list";
|
||||||
private AppointmentWorkPolicy() { }
|
private AppointmentWorkPolicy() { }
|
||||||
|
public static String customerRequirement(String batchKey,String customerId) {
|
||||||
|
if(batchKey==null || customerId==null || customerId.isBlank()) throw unavailable("送仓需求缺少批次或客户身份");
|
||||||
|
return batchKey+"|CUSTOMER:"+customerId;
|
||||||
|
}
|
||||||
|
public static String deliveryCustomer(List<SourceReference> sources) {
|
||||||
|
var customers=sources.stream().map(SourceReference::customerId).distinct().toList();
|
||||||
|
if(customers.size()!=1 || customers.get(0)==null || customers.get(0).isBlank()) throw unavailable("送仓事项须明确唯一客户货物范围");
|
||||||
|
return customers.get(0);
|
||||||
|
}
|
||||||
public static String key(String containerId,Type type,String requirementKey) {
|
public static String key(String containerId,Type type,String requirementKey) {
|
||||||
return "AW-"+type.name()+"-"+digest(containerId+"\n"+Objects.toString(requirementKey,"")).substring(0,32);
|
return "AW-"+type.name()+"-"+digest(containerId+"\n"+Objects.toString(requirementKey,"")).substring(0,32);
|
||||||
}
|
}
|
||||||
@@ -89,6 +98,7 @@ public final class AppointmentWorkPolicy {
|
|||||||
var today=LocalDate.now(clock.withZone(timezone==null?REMINDER_ZONE:zone(timezone)));
|
var today=LocalDate.now(clock.withZone(timezone==null?REMINDER_ZONE:zone(timezone)));
|
||||||
if(timezone==null) missing.add("跟进日期按业务默认时区America/Los_Angeles判断;作业时区待核实");
|
if(timezone==null) missing.add("跟进日期按业务默认时区America/Los_Angeles判断;作业时区待核实");
|
||||||
if(state.getOwner()==null && execution!=ExecutionStatus.COMPLETED) missing.add("负责人待分配");
|
if(state.getOwner()==null && execution!=ExecutionStatus.COMPLETED) missing.add("负责人待分配");
|
||||||
|
if(state.getFollowupDate()==null && execution!=ExecutionStatus.COMPLETED && !state.isRetired()) missing.add("下次跟进日期待安排");
|
||||||
var overdue=new ArrayList<String>();
|
var overdue=new ArrayList<String>();
|
||||||
if(execution!=ExecutionStatus.COMPLETED && !state.isRetired()) {
|
if(execution!=ExecutionStatus.COMPLETED && !state.isRetired()) {
|
||||||
if(state.getFollowupDate()!=null && state.getFollowupDate().isBefore(today)) overdue.add("跟进日期已过");
|
if(state.getFollowupDate()!=null && state.getFollowupDate().isBefore(today)) overdue.add("跟进日期已过");
|
||||||
|
|||||||
+95
-10
@@ -7,7 +7,9 @@ import java.time.*;
|
|||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
import java.util.function.Predicate;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
|
||||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
|
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
|
||||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
|
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
|
||||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
|
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
|
||||||
@@ -23,19 +25,56 @@ public class AppointmentWorkQueryService {
|
|||||||
AppointmentWorkQueryService(AppointmentWorkStore store,AppointmentWorkFactGateway facts,Clock clock) { this.store=store; this.facts=facts; this.clock=clock; }
|
AppointmentWorkQueryService(AppointmentWorkStore store,AppointmentWorkFactGateway facts,Clock clock) { this.store=store; this.facts=facts; this.clock=clock; }
|
||||||
record Snapshot(List<AppointmentWorkState> states,Map<String,Fact> facts,List<WorkItem> items) { }
|
record Snapshot(List<AppointmentWorkState> states,Map<String,Fact> facts,List<WorkItem> items) { }
|
||||||
Snapshot snapshot(Actor actor) {
|
Snapshot snapshot(Actor actor) {
|
||||||
|
return snapshot(actor,s->true,true);
|
||||||
|
}
|
||||||
|
/** 按原顺序构造完整快照,计时只分离读取、筛选、事实及投影。 */
|
||||||
|
private Snapshot snapshot(Actor actor,Predicate<AppointmentWorkState> selected,boolean includeNext) {
|
||||||
|
observeTransaction();var timing=span(Stage.QUERY_GUARD);
|
||||||
|
try {
|
||||||
String scopeVersion=store.requireInitialized();
|
String scopeVersion=store.requireInitialized();
|
||||||
|
timing.close();timing=span(Stage.QUERY_DISCOVER);
|
||||||
if(!Objects.equals(scopeVersion,facts.discover(null,1).scopeVersion())) throw conflict("已开船工作范围变化,请先完成服务端同步");
|
if(!Objects.equals(scopeVersion,facts.discover(null,1).scopeVersion())) throw conflict("已开船工作范围变化,请先完成服务端同步");
|
||||||
|
timing.close();timing=span(Stage.QUERY_STORE);
|
||||||
var all=store.all();
|
var all=store.all();
|
||||||
var states=all.stream().filter(s->!s.isRetired()).toList();
|
timing.close();timing=span(Stage.QUERY_SELECT);
|
||||||
|
var stateByKey=all.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity()));
|
||||||
|
var selectedKeys=all.stream().filter(s->!s.isRetired() && selected.test(s)).map(AppointmentWorkState::getWorkItemKey).collect(Collectors.toSet());
|
||||||
|
var needed=new HashSet<>(selectedKeys);
|
||||||
|
if(includeNext) {
|
||||||
|
var pending=new ArrayDeque<>(selectedKeys);
|
||||||
|
while(!pending.isEmpty()) {
|
||||||
|
var state=stateByKey.get(pending.remove());
|
||||||
|
for(var key:effectiveKeys(state.getPrerequisites(),stateByKey)) if(stateByKey.containsKey(key) && needed.add(key)) pending.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var states=all.stream().filter(s->!s.isRetired() && needed.contains(s.getWorkItemKey())).toList();
|
||||||
|
timing.close();timing=span(Stage.QUERY_FACTS);
|
||||||
var current=facts.facts(states);
|
var current=facts.facts(states);
|
||||||
|
timing.close();timing=span(Stage.QUERY_PROJECT);
|
||||||
var visible=states.stream().filter(s->canRead(s,current.get(s.getWorkItemKey()),actor)).toList();
|
var visible=states.stream().filter(s->canRead(s,current.get(s.getWorkItemKey()),actor)).toList();
|
||||||
var items=visible.stream().map(s->project(s,current.get(s.getWorkItemKey()),actor,clock)).toList();
|
var items=visible.stream().map(s->project(s,current.get(s.getWorkItemKey()),actor,clock)).toList();
|
||||||
var byKey=items.stream().collect(Collectors.toMap(WorkItem::workItemKey,Function.identity()));
|
var byKey=items.stream().collect(Collectors.toMap(WorkItem::workItemKey,Function.identity()));
|
||||||
var stateByKey=all.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity()));
|
timing.close();timing=span(Stage.QUERY_NEXT);
|
||||||
return new Snapshot(all,current,items.stream().map(i->withNext(i,next(i,byKey,stateByKey,new HashSet<>()))).toList());
|
return new Snapshot(all,current,items.stream().filter(i->selectedKeys.contains(i.workItemKey()))
|
||||||
|
.map(i->includeNext?withNext(i,next(i,byKey,stateByKey,new HashSet<>())):i).toList());
|
||||||
|
} catch(RuntimeException|Error error){timing.failed();throw error;} finally{timing.close();}
|
||||||
}
|
}
|
||||||
private Action next(WorkItem item,Map<String,WorkItem> items,Map<String,AppointmentWorkState> states,Set<String> visited) {
|
private Action next(WorkItem item,Map<String,WorkItem> items,Map<String,AppointmentWorkState> states,Set<String> visited) {
|
||||||
if(!visited.add(item.workItemKey()) || item.blockReasons().isEmpty() || item.reviewRequired()) return item.nextAction();
|
if(!visited.add(item.workItemKey()) || item.reviewRequired()) return item.nextAction();
|
||||||
|
if(item.type()==Type.DELIVERY && item.executionStatus()!=ExecutionStatus.COMPLETED && item.blockReasons().isEmpty()
|
||||||
|
&& (item.planStatus()==PlanStatus.CONFIRMED || item.planStatus()==PlanStatus.NOT_REQUIRED)) {
|
||||||
|
for(var key:effectiveKeys(states.get(item.workItemKey()).getPrerequisites(),states)) {
|
||||||
|
var transport=items.get(key);
|
||||||
|
if(transport==null || transport.type()!=Type.TRANSPORT || transport.executionStatus()==ExecutionStatus.COMPLETED) continue;
|
||||||
|
var action=transport.nextAction();
|
||||||
|
if(action==null) continue;
|
||||||
|
if(action.code()==ActionCode.RESOLVE_REVIEW)
|
||||||
|
return new Action(action.code(),"先复核车辆安排",transport.workItemKey(),action.target());
|
||||||
|
if(action.code()==ActionCode.ARRANGE_TRANSPORT && action.target() instanceof TripTarget target)
|
||||||
|
return new Action(action.code(),target.tripId()==null?"继续安排本批车辆":"核对本车发车安排",transport.workItemKey(),target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(item.blockReasons().isEmpty()) return item.nextAction();
|
||||||
for(var key:effectiveKeys(states.get(item.workItemKey()).getPrerequisites(),states)) {
|
for(var key:effectiveKeys(states.get(item.workItemKey()).getPrerequisites(),states)) {
|
||||||
var prior=items.get(key);
|
var prior=items.get(key);
|
||||||
if(prior!=null && prior.executionStatus()!=ExecutionStatus.COMPLETED && (prior.reviewRequired() || prior.planStatus()==PlanStatus.UNPLANNED || prior.planStatus()==PlanStatus.AWAITING_REPLY)) {
|
if(prior!=null && prior.executionStatus()!=ExecutionStatus.COMPLETED && (prior.reviewRequired() || prior.planStatus()==PlanStatus.UNPLANNED || prior.planStatus()==PlanStatus.AWAITING_REPLY)) {
|
||||||
@@ -45,23 +84,34 @@ public class AppointmentWorkQueryService {
|
|||||||
}
|
}
|
||||||
return item.nextAction();
|
return item.nextAction();
|
||||||
}
|
}
|
||||||
|
/** 完整快照之后单独统计汇总耗时,不改变待办筛选语义。 */
|
||||||
public Summary summary(Actor actor) {
|
public Summary summary(Actor actor) {
|
||||||
var items=snapshot(actor).items().stream().filter(i->i.executionStatus()!=ExecutionStatus.COMPLETED).toList();
|
var snapshotItems=snapshot(actor,s->true,false).items();
|
||||||
|
return measure(Stage.QUERY_FINAL,()->{
|
||||||
|
var items=snapshotItems.stream().filter(i->i.executionStatus()!=ExecutionStatus.COMPLETED).toList();
|
||||||
return new Summary(items.size(),items.stream().filter(i->followToday(i,clock)).count(),items.stream().filter(i->!i.overdueReasons().isEmpty()).count(),items.stream().filter(i->i.calendarDate()==null).count(),items.stream().filter(WorkItem::reviewRequired).count());
|
return new Summary(items.size(),items.stream().filter(i->followToday(i,clock)).count(),items.stream().filter(i->!i.overdueReasons().isEmpty()).count(),items.stream().filter(i->i.calendarDate()==null).count(),items.stream().filter(WorkItem::reviewRequired).count());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
/** 日历最终分组保持原顺序,单独记录快照后处理。 */
|
||||||
public CalendarResult calendar(LocalDate from,LocalDate to,Flow flow,Set<Type> types,Actor actor) {
|
public CalendarResult calendar(LocalDate from,LocalDate to,Flow flow,Set<Type> types,Actor actor) {
|
||||||
if(from==null || to==null || to.isBefore(from) || ChronoUnit.DAYS.between(from,to)>=62) throw bad("日历范围须为1至62天");
|
if(from==null || to==null || to.isBefore(from) || ChronoUnit.DAYS.between(from,to)>=62) throw bad("日历范围须为1至62天");
|
||||||
if(types!=null && types.contains(Type.ROUTE)) throw bad("路线待确认需求请从全局待办查看");
|
if(types!=null && types.contains(Type.ROUTE)) throw bad("路线待确认需求请从全局待办查看");
|
||||||
var items=snapshot(actor).items().stream().filter(i->i.type()!=Type.ROUTE && i.calendarDate()!=null && !i.calendarDate().isBefore(from) && !i.calendarDate().isAfter(to))
|
var snapshotItems=snapshot(actor,s->s.getType()!=Type.ROUTE && matches(s,flow,types,null),false).items();
|
||||||
|
return measure(Stage.QUERY_FINAL,()->{
|
||||||
|
var items=snapshotItems.stream().filter(i->i.type()!=Type.ROUTE && i.calendarDate()!=null && !i.calendarDate().isBefore(from) && !i.calendarDate().isAfter(to))
|
||||||
.filter(i->flow==null || i.flow()==flow).filter(i->types==null || types.contains(i.type())).toList();
|
.filter(i->flow==null || i.flow()==flow).filter(i->types==null || types.contains(i.type())).toList();
|
||||||
var groups=items.stream().collect(Collectors.groupingBy(WorkItem::calendarDate,TreeMap::new,Collectors.toList()));
|
var groups=items.stream().collect(Collectors.groupingBy(WorkItem::calendarDate,TreeMap::new,Collectors.toList()));
|
||||||
var days=groups.entrySet().stream().map(e->new DayAggregate(e.getKey(),e.getValue().size(),e.getValue().stream().collect(Collectors.groupingBy(WorkItem::type,()->new EnumMap<>(Type.class),Collectors.counting())),counts(e.getValue()))).toList();
|
var days=groups.entrySet().stream().map(e->new DayAggregate(e.getKey(),e.getValue().size(),e.getValue().stream().collect(Collectors.groupingBy(WorkItem::type,()->new EnumMap<>(Type.class),Collectors.counting())),counts(e.getValue()))).toList();
|
||||||
return new CalendarResult(from,to,items.size(),counts(items),days);
|
return new CalendarResult(from,to,items.size(),counts(items),days);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
/** 保留事实核对之后的最终过滤排序分页,仅拆出其耗时。 */
|
||||||
public PageResult<WorkItem> items(View view,int page,int size,LocalDate date,Flow flow,Set<Type> types,String containerId,Bucket bucket,String keyword,Actor actor) {
|
public PageResult<WorkItem> items(View view,int page,int size,LocalDate date,Flow flow,Set<Type> types,String containerId,Bucket bucket,String keyword,Actor actor) {
|
||||||
if(page<1 || size<1 || size>100 || keyword!=null && keyword.length()>128) throw bad("分页或搜索条件无效");
|
if(page<1 || size<1 || size>100 || keyword!=null && keyword.length()>128) throw bad("分页或搜索条件无效");
|
||||||
var word=keyword==null?"":keyword.trim().toLowerCase(Locale.ROOT);
|
var word=keyword==null?"":keyword.trim().toLowerCase(Locale.ROOT);
|
||||||
var items=snapshot(actor).items().stream()
|
var snapshotItems=snapshot(actor,s->matches(s,flow,types,containerId) && candidate(s,view),true).items();
|
||||||
|
return measure(Stage.QUERY_FINAL,()->{
|
||||||
|
var items=snapshotItems.stream()
|
||||||
.filter(i->date!=null || i.executionStatus()!=ExecutionStatus.COMPLETED)
|
.filter(i->date!=null || i.executionStatus()!=ExecutionStatus.COMPLETED)
|
||||||
.filter(i->date==null || date.equals(i.calendarDate()))
|
.filter(i->date==null || date.equals(i.calendarDate()))
|
||||||
.filter(i->flow==null || flow==i.flow()).filter(i->types==null || types.contains(i.type()))
|
.filter(i->flow==null || flow==i.flow()).filter(i->types==null || types.contains(i.type()))
|
||||||
@@ -72,11 +122,30 @@ public class AppointmentWorkQueryService {
|
|||||||
.sorted(Comparator.comparing(WorkItem::followupDate,Comparator.nullsLast(Comparator.naturalOrder())).thenComparing(WorkItem::workItemKey)).toList();
|
.sorted(Comparator.comparing(WorkItem::followupDate,Comparator.nullsLast(Comparator.naturalOrder())).thenComparing(WorkItem::workItemKey)).toList();
|
||||||
long offset=(long)(page-1)*size; int start=(int)Math.min(offset,items.size());
|
long offset=(long)(page-1)*size; int start=(int)Math.min(offset,items.size());
|
||||||
return new PageResult<>(items.subList(start,Math.min(start+size,items.size())),page,size,items.size(),(items.size()+size-1L)/size);
|
return new PageResult<>(items.subList(start,Math.min(start+size,items.size())),page,size,items.size(),(items.size()+size-1L)/size);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
public BatchWorkResolution resolveBatch(String batchKey,String customerId,Actor actor) {
|
||||||
|
if(batchKey==null || !batchKey.matches("(?:FBA_APPOINTMENT|DISPATCH):[1-9][0-9]*")) throw bad("请提供原业务返回的完整批次键");
|
||||||
|
if(customerId!=null && customerId.isBlank()) throw bad("客户身份不能为空");
|
||||||
|
if(!Objects.equals(store.requireInitialized(),facts.discover(null,1).scopeVersion())) throw conflict("已开船工作范围变化,请先完成服务端同步");
|
||||||
|
var matches=store.all().stream().filter(s->!s.isRetired() && s.getType()==Type.DELIVERY && batchKey.equals(s.getBatchKey()))
|
||||||
|
.filter(s->customerId==null || customerId.equals(deliveryCustomer(s.getSources()))).toList();
|
||||||
|
if(matches.isEmpty()) throw missing("当前批次的送仓事项尚不存在,请先同步当前批次");
|
||||||
|
var current=facts.facts(matches);
|
||||||
|
var visible=matches.stream().filter(s->canRead(s,current.get(s.getWorkItemKey()),actor)).toList();
|
||||||
|
if(visible.isEmpty()) throw forbidden("无权读取关联来源");
|
||||||
|
var candidates=visible.stream().map(s->new BatchWorkCandidate(deliveryCustomer(s.getSources()),s.getWorkItemKey(),s.getBatchNo())).sorted(Comparator.comparing(BatchWorkCandidate::customerId)).toList();
|
||||||
|
if(candidates.stream().map(BatchWorkCandidate::customerId).distinct().count()!=candidates.size()) throw conflict("同一批次客户存在重复送仓事项");
|
||||||
|
if(candidates.size()>1) return new BatchWorkResolution(batchKey,null,null,candidates);
|
||||||
|
var selected=candidates.get(0);
|
||||||
|
return new BatchWorkResolution(batchKey,selected.customerId(),detail(selected.workItemKey(),actor),candidates);
|
||||||
|
}
|
||||||
|
/** 保留详情完整快照与退休事实读取,历史查询单独观察。 */
|
||||||
public WorkDetail detail(String key,Actor actor) {
|
public WorkDetail detail(String key,Actor actor) {
|
||||||
var snapshot=snapshot(actor); var state=store.get(key);
|
var snapshot=snapshot(actor); var state=measure(Stage.DETAIL_STORE,()->store.get(key));
|
||||||
var fact=snapshot.facts().get(key);
|
var fact=snapshot.facts().get(key);
|
||||||
if(state.isRetired() && state.getType()!=Type.ROUTE) fact=facts.facts(List.of(state)).get(key);
|
if(state.isRetired() && state.getType()!=Type.ROUTE) fact=measure(Stage.DETAIL_FACTS,()->facts.facts(List.of(state))).get(key);
|
||||||
|
try(var timing=span(Stage.DETAIL_PROJECT)) {
|
||||||
if(!canRead(state,fact,actor)) throw forbidden("无权读取关联来源");
|
if(!canRead(state,fact,actor)) throw forbidden("无权读取关联来源");
|
||||||
var item=state.isRetired()?project(state,fact,actor,clock):snapshot.items().stream().filter(i->i.workItemKey().equals(key)).findFirst().orElseThrow(()->missing("事项不存在"));
|
var item=state.isRetired()?project(state,fact,actor,clock):snapshot.items().stream().filter(i->i.workItemKey().equals(key)).findFirst().orElseThrow(()->missing("事项不存在"));
|
||||||
var relatedKeys=new HashSet<>(state.getPrerequisites()); relatedKeys.addAll(state.getReplacementKeys());
|
var relatedKeys=new HashSet<>(state.getPrerequisites()); relatedKeys.addAll(state.getReplacementKeys());
|
||||||
@@ -84,11 +153,27 @@ public class AppointmentWorkQueryService {
|
|||||||
relatedKeys.addAll(effectiveKeys(relatedKeys,statesByKey));
|
relatedKeys.addAll(effectiveKeys(relatedKeys,statesByKey));
|
||||||
snapshot.states().stream().filter(s->effectiveKeys(s.getPrerequisites(),statesByKey).contains(key)).map(AppointmentWorkState::getWorkItemKey).forEach(relatedKeys::add);
|
snapshot.states().stream().filter(s->effectiveKeys(s.getPrerequisites(),statesByKey).contains(key)).map(AppointmentWorkState::getWorkItemKey).forEach(relatedKeys::add);
|
||||||
var refs=new ArrayList<>(state.getReferences()); if(fact!=null) refs.addAll(fact.references());
|
var refs=new ArrayList<>(state.getReferences()); if(fact!=null) refs.addAll(fact.references());
|
||||||
return new WorkDetail(item,snapshot.items().stream().filter(i->relatedKeys.contains(i.workItemKey())).toList(),refs.stream().distinct().toList(),store.history(key),
|
return new WorkDetail(item,snapshot.items().stream().filter(i->relatedKeys.contains(i.workItemKey())).toList(),refs.stream().distinct().toList(),measure(Stage.DETAIL_HISTORY,()->store.history(key)),
|
||||||
state.isReviewRequired()?new Review(state.getReviewVersion(),List.of(Objects.toString(state.getReviewReason(),"关联安排变化"))):null,
|
state.isReviewRequired()?new Review(state.getReviewVersion(),List.of(Objects.toString(state.getReviewReason(),"关联安排变化"))):null,
|
||||||
requirementOwned(state,fact)?new RequirementDetails(state.isBookingRequired(),Objects.toString(state.getReceivingRequirements(),""),state.getRequirementReason()):null,state.getReturnHandoff());
|
requirementOwned(state,fact)?new RequirementDetails(state.isBookingRequired(),Objects.toString(state.getReceivingRequirements(),""),state.getRequirementReason()):null,state.getReturnHandoff());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
WorkItem current(AppointmentWorkState state,Fact fact,Actor actor) { return project(state,fact,actor,clock); }
|
WorkItem current(AppointmentWorkState state,Fact fact,Actor actor) { return project(state,fact,actor,clock); }
|
||||||
|
private boolean matches(AppointmentWorkState state,Flow flow,Set<Type> types,String containerId) {
|
||||||
|
return (flow==null || state.getFlow()==flow) && (types==null || types.contains(state.getType()))
|
||||||
|
&& (containerId==null || (state.getContainers().isEmpty()?containerId.equals(state.getContainerBookingId()):state.getContainers().stream().anyMatch(c->containerId.equals(c.containerBookingId()))));
|
||||||
|
}
|
||||||
|
private boolean candidate(AppointmentWorkState state,View view) {
|
||||||
|
// Follow-up dates and handoffs belong to the work state. Use the latest possible local day,
|
||||||
|
// not its stored timezone: the authoritative fact can supply a different current zone.
|
||||||
|
// Exact local-day, completion and permission checks still run after the live fact read.
|
||||||
|
return switch(view) {
|
||||||
|
case FOLLOW_TODAY -> pendingHandoff(state) || state.getFollowupDate()!=null
|
||||||
|
&& !state.getFollowupDate().isAfter(LocalDate.now(clock.withZone(ZoneOffset.MAX)));
|
||||||
|
case REVIEW -> state.isReviewRequired();
|
||||||
|
default -> true;
|
||||||
|
};
|
||||||
|
}
|
||||||
private String searchable(WorkItem i) {
|
private String searchable(WorkItem i) {
|
||||||
return String.join(" ",Objects.toString(i.businessNo(),""),Objects.toString(i.batchNo(),""),Objects.toString(i.originName(),""),Objects.toString(i.destinationName(),""),i.containers().stream().map(c->Objects.toString(c.containerNo(),"")+" "+Objects.toString(c.bookingNo(),"")).collect(Collectors.joining(" "))).toLowerCase(Locale.ROOT);
|
return String.join(" ",Objects.toString(i.businessNo(),""),Objects.toString(i.batchNo(),""),Objects.toString(i.originName(),""),Objects.toString(i.destinationName(),""),i.containers().stream().map(c->Objects.toString(c.containerNo(),"")+" "+Objects.toString(c.bookingNo(),"")).collect(Collectors.joining(" "))).toLowerCase(Locale.ROOT);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -36,6 +36,7 @@ public class AppointmentWorkState {
|
|||||||
private String reviewReason;
|
private String reviewReason;
|
||||||
private String observedFingerprint;
|
private String observedFingerprint;
|
||||||
private String observedImpactToken;
|
private String observedImpactToken;
|
||||||
|
private String observedDependentImpactToken;
|
||||||
private boolean observedArranged;
|
private boolean observedArranged;
|
||||||
private Responsibility responsibility;
|
private Responsibility responsibility;
|
||||||
private ReturnHandoff returnHandoff;
|
private ReturnHandoff returnHandoff;
|
||||||
|
|||||||
+44
-3
@@ -1,23 +1,59 @@
|
|||||||
package com.baiyue.lastleg.appointmentwork;
|
package com.baiyue.lastleg.appointmentwork;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.slf4j.MDC;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import java.time.*;
|
import java.time.*;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
|
||||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
|
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
|
||||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
|
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class AppointmentWorkStore {
|
public class AppointmentWorkStore {
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(AppointmentWorkStore.class);
|
||||||
public static final int MAX_ITEMS=10000;
|
public static final int MAX_ITEMS=10000;
|
||||||
private final AppointmentWorkMapper mapper;
|
private final AppointmentWorkMapper mapper;
|
||||||
private final ObjectMapper json;
|
private final ObjectMapper json;
|
||||||
public AppointmentWorkStore(AppointmentWorkMapper mapper,ObjectMapper json) { this.mapper=mapper; this.json=json; }
|
public AppointmentWorkStore(AppointmentWorkMapper mapper,ObjectMapper json) { this.mapper=mapper; this.json=json; }
|
||||||
public AppointmentWorkMapper.Guard lock() {
|
/** 只测量守卫更新语句,不改变锁、事务或版本,用于区分慢写是否卡在该语句。 */
|
||||||
var guard=mapper.lockGuard(); if(guard==null) throw unavailable("工作事项DDL尚未集成"); return guard;
|
public void invalidateRequirements() {
|
||||||
|
long started = System.nanoTime();
|
||||||
|
try {
|
||||||
|
if (measure(Stage.GUARD_UPDATE,mapper::invalidateRequirements) != 1) throw unavailable("工作事项同步守卫未初始化");
|
||||||
|
} finally {
|
||||||
|
long elapsedMs = java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started);
|
||||||
|
if (elapsedMs >= 250) {
|
||||||
|
// 语句耗时可能包含等锁或连接等待,不能仅凭日志认定具体锁根因;不记录请求业务载荷。
|
||||||
|
try { LOG.warn("Appointment work guard update slow: elapsedMs={}, requestId={}, traceId={}, scopeId={}",
|
||||||
|
elapsedMs, MDC.get("requestId"), MDC.get("traceId"), currentScopeId());
|
||||||
|
} catch(Throwable ignored) { /* 诊断输出失败不得覆盖原守卫结果或异常。 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
/** 测量守卫读取加锁耗时,保留原有锁和失败行为,覆盖尚未进入更新语句的慢命令。 */
|
||||||
|
public AppointmentWorkMapper.Guard lock() {
|
||||||
|
long started = System.nanoTime();
|
||||||
|
try {
|
||||||
|
observeTransaction();
|
||||||
|
var guard = measure(Stage.GUARD_LOCK,mapper::lockGuard);
|
||||||
|
if (guard == null) throw unavailable("工作事项DDL尚未集成");
|
||||||
|
return guard;
|
||||||
|
} finally {
|
||||||
|
long elapsedMs = java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started);
|
||||||
|
if (elapsedMs >= 250) {
|
||||||
|
// 耗时包含连接及语句执行,单条日志不能区分具体等待原因,不记录业务载荷。
|
||||||
|
try { LOG.warn("Appointment work guard lock slow: elapsedMs={}, requestId={}, traceId={}, scopeId={}",
|
||||||
|
elapsedMs, MDC.get("requestId"), MDC.get("traceId"), currentScopeId());
|
||||||
|
} catch(Throwable ignored) { /* 诊断输出失败不得覆盖原守卫结果或异常。 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** 完整性守卫保持原语义,读取耗时与事务完成只作诊断。 */
|
||||||
public String requireInitialized() {
|
public String requireInitialized() {
|
||||||
var guard=mapper.guard();
|
observeTransaction();var guard=measure(Stage.GUARD_READ,mapper::guard);
|
||||||
if(guard==null || !guard.isInitialized()) throw unavailable("完整工作范围尚未完成同步,不能显示零项");
|
if(guard==null || !guard.isInitialized()) throw unavailable("完整工作范围尚未完成同步,不能显示零项");
|
||||||
if(guard.getRequirementVersion()!=guard.getSynchronizedRequirementVersion()) throw conflict("预约批次或运输关联已变化,服务端正在协调完整工作清单");
|
if(guard.getRequirementVersion()!=guard.getSynchronizedRequirementVersion()) throw conflict("预约批次或运输关联已变化,服务端正在协调完整工作清单");
|
||||||
return guard.getScopeVersion();
|
return guard.getScopeVersion();
|
||||||
@@ -32,6 +68,11 @@ public class AppointmentWorkStore {
|
|||||||
public AppointmentWorkState get(String key) {
|
public AppointmentWorkState get(String key) {
|
||||||
var row=mapper.get(key); if(row==null) throw missing("事项不存在"); return decode(row);
|
var row=mapper.get(key); if(row==null) throw missing("事项不存在"); return decode(row);
|
||||||
}
|
}
|
||||||
|
public AppointmentWorkState find(String key) { var row=mapper.get(key);return row==null?null:decode(row); }
|
||||||
|
public record Followup(Owner owner,LocalDate nextFollowupDate) { }
|
||||||
|
public Followup inheritedFollowup(String key) {
|
||||||
|
var row=mapper.inheritedFollowup(key);return row==null?null:read(row.getResponseJson(),Followup.class);
|
||||||
|
}
|
||||||
public void insert(AppointmentWorkState state) { mapper.insert(row(state)); }
|
public void insert(AppointmentWorkState state) { mapper.insert(row(state)); }
|
||||||
public void save(AppointmentWorkState state) {
|
public void save(AppointmentWorkState state) {
|
||||||
state.setVersion(state.getVersion()+1);
|
state.setVersion(state.getVersion()+1);
|
||||||
|
|||||||
+138
-21
@@ -2,45 +2,58 @@ package com.baiyue.lastleg.appointmentwork;
|
|||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import java.time.*;
|
import java.time.*;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
|
||||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
|
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
|
||||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
|
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
|
||||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
|
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class AppointmentWorkSyncService {
|
public class AppointmentWorkSyncService {
|
||||||
|
private static final Logger LOG=LoggerFactory.getLogger(AppointmentWorkSyncService.class);
|
||||||
private static final Actor SYSTEM=new Actor("appointment-work-sync","服务端工作范围同步",Set.of(LIST,ORDER_READ));
|
private static final Actor SYSTEM=new Actor("appointment-work-sync","服务端工作范围同步",Set.of(LIST,ORDER_READ));
|
||||||
private final AppointmentWorkStore store;
|
private final AppointmentWorkStore store;
|
||||||
private final AppointmentWorkFactGateway gateway;
|
private final AppointmentWorkFactGateway gateway;
|
||||||
public AppointmentWorkSyncService(AppointmentWorkStore store,AppointmentWorkFactGateway gateway) { this.store=store; this.gateway=gateway; }
|
public AppointmentWorkSyncService(AppointmentWorkStore store,AppointmentWorkFactGateway gateway) { this.store=store; this.gateway=gateway; }
|
||||||
/** Called by trusted server orchestration, never by GET or by opening a particular container. */
|
/** 服务端按完整范围同步;同一守卫事务内复用工作集,GET 不触发写入。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public SyncResult synchronize(SyncRequest request) {
|
public SyncResult synchronize(SyncRequest request) {
|
||||||
if(request==null || request.size()<1 || request.size()>100) throw bad("同步批次须为1至100柜");
|
if(request==null || request.size()<1 || request.size()>100) throw bad("同步批次须为1至100柜");
|
||||||
|
observeTransaction();var timing=new SyncTiming();boolean success=false;
|
||||||
|
try {
|
||||||
var guard=store.lock();
|
var guard=store.lock();
|
||||||
if(request.afterContainerBookingId()!=null && !Objects.equals(guard.getScanCursor(),request.afterContainerBookingId())) throw conflict("来源同步游标已变化,请从首批重新核对");
|
if(request.afterContainerBookingId()!=null && !Objects.equals(guard.getScanCursor(),request.afterContainerBookingId())) throw conflict("来源同步游标已变化,请从首批重新核对");
|
||||||
|
timing.next(SyncPhase.DISCOVER);
|
||||||
var page=gateway.discover(request.afterContainerBookingId(),request.size());
|
var page=gateway.discover(request.afterContainerBookingId(),request.size());
|
||||||
|
timing.pageContainers=page.containers().size();timing.complete=page.complete();
|
||||||
if(request.afterContainerBookingId()==null) { guard.setScanScopeVersion(page.scopeVersion()); guard.setScanRequirementVersion(guard.getRequirementVersion()); }
|
if(request.afterContainerBookingId()==null) { guard.setScanScopeVersion(page.scopeVersion()); guard.setScanRequirementVersion(guard.getRequirementVersion()); }
|
||||||
else if(!Objects.equals(guard.getScanScopeVersion(),page.scopeVersion())) throw conflict("来源范围扫描中变化,请从首批重试");
|
else if(!Objects.equals(guard.getScanScopeVersion(),page.scopeVersion())) throw conflict("来源范围扫描中变化,请从首批重试");
|
||||||
else if(guard.getScanRequirementVersion()!=guard.getRequirementVersion()) throw conflict("预约需求扫描中变化,请从首批重试");
|
else if(guard.getScanRequirementVersion()!=guard.getRequirementVersion()) throw conflict("预约需求扫描中变化,请从首批重试");
|
||||||
|
timing.next(SyncPhase.GENERATE);
|
||||||
|
// 保留全部历史与稳定键顺序;逐柜更新立即进入本次工作集,不跨事务缓存。
|
||||||
|
var known=workingSet();
|
||||||
|
var requirementReader=gateway.openRequirements();
|
||||||
int created=0; var ids=new HashSet<String>();
|
int created=0; var ids=new HashSet<String>();
|
||||||
for(var source:page.containers()) {
|
for(var source:page.containers()) {
|
||||||
validateSource(source);
|
validateSource(source);
|
||||||
if(!ids.add(source.containerBookingId())) throw unavailable("来源响应重复柜次");
|
if(!ids.add(source.containerBookingId())) throw unavailable("来源响应重复柜次");
|
||||||
var existing=store.container(source.containerBookingId());
|
var existing=known.values().stream().filter(s->source.containerBookingId().equals(s.getContainerBookingId())
|
||||||
|
|| s.getContainers().stream().anyMatch(c->source.containerBookingId().equals(c.containerBookingId()))).toList();
|
||||||
if(existing.isEmpty()) {
|
if(existing.isEmpty()) {
|
||||||
if(source.establishedFlow()==Flow.UNKNOWN) {
|
if(source.establishedFlow()==Flow.UNKNOWN) {
|
||||||
var route=create(source,Type.ROUTE,null,Flow.UNKNOWN); route.setFollowupDate(LocalDate.now(route.getTimezone()==null?REMINDER_ZONE:zone(route.getTimezone()))); store.insert(route); created++;
|
var route=create(source,Type.ROUTE,null,Flow.UNKNOWN); route.setFollowupDate(LocalDate.now(route.getTimezone()==null?REMINDER_ZONE:zone(route.getTimezone()))); store.insert(route); known.put(route.getWorkItemKey(),route); created++;
|
||||||
} else created+=generate(source,source.establishedFlow(),existing,SYSTEM).size();
|
} else created+=generate(source,source.establishedFlow(),existing,SYSTEM,known,requirementReader).size();
|
||||||
} else {
|
} else {
|
||||||
var activeFlow=existing.stream().filter(s->s.getType()!=Type.ROUTE && !s.isRetired()).map(AppointmentWorkState::getFlow).findFirst().orElse(null);
|
var activeFlow=existing.stream().filter(s->s.getType()!=Type.ROUTE && !s.isRetired()).map(AppointmentWorkState::getFlow).findFirst().orElse(null);
|
||||||
if(activeFlow!=null) created+=generate(source,activeFlow,existing,SYSTEM).size();
|
if(activeFlow!=null) created+=generate(source,activeFlow,existing,SYSTEM,known,requirementReader).size();
|
||||||
else for(var route:existing) if(route.getType()==Type.ROUTE && !route.isRetired()) {
|
else for(var route:existing) if(route.getType()==Type.ROUTE && !route.isRetired()) {
|
||||||
if(source.establishedFlow()!=Flow.UNKNOWN) {
|
if(source.establishedFlow()!=Flow.UNKNOWN) {
|
||||||
var generated=generate(source,source.establishedFlow(),existing,SYSTEM); created+=generated.size();
|
var generated=generate(source,source.establishedFlow(),existing,SYSTEM,known,requirementReader); created+=generated.size();
|
||||||
route.setFlow(source.establishedFlow()); route.setRetired(true); route.setRetirementReason("ROUTE_CONFIRMED"); route.setReplacementKeys(generated); store.save(route);
|
route.setFlow(source.establishedFlow()); route.setRetired(true); route.setRetirementReason("ROUTE_CONFIRMED"); route.setReplacementKeys(generated); store.save(route);
|
||||||
} else if(!Objects.equals(route.getSourceVersion(),source.sourceVersion())) {
|
} else if(!Objects.equals(route.getSourceVersion(),source.sourceVersion())) {
|
||||||
route.setSourceVersion(source.sourceVersion()); route.setContainerNo(source.containerNo()); route.setTimezone(source.timezone()); store.save(route);
|
route.setSourceVersion(source.sourceVersion()); route.setContainerNo(source.containerNo()); route.setTimezone(source.timezone()); store.save(route);
|
||||||
@@ -48,18 +61,66 @@ public class AppointmentWorkSyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// An existing row is never deleted because an order left the active source page.
|
// 原来源离开当前页也保留历史事项,未完成工作仍在第二轮按原业务完整核对。
|
||||||
// Empty return and other unfinished work continue to read their original authority.
|
timing.next(SyncPhase.TRANSPORT_FACTS);
|
||||||
var all=new ArrayList<>(store.all());
|
var all=new ArrayList<>(store.all());
|
||||||
var facts=gateway.facts(all.stream().filter(s->s.getType()!=Type.ROUTE && s.getTripKey()==null && !"BATCH_REPLACED".equals(s.getRetirementReason())).toList());
|
// 首轮仅归并运输关系;保留已关联车次的退役成员,才能发现解绑并恢复原运输事项。
|
||||||
|
var transportItems=all.stream().filter(s->s.getType()==Type.TRANSPORT && s.getTripKey()==null && !"BATCH_REPLACED".equals(s.getRetirementReason())).toList();
|
||||||
|
timing.transportFactsCount=transportItems.size();
|
||||||
|
var facts=gateway.facts(transportItems);
|
||||||
|
timing.next(SyncPhase.RECONCILE);
|
||||||
reconcileTransport(all,facts);
|
reconcileTransport(all,facts);
|
||||||
|
timing.next(SyncPhase.ACTIVE_STORE);
|
||||||
all=new ArrayList<>(store.all());
|
all=new ArrayList<>(store.all());
|
||||||
facts=gateway.facts(all.stream().filter(s->!s.isRetired()).toList());
|
var activeItems=all.stream().filter(s->!s.isRetired()).toList();
|
||||||
|
timing.activeFactsCount=activeItems.size();
|
||||||
|
timing.next(SyncPhase.ACTIVE_FACTS);
|
||||||
|
facts=gateway.facts(activeItems);
|
||||||
|
timing.next(SyncPhase.OBSERVE_CHECKPOINT);
|
||||||
observe(all,facts,SYSTEM);
|
observe(all,facts,SYSTEM);
|
||||||
guard.setScanCursor(page.complete()?null:page.nextCursor());
|
guard.setScanCursor(page.complete()?null:page.nextCursor());
|
||||||
if(page.complete()) { guard.setInitialized(true); guard.setScopeVersion(page.scopeVersion()); guard.setSynchronizedRequirementVersion(guard.getRequirementVersion()); guard.setCompletedAt(LocalDateTime.now(ZoneOffset.UTC)); }
|
if(page.complete()) { guard.setInitialized(true); guard.setScopeVersion(page.scopeVersion()); guard.setSynchronizedRequirementVersion(guard.getRequirementVersion()); guard.setCompletedAt(LocalDateTime.now(ZoneOffset.UTC)); }
|
||||||
store.checkpoint(guard);
|
store.checkpoint(guard);
|
||||||
|
success=true;
|
||||||
return new SyncResult(page.containers().size(),created,page.nextCursor(),page.complete());
|
return new SyncResult(page.containers().size(),created,page.nextCursor(),page.complete());
|
||||||
|
} finally { timing.finish(success); }
|
||||||
|
}
|
||||||
|
/** 固定阶段名用于慢同步定位,不包含来源、游标或异常业务内容。 */
|
||||||
|
private enum SyncPhase { GUARD, DISCOVER, GENERATE, TRANSPORT_FACTS, RECONCILE, ACTIVE_STORE, ACTIVE_FACTS, OBSERVE_CHECKPOINT }
|
||||||
|
/** 仅统计本次方法执行,不含事务代理提交,不能当作精确锁持有时间。 */
|
||||||
|
private static final class SyncTiming {
|
||||||
|
private final Span body=span(Stage.SYNC_BODY);
|
||||||
|
private Span diagnosticPhase=span(Stage.SYNC_GUARD);
|
||||||
|
private final long started=System.nanoTime();
|
||||||
|
private final long[] elapsed=new long[SyncPhase.values().length];
|
||||||
|
private long phaseStarted=started;
|
||||||
|
private SyncPhase phase=SyncPhase.GUARD;
|
||||||
|
private int pageContainers=-1,transportFactsCount=-1,activeFactsCount=-1;
|
||||||
|
private boolean complete;
|
||||||
|
/** 完成前一阶段后开始下一阶段;事实阶段包含紧邻的数据库回读。 */
|
||||||
|
private void next(SyncPhase next) {
|
||||||
|
long now=System.nanoTime();elapsed[phase.ordinal()]+=now-phaseStarted;phaseStarted=now;phase=next;
|
||||||
|
diagnosticPhase.close();diagnosticPhase=span(switch(next){
|
||||||
|
case GUARD->Stage.SYNC_GUARD;case DISCOVER->Stage.SYNC_DISCOVER;case GENERATE->Stage.SYNC_GENERATE;
|
||||||
|
case TRANSPORT_FACTS->Stage.SYNC_TRANSPORT_FACTS;case RECONCILE->Stage.SYNC_RECONCILE;
|
||||||
|
case ACTIVE_STORE->Stage.SYNC_ACTIVE_STORE;case ACTIVE_FACTS->Stage.SYNC_ACTIVE_FACTS;case OBSERVE_CHECKPOINT->Stage.SYNC_CHECKPOINT;});
|
||||||
|
}
|
||||||
|
/** 成功和失败共用一条慢调用摘要,未到达的计数保持为负一。 */
|
||||||
|
private void finish(boolean success) {
|
||||||
|
long now=System.nanoTime();elapsed[phase.ordinal()]+=now-phaseStarted;
|
||||||
|
if(!success){diagnosticPhase.failed();body.failed();}diagnosticPhase.close();body.close();
|
||||||
|
long totalMs=java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(now-started);
|
||||||
|
if(totalMs<250) return;
|
||||||
|
try {
|
||||||
|
// 旧 activeFactsMs 仍包含工作表读取、解析及适配;新增明细不能改变旧指标口径。
|
||||||
|
long activeMs=java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(elapsed[SyncPhase.ACTIVE_STORE.ordinal()]+elapsed[SyncPhase.ACTIVE_FACTS.ordinal()]);
|
||||||
|
LOG.warn("Appointment work sync slow: totalMs={}, guardMs={}, discoverMs={}, generateMs={}, transportFactsMs={}, reconcileMs={}, activeFactsMs={}, activeStoreMs={}, activeAdapterMs={}, observeCheckpointMs={}, pageContainers={}, transportFactsCount={}, activeFactsCount={}, complete={}, failedStage={}, requestId={}, traceId={}, scopeId={}",
|
||||||
|
totalMs,millis(SyncPhase.GUARD),millis(SyncPhase.DISCOVER),millis(SyncPhase.GENERATE),millis(SyncPhase.TRANSPORT_FACTS),millis(SyncPhase.RECONCILE),activeMs,millis(SyncPhase.ACTIVE_STORE),millis(SyncPhase.ACTIVE_FACTS),millis(SyncPhase.OBSERVE_CHECKPOINT),
|
||||||
|
pageContainers,transportFactsCount,activeFactsCount,complete,success?"NONE":phase.name(),org.slf4j.MDC.get("requestId"),org.slf4j.MDC.get("traceId"),currentScopeId());
|
||||||
|
} catch(Throwable diagnosticFailure) { /* 诊断失败不能替换原业务异常或阻断提交。 */ }
|
||||||
|
}
|
||||||
|
/** 阶段仅输出整数毫秒,不携带业务标识。 */
|
||||||
|
private long millis(SyncPhase value) { return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(elapsed[value.ordinal()]); }
|
||||||
}
|
}
|
||||||
static void validateSource(ContainerSource source) {
|
static void validateSource(ContainerSource source) {
|
||||||
if(source==null || source.containerBookingId()==null || source.containerBookingId().isBlank() || source.departedAt()==null && source.departedLocalAt()==null && source.sailedEvidenceLocalAt()==null || source.sourceVersion()==null || source.establishedFlow()==null || source.requirements()==null)
|
if(source==null || source.containerBookingId()==null || source.containerBookingId().isBlank() || source.departedAt()==null && source.departedLocalAt()==null && source.sailedEvidenceLocalAt()==null || source.sourceVersion()==null || source.establishedFlow()==null || source.requirements()==null)
|
||||||
@@ -69,19 +130,32 @@ public class AppointmentWorkSyncService {
|
|||||||
for(var requirement:source.requirements()) {
|
for(var requirement:source.requirements()) {
|
||||||
if(requirement==null || requirement.requirementKey()==null || requirement.requirementKey().isBlank() || !seen.add(requirement.requirementKey()) || requirement.references()==null || requirement.replacesRequirementKeys()==null || requirement.sources()==null)
|
if(requirement==null || requirement.requirementKey()==null || requirement.requirementKey().isBlank() || !seen.add(requirement.requirementKey()) || requirement.references()==null || requirement.replacesRequirementKeys()==null || requirement.sources()==null)
|
||||||
throw unavailable("送货需求缺少稳定身份或重复");
|
throw unavailable("送货需求缺少稳定身份或重复");
|
||||||
if(requirement.batchKey()!=null && !batches.add(requirement.batchKey())) throw unavailable("同柜批次需求重复,必须由原域归并来源");
|
if(requirement.batchKey()!=null) {
|
||||||
|
var identity=requirement.batchKey().startsWith("FBA_APPOINTMENT:")?customerRequirement(requirement.batchKey(),deliveryCustomer(requirement.sources())):requirement.batchKey();
|
||||||
|
if(!batches.add(identity)) throw unavailable("同柜批次客户需求重复,必须由原域归并来源");
|
||||||
|
}
|
||||||
if(requirement.timezone()!=null) zone(requirement.timezone());
|
if(requirement.timezone()!=null) zone(requirement.timezone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/** Caller holds work guard and validates the route against current original facts. */
|
/** 命令入口仍在调用方守卫事务中即时加载工作集。 */
|
||||||
List<String> generate(ContainerSource source,Flow flow,List<AppointmentWorkState> existing,Actor actor) {
|
List<String> generate(ContainerSource source,Flow flow,List<AppointmentWorkState> existing,Actor actor) {
|
||||||
|
return generate(source,flow,existing,actor,workingSet(),gateway.openRequirements());
|
||||||
|
}
|
||||||
|
/** 与持久层全量读取保持稳定键顺序,并保留退役事项及原读取上限。 */
|
||||||
|
private Map<String,AppointmentWorkState> workingSet() {
|
||||||
|
var known=new TreeMap<String,AppointmentWorkState>();
|
||||||
|
store.all().forEach(state->known.put(state.getWorkItemKey(),state));
|
||||||
|
return known;
|
||||||
|
}
|
||||||
|
/** 同页各柜共享最新状态,新增、跨柜关联和退役仍逐项持久化。 */
|
||||||
|
private List<String> generate(ContainerSource source,Flow flow,List<AppointmentWorkState> existing,Actor actor,
|
||||||
|
Map<String,AppointmentWorkState> known,RequirementReader requirementReader) {
|
||||||
validateSource(source);
|
validateSource(source);
|
||||||
if(flow==Flow.UNKNOWN) throw conflict("真实送货路线尚未明确");
|
if(flow==Flow.UNKNOWN) throw conflict("真实送货路线尚未明确");
|
||||||
var requirements=gateway.requirements(source,flow);
|
var requirements=requirementReader.read(source,flow);
|
||||||
source=new ContainerSource(source.containerBookingId(),source.containerNo(),source.departedAt(),source.departedLocalAt(),source.sailedEvidenceLocalAt(),source.sourceVersion(),source.establishedFlow(),source.timezone(),requirements);
|
source=new ContainerSource(source.containerBookingId(),source.containerNo(),source.departedAt(),source.departedLocalAt(),source.sailedEvidenceLocalAt(),source.sourceVersion(),source.establishedFlow(),source.timezone(),requirements);
|
||||||
validateSource(source);
|
validateSource(source);
|
||||||
if(requirements.isEmpty()) throw conflict("真实送货需求尚未明确,不能生成空清单");
|
if(requirements.isEmpty()) throw conflict("真实送货需求尚未明确,不能生成空清单");
|
||||||
var known=store.all().stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity(),(a,b)->a));
|
|
||||||
var created=new ArrayList<AppointmentWorkState>();
|
var created=new ArrayList<AppointmentWorkState>();
|
||||||
for(var type:flow==Flow.TRANSFER?List.of(Type.PICKUP,Type.WAREHOUSE_RECEIPT,Type.EMPTY_RETURN):List.of(Type.PICKUP,Type.EMPTY_RETURN)) add(source,type,null,flow,known,created);
|
for(var type:flow==Flow.TRANSFER?List.of(Type.PICKUP,Type.WAREHOUSE_RECEIPT,Type.EMPTY_RETURN):List.of(Type.PICKUP,Type.EMPTY_RETURN)) add(source,type,null,flow,known,created);
|
||||||
for(var requirement:source.requirements()) {
|
for(var requirement:source.requirements()) {
|
||||||
@@ -91,10 +165,17 @@ public class AppointmentWorkSyncService {
|
|||||||
for(var state:created) {
|
for(var state:created) {
|
||||||
state.setOwner(actor.id().equals(SYSTEM.id())?null:new Owner(actor.id(),actor.name()));
|
state.setOwner(actor.id().equals(SYSTEM.id())?null:new Owner(actor.id(),actor.name()));
|
||||||
state.setFollowupDate(LocalDate.now(state.getTimezone()==null?REMINDER_ZONE:zone(state.getTimezone())));
|
state.setFollowupDate(LocalDate.now(state.getTimezone()==null?REMINDER_ZONE:zone(state.getTimezone())));
|
||||||
|
var inherited=store.inheritedFollowup(state.getWorkItemKey());
|
||||||
|
if(inherited!=null) { state.setOwner(inherited.owner());state.setFollowupDate(inherited.nextFollowupDate()); }
|
||||||
String pickup=key(source.containerBookingId(),Type.PICKUP,null),receipt=key(source.containerBookingId(),Type.WAREHOUSE_RECEIPT,null);
|
String pickup=key(source.containerBookingId(),Type.PICKUP,null),receipt=key(source.containerBookingId(),Type.WAREHOUSE_RECEIPT,null);
|
||||||
if(state.getType()==Type.WAREHOUSE_RECEIPT) state.setPrerequisites(List.of(pickup));
|
if(state.getType()==Type.WAREHOUSE_RECEIPT) state.setPrerequisites(List.of(pickup));
|
||||||
if(state.getType()==Type.DELIVERY) state.setPrerequisites(List.of(flow==Flow.TRANSFER?receipt:pickup,requirementKey(source.containerBookingId(),Type.TRANSPORT,state.getRequirementKey(),state.getBatchKey())));
|
if(state.getType()==Type.DELIVERY) state.setPrerequisites(List.of(flow==Flow.TRANSFER?receipt:pickup,requirementKey(source.containerBookingId(),Type.TRANSPORT,state.getRequirementKey(),state.getBatchKey())));
|
||||||
if(state.getType()==Type.TRANSPORT) state.setPrerequisites(List.of(flow==Flow.TRANSFER?receipt:pickup,requirementKey(source.containerBookingId(),Type.DELIVERY,state.getRequirementKey(),state.getBatchKey())));
|
if(state.getType()==Type.TRANSPORT) {
|
||||||
|
var prerequisites=new LinkedHashSet<String>();prerequisites.add(flow==Flow.TRANSFER?receipt:pickup);
|
||||||
|
for(var r:requirements) if(Objects.equals(state.getBatchKey(),r.batchKey()) && (r.batchKey()!=null || Objects.equals(state.getRequirementKey(),r.requirementKey())))
|
||||||
|
prerequisites.add(requirementKey(source.containerBookingId(),Type.DELIVERY,r.requirementKey(),r.batchKey()));
|
||||||
|
state.setPrerequisites(List.copyOf(prerequisites));
|
||||||
|
}
|
||||||
if(state.getType()==Type.EMPTY_RETURN) {
|
if(state.getType()==Type.EMPTY_RETURN) {
|
||||||
String containerId=source.containerBookingId();
|
String containerId=source.containerBookingId();
|
||||||
state.setPrerequisites(flow==Flow.TRANSFER?List.of(receipt):source.requirements().stream().map(r->requirementKey(containerId,Type.DELIVERY,r.requirementKey(),r.batchKey())).toList());
|
state.setPrerequisites(flow==Flow.TRANSFER?List.of(receipt):source.requirements().stream().map(r->requirementKey(containerId,Type.DELIVERY,r.requirementKey(),r.batchKey())).toList());
|
||||||
@@ -102,6 +183,18 @@ public class AppointmentWorkSyncService {
|
|||||||
store.insert(state); store.history(state,actor,"GENERATED","根据已确认路线建立工作需求",null,null,null);
|
store.insert(state); store.history(state,actor,"GENERATED","根据已确认路线建立工作需求",null,null,null);
|
||||||
}
|
}
|
||||||
String generationContainerId=source.containerBookingId();
|
String generationContainerId=source.containerBookingId();
|
||||||
|
// Rebuild each batch vehicle's customer edges from the complete current manifest.
|
||||||
|
// A shared vehicle retains every customer delivery; no last customer wins the edge.
|
||||||
|
for(var batch:requirements.stream().map(Requirement::batchKey).filter(Objects::nonNull).distinct().toList()) {
|
||||||
|
var vehicle=known.get(requirementKey(generationContainerId,Type.TRANSPORT,null,batch));
|
||||||
|
var dependencies=new LinkedHashSet<>(vehicle.getPrerequisites());
|
||||||
|
dependencies.removeIf(k->{var s=known.get(k);return s!=null && s.getType()==Type.DELIVERY && batch.equals(s.getBatchKey());});
|
||||||
|
requirements.stream().filter(r->batch.equals(r.batchKey())).map(r->requirementKey(generationContainerId,Type.DELIVERY,r.requirementKey(),batch)).forEach(dependencies::add);
|
||||||
|
var sources=requirements.stream().filter(r->batch.equals(r.batchKey())).flatMap(r->r.sources().stream()).distinct().toList();
|
||||||
|
if(!vehicle.getPrerequisites().equals(List.copyOf(dependencies)) || !vehicle.getSources().equals(sources)) {
|
||||||
|
vehicle.setPrerequisites(List.copyOf(dependencies));vehicle.setSources(sources);store.save(vehicle);
|
||||||
|
}
|
||||||
|
}
|
||||||
var activeKeys=new HashSet<String>(); requirements.forEach(r->{ activeKeys.add(requirementKey(generationContainerId,Type.DELIVERY,r.requirementKey(),r.batchKey())); activeKeys.add(requirementKey(generationContainerId,Type.TRANSPORT,r.requirementKey(),r.batchKey())); });
|
var activeKeys=new HashSet<String>(); requirements.forEach(r->{ activeKeys.add(requirementKey(generationContainerId,Type.DELIVERY,r.requirementKey(),r.batchKey())); activeKeys.add(requirementKey(generationContainerId,Type.TRANSPORT,r.requirementKey(),r.batchKey())); });
|
||||||
for(var old:existing) if(old.getRequirementKey()!=null && !"BATCH_REPLACED".equals(old.getRetirementReason()) && !activeKeys.contains(old.getWorkItemKey())) {
|
for(var old:existing) if(old.getRequirementKey()!=null && !"BATCH_REPLACED".equals(old.getRetirementReason()) && !activeKeys.contains(old.getWorkItemKey())) {
|
||||||
String containerId=source.containerBookingId();
|
String containerId=source.containerBookingId();
|
||||||
@@ -116,6 +209,7 @@ public class AppointmentWorkSyncService {
|
|||||||
store.save(old);store.history(old,actor,"SOURCE_REMOVED","原排车已移除该柜实物,保留其他柜的当前关联",null,null,null);continue;
|
store.save(old);store.history(old,actor,"SOURCE_REMOVED","原排车已移除该柜实物,保留其他柜的当前关联",null,null,null);continue;
|
||||||
}
|
}
|
||||||
var replacements=requirements.stream().filter(r->r.replacesRequirementKeys().contains(old.getRequirementKey()))
|
var replacements=requirements.stream().filter(r->r.replacesRequirementKeys().contains(old.getRequirementKey()))
|
||||||
|
.filter(r->old.getType()!=Type.DELIVERY || old.getSources().isEmpty() || r.sources().isEmpty() || r.sources().stream().anyMatch(s->old.getSources().stream().anyMatch(o->Objects.equals(o.customerId(),s.customerId()))))
|
||||||
.map(r->requirementKey(containerId,old.getType(),r.requirementKey(),r.batchKey())).distinct().toList();
|
.map(r->requirementKey(containerId,old.getType(),r.requirementKey(),r.batchKey())).distinct().toList();
|
||||||
// Removed dispatch items no longer exist in the original allocation table. Its full current
|
// Removed dispatch items no longer exist in the original allocation table. Its full current
|
||||||
// container manifest restores the unallocated demand or names the newly assigned dispatch.
|
// container manifest restores the unallocated demand or names the newly assigned dispatch.
|
||||||
@@ -126,7 +220,9 @@ public class AppointmentWorkSyncService {
|
|||||||
}
|
}
|
||||||
return created.stream().map(AppointmentWorkState::getWorkItemKey).toList();
|
return created.stream().map(AppointmentWorkState::getWorkItemKey).toList();
|
||||||
}
|
}
|
||||||
private static String requirementKey(String container,Type type,String requirement,String batch) { return batch==null?key(container,type,requirement):key("BATCH:"+batch,type,null); }
|
private static String requirementKey(String container,Type type,String requirement,String batch) {
|
||||||
|
return batch==null?key(container,type,requirement):key("BATCH:"+batch,type,type==Type.DELIVERY && batch.startsWith("FBA_APPOINTMENT:")?requirement:null);
|
||||||
|
}
|
||||||
private void add(ContainerSource source,Type type,Requirement requirement,Flow flow,Map<String,AppointmentWorkState> known,List<AppointmentWorkState> created) {
|
private void add(ContainerSource source,Type type,Requirement requirement,Flow flow,Map<String,AppointmentWorkState> known,List<AppointmentWorkState> created) {
|
||||||
var state=create(source,type,requirement,flow); var old=known.get(state.getWorkItemKey());
|
var state=create(source,type,requirement,flow); var old=known.get(state.getWorkItemKey());
|
||||||
if(old==null) { created.add(state); known.put(state.getWorkItemKey(),state); }
|
if(old==null) { created.add(state); known.put(state.getWorkItemKey(),state); }
|
||||||
@@ -147,14 +243,14 @@ public class AppointmentWorkSyncService {
|
|||||||
}
|
}
|
||||||
old.setBatchNo(requirement.batchNo()); old.setOriginName(requirement.originName()); old.setDestinationName(requirement.destinationName()); old.setDestinationKind(requirement.destinationKind()); old.setReferences(requirement.references()); old.setSources(requirement.sources());
|
old.setBatchNo(requirement.batchNo()); old.setOriginName(requirement.originName()); old.setDestinationName(requirement.destinationName()); old.setDestinationKind(requirement.destinationKind()); old.setReferences(requirement.references()); old.setSources(requirement.sources());
|
||||||
if(type==Type.DELIVERY) old.setTimezone(requirement.timezone());
|
if(type==Type.DELIVERY) old.setTimezone(requirement.timezone());
|
||||||
if(update) store.save(old);
|
if(update && !created.contains(old)) store.save(old);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private AppointmentWorkState create(ContainerSource source,Type type,Requirement requirement,Flow flow) {
|
private AppointmentWorkState create(ContainerSource source,Type type,Requirement requirement,Flow flow) {
|
||||||
var state=new AppointmentWorkState(); state.setWorkItemKey(requirement==null?key(source.containerBookingId(),type,null):requirementKey(source.containerBookingId(),type,requirement.requirementKey(),requirement.batchKey()));
|
var state=new AppointmentWorkState(); state.setWorkItemKey(requirement==null?key(source.containerBookingId(),type,null):requirementKey(source.containerBookingId(),type,requirement.requirementKey(),requirement.batchKey()));
|
||||||
state.setType(type); state.setFlow(flow); state.setContainerBookingId(source.containerBookingId()); state.setContainerNo(source.containerNo()); state.setSourceVersion(source.sourceVersion());
|
state.setType(type); state.setFlow(flow); state.setContainerBookingId(source.containerBookingId()); state.setContainerNo(source.containerNo()); state.setSourceVersion(source.sourceVersion());
|
||||||
state.setTimezone(type==Type.ROUTE?source.timezone():null); state.setContainers(List.of(new ContainerReference(source.containerBookingId(),null,source.containerNo())));
|
state.setTimezone(type==Type.ROUTE?source.timezone():null); state.setContainers(List.of(new ContainerReference(source.containerBookingId(),null,source.containerNo())));
|
||||||
if(requirement!=null) { state.setRequirementKey(requirement.requirementKey()); state.setBatchKey(requirement.batchKey()); state.setBatchNo(requirement.batchNo()); state.setDestinationKind(requirement.destinationKind());
|
if(requirement!=null) { state.setRequirementKey(type==Type.TRANSPORT && requirement.batchKey()!=null?requirement.batchKey():requirement.requirementKey()); state.setBatchKey(requirement.batchKey()); state.setBatchNo(requirement.batchNo()); state.setDestinationKind(requirement.destinationKind());
|
||||||
state.setOriginName(requirement.originName()); state.setDestinationName(requirement.destinationName()); state.setTimezone(type==Type.DELIVERY?requirement.timezone():null); state.setReferences(requirement.references()); state.setSources(requirement.sources());
|
state.setOriginName(requirement.originName()); state.setDestinationName(requirement.destinationName()); state.setTimezone(type==Type.DELIVERY?requirement.timezone():null); state.setReferences(requirement.references()); state.setSources(requirement.sources());
|
||||||
if(requirement.batchKey()!=null) state.setBatchKeys(List.of(requirement.batchKey())); }
|
if(requirement.batchKey()!=null) state.setBatchKeys(List.of(requirement.batchKey())); }
|
||||||
return state;
|
return state;
|
||||||
@@ -166,17 +262,18 @@ public class AppointmentWorkSyncService {
|
|||||||
var fact=facts.get(state.getWorkItemKey()); if(fact==null) continue;
|
var fact=facts.get(state.getWorkItemKey()); if(fact==null) continue;
|
||||||
if(fact!=null && state.getObservedImpactToken()!=null && !Objects.equals(state.getObservedImpactToken(),fact.impactToken()) && state.isObservedArranged()) {
|
if(fact!=null && state.getObservedImpactToken()!=null && !Objects.equals(state.getObservedImpactToken(),fact.impactToken()) && state.isObservedArranged()) {
|
||||||
affected.add(state.getWorkItemKey());
|
affected.add(state.getWorkItemKey());
|
||||||
expandDependents(state.getWorkItemKey(),states,affected);
|
if(state.getObservedDependentImpactToken()!=null && !Objects.equals(state.getObservedDependentImpactToken(),fact.dependentImpactToken()))
|
||||||
|
expandDependents(state.getWorkItemKey(),states,affected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for(var state:states) {
|
for(var state:states) {
|
||||||
if(state.isRetired()) continue;
|
if(state.isRetired()) continue;
|
||||||
var fact=facts.get(state.getWorkItemKey()); if(fact==null) continue;
|
var fact=facts.get(state.getWorkItemKey()); if(fact==null) continue;
|
||||||
boolean changed=changed(state,fact) || !Objects.equals(state.getObservedImpactToken(),fact.impactToken()) || state.isObservedArranged()!=fact.arranged() || !state.getReadPermissions().equals(fact.readPermissions()),baseline=state.getObservedFingerprint()==null;
|
boolean changed=changed(state,fact) || !Objects.equals(state.getObservedImpactToken(),fact.impactToken()) || !Objects.equals(state.getObservedDependentImpactToken(),fact.dependentImpactToken()) || state.isObservedArranged()!=fact.arranged() || !state.getReadPermissions().equals(fact.readPermissions()),baseline=state.getObservedFingerprint()==null;
|
||||||
boolean review=affected.contains(state.getWorkItemKey()) && arranged(state,fact) && !completed(fact);
|
boolean review=affected.contains(state.getWorkItemKey()) && arranged(state,fact) && !completed(fact);
|
||||||
if(review) { state.setReviewRequired(true); state.setReviewVersion(state.getReviewVersion()+1); state.setReviewReason("原业务时间、货量或执行关联发生变化,请核对已有安排"); }
|
if(review) { state.setReviewRequired(true); state.setReviewVersion(state.getReviewVersion()+1); state.setReviewReason("原业务时间、货量或执行关联发生变化,请核对已有安排"); }
|
||||||
if(changed || baseline || review) {
|
if(changed || baseline || review) {
|
||||||
state.setObservedFingerprint(fact.fingerprint()); state.setObservedImpactToken(fact.impactToken()); state.setObservedArranged(fact.arranged()); state.setReadPermissions(List.copyOf(fact.readPermissions())); store.save(state);
|
state.setObservedFingerprint(fact.fingerprint()); state.setObservedImpactToken(fact.impactToken()); state.setObservedDependentImpactToken(fact.dependentImpactToken()); state.setObservedArranged(fact.arranged()); state.setReadPermissions(List.copyOf(fact.readPermissions())); store.save(state);
|
||||||
store.history(state,actor,review?"IMPACT_OBSERVED":"BASELINE_OBSERVED",review?state.getReviewReason():"记录当前原业务基线,不生成执行事实",null,null,null);
|
store.history(state,actor,review?"IMPACT_OBSERVED":"BASELINE_OBSERVED",review?state.getReviewReason():"记录当前原业务基线,不生成执行事实",null,null,null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,9 +282,29 @@ public class AppointmentWorkSyncService {
|
|||||||
var queue=new ArrayDeque<String>(); queue.add(key); var seen=new HashSet<String>();
|
var queue=new ArrayDeque<String>(); queue.add(key); var seen=new HashSet<String>();
|
||||||
var byKey=states.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity()));
|
var byKey=states.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity()));
|
||||||
while(!queue.isEmpty()) { var current=queue.remove(); if(!seen.add(current)) continue;
|
while(!queue.isEmpty()) { var current=queue.remove(); if(!seen.add(current)) continue;
|
||||||
|
// A changed customer batch requires its shared vehicle to be checked. That does not
|
||||||
|
// make the vehicle's other customers' appointments change. A change originating in
|
||||||
|
// the vehicle itself still reaches all of its current dependent appointments.
|
||||||
|
var currentState=byKey.get(current);
|
||||||
|
if(!current.equals(key) && currentState!=null && currentState.getType()==Type.TRANSPORT) continue;
|
||||||
for(var candidate:states) if(!candidate.isRetired() && effectiveKeys(candidate.getPrerequisites(),byKey).contains(current) && affected.add(candidate.getWorkItemKey())) queue.add(candidate.getWorkItemKey());
|
for(var candidate:states) if(!candidate.isRetired() && effectiveKeys(candidate.getPrerequisites(),byKey).contains(current) && affected.add(candidate.getWorkItemKey())) queue.add(candidate.getWorkItemKey());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/** Reverse of the impact traversal: a shared vehicle is an authority, not a sibling bridge. */
|
||||||
|
static List<AppointmentWorkState> impactPrerequisites(AppointmentWorkState target,List<AppointmentWorkState> states) {
|
||||||
|
var byKey=states.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity()));
|
||||||
|
var pending=new ArrayDeque<>(effectiveKeys(target.getPrerequisites(),byKey));
|
||||||
|
var seen=new HashSet<String>(); seen.add(target.getWorkItemKey());
|
||||||
|
var result=new ArrayList<AppointmentWorkState>();
|
||||||
|
while(!pending.isEmpty()) {
|
||||||
|
var key=pending.remove(); if(!seen.add(key)) continue;
|
||||||
|
var state=byKey.get(key);
|
||||||
|
if(state==null || state.isRetired()) throw conflict("关联安排已变化,须先同步当前工作关系");
|
||||||
|
result.add(state);
|
||||||
|
if(state.getType()!=Type.TRANSPORT) pending.addAll(effectiveKeys(state.getPrerequisites(),byKey));
|
||||||
|
}
|
||||||
|
return List.copyOf(result);
|
||||||
|
}
|
||||||
private void reconcileTransport(List<AppointmentWorkState> all,Map<String,Fact> facts) {
|
private void reconcileTransport(List<AppointmentWorkState> all,Map<String,Fact> facts) {
|
||||||
var members=all.stream().filter(s->s.getType()==Type.TRANSPORT && s.getTripKey()==null && facts.containsKey(s.getWorkItemKey()) && facts.get(s.getWorkItemKey()).tripKey()!=null)
|
var members=all.stream().filter(s->s.getType()==Type.TRANSPORT && s.getTripKey()==null && facts.containsKey(s.getWorkItemKey()) && facts.get(s.getWorkItemKey()).tripKey()!=null)
|
||||||
.collect(Collectors.groupingBy(s->facts.get(s.getWorkItemKey()).tripKey()));
|
.collect(Collectors.groupingBy(s->facts.get(s.getWorkItemKey()).tripKey()));
|
||||||
|
|||||||
+14
-1
@@ -21,7 +21,20 @@ public class AppointmentWorkSynchronizationJob {
|
|||||||
cursor=result.complete()?null:result.nextCursor();
|
cursor=result.complete()?null:result.nextCursor();
|
||||||
} catch(RuntimeException error) {
|
} catch(RuntimeException error) {
|
||||||
cursor=null;
|
cursor=null;
|
||||||
log.warn("Appointment work synchronization incomplete; next scan restarts from first page: {}",error.getClass().getSimpleName());
|
log.warn("Appointment work synchronization incomplete; next scan restarts from first page: {}",failureDiagnostic(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/** Keep causes and code locations, never exception messages that may contain remote payloads or credentials. */
|
||||||
|
static String failureDiagnostic(Throwable error) {
|
||||||
|
var causes=new java.util.ArrayList<String>();
|
||||||
|
var seen=java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<Throwable,Boolean>());
|
||||||
|
for(var current=error;current!=null && causes.size()<6 && seen.add(current);current=current.getCause()) {
|
||||||
|
String status=current instanceof org.springframework.web.server.ResponseStatusException response?"["+response.getStatusCode().value()+"]":"";
|
||||||
|
String location=java.util.Arrays.stream(current.getStackTrace())
|
||||||
|
.filter(frame->frame.getClassName().startsWith("com.baiyue.") && !frame.getClassName().equals(AppointmentWorkPolicy.class.getName()))
|
||||||
|
.findFirst().map(frame->"@"+frame.getClassName()+":"+frame.getLineNumber()).orElse("");
|
||||||
|
causes.add(current.getClass().getSimpleName()+status+location);
|
||||||
|
}
|
||||||
|
return String.join(" <- ",causes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package com.baiyue.lastleg.appointmentwork.diagnostics;
|
||||||
|
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import org.aopalliance.intercept.MethodInterceptor;
|
||||||
|
import org.springframework.aop.framework.ProxyFactory;
|
||||||
|
import org.springframework.aop.support.AopUtils;
|
||||||
|
import org.springframework.beans.factory.config.BeanPostProcessor;
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
|
||||||
|
|
||||||
|
/** 仅观察原Hikari getConnection;同池同连接,unwrap保持容器bean self契约而非隐藏target身份。 */
|
||||||
|
final class AppointmentWorkDataSourcePostProcessor implements BeanPostProcessor {
|
||||||
|
/** 不识别或已有代理的数据源保持原对象,明确记录未覆盖而不主动探测连接。 */
|
||||||
|
@Override public Object postProcessAfterInitialization(Object bean,String name) {
|
||||||
|
if(!(bean instanceof DataSource))return bean;
|
||||||
|
if(!(bean instanceof HikariDataSource) || AopUtils.isAopProxy(bean)) {
|
||||||
|
warnUnavailable();return bean;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var factory=new ProxyFactory(bean);factory.setProxyTargetClass(true);
|
||||||
|
factory.addAdvice((MethodInterceptor)invocation->{
|
||||||
|
if(!invocation.getMethod().getName().equals("getConnection") || !active())return invocation.proceed();
|
||||||
|
try(var span=span(Stage.CONNECTION_ACQUIRE)) {
|
||||||
|
try{return invocation.proceed();}catch(Throwable error){span.failed();throw error;}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return factory.getProxy(bean.getClass().getClassLoader());
|
||||||
|
} catch(RuntimeException failure) { warnUnavailable();return bean; }
|
||||||
|
}
|
||||||
|
/** 不输出 Bean 名、数据源属性或异常内容。 */
|
||||||
|
private static void warnUnavailable() {
|
||||||
|
try{org.slf4j.LoggerFactory.getLogger(AppointmentWorkDataSourcePostProcessor.class).warn("Appointment connection diagnostic unavailable: unsupported or already proxied datasource");}
|
||||||
|
catch(Throwable ignored){ /* 日志故障不改变 Bean 注册。 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
package com.baiyue.lastleg.appointmentwork.diagnostics;
|
||||||
|
|
||||||
|
import jakarta.servlet.*;
|
||||||
|
import jakarta.servlet.http.*;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
import java.io.IOException;
|
||||||
|
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
|
||||||
|
|
||||||
|
/** 仅测量服务端同步 filter dispatch,不代表客户端总耗时或异步响应完成时间。 */
|
||||||
|
@Component
|
||||||
|
@Order(Ordered.HIGHEST_PRECEDENCE+10)
|
||||||
|
public class AppointmentWorkDiagnosticFilter extends OncePerRequestFilter {
|
||||||
|
private static final String BASE="/api/platform/v1/last-leg/appointment-work";
|
||||||
|
/** 仅识别固定模板;路径参数、query string和载荷不进入上下文。 */
|
||||||
|
static Route route(String method,String path){
|
||||||
|
if("GET".equals(method)){
|
||||||
|
if((BASE+"/summary").equals(path))return Route.SUMMARY;
|
||||||
|
if((BASE+"/calendar").equals(path))return Route.CALENDAR;
|
||||||
|
if((BASE+"/items").equals(path))return Route.ITEMS;
|
||||||
|
if((BASE+"/items/resolve").equals(path))return Route.RESOLVE;
|
||||||
|
if(path!=null && path.matches(BASE+"/items/[^/]+"))return Route.DETAIL;
|
||||||
|
}
|
||||||
|
if("POST".equals(method) && path!=null && path.matches(BASE+"/items/[^/]+/commands"))return Route.COMMAND;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
/** 不采集其他端点,也不为诊断增加权限判断。 */
|
||||||
|
@Override protected boolean shouldNotFilter(HttpServletRequest request){return route(request.getMethod(),request.getRequestURI())==null;}
|
||||||
|
/** 创建及清理线程作用域;不覆写上游相关标识、状态、响应或异常。 */
|
||||||
|
@Override protected void doFilterInternal(HttpServletRequest request,HttpServletResponse response,FilterChain chain)throws ServletException,IOException{
|
||||||
|
Scope scope=null;
|
||||||
|
try{scope=open(Origin.HTTP,route(request.getMethod(),request.getRequestURI()));}catch(RuntimeException ignored){ /* 无诊断仍执行原请求。 */ }
|
||||||
|
try{chain.doFilter(request,response);}
|
||||||
|
catch(ServletException|IOException|RuntimeException|Error error){if(scope!=null)scope.failed();throw error;}
|
||||||
|
finally{if(scope!=null){try{scope.status(response.getStatus());}catch(Throwable ignored){ /* 响应诊断读取失败不替换原异常。 */ }finally{scope.close();}}}
|
||||||
|
}
|
||||||
|
}
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
package com.baiyue.lastleg.appointmentwork.diagnostics;
|
||||||
|
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.slf4j.MDC;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.function.LongSupplier;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/** 仅在当前线程的预约调用内累加固定计时桶,不保存业务参数或跨请求状态。 */
|
||||||
|
public final class AppointmentWorkDiagnostics {
|
||||||
|
public enum Origin { HTTP, QUERY, COMMAND, SYNC }
|
||||||
|
public enum Route { INTERNAL, SUMMARY, CALENDAR, ITEMS, RESOLVE, DETAIL, COMMAND }
|
||||||
|
public enum Stage {
|
||||||
|
TRANSACTION_ADVISOR, QUERY_GUARD, QUERY_DISCOVER, QUERY_STORE, QUERY_SELECT, QUERY_FACTS,
|
||||||
|
QUERY_PROJECT, QUERY_NEXT, QUERY_FINAL, DETAIL_STORE, DETAIL_FACTS, DETAIL_PROJECT, DETAIL_HISTORY,
|
||||||
|
GUARD_LOCK, GUARD_READ, GUARD_UPDATE, FACTS_PREPARE, FACTS_FIRSTLEG, FACTS_RECEIVING,
|
||||||
|
FACTS_DELIVERY, FACTS_TRANSPORT, FACTS_DIRECT_TRANSPORT, FACTS_OTHER, FACTS_VALIDATE,
|
||||||
|
DELIVERY_SOURCE_MAP, DELIVERY_CARGO, DELIVERY_SNAPSHOTS, DELIVERY_ROWS, DELIVERY_RECEIVING,
|
||||||
|
DELIVERY_IMPACTS, DELIVERY_ASSEMBLY, SYNC_BODY, SYNC_GUARD, SYNC_DISCOVER, SYNC_GENERATE,
|
||||||
|
SYNC_TRANSPORT_FACTS, SYNC_RECONCILE, SYNC_ACTIVE_STORE, SYNC_ACTIVE_FACTS, SYNC_CHECKPOINT, CONNECTION_ACQUIRE,
|
||||||
|
DB_EXECUTOR_QUERY, DB_EXECUTOR_UPDATE, DB_PREPARE, DB_STATEMENT_QUERY, DB_STATEMENT_UPDATE, DB_RESULT_MAPPING
|
||||||
|
}
|
||||||
|
public enum StatementBucket { NONE, GUARD_LOCK, GUARD_READ, GUARD_UPDATE, WORK_STATE, WORK_HISTORY, DELIVERY, RECEIVING, OTHER }
|
||||||
|
public record Metric(long calls,long inclusiveNanos,long selfNanos,long failures,long firstOffsetNanos,long lastOffsetNanos) { }
|
||||||
|
public record Snapshot(String scopeId,String parentScopeId,Origin origin,Route route,String requestId,String traceId,
|
||||||
|
long elapsedNanos,boolean failed,int status,Map<String,Metric> metrics,boolean transactionObserved,int completionStatus,long completionOffsetNanos,long startedNanos) { }
|
||||||
|
private static final org.slf4j.Logger LOG=LoggerFactory.getLogger(AppointmentWorkDiagnostics.class);
|
||||||
|
private static final ThreadLocal<Scope> CURRENT=new ThreadLocal<>();
|
||||||
|
private static final Span NO_SPAN=new Span(null,null,null,0);
|
||||||
|
/** 诊断工具无需实例化。 */
|
||||||
|
private AppointmentWorkDiagnostics() { }
|
||||||
|
|
||||||
|
/** 新建独立作用域;关闭时恢复调用方原作用域,不改变 MDC。 */
|
||||||
|
public static Scope open(Origin origin,Route route) { return new Scope(origin,route,System::nanoTime); }
|
||||||
|
/** 返回纯诊断标识,缺少作用域时明确为空,不生成业务关联键。 */
|
||||||
|
public static String currentScopeId() { var scope=CURRENT.get();return scope==null?null:scope.id; }
|
||||||
|
/** 无作用域时所有阶段计时均为空操作。 */
|
||||||
|
public static boolean active() { return CURRENT.get()!=null; }
|
||||||
|
/** 只监听既有事务完成,不开启事务或改变其同步、隔离级别及连接属性。 */
|
||||||
|
public static void observeTransaction() {
|
||||||
|
try {
|
||||||
|
var scope=CURRENT.get();
|
||||||
|
if(scope==null || scope.transactionObserved || !org.springframework.transaction.support.TransactionSynchronizationManager.isSynchronizationActive()
|
||||||
|
|| !org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive())return;
|
||||||
|
org.springframework.transaction.support.TransactionSynchronizationManager.registerSynchronization(new org.springframework.transaction.support.TransactionSynchronization(){
|
||||||
|
/** 回调发生在原事务完成后,仅记录相对时刻;不声称这是数据库精确释放锁时刻。 */
|
||||||
|
@Override public void afterCompletion(int status){try{scope.completionStatus=status;scope.completionOffsetNanos=Math.max(0,scope.clock.getAsLong()-scope.started);}catch(Throwable ignored){}}
|
||||||
|
});
|
||||||
|
scope.transactionObserved=true;
|
||||||
|
}catch(Throwable ignored){ /* 诊断监听失败不能改变事务。 */ }
|
||||||
|
}
|
||||||
|
/** 将可信相关标识限制为诊断字符集;任意文本不进入日志。 */
|
||||||
|
private static String correlation(String value) { return value!=null && value.matches("[A-Za-z0-9_-]{1,80}")?value:null; }
|
||||||
|
/** 固定阶段开始计时;最多保留64层,避免异常递归造成诊断状态膨胀。 */
|
||||||
|
public static Span span(Stage stage) { return span(stage,StatementBucket.NONE); }
|
||||||
|
/** 数据库阶段只接受固定语句桶,不记录 SQL、参数或动态 Mapper 名称。 */
|
||||||
|
public static Span span(Stage stage,StatementBucket bucket) {
|
||||||
|
try {
|
||||||
|
var scope=CURRENT.get();if(scope==null || scope.stack.size()>=64)return NO_SPAN;
|
||||||
|
var span=new Span(scope,stage,bucket,scope.clock.getAsLong());scope.stack.push(span);return span;
|
||||||
|
} catch(RuntimeException ignored) { return NO_SPAN; }
|
||||||
|
}
|
||||||
|
/** 保留返回对象和原异常;测量只包围原有调用一次。 */
|
||||||
|
public static <T> T measure(Stage stage,Supplier<T> operation) {
|
||||||
|
try(var span=span(stage)) { try{return operation.get();}catch(RuntimeException|Error error){span.failed();throw error;} }
|
||||||
|
}
|
||||||
|
/** 无返回值调用沿相同路径执行,不增加重试或业务判断。 */
|
||||||
|
public static void run(Stage stage,Runnable operation) { measure(stage,()->{operation.run();return null;}); }
|
||||||
|
/** 读取当前统计仅用于固定诊断摘要和隔离验证。 */
|
||||||
|
static Snapshot currentSnapshot() { var scope=CURRENT.get();return scope==null?null:scope.snapshot(); }
|
||||||
|
|
||||||
|
public static final class Scope implements AutoCloseable {
|
||||||
|
private final Scope previous;
|
||||||
|
private final String id=UUID.randomUUID().toString();
|
||||||
|
private final Origin origin;private final Route route;private final LongSupplier clock;
|
||||||
|
private final String requestId,traceId;private final long started;
|
||||||
|
private final Deque<Span> stack=new ArrayDeque<>();
|
||||||
|
private final Map<String,long[]> totals=new LinkedHashMap<>();
|
||||||
|
private boolean failed,closed,transactionObserved;private int status,completionStatus=-1;private long completionOffsetNanos=-1;
|
||||||
|
/** 包内时钟注入仅供确定性验证,不修改全局时钟。 */
|
||||||
|
Scope(Origin origin,Route route,LongSupplier clock) {
|
||||||
|
this.previous=CURRENT.get();this.origin=origin;this.route=route;this.clock=clock;this.started=clock.getAsLong();
|
||||||
|
this.requestId=correlation(MDC.get("requestId"));this.traceId=correlation(MDC.get("traceId"));CURRENT.set(this);
|
||||||
|
}
|
||||||
|
/** 只标记失败,不保存异常类型以外的业务内容。 */
|
||||||
|
public void failed() { failed=true; }
|
||||||
|
/** HTTP状态由容器响应读取,业务code不从载荷解析。 */
|
||||||
|
public void status(int status) { this.status=status; }
|
||||||
|
/** 快照为累计已结束阶段;self仅扣同scope直接子段,独立子scope由parentScopeId关联且不可叠加。 */
|
||||||
|
public Snapshot snapshot() {
|
||||||
|
var metrics=new LinkedHashMap<String,Metric>();totals.forEach((key,v)->metrics.put(key,new Metric(v[0],v[1],v[2],v[3],v[4],v[5])));
|
||||||
|
return new Snapshot(id,previous==null?null:previous.id,origin,route,requestId,traceId,Math.max(0,clock.getAsLong()-started),failed,status,Collections.unmodifiableMap(metrics),transactionObserved,completionStatus,completionOffsetNanos,started);
|
||||||
|
}
|
||||||
|
/** 只有慢调用或失败输出汇总;清理优先,日志异常不得覆盖原业务异常。 */
|
||||||
|
@Override public void close() {
|
||||||
|
if(closed)return;closed=true;
|
||||||
|
try {
|
||||||
|
if(CURRENT.get()==this){if(previous==null)CURRENT.remove();else CURRENT.set(previous);}
|
||||||
|
var s=snapshot();if(!s.failed() && s.status()<500 && s.elapsedNanos()<250_000_000L)return;
|
||||||
|
// startedNanos及offset只可在同一JVM进程比较,不是数据库锁持有者或跨进程时钟。
|
||||||
|
LOG.warn("Appointment attribution: scopeId={}, parentScopeId={}, origin={}, route={}, requestId={}, traceId={}, startedNanos={}, elapsedNs={}, failed={}, httpStatus={}, connectionCoverage={}, transactionObserved={}, completionStatus={}, completionOffsetNs={}, boundary=SERVER_DISPATCH_OR_ADVISOR, stages={}",
|
||||||
|
s.scopeId(),s.parentScopeId(),s.origin(),s.route(),s.requestId(),s.traceId(),s.startedNanos(),s.elapsedNanos(),s.failed(),s.status(),
|
||||||
|
s.metrics().containsKey(Stage.CONNECTION_ACQUIRE.name())?"OBSERVED":"NOT_OBSERVED",s.transactionObserved(),s.completionStatus(),s.completionOffsetNanos(),s.metrics());
|
||||||
|
} catch(Throwable ignored) { /* 诊断设施故障不影响返回、原异常或线程清理。 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Span implements AutoCloseable {
|
||||||
|
private final Scope scope;private final Stage stage;private final StatementBucket bucket;private final long started;
|
||||||
|
private long children;private boolean failed,closed;
|
||||||
|
/** 保存常量阶段,不引用任何业务对象。 */
|
||||||
|
private Span(Scope scope,Stage stage,StatementBucket bucket,long started){this.scope=scope;this.stage=stage;this.bucket=bucket;this.started=started;}
|
||||||
|
/** 失败计数只作为耗时解释,不改变原异常。 */
|
||||||
|
public void failed(){if(scope!=null){failed=true;scope.failed=true;}}
|
||||||
|
/** 每个阶段只结束一次;父段self扣除子段inclusive,不能把两种口径相加。 */
|
||||||
|
@Override public void close(){
|
||||||
|
if(scope==null || closed)return;closed=true;
|
||||||
|
try{
|
||||||
|
long elapsed=Math.max(0,scope.clock.getAsLong()-started);
|
||||||
|
if(scope.stack.peek()!=this)return;
|
||||||
|
scope.stack.pop();if(!scope.stack.isEmpty())scope.stack.peek().children+=elapsed;
|
||||||
|
var key=stage.name()+(bucket==StatementBucket.NONE?"":":"+bucket.name());
|
||||||
|
var v=scope.totals.computeIfAbsent(key,ignored->new long[]{0,0,0,0,Math.max(0,started-scope.started),0});v[0]++;v[1]+=elapsed;v[2]+=Math.max(0,elapsed-children);if(failed)v[3]++;
|
||||||
|
v[4]=Math.min(v[4],Math.max(0,started-scope.started));v[5]=Math.max(v[5],Math.max(0,started+elapsed-scope.started));
|
||||||
|
}catch(Throwable ignored){ /* 诊断计数故障不能替换原结果。 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
package com.baiyue.lastleg.appointmentwork.diagnostics;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.appointmentwork.*;
|
||||||
|
import org.aopalliance.intercept.MethodInterceptor;
|
||||||
|
import org.springframework.aop.Advisor;
|
||||||
|
import org.springframework.aop.support.DefaultPointcutAdvisor;
|
||||||
|
import org.springframework.aop.support.StaticMethodMatcherPointcut;
|
||||||
|
import org.springframework.beans.factory.config.BeanDefinition;
|
||||||
|
import org.springframework.beans.factory.config.BeanPostProcessor;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Role;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.Set;
|
||||||
|
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
|
||||||
|
|
||||||
|
/** 使用现有 Spring 代理设施观察事务边界,不新增 AspectJ 或改变事务属性。 */
|
||||||
|
@Configuration(proxyBeanMethods=false)
|
||||||
|
public class AppointmentWorkDiagnosticsConfiguration {
|
||||||
|
/** 基础设施角色确保事务自动代理器纳入此 Advisor;顺序早于默认事务 Advisor。 */
|
||||||
|
@Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
|
||||||
|
public Advisor appointmentWorkDiagnosticAdvisor() {
|
||||||
|
var pointcut=new StaticMethodMatcherPointcut() {
|
||||||
|
/** 仅匹配预约直接入口,不拦截其他模块或内部辅助方法。 */
|
||||||
|
@Override public boolean matches(Method method,Class<?> targetClass) {
|
||||||
|
return AppointmentWorkSyncService.class.isAssignableFrom(targetClass) && method.getName().equals("synchronize")
|
||||||
|
|| AppointmentWorkCommandService.class.isAssignableFrom(targetClass) && method.getName().equals("execute")
|
||||||
|
|| AppointmentWorkQueryService.class.isAssignableFrom(targetClass) && Set.of("summary","calendar","items","resolveBatch","detail").contains(method.getName());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
MethodInterceptor interceptor=invocation->{
|
||||||
|
boolean sync=invocation.getThis() instanceof AppointmentWorkSyncService;
|
||||||
|
Origin origin=sync?Origin.SYNC:invocation.getThis() instanceof AppointmentWorkCommandService?Origin.COMMAND:Origin.QUERY;
|
||||||
|
Scope owned=null;
|
||||||
|
try { if(sync || !active())owned=open(origin,Route.INTERNAL); } catch(RuntimeException ignored) { /* 无法计时仍执行原方法。 */ }
|
||||||
|
try(var span=span(Stage.TRANSACTION_ADVISOR)) {
|
||||||
|
try{return invocation.proceed();}catch(Throwable error){span.failed();if(owned!=null)owned.failed();throw error;}
|
||||||
|
} finally { if(owned!=null)owned.close(); }
|
||||||
|
};
|
||||||
|
var advisor=new DefaultPointcutAdvisor(pointcut,interceptor);advisor.setOrder(Ordered.HIGHEST_PRECEDENCE+100);return advisor;
|
||||||
|
}
|
||||||
|
/** 静态 Bean 避免提早实例化业务依赖,只对既有 Hikari 实例增加委托代理。 */
|
||||||
|
@Bean public static BeanPostProcessor appointmentWorkDataSourceDiagnostics() { return new AppointmentWorkDataSourcePostProcessor(); }
|
||||||
|
/** MyBatis 按现有插件入口注册,不注册 Mapper、执行 SQL 或改变缓存配置。 */
|
||||||
|
@Bean public AppointmentWorkMyBatisDiagnostics appointmentWorkMyBatisDiagnostics() { return new AppointmentWorkMyBatisDiagnostics(); }
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
package com.baiyue.lastleg.appointmentwork.diagnostics;
|
||||||
|
|
||||||
|
import org.apache.ibatis.executor.Executor;
|
||||||
|
import org.apache.ibatis.executor.resultset.ResultSetHandler;
|
||||||
|
import org.apache.ibatis.executor.statement.StatementHandler;
|
||||||
|
import org.apache.ibatis.mapping.BoundSql;
|
||||||
|
import org.apache.ibatis.mapping.MappedStatement;
|
||||||
|
import org.apache.ibatis.cache.CacheKey;
|
||||||
|
import org.apache.ibatis.plugin.*;
|
||||||
|
import org.apache.ibatis.session.*;
|
||||||
|
import java.sql.*;
|
||||||
|
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
|
||||||
|
|
||||||
|
/** query 为含映射的 inclusive 调用;结果读取另记子段,不推断为纯服务器锁等待。 */
|
||||||
|
@Intercepts({
|
||||||
|
@Signature(type=Executor.class,method="query",args={MappedStatement.class,Object.class,RowBounds.class,ResultHandler.class}),
|
||||||
|
@Signature(type=Executor.class,method="query",args={MappedStatement.class,Object.class,RowBounds.class,ResultHandler.class,CacheKey.class,BoundSql.class}),
|
||||||
|
@Signature(type=Executor.class,method="update",args={MappedStatement.class,Object.class}),
|
||||||
|
@Signature(type=StatementHandler.class,method="prepare",args={Connection.class,Integer.class}),
|
||||||
|
@Signature(type=StatementHandler.class,method="query",args={Statement.class,ResultHandler.class}),
|
||||||
|
@Signature(type=StatementHandler.class,method="update",args={Statement.class}),
|
||||||
|
@Signature(type=ResultSetHandler.class,method="handleResultSets",args={Statement.class})
|
||||||
|
})
|
||||||
|
public final class AppointmentWorkMyBatisDiagnostics implements Interceptor {
|
||||||
|
private static final ThreadLocal<StatementBucket> BUCKET=new ThreadLocal<>();
|
||||||
|
/** 所有调用只 proceed 一次,异常原样返回;固定桶上下文在 finally 恢复。 */
|
||||||
|
@Override public Object intercept(Invocation invocation)throws Throwable {
|
||||||
|
if(!active())return invocation.proceed();
|
||||||
|
var previous=BUCKET.get();boolean executor=invocation.getTarget() instanceof Executor;
|
||||||
|
var bucket=previous==null?StatementBucket.OTHER:previous;
|
||||||
|
if(executor){bucket=bucket(((MappedStatement)invocation.getArgs()[0]).getId());BUCKET.set(bucket);}
|
||||||
|
Stage stage=executor?(invocation.getMethod().getName().equals("query")?Stage.DB_EXECUTOR_QUERY:Stage.DB_EXECUTOR_UPDATE)
|
||||||
|
:invocation.getTarget() instanceof ResultSetHandler?Stage.DB_RESULT_MAPPING
|
||||||
|
:switch(invocation.getMethod().getName()){case "prepare"->Stage.DB_PREPARE;case "query"->Stage.DB_STATEMENT_QUERY;default->Stage.DB_STATEMENT_UPDATE;};
|
||||||
|
try(var span=span(stage,bucket)){
|
||||||
|
try{return invocation.proceed();}catch(Throwable error){span.failed();throw error;}
|
||||||
|
}finally{if(executor){if(previous==null)BUCKET.remove();else BUCKET.set(previous);}}
|
||||||
|
}
|
||||||
|
/** 只有明确白名单语句有独立桶,其他名字一律合入 OTHER,绝不打印原 ID。 */
|
||||||
|
private static StatementBucket bucket(String id){
|
||||||
|
return switch(id){
|
||||||
|
case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.lockGuard"->StatementBucket.GUARD_LOCK;
|
||||||
|
case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.guard"->StatementBucket.GUARD_READ;
|
||||||
|
case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.invalidateRequirements","com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.saveGuard"->StatementBucket.GUARD_UPDATE;
|
||||||
|
case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.all","com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.get"->StatementBucket.WORK_STATE;
|
||||||
|
case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.selectHistory"->StatementBucket.WORK_HISTORY;
|
||||||
|
case "com.baiyue.lastleg.inboundplan.work.AppointmentWorkDeliveryMapper.batches","com.baiyue.lastleg.inboundplan.work.AppointmentWorkDeliveryMapper.customer","com.baiyue.lastleg.inboundplan.work.AppointmentWorkDeliveryMapper.privateBatch"->StatementBucket.DELIVERY;
|
||||||
|
default->StatementBucket.OTHER;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package com.baiyue.lastleg.calendar;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/platform/v1/last-leg/appointment-calendar")
|
||||||
|
public class WarehouseCalendarController {
|
||||||
|
private final WarehouseCalendarService service;
|
||||||
|
/** 装配整柜到仓日历读取服务。 */
|
||||||
|
public WarehouseCalendarController(WarehouseCalendarService service) { this.service = service; }
|
||||||
|
/** 使用预约和原订单读取权限,写资格继续使用原订单编辑权限。 */
|
||||||
|
@PostMapping("/warehouse-arrivals/query")
|
||||||
|
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
|
||||||
|
public ApiResponse<List<WarehouseCalendarModels.Entry>> calendar(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@Valid @RequestBody WarehouseCalendarModels.Query query) {
|
||||||
|
if (user == null || !"PLATFORM".equals(user.appCode()) || user.tenantId() == null || user.tenantId() != 0L)
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台可读预约日历");
|
||||||
|
return ApiResponse.success(service.calendar(query, user.permissions().contains(OverseasPermissions.LAST_MILE_ORDER_EDIT)));
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
package com.baiyue.lastleg.calendar;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface WarehouseCalendarMapper {
|
||||||
|
/** 同柜全部客户合并为一条;人工时间和已建入库计划的时间不能被到港推算覆盖。 */
|
||||||
|
@Select("""
|
||||||
|
<script>
|
||||||
|
SELECT * FROM (
|
||||||
|
SELECT o.source_container_booking_id sourceContainerBookingId,MAX(o.container_no) containerNo,
|
||||||
|
CASE WHEN COUNT(DISTINCT o.overseas_warehouse_id)=1 THEN MAX(o.overseas_warehouse_name) END warehouseName,
|
||||||
|
CASE WHEN COUNT(DISTINCT o.overseas_warehouse_id)=1 THEN MAX(o.overseas_warehouse_code) END warehouseCode,
|
||||||
|
CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) AND COUNT(DISTINCT o.estimated_warehouse_at)=1
|
||||||
|
AND (MAX(o.arrival_time_version)>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) END plannedAt,
|
||||||
|
(SELECT MIN(s.receiving_at) FROM ll_receiving_container_session s
|
||||||
|
WHERE s.source_container_booking_id=o.source_container_booking_id) actualAt,
|
||||||
|
CASE WHEN COUNT(DISTINCT o.arrival_time_version)=1 THEN MAX(o.arrival_time_version) ELSE -1 END 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) planCount,
|
||||||
|
FALSE canEdit
|
||||||
|
FROM ll_order o WHERE o.source_container_booking_id IS NOT NULL
|
||||||
|
GROUP BY o.source_container_booking_id
|
||||||
|
) c WHERE
|
||||||
|
(c.plannedAt >= #{from} AND c.plannedAt < #{to})
|
||||||
|
<if test="ids != null and !ids.isEmpty()">
|
||||||
|
OR c.sourceContainerBookingId IN
|
||||||
|
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||||
|
</if>
|
||||||
|
ORDER BY c.sourceContainerBookingId LIMIT 2001
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
List<WarehouseCalendarModels.Entry> calendar(@Param("from") LocalDateTime from,
|
||||||
|
@Param("to") LocalDateTime to, @Param("ids") List<String> ids);
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package com.baiyue.lastleg.calendar;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 只读日历沿用整柜预计到仓版本,不保存独立计划台账。 */
|
||||||
|
public final class WarehouseCalendarModels {
|
||||||
|
/** 禁止构造工具类。 */
|
||||||
|
private WarehouseCalendarModels() { }
|
||||||
|
public record Query(@NotNull LocalDate from, @NotNull LocalDate to,
|
||||||
|
@NotNull @Size(max = 2000) List<@NotBlank @Pattern(regexp = "[1-9][0-9]*") String> sourceContainerBookingIds) { }
|
||||||
|
public record Entry(String sourceContainerBookingId, String containerNo, String warehouseName,
|
||||||
|
String warehouseCode, LocalDateTime plannedAt, LocalDateTime actualAt, int version,
|
||||||
|
long planCount, boolean canEdit) { }
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package com.baiyue.lastleg.calendar;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class WarehouseCalendarRepository {
|
||||||
|
private final WarehouseCalendarMapper mapper;
|
||||||
|
/** 只通过本服务 Mapper 查询柜级到仓事实。 */
|
||||||
|
public WarehouseCalendarRepository(WarehouseCalendarMapper mapper) { this.mapper = mapper; }
|
||||||
|
/** 同时覆盖跨月人工改期的柜与本次到港来源柜。 */
|
||||||
|
public List<WarehouseCalendarModels.Entry> calendar(WarehouseCalendarModels.Query query) {
|
||||||
|
return mapper.calendar(query.from().atStartOfDay(), query.to().atStartOfDay(), query.sourceContainerBookingIds());
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package com.baiyue.lastleg.calendar;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class WarehouseCalendarService {
|
||||||
|
private final WarehouseCalendarRepository repository;
|
||||||
|
/** 装配日历只读仓储查询。 */
|
||||||
|
public WarehouseCalendarService(WarehouseCalendarRepository repository) { this.repository = repository; }
|
||||||
|
/** 明确权限、版本与计划冻结资格;不以默认计划改写真实到仓时间。 */
|
||||||
|
public List<WarehouseCalendarModels.Entry> calendar(WarehouseCalendarModels.Query query, boolean canEdit) {
|
||||||
|
if (query.from() == null || query.to() == null || !query.to().isAfter(query.from())
|
||||||
|
|| ChronoUnit.DAYS.between(query.from(), query.to()) > 42
|
||||||
|
|| new HashSet<>(query.sourceContainerBookingIds()).size() != query.sourceContainerBookingIds().size())
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历范围须为1至42天,来源柜不能重复");
|
||||||
|
var rows = repository.calendar(query);
|
||||||
|
if (rows.size() > 2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "到仓记录过多,请缩小日期范围");
|
||||||
|
return rows.stream().map(row -> new WarehouseCalendarModels.Entry(row.sourceContainerBookingId(), row.containerNo(),
|
||||||
|
row.warehouseName(), row.warehouseCode(), row.plannedAt(), row.actualAt(), row.version(), row.planCount(),
|
||||||
|
canEdit && row.version() >= 0 && row.planCount() == 0 && row.actualAt() == null)).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -20,6 +20,6 @@ public class InternalCustomerOrderProgressController {
|
|||||||
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||||
@Valid @RequestBody CustomerOrderProgress.Request request) {
|
@Valid @RequestBody CustomerOrderProgress.Request request) {
|
||||||
guard.verify(token);
|
guard.verify(token);
|
||||||
return ApiResponse.success(service.read(request.customerId()));
|
return ApiResponse.success(service.read(request.customerId(), request.orderIds()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -52,6 +52,7 @@ public class CustomerVisitService {
|
|||||||
@Resource private LastlegInboundOrderMapper inboundOrders;
|
@Resource private LastlegInboundOrderMapper inboundOrders;
|
||||||
@Resource private CustomerVisitFileGateway files;
|
@Resource private CustomerVisitFileGateway files;
|
||||||
@Resource private ObjectMapper objectMapper;
|
@Resource private ObjectMapper objectMapper;
|
||||||
|
@Resource private com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository deliveryExecution;
|
||||||
|
|
||||||
public CustomerVisitResponses.Page page(String scopedCustomerId, CustomerVisitRequests.Query query) {
|
public CustomerVisitResponses.Page page(String scopedCustomerId, CustomerVisitRequests.Query query) {
|
||||||
String customerId = customerScope(scopedCustomerId, query.customerId());
|
String customerId = customerScope(scopedCustomerId, query.customerId());
|
||||||
@@ -76,6 +77,7 @@ public class CustomerVisitService {
|
|||||||
return detail(visit);
|
return detail(visit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 人工派送动作必须在真实出库后执行,取消统一走出库聚合命令。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public CustomerVisitResponses.Completed updateDeliveryStatus(String customerId, String operatorId,
|
public CustomerVisitResponses.Completed updateDeliveryStatus(String customerId, String operatorId,
|
||||||
String deliveryNo,
|
String deliveryNo,
|
||||||
@@ -84,8 +86,14 @@ public class CustomerVisitService {
|
|||||||
if (!DeliveryStatusPolicy.isEditableTarget(target)) {
|
if (!DeliveryStatusPolicy.isEditableTarget(target)) {
|
||||||
throw badRequest("不支持的派送状态");
|
throw badRequest("不支持的派送状态");
|
||||||
}
|
}
|
||||||
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
|
var observed = deliveryExecution.findDelivery(customerId, deliveryNo);
|
||||||
|
if (observed == null) throw notFound("派送单不存在");
|
||||||
|
// 不反向申请排车锁;按出库→派送顺序与取消、发车串行。
|
||||||
|
var outbound = deliveryExecution.lockOutbound(customerId, observed.getOutboundNo());
|
||||||
|
DeliveryOrder delivery = deliveryExecution.lockDelivery(customerId, deliveryNo);
|
||||||
if (delivery == null) throw notFound("派送单不存在");
|
if (delivery == null) throw notFound("派送单不存在");
|
||||||
|
if (!com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard.matches(delivery, outbound))
|
||||||
|
throw conflict("派送尚未实际出库,不能开始派送或拒签");
|
||||||
if (target.equals(delivery.getStatus())) {
|
if (target.equals(delivery.getStatus())) {
|
||||||
CustomerVisit existing = visits.findByDelivery(customerId, delivery.getId());
|
CustomerVisit existing = visits.findByDelivery(customerId, delivery.getId());
|
||||||
return new CustomerVisitResponses.Completed(existing == null ? null : existing.getId(), deliveryNo,
|
return new CustomerVisitResponses.Completed(existing == null ? null : existing.getId(), deliveryNo,
|
||||||
@@ -99,7 +107,7 @@ public class CustomerVisitService {
|
|||||||
if ("DELIVERING".equals(target) && delivery.getDispatchedAt() == null) delivery.setDispatchedAt(now);
|
if ("DELIVERING".equals(target) && delivery.getDispatchedAt() == null) delivery.setDispatchedAt(now);
|
||||||
delivery.setUpdatedAt(now);
|
delivery.setUpdatedAt(now);
|
||||||
delivery.setVersion(version(delivery) + 1);
|
delivery.setVersion(version(delivery) + 1);
|
||||||
deliveryOrders.updateById(delivery);
|
deliveryExecution.save(delivery);
|
||||||
String result = "派送状态由 " + from + " 修改为 " + target;
|
String result = "派送状态由 " + from + " 修改为 " + target;
|
||||||
if (StringUtils.hasText(request.remark())) result += ",备注:" + trim(request.remark());
|
if (StringUtils.hasText(request.remark())) result += ",备注:" + trim(request.remark());
|
||||||
recordOperation(delivery, operatorId, "UPDATE_DELIVERY_STATUS", result, now);
|
recordOperation(delivery, operatorId, "UPDATE_DELIVERY_STATUS", result, now);
|
||||||
|
|||||||
+5
-1
@@ -12,6 +12,7 @@ import java.util.List;
|
|||||||
@Mapper
|
@Mapper
|
||||||
public interface DeliveryOperationMapper extends BaseMapper<DeliveryOperation> {
|
public interface DeliveryOperationMapper extends BaseMapper<DeliveryOperation> {
|
||||||
|
|
||||||
|
/** 普通库存流水与原箱回仓凭据共同证明实际退回,每个原箱只计一次箱数。 */
|
||||||
@Select("SELECT l.id AS ledger_id, di.id AS dispatch_item_id, oi.warehouse_pallet_id AS pallet_id, " +
|
@Select("SELECT l.id AS ledger_id, di.id AS dispatch_item_id, oi.warehouse_pallet_id AS pallet_id, " +
|
||||||
"l.delta_packages AS packages, l.occurred_at AS received_at FROM ll_delivery_order d " +
|
"l.delta_packages AS packages, l.occurred_at AS received_at FROM ll_delivery_order d " +
|
||||||
"JOIN ll_exception_order e ON e.customer_id=d.customer_id AND e.delivery_order_id=d.id " +
|
"JOIN ll_exception_order e ON e.customer_id=d.customer_id AND e.delivery_order_id=d.id " +
|
||||||
@@ -27,7 +28,10 @@ public interface DeliveryOperationMapper extends BaseMapper<DeliveryOperation> {
|
|||||||
"JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " +
|
"JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " +
|
||||||
"WHERE p.customer_id=d.customer_id AND t.dispatch_order_id=d.dispatch_order_id " +
|
"WHERE p.customer_id=d.customer_id AND t.dispatch_order_id=d.dispatch_order_id " +
|
||||||
"AND p.dispatch_item_id=di.id AND p.inventory_position_id=i.inventory_position_id AND a.order_id=e.last_mile_order_id) " +
|
"AND p.dispatch_item_id=di.id AND p.inventory_position_id=i.inventory_position_id AND a.order_id=e.last_mile_order_id) " +
|
||||||
"WHERE d.customer_id=#{customerId} AND d.id=#{deliveryOrderId} ORDER BY l.occurred_at,l.id")
|
"WHERE d.customer_id=#{customerId} AND d.id=#{deliveryOrderId} UNION ALL SELECT MIN(r.id),r.source_dispatch_item_id,oi.warehouse_pallet_id,SUM(r.packages),r.received_at " +
|
||||||
|
"FROM ll_return_carton_stock_receipt r JOIN ll_outbound_pallet_item oi ON oi.id=r.source_outbound_pallet_item_id AND oi.customer_id=r.customer_id " +
|
||||||
|
"WHERE r.customer_id=#{customerId} AND r.delivery_order_id=#{deliveryOrderId} " +
|
||||||
|
"GROUP BY r.receipt_no,r.source_dispatch_item_id,oi.warehouse_pallet_id,r.received_at ORDER BY received_at,ledger_id")
|
||||||
List<DeliveryReturnReceiptFact> returnReceipts(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId);
|
List<DeliveryReturnReceiptFact> returnReceipts(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId);
|
||||||
|
|
||||||
@Select("SELECT COUNT(*) FROM ll_exception_order WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} " +
|
@Select("SELECT COUNT(*) FROM ll_exception_order WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} " +
|
||||||
|
|||||||
+2
-5
@@ -4,7 +4,7 @@ import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels;
|
|||||||
import org.apache.ibatis.annotations.*;
|
import org.apache.ibatis.annotations.*;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/** Reconstruct every selected inbound source, including shared pallets and already picked quantities. */
|
/** 还原整托实际入仓组成及本次已拣数量,不按展示计划过滤合托来源。 */
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface DispatchFeeCargoMapper {
|
public interface DispatchFeeCargoMapper {
|
||||||
@Select("""
|
@Select("""
|
||||||
@@ -22,13 +22,10 @@ public interface DispatchFeeCargoMapper {
|
|||||||
GROUP BY pk.dispatch_item_id,pk.inventory_position_id) picked
|
GROUP BY pk.dispatch_item_id,pk.inventory_position_id) picked
|
||||||
ON picked.dispatch_item_id=di.id AND picked.inventory_position_id=p.id
|
ON picked.dispatch_item_id=di.id AND picked.inventory_position_id=p.id
|
||||||
WHERE di.dispatch_order_id=#{dispatchId} AND di.customer_id=#{customerId}
|
WHERE di.dispatch_order_id=#{dispatchId} AND di.customer_id=#{customerId}
|
||||||
AND (di.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=di.customer_id
|
|
||||||
WHERE a.pallet_id=di.plan_pallet_id AND line.order_id=b.inbound_order_id))
|
|
||||||
GROUP BY di.id,di.warehouse_pallet_id,di.pallet_no,b.inbound_order_id,o.source_region,o.source_inbound_id,b.inbound_code,di.inbound_code
|
GROUP BY di.id,di.warehouse_pallet_id,di.pallet_no,b.inbound_order_id,o.source_region,o.source_inbound_id,b.inbound_code,di.inbound_code
|
||||||
HAVING SUM(p.available_packages+COALESCE(picked.packages,0))>0
|
HAVING SUM(p.available_packages+COALESCE(picked.packages,0))>0
|
||||||
ORDER BY di.id,b.inbound_order_id
|
ORDER BY di.id,b.inbound_order_id
|
||||||
""")
|
""")
|
||||||
|
/** 整托候选箱数来自物理库存,因此组成也必须包含该托所有实际来源。 */
|
||||||
List<DeliveryFeeCoverageModels.Cargo> list(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId);
|
List<DeliveryFeeCoverageModels.Cargo> list(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId);
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-3
@@ -6,9 +6,9 @@ import java.util.Set;
|
|||||||
|
|
||||||
public final class DeliveryStatusPolicy {
|
public final class DeliveryStatusPolicy {
|
||||||
private static final Set<String> EDITABLE_TARGETS = Set.of(
|
private static final Set<String> EDITABLE_TARGETS = Set.of(
|
||||||
"WAIT_DELIVERY", "DELIVERING", "REJECTED", "CANCELLED");
|
"WAIT_DELIVERY", "DELIVERING", "REJECTED");
|
||||||
private static final Map<String, Set<String>> TRANSITIONS = Map.of(
|
private static final Map<String, Set<String>> TRANSITIONS = Map.of(
|
||||||
"WAIT_DELIVERY", Set.of("DELIVERING", "DELIVERED", "REJECTED", "CANCELLED"),
|
"WAIT_DELIVERY", Set.of("DELIVERING", "DELIVERED", "REJECTED"),
|
||||||
"DELIVERING", Set.of("DELIVERED", "REJECTED"),
|
"DELIVERING", Set.of("DELIVERED", "REJECTED"),
|
||||||
"DELIVERED", Set.of(),
|
"DELIVERED", Set.of(),
|
||||||
"REJECTED", Set.of(),
|
"REJECTED", Set.of(),
|
||||||
@@ -16,23 +16,32 @@ public final class DeliveryStatusPolicy {
|
|||||||
"RETURNED", Set.of(),
|
"RETURNED", Set.of(),
|
||||||
"CANCELLED", Set.of());
|
"CANCELLED", Set.of());
|
||||||
|
|
||||||
|
/** 状态规则为纯策略,实际出库由命令及查询层提供。 */
|
||||||
private DeliveryStatusPolicy() { }
|
private DeliveryStatusPolicy() { }
|
||||||
|
|
||||||
|
/** 人工只允许开始派送或拒签,取消必须由出库聚合命令处理。 */
|
||||||
public static boolean isEditableTarget(String status) {
|
public static boolean isEditableTarget(String status) {
|
||||||
return EDITABLE_TARGETS.contains(status);
|
return EDITABLE_TARGETS.contains(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 校验状态机,签收终态仍由签收资料命令触发。 */
|
||||||
public static boolean canTransition(String from, String target) {
|
public static boolean canTransition(String from, String target) {
|
||||||
return TRANSITIONS.getOrDefault(from, Set.of()).contains(target);
|
return TRANSITIONS.getOrDefault(from, Set.of()).contains(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 返回状态本身允许的动作;执行动作还须满足实际出库事实。 */
|
||||||
public static List<String> allowedActions(String status) {
|
public static List<String> allowedActions(String status) {
|
||||||
return switch (status == null ? "" : status) {
|
return switch (status == null ? "" : status) {
|
||||||
case "WAIT_DELIVERY" -> List.of(
|
case "WAIT_DELIVERY" -> List.of(
|
||||||
"START_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY");
|
"START_DELIVERY", "REJECT_DELIVERY");
|
||||||
case "DELIVERING" -> List.of("REJECT_DELIVERY", "MARK_RETURN");
|
case "DELIVERING" -> List.of("REJECT_DELIVERY", "MARK_RETURN");
|
||||||
case "DELIVERED", "REJECTED", "RETURN_PENDING" -> List.of("MARK_RETURN");
|
case "DELIVERED", "REJECTED", "RETURN_PENDING" -> List.of("MARK_RETURN");
|
||||||
default -> List.of();
|
default -> List.of();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 未实际出库不得开始派送、拒签或创建运输退回。 */
|
||||||
|
public static List<String> allowedActions(String status, boolean outboundCompleted) {
|
||||||
|
return outboundCompleted ? allowedActions(status) : List.of();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
package com.baiyue.lastleg.delivery.repository;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||||
|
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||||
|
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
|
||||||
|
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 派送执行门禁的数据入口,保留取消单据历史。 */
|
||||||
|
@Repository
|
||||||
|
public class DeliveryExecutionRepository {
|
||||||
|
private final OutboundOrderMapper outbounds;
|
||||||
|
private final DeliveryOrderMapper deliveries;
|
||||||
|
|
||||||
|
/** 装配同库的出库及派送访问。 */
|
||||||
|
public DeliveryExecutionRepository(OutboundOrderMapper outbounds, DeliveryOrderMapper deliveries) {
|
||||||
|
this.outbounds = outbounds;
|
||||||
|
this.deliveries = deliveries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按客户和业务编号读取原派送,供命令确定出库锁顺序。 */
|
||||||
|
public DeliveryOrder findDelivery(String customerId, String deliveryNo) {
|
||||||
|
return deliveries.findByNo(customerId, deliveryNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 先锁出库,再锁派送,与取消和发车保持一致。 */
|
||||||
|
public OutboundOrder lockOutbound(String customerId, String outboundNo) {
|
||||||
|
return outbounds.lockByNo(customerId, outboundNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 锁定客户范围内的派送,锁后由业务层复核关联。 */
|
||||||
|
public DeliveryOrder lockDelivery(String customerId, String deliveryNo) {
|
||||||
|
return deliveries.lockByNo(customerId, deliveryNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取客户范围内的出库事实,用于动作展示。 */
|
||||||
|
public OutboundOrder findOutbound(String customerId, String outboundNo) {
|
||||||
|
return outbounds.findByNo(customerId, outboundNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发车时锁定全部派送,取消与执行只能产生一个合法结果。 */
|
||||||
|
public List<DeliveryOrder> deliveries(OutboundOrder outbound, boolean lock) {
|
||||||
|
return lock ? deliveries.lockByOutbound(outbound.getCustomerId(), outbound.getId())
|
||||||
|
: deliveries.findByOutbound(outbound.getCustomerId(), outbound.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存已校验版本的派送动作。 */
|
||||||
|
public void save(DeliveryOrder delivery) {
|
||||||
|
deliveries.updateById(delivery);
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package com.baiyue.lastleg.delivery.repository;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels;
|
||||||
|
import com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 复用库存组成及本次拣货来源,不把展示入仓码当作整托归属。 */
|
||||||
|
@Repository
|
||||||
|
public class DispatchCargoSourceRepository {
|
||||||
|
private final DispatchFeeCargoMapper sources;
|
||||||
|
/** 装配实际货物组成查询。 */
|
||||||
|
public DispatchCargoSourceRepository(DispatchFeeCargoMapper sources) { this.sources = sources; }
|
||||||
|
/** 按客户和当前排车读取逐入仓来源数量。 */
|
||||||
|
public List<DeliveryFeeCoverageModels.Cargo> sources(String customerId, long dispatchId) {
|
||||||
|
return sources.list(customerId, dispatchId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package com.baiyue.lastleg.delivery.service;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||||
|
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||||
|
import com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** 统一派送执行事实,防止状态编辑绕过出库及车次事务。 */
|
||||||
|
@Service
|
||||||
|
public class DeliveryExecutionGuard {
|
||||||
|
private final DeliveryExecutionRepository repository;
|
||||||
|
|
||||||
|
/** 装配执行事实的数据入口。 */
|
||||||
|
public DeliveryExecutionGuard(DeliveryExecutionRepository repository) { this.repository = repository; }
|
||||||
|
|
||||||
|
/** 展示动作只认同客户、同出库单的实际出库事实。 */
|
||||||
|
public boolean outboundCompleted(DeliveryOrder delivery) {
|
||||||
|
return matches(delivery, repository.findOutbound(delivery.getCustomerId(), delivery.getOutboundNo()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发车前要求派送覆盖完整、均未执行,锁定检查与出库提交同事务。 */
|
||||||
|
public String departureBlockReason(OutboundOrder outbound, boolean lock) {
|
||||||
|
var rows = repository.deliveries(outbound, lock);
|
||||||
|
if (rows.isEmpty()) return "缺少对应派送单,请先核对转派送结果";
|
||||||
|
if (rows.stream().anyMatch(row -> !Objects.equals(row.getCustomerId(), outbound.getCustomerId())
|
||||||
|
|| !Objects.equals(row.getOutboundOrderId(), outbound.getId())
|
||||||
|
|| !Objects.equals(row.getOutboundNo(), outbound.getOutboundNo())
|
||||||
|
|| !"WAIT_DELIVERY".equals(row.getStatus()) || row.getDispatchedAt() != null || row.getSignedAt() != null
|
||||||
|
|| row.getPackageCount() == null || row.getPackageCount() <= 0))
|
||||||
|
return "关联派送单已取消、已执行或来源不一致,不能发车";
|
||||||
|
if (rows.stream().mapToLong(DeliveryOrder::getPackageCount).sum()
|
||||||
|
!= Objects.requireNonNullElse(outbound.getPackageCount(), 0))
|
||||||
|
return "关联派送数量未完整覆盖出库货物";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 真正发车时再次加锁校验,不能依赖列表查询结果。 */
|
||||||
|
public void requireDeparture(OutboundOrder outbound) {
|
||||||
|
String reason = departureBlockReason(outbound, true);
|
||||||
|
if (reason != null) throw new ResponseStatusException(HttpStatus.CONFLICT, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 共享实际出库判据,不能只凭派送状态或发车时间推断。 */
|
||||||
|
public static boolean matches(DeliveryOrder delivery, OutboundOrder outbound) {
|
||||||
|
return outbound != null && "OUTBOUNDED".equals(outbound.getStatus())
|
||||||
|
&& Objects.equals(delivery.getCustomerId(), outbound.getCustomerId())
|
||||||
|
&& Objects.equals(delivery.getOutboundOrderId(), outbound.getId())
|
||||||
|
&& Objects.equals(delivery.getOutboundNo(), outbound.getOutboundNo());
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-7
@@ -78,14 +78,15 @@ public class DeliveryFeeCoverageService {
|
|||||||
return "实物库存及本次已拣数量与排车箱数不一致,请先核对货物范围";
|
return "实物库存及本次已拣数量与排车箱数不一致,请先核对货物范围";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
public record Decision(Set<String> includedServices, DeliveryFeeCoverageModels.Evidence evidence, Set<Long> externalItems) {
|
public record Decision(Set<String> includedServices, DeliveryFeeCoverageModels.Evidence evidence, Set<String> externalInboundCodes) {
|
||||||
public static Decision none() { return new Decision(Set.of(), null, Set.of()); }
|
public static Decision none() { return new Decision(Set.of(), null, Set.of()); }
|
||||||
public List<DeliveryFeeCoverageModels.Cargo> uncovered(String service) {
|
public List<DeliveryFeeCoverageModels.Cargo> uncovered(String service) {
|
||||||
if (evidence == null) return List.of();
|
if (evidence == null) return List.of();
|
||||||
return evidence.cargo().stream().filter(row -> !covers(row, service, evidence.references(), externalItems)).toList();
|
return evidence.cargo().stream().filter(row -> !covers(row, service, evidence.references(), externalInboundCodes)).toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public Decision confirm(String dispatchNo, DeliveryRequests.Transfer request, String operator, Set<Long> externalItems) {
|
/** 外接覆盖按实际入仓来源判定,不能免除同托普通货的派送费。 */
|
||||||
|
public Decision confirm(String dispatchNo, DeliveryRequests.Transfer request, String operator, Set<String> externalInboundCodes) {
|
||||||
if (request.feeCoverage() == null || request.feeCoverage().isEmpty()) return Decision.none();
|
if (request.feeCoverage() == null || request.feeCoverage().isEmpty()) return Decision.none();
|
||||||
var preview = preview(dispatchNo);
|
var preview = preview(dispatchNo);
|
||||||
if (!preview.sourceComplete()) throw conflict(preview.blockReason());
|
if (!preview.sourceComplete()) throw conflict(preview.blockReason());
|
||||||
@@ -106,13 +107,13 @@ public class DeliveryFeeCoverageService {
|
|||||||
}
|
}
|
||||||
var included = new HashSet<String>();
|
var included = new HashSet<String>();
|
||||||
for (String service : List.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE"))
|
for (String service : List.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE"))
|
||||||
if (preview.cargo().stream().allMatch(row -> covers(row, service, references, externalItems))) included.add(service);
|
if (preview.cargo().stream().allMatch(row -> covers(row, service, references, externalInboundCodes))) included.add(service);
|
||||||
return new Decision(Set.copyOf(included), new DeliveryFeeCoverageModels.Evidence(preview.cargoHash(), preview.cargo(),
|
return new Decision(Set.copyOf(included), new DeliveryFeeCoverageModels.Evidence(preview.cargoHash(), preview.cargo(),
|
||||||
List.copyOf(references), operator, Instant.now()), Set.copyOf(externalItems));
|
List.copyOf(references), operator, Instant.now()), Set.copyOf(externalInboundCodes));
|
||||||
}
|
}
|
||||||
private static boolean covers(DeliveryFeeCoverageModels.Cargo cargo, String service,
|
private static boolean covers(DeliveryFeeCoverageModels.Cargo cargo, String service,
|
||||||
List<DeliveryFeeCoverageModels.Reference> references, Set<Long> externalItems) {
|
List<DeliveryFeeCoverageModels.Reference> references, Set<String> externalInboundCodes) {
|
||||||
if ("DELIVERY_FEE".equals(service) && externalItems.contains(cargo.dispatchItemId())) return true;
|
if ("DELIVERY_FEE".equals(service) && externalInboundCodes.contains(cargo.inboundCode())) return true;
|
||||||
return "FIRSTLEG".equals(cargo.sourceRegion()) && references.stream().anyMatch(reference ->
|
return "FIRSTLEG".equals(cargo.sourceRegion()) && references.stream().anyMatch(reference ->
|
||||||
reference.source().sourceInboundId().toString().equals(cargo.sourceInboundId()) && reference.services().contains(service));
|
reference.source().sourceInboundId().toString().equals(cargo.sourceInboundId()) && reference.services().contains(service));
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-17
@@ -132,6 +132,8 @@ public class DeliveryTransferService {
|
|||||||
@Resource private FleetDispatchBindingGuard fleetBindings;
|
@Resource private FleetDispatchBindingGuard fleetBindings;
|
||||||
@Resource private FleetBookingFreight bookingFreight;
|
@Resource private FleetBookingFreight bookingFreight;
|
||||||
@Resource private DeliveryFeeCoverageService feeCoverage;
|
@Resource private DeliveryFeeCoverageService feeCoverage;
|
||||||
|
@Resource private DeliveryExecutionGuard deliveryExecution;
|
||||||
|
@Resource private DispatchCargoSourceQuery cargoSources;
|
||||||
|
|
||||||
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
|
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
|
||||||
public FleetBookingFreight.Preview externalFreight(String dispatchNo) {
|
public FleetBookingFreight.Preview externalFreight(String dispatchNo) {
|
||||||
@@ -140,13 +142,10 @@ public class DeliveryTransferService {
|
|||||||
return externalFreight(dispatch, dispatchItems.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
|
return externalFreight(dispatch, dispatchItems.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 外接运费与车次绑定使用相同的实际来源和数量。 */
|
||||||
private FleetBookingFreight.Preview externalFreight(DispatchOrder dispatch, List<DispatchItem> items) {
|
private FleetBookingFreight.Preview externalFreight(DispatchOrder dispatch, List<DispatchItem> items) {
|
||||||
return bookingFreight.preview(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(), items.stream()
|
return bookingFreight.preview(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(),
|
||||||
.map(item -> new FleetDispatchGateway.SourceCargo(item.getInboundCode(),
|
cargoSources.sources(dispatch, items));
|
||||||
item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), item.getPalletNo(),
|
|
||||||
Objects.requireNonNullElse(item.getPackageCount(), 0), FleetDispatchGateway.destinationKey(item.getDestinationType(),
|
|
||||||
item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode())))
|
|
||||||
.toList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -169,6 +168,7 @@ public class DeliveryTransferService {
|
|||||||
return transferResolved(dispatch, operatorId, request);
|
return transferResolved(dispatch, operatorId, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 排车锁内创建完整目的地派送,历史重放不生成新单或重复发布费用。 */
|
||||||
private DeliveryResponses.TransferResult transferResolved(DispatchOrder dispatch, String operatorId,
|
private DeliveryResponses.TransferResult transferResolved(DispatchOrder dispatch, String operatorId,
|
||||||
DeliveryRequests.Transfer request) {
|
DeliveryRequests.Transfer request) {
|
||||||
String customerId = dispatch.getCustomerId();
|
String customerId = dispatch.getCustomerId();
|
||||||
@@ -197,10 +197,9 @@ public class DeliveryTransferService {
|
|||||||
if ((!freight.quotes().isEmpty() || StringUtils.hasText(request.externalFreightHash()))
|
if ((!freight.quotes().isEmpty() || StringUtils.hasText(request.externalFreightHash()))
|
||||||
&& !Objects.equals(freight.snapshotHash(), request.externalFreightHash()))
|
&& !Objects.equals(freight.snapshotHash(), request.externalFreightHash()))
|
||||||
throw conflict("外接实收或约定运费已变化,请重新打开转派送核对");
|
throw conflict("外接实收或约定运费已变化,请重新打开转派送核对");
|
||||||
var quotedIncomingNos = freight.quotes().stream().map(FleetBookingFreight.Quote::incomingNo).collect(Collectors.toSet());
|
var externalInboundCodes = freight.quotes().stream().map(FleetBookingFreight.Quote::inboundCode).collect(Collectors.toSet());
|
||||||
var externalItems = items.stream().filter(item -> quotedIncomingNos.contains(item.getInboundCode())).map(DispatchItem::getId).collect(Collectors.toSet());
|
|
||||||
var coverage = request.feeCoverage() == null || request.feeCoverage().isEmpty() ? DeliveryFeeCoverageService.Decision.none()
|
var coverage = request.feeCoverage() == null || request.feeCoverage().isEmpty() ? DeliveryFeeCoverageService.Decision.none()
|
||||||
: feeCoverage.confirm(dispatch.getDispatchNo(), request, operatorId, externalItems);
|
: feeCoverage.confirm(dispatch.getDispatchNo(), request, operatorId, externalInboundCodes);
|
||||||
validateTransferFees(request, freight.requiresDeliveryFee(), coverage.includedServices());
|
validateTransferFees(request, freight.requiresDeliveryFee(), coverage.includedServices());
|
||||||
if (!freight.priorInboundCodes().isEmpty()) feeSources.requireNoActiveDeliveryReceivables(customerId, freight.priorInboundCodes());
|
if (!freight.priorInboundCodes().isEmpty()) feeSources.requireNoActiveDeliveryReceivables(customerId, freight.priorInboundCodes());
|
||||||
|
|
||||||
@@ -303,8 +302,10 @@ public class DeliveryTransferService {
|
|||||||
deliveries.add(delivery);
|
deliveries.add(delivery);
|
||||||
}
|
}
|
||||||
for (var quote : freight.quotes()) {
|
for (var quote : freight.quotes()) {
|
||||||
var source = items.stream().filter(item -> quote.incomingNo().equals(item.getInboundCode())).findFirst().orElseThrow();
|
var destination = deliveries.stream().filter(item -> Objects.equals(quote.destinationKey(), FleetDispatchGateway.destinationKey(
|
||||||
var destination = deliveries.stream().filter(item -> deliveryGroupKey(source).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow();
|
item.getDestinationType(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseCode(),
|
||||||
|
item.getDeliveryAddress(), item.getPostalCode()))).findFirst()
|
||||||
|
.orElseThrow(() -> conflict("外接约定目的地未生成对应派送单"));
|
||||||
recordOperation(customerId, destination.getId(), operatorId, "CONFIRM_EXTERNAL_FREIGHT",
|
recordOperation(customerId, destination.getId(), operatorId, "CONFIRM_EXTERNAL_FREIGHT",
|
||||||
"外接预订 " + quote.bookingNo() + ";实收确认版本 " + quote.recordVersion() + ";约定运费 "
|
"外接预订 " + quote.bookingNo() + ";实收确认版本 " + quote.recordVersion() + ";约定运费 "
|
||||||
+ quote.currency() + " " + quote.amount().toPlainString(), now);
|
+ quote.currency() + " " + quote.amount().toPlainString(), now);
|
||||||
@@ -320,7 +321,8 @@ public class DeliveryTransferService {
|
|||||||
}
|
}
|
||||||
DeliveryOrder feeDelivery = deliveries.get(0);
|
DeliveryOrder feeDelivery = deliveries.get(0);
|
||||||
List<DeliveryRequests.FeeItem> feeInputs = request.fees() == null ? List.of() : request.fees();
|
List<DeliveryRequests.FeeItem> feeInputs = request.fees() == null ? List.of() : request.fees();
|
||||||
var quotedIncoming = freight.quotes().stream().map(FleetBookingFreight.Quote::incomingNo).collect(Collectors.toSet());
|
var ordinaryCargo = freight.requiresDeliveryFee() ? cargoSources.sources(dispatch, items).stream()
|
||||||
|
.filter(source -> !externalInboundCodes.contains(source.inboundCode())).toList() : List.<FleetDispatchGateway.SourceCargo>of();
|
||||||
for (DeliveryRequests.FeeItem input : feeInputs) {
|
for (DeliveryRequests.FeeItem input : feeInputs) {
|
||||||
String feeCode = input.feeType().trim().toUpperCase(Locale.ROOT);
|
String feeCode = input.feeType().trim().toUpperCase(Locale.ROOT);
|
||||||
var uncovered = coverage.uncovered(feeCode);
|
var uncovered = coverage.uncovered(feeCode);
|
||||||
@@ -328,11 +330,19 @@ public class DeliveryTransferService {
|
|||||||
var chargeItems = coverage.evidence() != null && "RECEIVABLE".equals(input.direction())
|
var chargeItems = coverage.evidence() != null && "RECEIVABLE".equals(input.direction())
|
||||||
? items.stream().filter(item -> uncoveredItemIds.contains(item.getId())).toList()
|
? items.stream().filter(item -> uncoveredItemIds.contains(item.getId())).toList()
|
||||||
: "RECEIVABLE".equals(input.direction()) && "DELIVERY_FEE".equals(feeCode) && freight.requiresDeliveryFee()
|
: "RECEIVABLE".equals(input.direction()) && "DELIVERY_FEE".equals(feeCode) && freight.requiresDeliveryFee()
|
||||||
? items.stream().filter(item -> !quotedIncoming.contains(item.getInboundCode())).toList() : items;
|
? items.stream().filter(item -> ordinaryCargo.stream().anyMatch(source -> Objects.equals(source.warehousePalletId(),
|
||||||
|
item.getWarehousePalletId().toString()) && Objects.equals(source.destinationKey(), FleetDispatchGateway.destinationKey(
|
||||||
|
item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode())))).toList() : items;
|
||||||
var feeDestination = "DELIVERY_FEE".equalsIgnoreCase(input.feeType()) && !chargeItems.isEmpty()
|
var feeDestination = "DELIVERY_FEE".equalsIgnoreCase(input.feeType()) && !chargeItems.isEmpty()
|
||||||
? deliveries.stream().filter(item -> deliveryGroupKey(chargeItems.get(0)).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow()
|
? deliveries.stream().filter(item -> deliveryGroupKey(chargeItems.get(0)).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow()
|
||||||
: feeDelivery;
|
: feeDelivery;
|
||||||
LastlegOrder firstOrder = firstTailOrder(customerId, chargeItems);
|
LastlegOrder firstOrder = firstTailOrder(customerId, chargeItems);
|
||||||
|
// 合托后按真实普通来源归属费用;多票时只绑定派送,不借展示入仓码挂到外接货。
|
||||||
|
if ("RECEIVABLE".equals(input.direction()) && "DELIVERY_FEE".equals(feeCode) && !externalInboundCodes.isEmpty()) {
|
||||||
|
var ordinaryOrders = cargoSources.ordinaryOrderIds(dispatch, externalInboundCodes);
|
||||||
|
firstOrder = ordinaryOrders.size() == 1 ? tailOrders.selectById(ordinaryOrders.iterator().next()) : null;
|
||||||
|
if (firstOrder != null && !customerId.equals(firstOrder.getCustomerId())) throw conflict("新增费用来源客户不一致");
|
||||||
|
}
|
||||||
if (coverage.evidence() != null && "RECEIVABLE".equals(input.direction())) {
|
if (coverage.evidence() != null && "RECEIVABLE".equals(input.direction())) {
|
||||||
var uncoveredOrderIds = uncovered.stream().map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::orderId).filter(Objects::nonNull).distinct().toList();
|
var uncoveredOrderIds = uncovered.stream().map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::orderId).filter(Objects::nonNull).distinct().toList();
|
||||||
firstOrder = uncoveredOrderIds.size() == 1 ? tailOrders.selectById(uncoveredOrderIds.get(0)) : null;
|
firstOrder = uncoveredOrderIds.size() == 1 ? tailOrders.selectById(uncoveredOrderIds.get(0)) : null;
|
||||||
@@ -354,6 +364,7 @@ public class DeliveryTransferService {
|
|||||||
return result(outbound, deliveries, false);
|
return result(outbound, deliveries, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 取消后的同载荷重放仍返回原取消单据,绝不作为新建成功。 */
|
||||||
private DeliveryResponses.TransferResult replayTransfer(OutboundOrder outbound, String requestHash) {
|
private DeliveryResponses.TransferResult replayTransfer(OutboundOrder outbound, String requestHash) {
|
||||||
if (!requestHash.equals(outbound.getRequestHash()))
|
if (!requestHash.equals(outbound.getRequestHash()))
|
||||||
throw conflict("转派送请求与原已保存内容不一致,请查看原单据;费用调整须通过正式费用更正流程");
|
throw conflict("转派送请求与原已保存内容不一致,请查看原单据;费用调整须通过正式费用更正流程");
|
||||||
@@ -1478,10 +1489,18 @@ public class DeliveryTransferService {
|
|||||||
&& !Objects.equals(delivery.getDestinationWarehouseCode(), item.getDestinationWarehouseCode())) continue;
|
&& !Objects.equals(delivery.getDestinationWarehouseCode(), item.getDestinationWarehouseCode())) continue;
|
||||||
ReturnInventoryCarton carton = returnCartons.findCustomerMark(customerId, item.getCartonMarks());
|
ReturnInventoryCarton carton = returnCartons.findCustomerMark(customerId, item.getCartonMarks());
|
||||||
if (carton == null) throw conflict("退货派送单来源箱记录缺失");
|
if (carton == null) throw conflict("退货派送单来源箱记录缺失");
|
||||||
for (var line : returnCartonItems.listCarton(carton.getId()))
|
boolean relabelCarton = "RELABEL_OUTBOUND".equals(sourceOutbound.getOutboundType());
|
||||||
|
boolean firstSku = true;
|
||||||
|
// Match receipt cargo semantics: stable first SKU carries the physical box once, never per piece.
|
||||||
|
for (var line : returnCartonItems.listCarton(carton.getId())) {
|
||||||
cargo.add(new DeliveryResponses.DeliverySku(item.getInboundCode(), delivery.getCustomerName(),
|
cargo.add(new DeliveryResponses.DeliverySku(item.getInboundCode(), delivery.getCustomerName(),
|
||||||
carton.getDestinationWarehouseCode(), delivery.getDeliveryAddress(), null, null,
|
relabelCarton ? item.getDestinationWarehouseCode() : carton.getDestinationWarehouseCode(),
|
||||||
line.getSkuCode(), line.getProductNameZh(), 1, null, null, line.getQuantity()));
|
delivery.getDeliveryAddress(), null, null,
|
||||||
|
line.getSkuCode(), line.getProductNameZh(), !relabelCarton || firstSku ? 1 : 0,
|
||||||
|
relabelCarton && firstSku ? item.getGrossWeightKg() : null,
|
||||||
|
relabelCarton && firstSku ? item.getVolumeCbm() : null, line.getQuantity()));
|
||||||
|
firstSku = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), cargo,
|
return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), cargo,
|
||||||
feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId()).stream().map(this::feeRow).toList(),
|
feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId()).stream().map(this::feeRow).toList(),
|
||||||
@@ -1629,7 +1648,8 @@ public class DeliveryTransferService {
|
|||||||
allocateMetric(candidate.getSourceVolumeWeightKg(), candidate.getSourcePackageCount(), available, 3),
|
allocateMetric(candidate.getSourceVolumeWeightKg(), candidate.getSourcePackageCount(), available, 3),
|
||||||
allocateMetric(candidate.getSourceVolumeCbm(), candidate.getSourcePackageCount(), available, 4));
|
allocateMetric(candidate.getSourceVolumeCbm(), candidate.getSourcePackageCount(), available, 4));
|
||||||
}
|
}
|
||||||
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getSignedPackages(), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); }
|
/** 派送动作由状态和实际出库共同决定,列表与详情复用同一资格。 */
|
||||||
|
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getSignedPackages(), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus(), deliveryExecution.outboundCompleted(d)), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); }
|
||||||
private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason(), f.getReviewStatus(), f.getSourceStatus(), f.getVoidStatus()); }
|
private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason(), f.getReviewStatus(), f.getSourceStatus(), f.getVoidStatus()); }
|
||||||
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
|
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
|
||||||
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
|
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user