Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b455c0756a | ||
|
|
a338d924c6 | ||
|
|
e0677e7ae7 | ||
|
|
44c76d8953 | ||
|
|
eb167347f6 | ||
|
|
c8608d50ba | ||
|
|
dff040b509 | ||
|
|
26a203b4eb | ||
|
|
21128b895f | ||
|
|
fafcf75014 | ||
|
|
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 源码已退出聚合和发布清单,退役待清理;实际停机仍须替代链验证与授权,不得据源码状态认定已部署。
|
||||
- `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 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 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`,不得增加子域扫描配置。
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 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`,不得增加子域扫描配置。
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Common Audit 模块指引
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 职责边界
|
||||
|
||||
- 负责业务审计事件采集、注解切面、敏感字段脱敏以及日志/HTTP 输出。
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Common Core 模块指引
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 职责边界
|
||||
|
||||
- 提供不依赖具体业务域的返回模型、异常、基础实体和 MyBatis-Plus 通用配置。
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Common Event Contract 模块指引
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 职责边界
|
||||
|
||||
- 维护 Firstleg、Lastleg、Finance 之间稳定、可版本化的业务事件契约。
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Common OpenAPI 模块指引
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 职责边界
|
||||
|
||||
- 为 Spring MVC 服务提供 SpringDoc/OpenAPI 自动配置和统一文档元数据。
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Common Security 模块指引
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 职责边界
|
||||
|
||||
- 提供业务服务通用的 JWT 解码、认证过滤、Redis Access Token 会话校验、认证用户上下文及 401/403 JSON 响应。
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Common Web 模块指引
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 职责边界
|
||||
|
||||
- 提供 Servlet Web 通用能力:请求链路标识、上下文和统一异常响应。
|
||||
|
||||
@@ -285,9 +285,14 @@ services:
|
||||
image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg
|
||||
expose: ["9303"]
|
||||
# 仅限制尾程服务,避免 JVM 异常时拖垮测试服务器.
|
||||
mem_limit: 3g
|
||||
memswap_limit: 3g
|
||||
cpus: "2.0"
|
||||
environment:
|
||||
<<: *common-environment
|
||||
SERVER_PORT: 9303
|
||||
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1536m -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
|
||||
SELFOP_EXECUTION_ENABLED: ${SELFOP_EXECUTION_ENABLED:-false}
|
||||
SELFOP_COLLECTION_MODE: ${SELFOP_COLLECTION_MODE:-OFF}
|
||||
SELFOP_SOURCE_ACCOUNT: ${SELFOP_SOURCE_ACCOUNT:-lingxing-primary}
|
||||
@@ -333,3 +338,5 @@ networks:
|
||||
infra-network:
|
||||
external: true
|
||||
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` 而中止,此项未通过;未为消除检查错误而创建替代业务文档。
|
||||
+12
-3
@@ -1,5 +1,7 @@
|
||||
# 单测试环境数据库空库初始化
|
||||
|
||||
本土散板简化下单的当前结构同步于 Lastleg 源基线及海外统一空库基线:移除散板柜型与旧要求日期,增加商品名称、申报预计托数及当地取货/送达时间,移除运输方式,增加起运地及目的地各自的联系人和联系电话;四项联系信息、取货及送货时间均必填;打盘与运输安排允许未知。预计托数不代表实收。字段语义唯一见工作区 `docs/海外仓业务/README.md`,不回填测试存量,不提供自动重置或启动时 SQL;需与匹配 Lastleg、Admin 和 Client 一起发布。本说明不表示 SQL 已执行。
|
||||
|
||||
`migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql` 将退货进销存 UNION 涉及的八张表、22 个文本字段及表默认规则显式统一为 `utf8mb4_unicode_ci`,同步 Lastleg 两份源基线与海外统一空库基线。执行前核对字段长度、默认值、可空性及目标规则下唯一索引无冲突;只修改规则,不回填或删除数据,不重放空库基线。DDL 非事务,失败时停止并复查实际已完成项;可按最终定义重跑。脚本存在不表示测试库已执行,执行后用原始计数/分页查询验证,无需发布查询转换代码。
|
||||
|
||||
`migrations/overseas/baiyue_lastleg/V22__selfop_warehouse_daily_sales.sql` 新增自营仓库每日汇总表及销量版本 `warehouse_daily_ready` 标记。与匹配 Lastleg 一起发布;汇总随 SKU 日事实批次原子构建,完整性验证后方可发布。已有未构建汇总的销量版本须重新构建发布,不回填旧版本、不回退旧查询;空版本同样必须有就绪证据。源基线与海外空库基线同步。
|
||||
@@ -34,14 +36,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 前拒绝,不能重放或擅自扩大重置范围。以上只维护脚本,不代表已执行或已部署。
|
||||
|
||||
换标直接出库的实物体积快照在 `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/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。
|
||||
|
||||
| 区域 | 完整入口 | 服务 schema | 表数 |
|
||||
|---|---|---|---:|
|
||||
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 |
|
||||
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 202 |
|
||||
| 国内 | `头程数据库统一增量-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` | 220 |
|
||||
|
||||
|
||||
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
|
||||
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
|
||||
@@ -63,7 +70,7 @@
|
||||
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
|
||||
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 +164,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同名配置同步);无业务建表及采集配置变更。执行前须逐项授权。
|
||||
|
||||
货物废弃通用库存与散箱字段迁移:`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
|
||||
IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE()
|
||||
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';
|
||||
END IF;
|
||||
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时间',
|
||||
PRIMARY KEY (id),
|
||||
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_planned (planned_at,appointment_status)
|
||||
) 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 '本批分配箱数,未知保持空;同一原来源总量守恒',
|
||||
cargo_verification_version INT 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时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_fba_item_source (appointment_id,source_key),
|
||||
|
||||
+223
-100
@@ -271,7 +271,7 @@ CREATE TABLE ll_delivery_order (
|
||||
package_count int NOT NULL DEFAULT '0' COMMENT '派送箱数',
|
||||
pallet_count int NOT NULL DEFAULT '0' 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 '预计派送时间',
|
||||
dispatched_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 '客户数据隔离键',
|
||||
exception_order_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 '原出库托盘码快照',
|
||||
sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原出库SKU快照',
|
||||
max_return_packages bigint NOT NULL COMMENT '原出库可退箱数上限',
|
||||
approved_return_packages bigint NOT NULL COMMENT '批准退回箱数',
|
||||
created_at datetime(3) NOT NULL 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),
|
||||
|
||||
PRIMARY KEY (id),
|
||||
@@ -768,6 +772,38 @@ CREATE TABLE ll_exception_return_item (
|
||||
KEY idx_ll_exception_return_order (customer_id,exception_order_id)
|
||||
) 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 (
|
||||
id bigint 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')))
|
||||
) 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 (
|
||||
id BIGINT NOT NULL COMMENT '主键ID',
|
||||
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时间',
|
||||
PRIMARY KEY (id),
|
||||
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_planned (planned_at,appointment_status)
|
||||
) 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 '本批分配箱数,未知保持空;同一原来源总量守恒',
|
||||
cargo_verification_version INT 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时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_fba_item_source (appointment_id,source_key),
|
||||
@@ -1401,22 +1487,21 @@ CREATE TABLE ll_local_looseboard_order (
|
||||
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
|
||||
lastleg_order_id bigint DEFAULT NULL COMMENT '关联尾程主订单ID',
|
||||
order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本土散板订单号',
|
||||
transport_mode varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输方式:拼车或优先拼载',
|
||||
pallet_required varchar(8) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空',
|
||||
transport_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输类型',
|
||||
transport_type VARCHAR(32) NULL DEFAULT NULL COMMENT '接单运输安排;下单不预设',
|
||||
origin varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '起运地',
|
||||
origin_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运详细地址',
|
||||
destination varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地',
|
||||
destination_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的详细地址、码头或仓号',
|
||||
container_type varchar(32) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜',
|
||||
product_name VARCHAR(255) NULL COMMENT '商品名称', estimated_pallet_count INT NULL COMMENT '客户申报预计托数,不代表实际收货',
|
||||
package_count int NOT NULL DEFAULT '0' COMMENT '货物件数',
|
||||
total_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '总重量(千克)',
|
||||
total_volume_cbm decimal(18,6) NOT NULL DEFAULT '0.000000' COMMENT '总体积(立方米)',
|
||||
budget_amount decimal(18,2) DEFAULT NULL COMMENT '预算总价',
|
||||
currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '预算币种',
|
||||
contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人姓名',
|
||||
contact_info varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人联系方式',
|
||||
requested_date date DEFAULT NULL COMMENT '要求服务日期',
|
||||
origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人', origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话',
|
||||
destination_contact_name VARCHAR(128) NULL COMMENT '目的地联系人', destination_contact_phone VARCHAR(128) NULL COMMENT '目的地联系电话',
|
||||
pickup_at DATETIME(3) NULL COMMENT '取货时间(当地时间,不转换时区)', delivery_at DATETIME(3) NULL COMMENT '送货时间(当地时间,不转换时区)',
|
||||
status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态',
|
||||
remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
|
||||
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键',
|
||||
@@ -1430,7 +1515,7 @@ CREATE TABLE ll_local_looseboard_order (
|
||||
UNIQUE KEY uk_ll_local_looseboard_key (customer_id,idempotency_key),
|
||||
UNIQUE KEY uk_ll_local_looseboard_lastleg (lastleg_order_id),
|
||||
KEY idx_ll_local_looseboard_list (customer_id,status,created_at),
|
||||
KEY idx_ll_local_looseboard_date (customer_id,requested_date,created_at)
|
||||
KEY idx_ll_local_looseboard_date (customer_id,pickup_at,created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土散板订单';
|
||||
|
||||
CREATE TABLE ll_monthly_exchange_rate (
|
||||
@@ -1725,7 +1810,7 @@ CREATE TABLE ll_outbound_order (
|
||||
pallet_count int NOT NULL DEFAULT '0' COMMENT '托盘数',
|
||||
gross_weight_kg DECIMAL(18,3) NULL 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 '本次发运引用的原费用版本、实际货物及明确服务覆盖确认快照',
|
||||
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键',
|
||||
created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID',
|
||||
@@ -1754,7 +1839,7 @@ CREATE TABLE ll_outbound_pallet_item (
|
||||
package_count int NOT NULL DEFAULT '0' COMMENT '箱数',
|
||||
gross_weight_kg DECIMAL(18,3) NULL 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 '已拣箱数',
|
||||
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',
|
||||
@@ -2215,7 +2300,7 @@ CREATE TABLE ll_relabel_application (
|
||||
label_deadline datetime(3) 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 '联系人电话',
|
||||
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 '是否需要打印面单',
|
||||
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 '客户上传面单文件名',
|
||||
@@ -2639,13 +2724,12 @@ CREATE TABLE ll_us_truck_order (
|
||||
id bigint NOT NULL COMMENT '主键ID',
|
||||
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
|
||||
order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '订单号',
|
||||
transport_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输类型',
|
||||
vehicle_type VARCHAR(32) NULL COMMENT '车辆类型:PALLET_15为15拖,PALLET_30为30拖;拼车未提供时为空',
|
||||
origin varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '起运地',
|
||||
origin_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运地址',
|
||||
destination varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地',
|
||||
destination_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地址',
|
||||
container_type varchar(32) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜',
|
||||
pallet_count int NOT NULL DEFAULT '1' COMMENT '客户申报托盘数',
|
||||
pallet_count INT NOT NULL DEFAULT 0 COMMENT '确认托盘数;独立下单首次实收确定,拼车来源取真实预报',
|
||||
package_count int DEFAULT NULL COMMENT '可选箱数,零或空值均有效',
|
||||
received_pallet_count int NOT NULL DEFAULT '0' COMMENT 'PDA实收托盘数',
|
||||
allocated_pallet_count int NOT NULL DEFAULT '0' COMMENT '已分配到合车行程的托盘数',
|
||||
@@ -2653,9 +2737,12 @@ CREATE TABLE ll_us_truck_order (
|
||||
total_volume_cbm decimal(18,6) NOT NULL DEFAULT '0.000000' COMMENT '总体积(立方米)',
|
||||
budget_amount decimal(18,2) DEFAULT NULL COMMENT '预算金额',
|
||||
currency varchar(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '币种',
|
||||
contact_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人姓名',
|
||||
contact_info varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人联系方式',
|
||||
requested_date date NOT NULL COMMENT '要求服务日期',
|
||||
origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人',
|
||||
origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话',
|
||||
destination_contact_name VARCHAR(128) NULL COMMENT '目的地联系人',
|
||||
destination_contact_phone VARCHAR(128) NULL COMMENT '目的地联系电话',
|
||||
pickup_at DATETIME NULL COMMENT '取货时间,按填写的日期时分保存,不转换时区',
|
||||
delivery_at DATETIME NULL COMMENT '送货时间,按填写的日期时分保存;拼车来源未提供时为空',
|
||||
status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '美国卡车订单状态',
|
||||
remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
|
||||
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键',
|
||||
@@ -2668,7 +2755,7 @@ CREATE TABLE ll_us_truck_order (
|
||||
UNIQUE KEY uk_ll_us_truck_order_no (customer_id,order_no),
|
||||
UNIQUE KEY uk_ll_us_truck_order_key (customer_id,idempotency_key),
|
||||
KEY idx_ll_us_truck_order_list (customer_id,status,created_at),
|
||||
KEY idx_ll_us_truck_order_date (customer_id,requested_date,created_at)
|
||||
KEY idx_ll_us_truck_order_date (customer_id,pickup_at,created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='美国卡车运输订单';
|
||||
|
||||
CREATE TABLE ll_us_truck_order_operation (
|
||||
@@ -2842,6 +2929,7 @@ CREATE TABLE ll_fleet_vehicle_record_history (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录不可变历史';
|
||||
|
||||
CREATE TABLE ll_fleet_trip (
|
||||
trip_purpose VARCHAR(16) NOT NULL DEFAULT 'DELIVERY' COMMENT 'DELIVERY出库派送 PICKUP上门取货入仓',
|
||||
id BIGINT NOT NULL COMMENT '内部主键',
|
||||
trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号',
|
||||
idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键',
|
||||
@@ -3206,9 +3294,21 @@ CREATE TABLE ll_wallet_event_inbox (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='跨区客户资金结果幂等收件箱';
|
||||
|
||||
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 '预报箱主键',
|
||||
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',
|
||||
carton_mark varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '系统生成的一箱一码箱唛',
|
||||
customer_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户原箱唛,用于换标现场绑定',
|
||||
@@ -3249,6 +3349,7 @@ CREATE TABLE ll_warehouse_transfer_inventory_allocation (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转仓单来源库存分配记录';
|
||||
|
||||
CREATE TABLE ll_warehouse_transfer_item (
|
||||
declared_quantity BIGINT NULL COMMENT 'SKU申报件数,非预计或实收箱数',
|
||||
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',
|
||||
@@ -3285,7 +3386,7 @@ CREATE TABLE ll_warehouse_transfer_order (
|
||||
pickup_contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '提货联系电话',
|
||||
package_count int 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 '预计发货时间',
|
||||
fba_shipment_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '可选FBA货件编号',
|
||||
tracking_no varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '物流跟踪号',
|
||||
@@ -4268,7 +4369,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 '稳定柜次关联键,不作业务编号展示',
|
||||
impact_no VARCHAR(40) NOT NULL COMMENT '船期核对事项业务号',
|
||||
baseline_json LONGTEXT NOT NULL COMMENT '已核对基准或首次变化前观察,原来源时间口径',
|
||||
@@ -4286,7 +4387,7 @@ CREATE TABLE IF NOT EXISTS ll_appointment_impact (
|
||||
CONSTRAINT chk_appointment_impact_status CHECK (status IN ('MONITORING','OPEN','RESOLVED'))
|
||||
) 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 '历史记录标识',
|
||||
container_booking_id VARCHAR(64) NOT NULL COMMENT '关联柜次标识',
|
||||
version INT NOT NULL COMMENT '记录版本',
|
||||
@@ -4335,47 +4436,29 @@ CREATE TABLE ll_return_sku_conversion (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物SKU换标库存转换和冲回记录';
|
||||
|
||||
-- PDA cargo discard photo evidence
|
||||
CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo (
|
||||
id BIGINT NOT NULL,
|
||||
customer_id VARCHAR(64) NOT NULL,
|
||||
application_id BIGINT NOT NULL,
|
||||
pallet_no VARCHAR(128) NOT NULL,
|
||||
file_id BIGINT NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
content_type VARCHAR(128) NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
operator_id VARCHAR(64) NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
submitted_at DATETIME(6) NULL,
|
||||
CREATE TABLE ll_cargo_discard_photo (
|
||||
id BIGINT NOT NULL COMMENT '照片记录标识',
|
||||
customer_id VARCHAR(64) NOT NULL COMMENT '所属客户标识',
|
||||
application_id BIGINT NOT NULL COMMENT '废弃申请标识',
|
||||
pallet_no VARCHAR(128) NOT NULL COMMENT '托盘业务码',
|
||||
file_id BIGINT NOT NULL COMMENT '照片文件标识',
|
||||
file_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
|
||||
content_type VARCHAR(128) NOT NULL COMMENT '文件媒体类型',
|
||||
size_bytes BIGINT NOT NULL COMMENT '文件字节数',
|
||||
operator_id VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
|
||||
created_at DATETIME(6) NOT NULL COMMENT '照片创建时间',
|
||||
submitted_at DATETIME(6) NULL COMMENT '照片提交时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_cargo_discard_photo_file (file_id),
|
||||
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 (
|
||||
id BIGINT NOT NULL COMMENT '主键',
|
||||
@@ -4414,39 +4497,15 @@ CREATE TABLE ll_selfop_product (
|
||||
|
||||
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
|
||||
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;
|
||||
|
||||
USE baiyue_lastleg;
|
||||
|
||||
-- 历史库存基线;执行前必须取得指定环境的建表授权。
|
||||
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_flow (
|
||||
CREATE TABLE ll_selfop_inventory_flow (
|
||||
id BIGINT NOT NULL COMMENT '应用生成的流水记录标识',
|
||||
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号',
|
||||
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码',
|
||||
@@ -4482,7 +4541,7 @@ CREATE TABLE IF NOT EXISTS ll_selfop_inventory_flow (
|
||||
CONSTRAINT chk_selfop_flow_revision CHECK (revision_no > 0)
|
||||
) 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 '不可变历史日版本标识',
|
||||
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号',
|
||||
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码',
|
||||
@@ -4512,7 +4571,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'))
|
||||
) 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 '所属历史日版本',
|
||||
customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码',
|
||||
sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码',
|
||||
@@ -4528,29 +4587,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))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日末分货主商品品质实物库存';
|
||||
|
||||
-- 仓储转运一箱多SKU清点;仅增加结构,不回填历史业务数据。
|
||||
USE baiyue_lastleg;
|
||||
|
||||
|
||||
-- Appointment work scope, audit and physical batch allocation.
|
||||
USE baiyue_lastleg;
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_appointment_allocation (
|
||||
dispatch_id BIGINT NOT NULL,
|
||||
dispatch_item_id BIGINT NOT NULL,
|
||||
plan_pallet_id BIGINT NOT NULL,
|
||||
batch_key VARCHAR(96) NOT NULL,
|
||||
customer_id VARCHAR(64) NOT NULL,
|
||||
source_key VARCHAR(160) NOT NULL,
|
||||
destination_key VARCHAR(160) NOT NULL,
|
||||
package_count INT NOT NULL,
|
||||
CREATE TABLE ll_dispatch_appointment_allocation (
|
||||
dispatch_id BIGINT NOT NULL COMMENT '排车单标识',
|
||||
dispatch_item_id BIGINT NOT NULL COMMENT '排车明细标识',
|
||||
plan_pallet_id BIGINT NOT NULL COMMENT '入仓计划托盘标识',
|
||||
batch_key VARCHAR(96) NOT NULL COMMENT '预约稳定批次键',
|
||||
customer_id VARCHAR(64) NOT NULL COMMENT '所属客户标识',
|
||||
source_key VARCHAR(160) NOT NULL COMMENT '货物来源稳定键',
|
||||
destination_key VARCHAR(160) NOT NULL COMMENT '目的地稳定键',
|
||||
package_count INT NOT NULL COMMENT '本票分配实物箱数',
|
||||
PRIMARY KEY(dispatch_item_id,batch_key,source_key,destination_key),
|
||||
KEY idx_dispatch_appointment_batch(batch_key,customer_id),
|
||||
KEY idx_dispatch_appointment_dispatch(dispatch_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车实物与预约批次逐票分配,原执行域事实';
|
||||
CREATE TABLE IF NOT EXISTS ll_appointment_work_commitment (
|
||||
entity_key VARCHAR(160) NOT NULL,
|
||||
CREATE TABLE ll_appointment_work_commitment (
|
||||
entity_key VARCHAR(160) 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)
|
||||
) 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',
|
||||
scan_cursor VARCHAR(128) NULL COMMENT '已开船来源同步游标',
|
||||
initialized BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否完成首次完整来源扫描',
|
||||
@@ -4562,9 +4624,9 @@ CREATE TABLE IF NOT EXISTS ll_appointment_work_guard (
|
||||
scan_requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '当前扫描需求版本,防跨页漂移',
|
||||
PRIMARY KEY (id)
|
||||
) 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 '不可变事项键,业务身份确定性生成',
|
||||
container_booking_id VARCHAR(64) NOT NULL COMMENT '来源柜次内部关联标识,不作展示编号',
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '工作命令乐观锁版本',
|
||||
@@ -4574,7 +4636,7 @@ CREATE TABLE IF NOT EXISTS ll_appointment_work (
|
||||
KEY idx_appointment_work_container (container_booking_id,retired)
|
||||
) 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 '历史记录标识',
|
||||
work_item_key VARCHAR(96) NOT NULL COMMENT '事项稳定键',
|
||||
operator_id VARCHAR(64) NOT NULL COMMENT '真实操作人员标识',
|
||||
@@ -4590,4 +4652,65 @@ CREATE TABLE IF NOT EXISTS ll_appointment_work_history (
|
||||
KEY idx_appointment_work_history (work_item_key,occurred_at)
|
||||
) 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;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 236 KiB |
@@ -0,0 +1,90 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1800" height="1280" viewBox="0 0 1800 1280">
|
||||
<defs>
|
||||
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto"><path d="M0,0 L10,3 L0,6 Z" fill="#335c81"/></marker>
|
||||
<marker id="greenArrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto"><path d="M0,0 L10,3 L0,6 Z" fill="#18865b"/></marker>
|
||||
<marker id="redArrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto"><path d="M0,0 L10,3 L0,6 Z" fill="#c94d4d"/></marker>
|
||||
<style>
|
||||
.title{font:700 34px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#17324d}
|
||||
.sub{font:400 17px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#547086}
|
||||
.lane{font:700 20px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#17324d}
|
||||
.boxTitle{font:700 19px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#17324d}
|
||||
.boxText{font:400 16px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#36566d}
|
||||
.small{font:400 14px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#547086}
|
||||
.assert{font:500 16px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#23445a}
|
||||
.line{stroke:#335c81;stroke-width:3;fill:none;marker-end:url(#arrow)}
|
||||
.gline{stroke:#18865b;stroke-width:3;fill:none;marker-end:url(#greenArrow)}
|
||||
.rline{stroke:#c94d4d;stroke-width:3;fill:none;marker-end:url(#redArrow)}
|
||||
</style>
|
||||
</defs>
|
||||
<rect width="1800" height="1280" fill="#f6f9fc"/>
|
||||
<text x="70" y="62" class="title">海外业务|库存变动测试总流程图</text>
|
||||
<text x="70" y="94" class="sub">测试看库存:先看库存状态,再看数量、位置、流水和幂等;灰色节点只改状态/资源,不直接改可用库存。</text>
|
||||
|
||||
<g transform="translate(70,125)">
|
||||
<rect width="1660" height="58" rx="12" fill="#e9f1f8"/>
|
||||
<circle cx="28" cy="29" r="10" fill="#18865b"/><text x="50" y="35" class="small">绿色:库存增加/恢复</text>
|
||||
<circle cx="250" cy="29" r="10" fill="#c94d4d"/><text x="272" y="35" class="small">红色:库存扣减</text>
|
||||
<circle cx="450" cy="29" r="10" fill="#e19a28"/><text x="472" y="35" class="small">橙色:冻结/释放</text>
|
||||
<circle cx="690" cy="29" r="10" fill="#7657a8"/><text x="712" y="35" class="small">紫色:SKU/箱唛变化</text>
|
||||
<circle cx="960" cy="29" r="10" fill="#78909c"/><text x="982" y="35" class="small">灰色:状态/资源变化</text>
|
||||
<text x="1260" y="35" class="small">所有写操作:租户隔离 + 版本/锁 + 幂等键</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(70,220)">
|
||||
<rect width="1660" height="196" rx="16" fill="#edf5ee" stroke="#b7d9c2" stroke-width="2"/>
|
||||
<text x="24" y="34" class="lane">A. 库存来源与入仓</text>
|
||||
<rect x="24" y="58" width="240" height="96" rx="12" fill="#fff" stroke="#6ea984" stroke-width="2"/>
|
||||
<text x="44" y="88" class="boxTitle">头程装柜完成事件</text><text x="44" y="116" class="boxText">生成尾程单 / 冻结明细</text><text x="44" y="140" class="small">在途:不可用库存</text>
|
||||
<path d="M264 106 H330" class="line"/>
|
||||
<rect x="330" y="58" width="250" height="96" rx="12" fill="#fff" stroke="#6ea984" stroke-width="2"/>
|
||||
<text x="350" y="88" class="boxTitle">平台转换入仓计划</text><text x="350" y="116" class="boxText">按入仓单 / 推荐托数建计划</text><text x="350" y="140" class="small">仍未形成可用库存</text>
|
||||
<path d="M580 106 H646" class="line"/>
|
||||
<rect x="646" y="58" width="290" height="96" rx="12" fill="#fff" stroke="#18865b" stroke-width="3"/>
|
||||
<text x="666" y="88" class="boxTitle">PDA 签收 → 实收 → 上架</text><text x="666" y="116" class="boxText">完成后入仓库存入账</text><text x="666" y="140" class="small">AVAILABLE + 位置 + 库存流水</text>
|
||||
<path d="M936 106 H1002" class="gline"/>
|
||||
<rect x="1002" y="58" width="280" height="96" rx="12" fill="#e6f6ed" stroke="#18865b" stroke-width="2"/>
|
||||
<text x="1022" y="88" class="boxTitle">可用库存池</text><text x="1022" y="116" class="boxText">客户 × 海外仓 × 目的仓</text><text x="1022" y="140" class="small">入仓单汇总;托盘只做作业维度</text>
|
||||
<rect x="1316" y="58" width="310" height="96" rx="12" fill="#f0f2f4" stroke="#9baab4" stroke-width="2"/>
|
||||
<text x="1336" y="88" class="boxTitle">门禁与失败</text><text x="1336" y="116" class="boxText">照片、仓绑定、客户/柜号</text><text x="1336" y="140" class="small">任一失败:库存与状态整体回滚</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(70,450)">
|
||||
<rect width="1660" height="222" rx="16" fill="#fff8ec" stroke="#efd39a" stroke-width="2"/>
|
||||
<text x="24" y="34" class="lane">B. 从可用库存出发的四类作业</text>
|
||||
<rect x="24" y="60" width="360" height="126" rx="12" fill="#fff" stroke="#dfa43c" stroke-width="2"/>
|
||||
<text x="44" y="88" class="boxTitle">排车 / 一件代发拣货</text><text x="44" y="116" class="boxText">AVAILABLE → 冻结 → PICKED</text><text x="44" y="144" class="boxText">发车/出库 → 扣减</text><text x="44" y="170" class="small">整托扫描也按入仓单组成核平</text>
|
||||
<rect x="430" y="60" width="360" height="126" rx="12" fill="#fff" stroke="#7657a8" stroke-width="2"/>
|
||||
<text x="450" y="88" class="boxTitle">换标</text><text x="450" y="116" class="boxText">冻结 hold_packages</text><text x="450" y="144" class="boxText">旧 SKU 转出 → 新 SKU 转入</text><text x="450" y="170" class="small">箱唛/托盘绑定变化,箱数守恒</text>
|
||||
<rect x="836" y="60" width="360" height="126" rx="12" fill="#fff" stroke="#dfa43c" stroke-width="2"/>
|
||||
<text x="856" y="88" class="boxTitle">仓储转运</text><text x="856" y="116" class="boxText">来源仓冻结/扣减</text><text x="856" y="144" class="boxText">目标仓收货 → 增加</text><text x="856" y="170" class="small">可联动换标;来源/目标核对</text>
|
||||
<rect x="1242" y="60" width="384" height="126" rx="12" fill="#fff" stroke="#c94d4d" stroke-width="2"/>
|
||||
<text x="1262" y="88" class="boxTitle">废弃</text><text x="1262" y="116" class="boxText">申请仅锁箱:DISCARD_PENDING</text><text x="1262" y="144" class="boxText">PDA 实际确认 → 扣减 → DISCARDED</text><text x="1262" y="170" class="small">驳回释放;平台结案不再扣一次</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(70,706)">
|
||||
<rect width="1660" height="222" rx="16" fill="#eef3fa" stroke="#b7cae2" stroke-width="2"/>
|
||||
<text x="24" y="34" class="lane">C. 出库后的结果与恢复</text>
|
||||
<rect x="24" y="60" width="350" height="126" rx="12" fill="#fff" stroke="#c94d4d" stroke-width="2"/>
|
||||
<text x="44" y="88" class="boxTitle">OUTBOUNDED / 派送中</text><text x="44" y="116" class="boxText">库存已扣减,保留出库事实</text><text x="44" y="144" class="boxText">签收:不再二次扣库存</text><text x="44" y="170" class="small">派送费用与库存动作分开核对</text>
|
||||
<path d="M374 123 H446" class="line"/>
|
||||
<rect x="446" y="60" width="380" height="126" rx="12" fill="#fff" stroke="#18865b" stroke-width="3"/>
|
||||
<text x="466" y="88" class="boxTitle">异常退回入仓</text><text x="466" y="116" class="boxText">只能选原出库拣货事实</text><text x="466" y="144" class="boxText">RETURN_STOCK_IN → 目标托盘/库位</text><text x="466" y="170" class="small">库存恢复 + 异常完成 + 派送 RETURNED</text>
|
||||
<rect x="900" y="60" width="330" height="126" rx="12" fill="#fff" stroke="#78909c" stroke-width="2"/>
|
||||
<text x="920" y="88" class="boxTitle">盘点调整</text><text x="920" y="116" class="boxText">实盘数量 − 账面数量 = 差异</text><text x="920" y="144" class="boxText">按正/负生成调整流水</text><text x="920" y="170" class="small">无差异不造流水;需 adjust 权限</text>
|
||||
<rect x="1270" y="60" width="356" height="126" rx="12" fill="#fff" stroke="#78909c" stroke-width="2"/>
|
||||
<text x="1290" y="88" class="boxTitle">移库 / 拆合托 / 换绑</text><text x="1290" y="116" class="boxText">同仓位置或托盘组成变化</text><text x="1290" y="144" class="boxText">数量净额 = 0</text><text x="1290" y="170" class="small">记录位置流水;资源锁防并发</text>
|
||||
</g>
|
||||
|
||||
<path d="M1142 416 V450" class="gline"/><path d="M120 638 V706" class="rline"/><path d="M610 638 V706" class="gline"/>
|
||||
<path d="M1020 638 V706" class="line"/><path d="M1435 638 V706" class="rline"/>
|
||||
|
||||
<g transform="translate(70,965)">
|
||||
<rect width="1660" height="250" rx="16" fill="#ffffff" stroke="#d5e0e8" stroke-width="2"/>
|
||||
<text x="24" y="36" class="lane">D. 测试统一断言(每个库存场景都要检查)</text>
|
||||
<text x="42" y="78" class="assert">① 数量守恒:客户 / 海外仓 / 目的仓 / 入仓单 / 托盘组成逐层核平</text>
|
||||
<text x="42" y="114" class="assert">② 三账一致:库存余额 = 位置明细汇总 = 库存流水结果</text>
|
||||
<text x="42" y="150" class="assert">③ 幂等并发:重复事件、重复请求、并发锁定不得产生第二笔变动</text>
|
||||
<text x="42" y="186" class="assert">④ 原子回滚:权限、门禁、资源锁、跨服务失败时,库存/单据/流水一起回滚</text>
|
||||
<text x="42" y="222" class="assert">⑤ 隔离校验:客户、仓、来源入仓单、原出库事实不可伪造或串用</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.2 KiB |
@@ -0,0 +1,40 @@
|
||||
# 海外业务库存变动测试整理
|
||||
|
||||
范围依据:`lastleg-service/AGENTS.md`、`docs/backend-business-index.md` 及当前海外仓库存、入仓、出库、换标、转运、废弃、异常退回和 PDA 作业入口。
|
||||
|
||||
## 先统一库存口径
|
||||
|
||||
- **可用库存**:按客户、海外仓、目的仓、入仓单汇总箱数;SKU、箱唛和托盘是明细快照/作业维度。
|
||||
- **冻结库存**:拣货、换标、废弃、转运等处理中暂时不可重复使用;冻结不等于已经扣减。
|
||||
- **库存流水**:每一次入账、冻结、释放、扣减、恢复、SKU 转换和位置变化都应能按业务单号、幂等键追溯。
|
||||
- **托盘不是库存归属**:合托后一个物理托盘可以包含多个入仓单,出库、换标和退回必须按托盘组成明细核平。
|
||||
|
||||
## 测试场景矩阵
|
||||
|
||||
| 场景 | 触发与前置 | 库存变动 | 测试重点 |
|
||||
|---|---|---|---|
|
||||
| 头程接柜/海外入仓 | 装柜完成事件 → 生成尾程单/入仓计划;PDA 签收、实收、上架、完成 | 在途冻结 → 入仓后 `AVAILABLE` 增加;写库存位置和流水 | 柜号/客户/入仓单隔离;卸柜照片门禁;重复事件不重复入账;失败整事务回滚 |
|
||||
| 普通排车出库/派送 | 计划已入仓;排车审核、拣货、箱数核平、发车 | `AVAILABLE` → 冻结/已拣;原子出库后可用库存扣减 | 整托/部分托按组成明细核平;未审核、数量不平、车辆门禁时不得出库;同键重放幂等 |
|
||||
| 一件代发 | 已完成入库且有可用箱唛/SKU;创建 `WAIT_PICK` 出库单 | SKU/箱唛库存冻结 → PDA 拣货 → `OUTBOUNDED` 扣减 | 候选只能来自真实可用库存;一单不得跨客户/跨仓;整托扫描任一明细失败必须整体回滚 |
|
||||
| 客户换标 | 历史入仓库存可用;客户申请、PDA 扫旧 SKU/箱唛、完工、客户确认 | 旧 SKU/箱唛冻结;换标完成后旧库存转出、新 SKU/箱唛转入;库存总箱数守恒 | 同箱多 SKU 不重复冻结;只移动本单 `hold_packages`;已生成出库单禁止再次拆托;照片/复核/客户确认门禁 |
|
||||
| 仓储转运 | 来源库存可用;创建转运、测量、换标(可选)、出库、目标仓收货 | 来源仓冻结/扣减 → 目标仓入账;换标时 SKU 转换 | 来源客户、入仓单和箱数核对;部分转运数量守恒;目标仓与来源仓不能串;重复完成不重复入账 |
|
||||
| 货物废弃 | 一件代发/库存货物申请废弃;审批后由绑定海外 PDA 实际确认 | `AVAILABLE` → `DISCARD_PENDING`;实际确认后 SKU、余额、位置扣减并记 `DISCARDED` | 申请阶段只锁箱不扣库存;驳回释放;批准不是实际扣减;实际数量与逐箱流水一致;失败整批回滚 |
|
||||
| 异常退回入仓 | 只能选择原出库拣货事实;异常单受理并完成退回 | 原出库来源核验 → `RETURN_STOCK_IN` 恢复到真实目标托盘/库位 | 不能伪造出库来源、借用其他客户主单;退回数量与原实发核平;异常单完成与派送 `RETURNED` 同事务 |
|
||||
| PDA 盘点调整 | 绑定海外仓;盘点锁定库存位置并提交实盘数量 | 按差异产生增加/减少调整流水;无差异不产生虚假变动 | 需要 `inventory:adjust`;并发版本校验;差异正负方向正确;盘点重复提交幂等 |
|
||||
| PDA 移库/托盘拆合/换绑 | 同仓有效托盘/库位;资源锁成功 | 数量净额不变,仅位置/托盘组成变化,写位置流水 | 不跨仓;托盘组成明细保留;资源锁释放;出库/换标占用时禁止绕过协调单独移动 |
|
||||
|
||||
## 不应误判为库存变动
|
||||
|
||||
- 目的仓修改、暂扣/解除、报价、排车预排、外接预约、车辆/司机资料:只改变状态、资格、资源占用或费用快照。
|
||||
- 海外采购样品履约:按当前契约走 Admin 台账,**不接入 PDA,也不写 WMS 可用库存**。
|
||||
- 退货申请/废弃申请/转派送建单:申请或建单阶段只形成冻结/候选/出库单,必须到实际收货、实际扣减或实际出库节点才确认数量变化。
|
||||
|
||||
## 每条库存用例的统一断言
|
||||
|
||||
1. 变动前后按客户、海外仓、目的仓、入仓单、托盘组成核对数量守恒。
|
||||
2. 查询余额、位置明细和库存流水三处结果一致,且业务单号/幂等键可追溯。
|
||||
3. 重复请求、重复事件、并发操作不得新增第二笔库存变动。
|
||||
4. 任一校验、权限、资源锁或跨服务依赖失败时,库存、单据状态和流水一起回滚。
|
||||
5. 客户越权、跨仓、跨客户、伪造来源入仓单/出库单均应拒绝。
|
||||
|
||||
流程图:`overseas-inventory-mutation-flow.svg` / `overseas-inventory-mutation-flow.png`
|
||||
@@ -1,5 +1,10 @@
|
||||
# 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`,不得增加子域扫描配置。
|
||||
|
||||
@@ -9,6 +9,10 @@ import java.util.Arrays;
|
||||
enum BusinessFileCategory {
|
||||
FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||
DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||
@@ -67,7 +71,17 @@ enum BusinessFileCategory {
|
||||
.orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型"));
|
||||
}
|
||||
|
||||
/** 校验文件归属;跨客户排车证据固定为平台所有,不随货物客户变化。 */
|
||||
void validateOwner(long tenantId) {
|
||||
// 整车运营照片归原排车单,不随同车客户变化,也不能串用装车和油耗类别。
|
||||
if ((this == OVERSEAS_DISPATCH_LOADING_PHOTO || this == OVERSEAS_DISPATCH_FUEL_PHOTO)
|
||||
&& tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台");
|
||||
}
|
||||
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE)
|
||||
&& tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台");
|
||||
}
|
||||
if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 平台排车车辆与司机证据内部文件契约
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。
|
||||
两类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示,
|
||||
不使用车辆 ID、司机 ID、车牌或客户 ID。车辆与司机证据分别使用
|
||||
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE,不可互换。
|
||||
File 校验内部服务令牌、平台归属、正数业务 ID、文件非空、最大 10MiB(10485760 字节,包含边界)、
|
||||
PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId
|
||||
联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。
|
||||
上传生成不可变文件和真实元数据,不自动绑定证据或通过审核,不返回公开 URL 或存储路径。
|
||||
Lastleg 负责排车存在性、仓库与角色权限、当前及历史附件引用和审核状态;File 不查询 Lastleg 业务表。
|
||||
本文只描述排车网关实际消费的上传、元数据和内容三个操作,不改变通用文件接口的其他用途。
|
||||
统一异常处理使用 HTTP 200 JSON 业务错误,调用方必须检查 code;内容接口错误也返回 JSON,不能当作文件。
|
||||
security:
|
||||
- InternalServiceToken: []
|
||||
paths:
|
||||
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TenantId'}
|
||||
- {$ref: '#/components/parameters/BusinessType'}
|
||||
- {$ref: '#/components/parameters/BusinessId'}
|
||||
post:
|
||||
operationId: uploadDispatchEvidence
|
||||
summary: 上传本排车的车辆或司机证据
|
||||
parameters:
|
||||
- name: X-Operator-Id
|
||||
in: header
|
||||
required: true
|
||||
description: Lastleg 已认证操作者的正数 Long ID
|
||||
schema: {$ref: '#/components/schemas/PositiveLongString'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
description: 非空且不超过10485760字节;application/pdf、image/jpeg或image/png,并校验对应内容签名
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/MetadataOrError'}
|
||||
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TenantId'}
|
||||
- {$ref: '#/components/parameters/BusinessType'}
|
||||
- {$ref: '#/components/parameters/BusinessId'}
|
||||
- {$ref: '#/components/parameters/FileId'}
|
||||
get:
|
||||
operationId: readDispatchEvidenceMetadata
|
||||
summary: 按本排车与证据类别联合校验并读取元数据
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/MetadataOrError'}
|
||||
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TenantId'}
|
||||
- {$ref: '#/components/parameters/BusinessType'}
|
||||
- {$ref: '#/components/parameters/BusinessId'}
|
||||
- {$ref: '#/components/parameters/FileId'}
|
||||
get:
|
||||
operationId: downloadDispatchEvidence
|
||||
summary: 按完整复合归属读取文件内容
|
||||
responses:
|
||||
'200':
|
||||
description: >-
|
||||
成功返回真实文件;错误返回 application/json 统一业务错误。
|
||||
A0400 表示作用域、ID或文件格式无效,A0403 表示内部令牌无效,
|
||||
A0404 表示文件不存在、非有效文件或复合归属不匹配。
|
||||
headers:
|
||||
Cache-Control: {schema: {type: string, enum: [no-store]}}
|
||||
Content-Disposition: {schema: {type: string}, description: 成功时为attachment及UTF-8文件名}
|
||||
Content-Length: {schema: {type: integer, format: int64}, description: 成功时为真实文件字节数}
|
||||
content:
|
||||
application/pdf: {schema: {type: string, format: binary}}
|
||||
image/jpeg: {schema: {type: string, format: binary}}
|
||||
image/png: {schema: {type: string, format: binary}}
|
||||
application/json: {schema: {$ref: '#/components/schemas/ErrorEnvelope'}}
|
||||
components:
|
||||
securitySchemes:
|
||||
InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token}
|
||||
parameters:
|
||||
TenantId:
|
||||
name: tenantId
|
||||
in: path
|
||||
required: true
|
||||
description: 两类排车证据只属于平台,客户作用域一律拒绝
|
||||
schema: {type: string, enum: ['0']}
|
||||
BusinessType:
|
||||
name: businessType
|
||||
in: path
|
||||
required: true
|
||||
schema: {$ref: '#/components/schemas/DispatchEvidenceType'}
|
||||
BusinessId:
|
||||
name: businessId
|
||||
in: path
|
||||
required: true
|
||||
description: 排车内部正数Long ID,上限9223372036854775807;Lastleg以标准十进制字符串传入
|
||||
schema: {$ref: '#/components/schemas/PositiveLongString'}
|
||||
FileId:
|
||||
name: fileId
|
||||
in: path
|
||||
required: true
|
||||
schema: {$ref: '#/components/schemas/PositiveLongString'}
|
||||
responses:
|
||||
MetadataOrError:
|
||||
description: >-
|
||||
统一JSON响应,业务成功code为200。A0400为无效平台作用域、ID、操作者或文件;
|
||||
A0403为内部服务令牌无效;A0404为文件不存在、非ACTIVE、已删除或复合归属不匹配;
|
||||
S0503为存储不可用,S0500为元数据保存失败等内部异常。
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- {$ref: '#/components/schemas/MetadataEnvelope'}
|
||||
- {$ref: '#/components/schemas/ErrorEnvelope'}
|
||||
schemas:
|
||||
PositiveLongString:
|
||||
type: string
|
||||
pattern: '^[1-9][0-9]*$'
|
||||
maxLength: 19
|
||||
description: 正数Long的十进制字符串,值不得超过9223372036854775807
|
||||
DispatchEvidenceType:
|
||||
type: string
|
||||
enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE]
|
||||
MetadataEnvelope:
|
||||
type: object
|
||||
required: [code, data]
|
||||
properties:
|
||||
code: {type: string, enum: ['200']}
|
||||
message: {type: string}
|
||||
data: {$ref: '#/components/schemas/Metadata'}
|
||||
requestId: {type: string, nullable: true}
|
||||
traceId: {type: string, nullable: true}
|
||||
timestamp: {type: string, format: date-time}
|
||||
Metadata:
|
||||
type: object
|
||||
required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes, createdAt]
|
||||
properties:
|
||||
fileId: {$ref: '#/components/schemas/PositiveLongString'}
|
||||
ownerScope: {type: string, enum: [PLATFORM]}
|
||||
tenantId: {type: string, enum: ['0']}
|
||||
businessType: {$ref: '#/components/schemas/DispatchEvidenceType'}
|
||||
businessId: {$ref: '#/components/schemas/PositiveLongString'}
|
||||
fileName: {type: string, minLength: 1, description: 清理路径和控制字符后的原始文件名}
|
||||
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
contentType: {type: string, enum: [application/pdf, image/jpeg, image/png]}
|
||||
sizeBytes:
|
||||
type: string
|
||||
pattern: '^[1-9][0-9]*$'
|
||||
description: 实际字节数,范围1到10485760;公共Jackson配置将Long输出为字符串
|
||||
example: '1024'
|
||||
createdAt: {type: string, description: File真实上传时间,LocalDateTime的ISO本地时间表示,无时区偏移}
|
||||
ErrorEnvelope:
|
||||
type: object
|
||||
required: [code, data]
|
||||
properties:
|
||||
code: {type: string, enum: [A0400, A0403, A0404, A0422, S0500, S0503]}
|
||||
message: {type: string}
|
||||
data: {type: object, nullable: true, description: 业务错误为null;参数校验错误可携带字段信息}
|
||||
requestId: {type: string, nullable: true}
|
||||
traceId: {type: string, nullable: true}
|
||||
timestamp: {type: string, format: date-time}
|
||||
@@ -0,0 +1,14 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 自有业务排车运营照片归属契约
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
自有业务消费原排车事实,仅补充运营照片,不创建另一套排车或装车状态。
|
||||
复用InternalBusinessFileController,不增加公网File路径。
|
||||
businessType分为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO,
|
||||
tenantId固定0,businessId固定原Lastleg排车内部主键,不接受车次主键、车牌或客户ID替代。
|
||||
仅非空JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。
|
||||
Lastleg按公开dispatchNo解析原排车,负责车辆归属、可见阶段、读写权限、附加记录版本、
|
||||
分类附件引用和历史。文件上传不代表装车确认、签收、实到或运营信息保存。
|
||||
File不读取Lastleg表,只按客户范围、业务类别、业务ID和文件ID核对文件复合归属。
|
||||
paths: {}
|
||||
@@ -0,0 +1,245 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.file.controller.InternalBusinessFileController;
|
||||
import com.baiyue.file.entity.FileObjectEntity;
|
||||
import com.baiyue.file.mapper.FileObjectMapper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class DispatchEvidenceFileTest {
|
||||
private static final String VEHICLE = "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE";
|
||||
private static final String DRIVER = "OVERSEAS_DISPATCH_DRIVER_EVIDENCE";
|
||||
private static final int MAX_BYTES = 10 * 1024 * 1024;
|
||||
@TempDir Path root;
|
||||
|
||||
/** 初始化真实 Lambda 查询列映射,使测试能逐列核对复合归属条件。 */
|
||||
@BeforeAll static void metadata() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "dispatch-evidence"),
|
||||
FileObjectEntity.class);
|
||||
}
|
||||
|
||||
/** 两类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||
void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) {
|
||||
var files = mock(FileObjectMapper.class);
|
||||
var storage = mock(BusinessFileStorage.class);
|
||||
var service = service(files, storage);
|
||||
var upload = pdf();
|
||||
for (long customer : new long[]{-1, 1, 900}) {
|
||||
assertThatThrownBy(() -> service.upload(customer, type, "71", 7, upload)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.metadata(customer, type, "71", 501)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.download(customer, type, "71", 501)).isInstanceOf(BizException.class);
|
||||
}
|
||||
for (String id : new String[]{"0", "-1", "DISPATCH-71", "../71", "9223372036854775808"}) {
|
||||
assertThatThrownBy(() -> service.upload(0, type, id, 7, upload)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.metadata(0, type, id, 501)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.download(0, type, id, 501)).isInstanceOf(BizException.class);
|
||||
}
|
||||
assertThatThrownBy(() -> service.upload(0, type, "71", 0, upload)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.metadata(0, type, "71", 0)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.download(0, type, "71", 0)).isInstanceOf(BizException.class);
|
||||
verifyNoInteractions(files, storage);
|
||||
}
|
||||
|
||||
/** 真实 PDF、JPG、PNG 均可写入并保持内容;上传不以扩展名代替 MIME 和签名。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||
void storesPdfJpegPngAndRejectsEmptyUnsupportedAndSpoofedContent(String type) throws Exception {
|
||||
var storage = storage();
|
||||
var category = BusinessFileCategory.require(type);
|
||||
for (var file : new MockMultipartFile[]{pdf(),
|
||||
new MockMultipartFile("file", "vehicle.jpg", "image/jpeg", image("jpeg")),
|
||||
new MockMultipartFile("file", "driver.png", "image/png", image("png"))}) {
|
||||
var saved = storage.store(0, "71", file, category);
|
||||
assertThat(storage.read(saved.objectKey())).isEqualTo(file.getBytes());
|
||||
assertThat(saved.contentType()).isEqualTo(file.getContentType());
|
||||
assertThat(saved.size()).isEqualTo(file.getSize());
|
||||
assertThat(saved.sha256()).matches("[0-9a-f]{64}");
|
||||
}
|
||||
for (var file : new MockMultipartFile[]{
|
||||
new MockMultipartFile("file", "empty.pdf", "application/pdf", new byte[0]),
|
||||
new MockMultipartFile("file", "fake.pdf", "application/pdf", "plain text".getBytes(StandardCharsets.UTF_8)),
|
||||
new MockMultipartFile("file", "fake.jpg", "image/jpeg", image("png")),
|
||||
new MockMultipartFile("file", "fake.png", "image/png", image("jpeg")),
|
||||
new MockMultipartFile("file", "fake.pdf", "text/plain", pdf().getBytes()),
|
||||
new MockMultipartFile("file", "fake.pdf", "application/octet-stream", pdf().getBytes()),
|
||||
new MockMultipartFile("file", "missing.pdf", null, pdf().getBytes())}) {
|
||||
assertThatThrownBy(() -> storage.store(0, "71", file, category)).isInstanceOf(BizException.class);
|
||||
}
|
||||
assertThatThrownBy(() -> storage.store(0, "71", null, category)).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
/** 10MiB 为包含边界,超限在读取内容或落盘前拒绝。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||
void acceptsExactlyTenMibAndRejectsOneByteOverBeforeReading(String type) throws Exception {
|
||||
var storage = storage();
|
||||
var category = BusinessFileCategory.require(type);
|
||||
byte[] content = Arrays.copyOf(pdf().getBytes(), MAX_BYTES);
|
||||
var boundary = new MockMultipartFile("file", "boundary.pdf", "application/pdf", content);
|
||||
var stored = storage.store(0, "71", boundary, category);
|
||||
assertThat(Files.size(root.resolve(stored.objectKey()))).isEqualTo(MAX_BYTES);
|
||||
var oversized = mock(MultipartFile.class);
|
||||
when(oversized.isEmpty()).thenReturn(false);
|
||||
when(oversized.getSize()).thenReturn((long) MAX_BYTES + 1);
|
||||
assertThatThrownBy(() -> storage.store(0, "71", oversized, category))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("10MB");
|
||||
verify(oversized, never()).getInputStream();
|
||||
verify(oversized, never()).transferTo(any(Path.class));
|
||||
}
|
||||
|
||||
/** 上传生成独立不可变对象和真实元数据;替换引用不覆盖原文件。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||
void uploadsKeepPlatformDispatchScopeAndImmutableObjects(String type) throws Exception {
|
||||
var files = mock(FileObjectMapper.class);
|
||||
var storage = storage();
|
||||
var service = service(files, storage);
|
||||
var ids = new AtomicLong(500);
|
||||
when(files.insert(any(FileObjectEntity.class))).thenAnswer(call -> {
|
||||
FileObjectEntity row = call.getArgument(0);
|
||||
row.setId(ids.incrementAndGet());
|
||||
return 1;
|
||||
});
|
||||
var first = service.upload(0, type, "71", 7, pdf());
|
||||
var secondFile = new MockMultipartFile("file", "updated.pdf", "application/pdf",
|
||||
"%PDF-1.7\nupdated".getBytes(StandardCharsets.US_ASCII));
|
||||
var second = service.upload(0, type, "71", 7, secondFile);
|
||||
var rows = org.mockito.ArgumentCaptor.forClass(FileObjectEntity.class);
|
||||
verify(files, times(2)).insert(rows.capture());
|
||||
assertThat(first.ownerScope()).isEqualTo("PLATFORM");
|
||||
assertThat(first.tenantId()).isZero();
|
||||
assertThat(first.businessType()).isEqualTo(type);
|
||||
assertThat(first.businessId()).isEqualTo("71");
|
||||
assertThat(first.fileId()).isEqualTo(501L);
|
||||
assertThat(second.fileId()).isEqualTo(502L);
|
||||
assertThat(first.fileName()).isEqualTo("evidence.pdf");
|
||||
assertThat(first.contentType()).isEqualTo("application/pdf");
|
||||
assertThat(first.sizeBytes()).isEqualTo(pdf().getSize());
|
||||
assertThat(first.sha256()).matches("[0-9a-f]{64}");
|
||||
assertThat(first.createdAt()).isNotNull().isEqualTo(rows.getAllValues().get(0).getCreatedAt());
|
||||
assertThat(rows.getAllValues().get(0).getObjectKey()).isNotEqualTo(rows.getAllValues().get(1).getObjectKey());
|
||||
assertThat(storage.read(rows.getAllValues().get(0).getObjectKey())).isEqualTo(pdf().getBytes());
|
||||
assertThat(storage.read(rows.getAllValues().get(1).getObjectKey())).isEqualTo(secondFile.getBytes());
|
||||
verify(files, never()).updateById(any(FileObjectEntity.class));
|
||||
}
|
||||
|
||||
/** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||
void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception {
|
||||
var files = mock(FileObjectMapper.class);
|
||||
var storage = mock(BusinessFileStorage.class);
|
||||
var service = service(files, storage);
|
||||
var row = new FileObjectEntity();
|
||||
row.setId(501L); row.setTenantId(0L); row.setBusinessType(type); row.setBusinessId("71");
|
||||
row.setOriginalName("evidence.pdf"); row.setContentType("application/pdf");
|
||||
row.setObjectKey("business-files/0/71/evidence.pdf"); row.setSizeBytes(pdf().getSize());
|
||||
row.setSha256("a".repeat(64)); row.setCreatedAt(java.time.LocalDateTime.now());
|
||||
when(files.selectOne(any())).thenAnswer(call -> {
|
||||
LambdaQueryWrapper<FileObjectEntity> query = call.getArgument(0);
|
||||
assertThat(boundValue(query, "status")).isEqualTo("ACTIVE");
|
||||
assertThat(boundValue(query, "deleted")).isEqualTo(false);
|
||||
return Long.valueOf(0).equals(boundValue(query, "tenant_id"))
|
||||
&& type.equals(boundValue(query, "business_type"))
|
||||
&& "71".equals(boundValue(query, "business_id"))
|
||||
&& Long.valueOf(501).equals(boundValue(query, "id")) ? row : null;
|
||||
});
|
||||
when(storage.read(row.getObjectKey())).thenReturn(pdf().getBytes());
|
||||
var metadata = service.metadata(0, type, "71", 501);
|
||||
assertThat(metadata.fileId()).isEqualTo(501L);
|
||||
assertThat(metadata.ownerScope()).isEqualTo("PLATFORM");
|
||||
assertThat(service.download(0, type, "71", 501).content()).isEqualTo(pdf().getBytes());
|
||||
verify(storage).read(row.getObjectKey());
|
||||
clearInvocations(storage);
|
||||
String otherType = VEHICLE.equals(type) ? DRIVER : VEHICLE;
|
||||
for (String candidate : new String[]{otherType, "FLEET_DRIVER_LICENSE", "FLEET_TRIP_OPERATION_EVIDENCE"}) {
|
||||
assertThatThrownBy(() -> service.metadata(0, candidate, "71", 501))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||
assertThatThrownBy(() -> service.download(0, candidate, "71", 501))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||
}
|
||||
assertThatThrownBy(() -> service.metadata(0, type, "72", 501)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.download(0, type, "72", 501)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.metadata(0, type, "71", 502)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.download(0, type, "71", 502)).isInstanceOf(BizException.class);
|
||||
verifyNoInteractions(storage);
|
||||
}
|
||||
|
||||
/** 内部服务令牌在业务访问前校验,不对未授权请求暴露元数据或文件。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||
void invalidInternalTokenCannotUploadReadMetadataOrDownload(String type) {
|
||||
var service = mock(InternalBusinessFileService.class);
|
||||
var controller = new InternalBusinessFileController();
|
||||
ReflectionTestUtils.setField(controller, "expected", "dispatch-test-token");
|
||||
ReflectionTestUtils.setField(controller, "service", service);
|
||||
for (String token : new String[]{null, "", "wrong-token"}) {
|
||||
assertThatThrownBy(() -> controller.upload(token, 7, 0, type, "71", pdf())).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> controller.metadata(token, 0, type, "71", 501)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> controller.download(token, 0, type, "71", 501)).isInstanceOf(BizException.class);
|
||||
}
|
||||
verifyNoInteractions(service);
|
||||
}
|
||||
|
||||
/** 从实际 SQL 绑定中取指定列的等值条件,缺少任一隔离条件直接失败。 */
|
||||
private static Object boundValue(LambdaQueryWrapper<FileObjectEntity> query, String column) {
|
||||
var matcher = Pattern.compile("(?<![A-Za-z0-9_])" + column
|
||||
+ " = #\\{ew\\.paramNameValuePairs\\.([^}]+)}").matcher(query.getSqlSegment());
|
||||
assertThat(matcher.find()).as("复合归属条件 %s", column).isTrue();
|
||||
return query.getParamNameValuePairs().get(matcher.group(1));
|
||||
}
|
||||
|
||||
/** 创建隔离临时目录中的真实存储,不访问运行环境。 */
|
||||
private BusinessFileStorage storage() {
|
||||
var storage = new BusinessFileStorage();
|
||||
ReflectionTestUtils.setField(storage, "configuredRoot", root);
|
||||
storage.initializeRoot();
|
||||
return storage;
|
||||
}
|
||||
|
||||
/** 复用生产服务,数据库边界由 Mapper 替身记录实际查询。 */
|
||||
private InternalBusinessFileService service(FileObjectMapper files, BusinessFileStorage storage) {
|
||||
var service = new InternalBusinessFileService();
|
||||
ReflectionTestUtils.setField(service, "files", files);
|
||||
ReflectionTestUtils.setField(service, "storage", storage);
|
||||
return service;
|
||||
}
|
||||
|
||||
/** 提供具有真实 PDF 文件头的最小上传内容。 */
|
||||
private static MockMultipartFile pdf() {
|
||||
return new MockMultipartFile("file", "evidence.pdf", "application/pdf",
|
||||
"%PDF-1.7\nevidence".getBytes(StandardCharsets.US_ASCII));
|
||||
}
|
||||
|
||||
/** 使用图像编码器生成真实 JPG、PNG,避免测试只验证扩展名。 */
|
||||
private static byte[] image(String format) throws java.io.IOException {
|
||||
var output = new ByteArrayOutputStream();
|
||||
var image = new java.awt.image.BufferedImage(2, 2, java.awt.image.BufferedImage.TYPE_INT_RGB);
|
||||
assertThat(javax.imageio.ImageIO.write(image, format, output)).isTrue();
|
||||
return output.toByteArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.file.entity.FileObjectEntity;
|
||||
import com.baiyue.file.mapper.FileObjectMapper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.file.Path;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class OwnedBusinessPhotoFileTest {
|
||||
private static final String LOADING = "OVERSEAS_DISPATCH_LOADING_PHOTO";
|
||||
private static final String FUEL = "OVERSEAS_DISPATCH_FUEL_PHOTO";
|
||||
@TempDir Path root;
|
||||
|
||||
@BeforeAll static void columns() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "owned-business-photo"), FileObjectEntity.class);
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
|
||||
void rejectsNonPlatformOrInvalidDispatchIdentityBeforeAnyStorage(String type) {
|
||||
var files = mock(FileObjectMapper.class);
|
||||
var storage = mock(BusinessFileStorage.class);
|
||||
var service = service(files, storage);
|
||||
var file = new MockMultipartFile("file", "photo.png", "image/png", new byte[]{1});
|
||||
for (long owner : new long[]{-1, 1, 900})
|
||||
assertThatThrownBy(() -> service.upload(owner, type, "71", 7, file)).isInstanceOf(BizException.class);
|
||||
for (String businessId : new String[]{"0", "-1", "DS-71", "../71", ""})
|
||||
assertThatThrownBy(() -> service.upload(0, type, businessId, 7, file)).isInstanceOf(BizException.class);
|
||||
verifyNoInteractions(files, storage);
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
|
||||
void storesRealImagesButRejectsPdfSpoofedEmptyAndOversizedFiles(String type) throws Exception {
|
||||
var storage = new BusinessFileStorage();
|
||||
ReflectionTestUtils.setField(storage, "configuredRoot", root);
|
||||
storage.initializeRoot();
|
||||
var category = BusinessFileCategory.require(type);
|
||||
for (String format : new String[]{"jpeg", "png"}) {
|
||||
byte[] bytes = image(format);
|
||||
var upload = new MockMultipartFile("file", "photo." + format, "image/" + format, bytes);
|
||||
var saved = storage.store(0, "71", upload, category);
|
||||
assertThat(storage.read(saved.objectKey())).isEqualTo(bytes);
|
||||
}
|
||||
for (var upload : new MockMultipartFile[]{
|
||||
new MockMultipartFile("file", "empty.png", "image/png", new byte[0]),
|
||||
new MockMultipartFile("file", "spoof.png", "image/png", "not-image".getBytes()),
|
||||
new MockMultipartFile("file", "receipt.pdf", "application/pdf", "%PDF-1.7\nreceipt".getBytes())})
|
||||
assertThatThrownBy(() -> storage.store(0, "71", upload, category)).isInstanceOf(BizException.class);
|
||||
MultipartFile oversized = mock(MultipartFile.class);
|
||||
when(oversized.isEmpty()).thenReturn(false);
|
||||
when(oversized.getSize()).thenReturn(10L * 1024 * 1024 + 1);
|
||||
assertThatThrownBy(() -> storage.store(0, "71", oversized, category)).isInstanceOf(BizException.class);
|
||||
verify(oversized, never()).getInputStream();
|
||||
}
|
||||
|
||||
/** 用实际Mapper查询条件核对四元组,错车、错类别和错文件必须先拒绝再读内容。 */
|
||||
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
|
||||
void metadataAndDownloadRequireExactDispatchCategoryAndFile(String type) throws Exception {
|
||||
var files = mock(FileObjectMapper.class);
|
||||
var storage = mock(BusinessFileStorage.class);
|
||||
var service = service(files, storage);
|
||||
byte[] bytes = image("png");
|
||||
var row = new FileObjectEntity();
|
||||
row.setId(501L); row.setTenantId(0L); row.setBusinessType(type); row.setBusinessId("71");
|
||||
row.setOriginalName("photo.png"); row.setContentType("image/png"); row.setSizeBytes((long) bytes.length);
|
||||
row.setObjectKey("owned-photo"); row.setSha256("a".repeat(64)); row.setCreatedAt(java.time.LocalDateTime.now());
|
||||
when(files.selectOne(any())).thenAnswer(call -> {
|
||||
LambdaQueryWrapper<FileObjectEntity> query = call.getArgument(0);
|
||||
assertThat(bound(query, "status")).isEqualTo("ACTIVE");
|
||||
assertThat(bound(query, "deleted")).isEqualTo(false);
|
||||
return Long.valueOf(0).equals(bound(query, "tenant_id")) && type.equals(bound(query, "business_type"))
|
||||
&& "71".equals(bound(query, "business_id")) && Long.valueOf(501).equals(bound(query, "id")) ? row : null;
|
||||
});
|
||||
when(storage.read("owned-photo")).thenReturn(bytes);
|
||||
assertThat(service.metadata(0, type, "71", 501).businessType()).isEqualTo(type);
|
||||
assertThat(service.download(0, type, "71", 501).content()).isEqualTo(bytes);
|
||||
clearInvocations(storage);
|
||||
for (String other : new String[]{LOADING.equals(type) ? FUEL : LOADING,
|
||||
"FLEET_VEHICLE_RECORD_PHOTO", "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE", "DELIVERY_LOADING_PHOTO"}) {
|
||||
assertThatThrownBy(() -> service.metadata(0, other, "71", 501)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.download(0, other, "71", 501)).isInstanceOf(BizException.class);
|
||||
}
|
||||
assertThatThrownBy(() -> service.download(0, type, "72", 501)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.download(0, type, "71", 502)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.download(1, type, "71", 501)).isInstanceOf(BizException.class);
|
||||
verifyNoInteractions(storage);
|
||||
}
|
||||
|
||||
private static Object bound(LambdaQueryWrapper<FileObjectEntity> query, String column) {
|
||||
var matcher = Pattern.compile("(?<![A-Za-z0-9_])" + column + " = #\\{ew\\.paramNameValuePairs\\.([^}]+)}")
|
||||
.matcher(query.getSqlSegment());
|
||||
assertThat(matcher.find()).as("scope column %s", column).isTrue();
|
||||
return query.getParamNameValuePairs().get(matcher.group(1));
|
||||
}
|
||||
|
||||
private static InternalBusinessFileService service(FileObjectMapper files, BusinessFileStorage storage) {
|
||||
var service = new InternalBusinessFileService();
|
||||
ReflectionTestUtils.setField(service, "files", files);
|
||||
ReflectionTestUtils.setField(service, "storage", storage);
|
||||
return service;
|
||||
}
|
||||
|
||||
private static byte[] image(String format) throws Exception {
|
||||
var bytes = new ByteArrayOutputStream();
|
||||
assertThat(javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(2, 2,
|
||||
java.awt.image.BufferedImage.TYPE_INT_RGB), format, bytes)).isTrue();
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
# Finance Service Instructions
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 模块定位
|
||||
|
||||
- 本模块是独立财务服务,拥有客户结算条款、财务费用来源、CNY 账单与明细、客户资金账户及充值申请、应收款月度统计、客户对账确认、收款及分配、成本及分摊、账单修订、单据编号、幂等记录和财务审计状态。
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 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`。
|
||||
|
||||
+22
@@ -35,6 +35,28 @@ public class PlatformDropshipPalletizationController {
|
||||
@Resource private DropshipPalletizationService service;
|
||||
@Resource private InboundCartonLabelPrintService printService;
|
||||
|
||||
/** 跨票识别箱码只读预览,清点仍按原任务领取并提交。 */
|
||||
@GetMapping("/cartons/resolve")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
|
||||
public ApiResponse<DropshipPalletizationDtos.ScanContext> resolveWorkspaceCarton(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam @Positive long warehouseId,
|
||||
@RequestParam @jakarta.validation.constraints.NotBlank @Size(max = 80) String boxCode) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.resolveWorkspaceCarton(warehouseId, boxCode, user.userId()));
|
||||
}
|
||||
|
||||
/** 获取当前实物托盘及客户组成,不要求托盘已与当前票绑定。 */
|
||||
@GetMapping("/pallets/resolve")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
|
||||
public ApiResponse<DropshipPalletizationDtos.PalletWorkspace> workspacePallet(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam @Positive long warehouseId,
|
||||
@RequestParam @jakarta.validation.constraints.Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.workspacePallet(warehouseId, palletNo));
|
||||
}
|
||||
|
||||
@GetMapping("/tasks/pending")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
|
||||
public ApiResponse<List<DropshipPalletizationDtos.PendingTask>> pending(
|
||||
|
||||
@@ -18,6 +18,15 @@ public final class DropshipPalletizationDtos {
|
||||
|
||||
public record ClaimRequest(@Positive long warehouseId) {}
|
||||
|
||||
/** 跨票只读识别结果;确认清点前仍须领取对应任务。 */
|
||||
public record ScanContext(Task task, Carton carton) {}
|
||||
|
||||
/** 当前实物托盘及逐客户、逐票组成。 */
|
||||
public record PalletWorkspace(Pallet pallet, List<PalletMember> members) {}
|
||||
|
||||
public record PalletMember(Long taskId, Long inboundId, String inboundCode,
|
||||
Long customerId, String customerName, Integer cartonCount) {}
|
||||
|
||||
public enum DiscrepancyAction { MATCH, ACCEPT_VARIANCE }
|
||||
|
||||
public record CountCartonRequest(@Positive long warehouseId,
|
||||
|
||||
@@ -10,7 +10,8 @@ public final class MerchantWorkbenchFilters {
|
||||
private MerchantWorkbenchFilters() {}
|
||||
|
||||
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,
|
||||
List<Long> customsOrderIds, List<Progress> progress) {}
|
||||
|
||||
@@ -15,6 +15,45 @@ import java.util.List;
|
||||
/** 一件代发逐箱清点、待绑托与封托事实。 */
|
||||
@Mapper
|
||||
public interface DropshipPalletizationMapper {
|
||||
/** 在指定仓库按稳定箱码识别任务,多个匹配由业务层拒绝。 */
|
||||
@Select("""
|
||||
SELECT DISTINCT t.id FROM fl_dropship_pallet_task t
|
||||
JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id AND o.deleted=0
|
||||
JOIN fl_inbound_carton_label l ON l.inbound_id=t.inbound_id AND l.tenant_id=t.tenant_id
|
||||
WHERE t.warehouse_id=#{warehouseId} AND l.box_code=#{boxCode} AND l.batch_status='ACTIVE'
|
||||
AND o.fulfillment_type='ONE_CLICK_DROPSHIP'
|
||||
""")
|
||||
List<Long> workspaceTaskIds(@Param("warehouseId") long warehouseId,@Param("boxCode") String boxCode);
|
||||
|
||||
/** 扫码预览为只读查询,不能把未知或已封托托盘默认成开放。 */
|
||||
@Select("SELECT * FROM fl_dropship_pallet WHERE warehouse_id=#{warehouseId} AND pallet_no=#{palletNo}")
|
||||
PalletRow workspacePallet(@Param("warehouseId") long warehouseId,@Param("palletNo") String palletNo);
|
||||
|
||||
/** 按实际成员汇总客户与票,空的预生成托盘不虚构成员。 */
|
||||
@Select("""
|
||||
SELECT c.task_id,c.inbound_id,o.inbound_code,c.tenant_id customer_id,o.customer_name,COUNT(*) carton_count
|
||||
FROM fl_dropship_pallet_carton c
|
||||
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
|
||||
WHERE c.pallet_id=#{palletId}
|
||||
GROUP BY c.task_id,c.inbound_id,o.inbound_code,c.tenant_id,o.customer_name
|
||||
ORDER BY o.inbound_code,c.task_id
|
||||
""")
|
||||
List<com.baiyue.firstleg.dto.DropshipPalletizationDtos.PalletMember> workspaceMembers(@Param("palletId") long palletId);
|
||||
|
||||
/** 混托成员必须与当前票同渠道、同目的仓,客户可以不同。 */
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM fl_dropship_pallet_carton c
|
||||
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
|
||||
JOIN fl_dropship_pallet_task t ON t.id=#{taskId}
|
||||
JOIN fl_inbound_order target ON target.id=t.inbound_id AND target.tenant_id=t.tenant_id
|
||||
WHERE c.pallet_id=#{palletId} AND (
|
||||
NOT (o.channel_code <=> target.channel_code)
|
||||
OR NOT (o.destination_type <=> target.destination_type)
|
||||
OR NOT (o.destination_warehouse_id <=> target.destination_warehouse_id)
|
||||
OR NOT (o.destination_code <=> target.destination_code))
|
||||
""")
|
||||
int workspaceIncompatibleMembers(@Param("taskId") long taskId,@Param("palletId") long palletId);
|
||||
|
||||
@Insert("""
|
||||
INSERT IGNORE INTO fl_dropship_pallet_task
|
||||
(id,tenant_id,inbound_id,warehouse_id,task_status,total_cartons,counted_cartons,allocated_cartons,discrepancy_cartons,version,created_at,updated_at)
|
||||
|
||||
+12
-2
@@ -65,6 +65,7 @@ public interface MerchantWorkbenchFilterMapper {
|
||||
List<Long> pendingCustoms(@Param("tenantId") long tenantId);
|
||||
/** Only physically loaded fragments can expose container milestones to the customer. */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT o.order_id, CASE WHEN owned.container_booking_id IS NULL THEN 0
|
||||
WHEN EXISTS (SELECT 1 FROM fl_carrier_schedule_node_fact n
|
||||
WHERE n.container_booking_id=owned.container_booking_id
|
||||
@@ -76,6 +77,11 @@ public interface MerchantWorkbenchFilterMapper {
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT i.inbound_id, i.account_id, p.id AS loading_plan_id, p.container_booking_id
|
||||
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_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
|
||||
@@ -84,8 +90,12 @@ public interface MerchantWorkbenchFilterMapper {
|
||||
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
|
||||
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
|
||||
</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) { }
|
||||
record Cargo(long inboundId, long customerId, long orderId, String orderNo, String inboundCode,
|
||||
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,
|
||||
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,
|
||||
@@ -21,23 +25,40 @@ public interface AppointmentWorkSourceMapper {
|
||||
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',
|
||||
'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
|
||||
ORDER BY b.id
|
||||
""";
|
||||
// A complete identity/version manifest also detects deletions or edits between cursor pages.
|
||||
@Select(SOURCES)
|
||||
@Select(SOURCE_ROWS + SOURCE_ORDER)
|
||||
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 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,
|
||||
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
|
||||
JOIN fl_inbound_order o ON o.id=i.inbound_id
|
||||
WHERE p.container_booking_id=#{id} AND p.plan_status='COMPLETED'
|
||||
GROUP BY i.inbound_id,i.account_id,i.order_id,i.order_no,i.inbound_code,o.destination_type,
|
||||
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,i.account_id,i.order_id,i.order_no,i.inbound_code,o.destination_type,
|
||||
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
|
||||
public class AppointmentWorkSourceService {
|
||||
public interface OriginalFacts {
|
||||
String establishedFlow(long containerBookingId);
|
||||
Fact read(FactRequest request, ContainerSource source);
|
||||
Map<Long,String> establishedFlows(List<Long> containerBookingIds);
|
||||
List<Fact> read(List<FactRequest> requests, Map<Long,ContainerSource> sources);
|
||||
}
|
||||
private final AppointmentWorkSourceMapper mapper;
|
||||
private final OriginalFacts original;
|
||||
@@ -37,14 +37,23 @@ public class AppointmentWorkSourceService {
|
||||
var page=rows.stream().filter(row->row.id()>cursor).limit(size+1L).toList();
|
||||
boolean complete=page.size()<=size;
|
||||
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) {
|
||||
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,"未找到有效已开船柜次"));
|
||||
return sources(List.of(row)).get(0);
|
||||
}
|
||||
private ContainerSource source(AppointmentWorkSourceMapper.Source row) {
|
||||
var cargo=mapper.cargo(row.id());
|
||||
private List<ContainerSource> sources(List<AppointmentWorkSourceMapper.Source> rows) {
|
||||
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,
|
||||
c.destinationKind(),row.departurePortName(),c.destinationName(),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("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(),
|
||||
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) {
|
||||
if(requests==null || requests.isEmpty() || requests.size()>200) throw bad("事实请求须包含1至200项");
|
||||
Set<String> keys=new HashSet<>(); Map<Long,ContainerSource> sources=new HashMap<>();
|
||||
return requests.stream().map(request->{
|
||||
Set<String> keys=new HashSet<>(); var ids=new LinkedHashSet<Long>();
|
||||
for(var request:requests) {
|
||||
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())))
|
||||
throw bad("事项身份或事实类型无效");
|
||||
long id=id(request.containerBookingId());
|
||||
return original.read(request,sources.computeIfAbsent(id,this::container));
|
||||
}).toList();
|
||||
ids.add(id(request.containerBookingId()));
|
||||
}
|
||||
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) {
|
||||
try {return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value)));}
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.PalletMember;
|
||||
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
|
||||
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletRow;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
/** 仓内跨票扫码与实物托盘组成查询,客户归属始终从箱唛事实取得。 */
|
||||
@Repository
|
||||
public class DropshipWorkspaceRepository {
|
||||
@Resource private DropshipPalletizationMapper mapper;
|
||||
|
||||
/** 按仓库和稳定箱码查找作业任务,不接受客户端指定客户归属。 */
|
||||
public List<Long> taskIdsForBox(long warehouseId, String boxCode) {
|
||||
return mapper.workspaceTaskIds(warehouseId, boxCode);
|
||||
}
|
||||
|
||||
/** 只读查询托盘,不因扫描托盘产生绑定或状态变化。 */
|
||||
public PalletRow pallet(long warehouseId, String palletNo) {
|
||||
return mapper.workspacePallet(warehouseId, palletNo);
|
||||
}
|
||||
|
||||
/** 保留每票客户与任务身份,供混托核对和按原任务移箱。 */
|
||||
public List<PalletMember> members(long palletId) {
|
||||
return mapper.workspaceMembers(palletId);
|
||||
}
|
||||
|
||||
/** 归托前锁定实物托盘,与封托互斥。 */
|
||||
public PalletRow lockPallet(String palletNo) {
|
||||
return mapper.palletByNo(palletNo);
|
||||
}
|
||||
|
||||
/** 按原始入仓快照检查运输兼容性,不接受客户端声明。 */
|
||||
public boolean compatible(long taskId, long palletId) {
|
||||
return mapper.workspaceIncompatibleMembers(taskId, palletId) == 0;
|
||||
}
|
||||
}
|
||||
+31
-1
@@ -21,6 +21,34 @@ import java.util.*;
|
||||
public class DropshipPalletizationService {
|
||||
@Resource private DropshipPalletizationMapper mapper;
|
||||
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
|
||||
@Resource private com.baiyue.firstleg.repository.DropshipWorkspaceRepository workspaceRepository;
|
||||
|
||||
/** 只读识别其他客户的箱码;不自动领取、清点或绑定托盘。 */
|
||||
public ScanContext resolveWorkspaceCarton(long warehouseId,String boxCode,long operatorId){
|
||||
String code=normalize(boxCode);
|
||||
if(code==null) throw invalid("箱码不能为空");
|
||||
List<Long> ids=workspaceRepository.taskIdsForBox(warehouseId,code);
|
||||
if(ids.isEmpty()) throw new BizException(ResultCode.NOT_FOUND,"当前仓库未找到该一件代发箱码");
|
||||
if(ids.size()!=1) throw conflict("箱码匹配多个任务,请联系后台核对");
|
||||
Task target=task(ids.get(0),warehouseId,operatorId);
|
||||
Carton carton=resolveCarton(target.taskId(),warehouseId,code,operatorId);
|
||||
// 未领取任务仅允许预览,员工确认后由正式领取与清点接口再次检查权限和状态。
|
||||
if("PENDING".equals(target.taskStatus()) && Boolean.TRUE.equals(target.palletSetupReady())
|
||||
&& "请先领取任务".equals(carton.blockReason())){
|
||||
carton=new Carton(carton.cartonId(),carton.boxCode(),carton.inboundId(),carton.inboundCode(),
|
||||
carton.skuLines(),carton.countStatus(),carton.palletId(),carton.palletNo(),true,null);
|
||||
}
|
||||
return new ScanContext(target,carton);
|
||||
}
|
||||
|
||||
/** 按仓库返回真实托盘状态及全部成员,跨客户不改变每箱的原归属。 */
|
||||
public PalletWorkspace workspacePallet(long warehouseId,String palletNo){
|
||||
String code=normalize(palletNo);
|
||||
if(code==null) throw invalid("托盘码不能为空");
|
||||
PalletRow row=workspaceRepository.pallet(warehouseId,code);
|
||||
if(row==null) throw new BizException(ResultCode.NOT_FOUND,"当前仓库未找到该托盘码");
|
||||
return new PalletWorkspace(palletView(row,null),workspaceRepository.members(row.getId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void createTask(InboundOrderEntity inbound,long operatorId,String operatorName) {
|
||||
@@ -195,11 +223,13 @@ public class DropshipPalletizationService {
|
||||
.map(p->palletView(p,task.getInboundCode())).toList();
|
||||
}
|
||||
|
||||
/** 跨客户归托仍须同仓、同渠道、同目的仓,并锁定开放托盘。 */
|
||||
private PalletRow requireAssignablePallet(TaskRow task,String palletNo){
|
||||
PalletRow existing=mapper.palletByNo(palletNo);
|
||||
PalletRow existing=workspaceRepository.lockPallet(palletNo);
|
||||
if(existing==null) throw new BizException(ResultCode.NOT_FOUND,"托盘码不存在,请扫描测量审核后生成的 DP 托盘码");
|
||||
if(!Objects.equals(existing.getWarehouseId(),task.getWarehouseId())) throw conflict("托盘码不属于当前作业仓库");
|
||||
if(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱");
|
||||
if(!workspaceRepository.compatible(task.getId(),existing.getId())) throw conflict("托内货物与当前票的渠道或目的仓不同,不能混托");
|
||||
return existing;
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -12,11 +12,15 @@ public class MerchantWorkbenchFilterService {
|
||||
|
||||
@org.springframework.transaction.annotation.Transactional(readOnly=true)
|
||||
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(
|
||||
request.mainStatus() == null ? null : mapper.byProcessStatuses(request.tenantId(),
|
||||
unified.internalStatuses(request.mainStatus())),
|
||||
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) {
|
||||
|
||||
+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,
|
||||
String idempotencyKey,String requestHash) { }
|
||||
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")
|
||||
@Options(flushCache=Options.FlushCachePolicy.TRUE,useCache=false)
|
||||
Container lockContainer(@Param("id") long id);
|
||||
|
||||
+10
@@ -114,6 +114,16 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
|
||||
ORDER BY created_at DESC,id DESC
|
||||
""")
|
||||
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 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.service.AppointmentWorkSourceService.OriginalFacts;
|
||||
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.TerminalDrayageMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -26,17 +28,43 @@ public class AppointmentWorkTerminalFacts implements OriginalFacts {
|
||||
CarrierScheduleQueryService nodes,UnloadingEvidenceGuard unloading,ObjectMapper json) {
|
||||
this.terminal=terminal;this.direct=direct;this.nodes=nodes;this.unloading=unloading;this.json=json;
|
||||
}
|
||||
public String establishedFlow(long id) {
|
||||
var container=direct.container(id);
|
||||
boolean transfer=container!=null && container.warehouseUnloadedAt()!=null;
|
||||
boolean delivery=direct.evidenceCount(id)>0;
|
||||
if(transfer && delivery) throw new ResponseStatusException(HttpStatus.CONFLICT,"货柜存在冲突的中转和直送执行事实");
|
||||
return transfer?"TRANSFER":delivery?"DIRECT":"UNKNOWN";
|
||||
public Map<Long,String> establishedFlows(List<Long> ids) {
|
||||
var result=new HashMap<Long,String>();
|
||||
if(ids.isEmpty()) return result;
|
||||
for(var row:direct.flowEvidence(ids)) {
|
||||
boolean transfer=row.warehouseUnloadedAt()!=null;
|
||||
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) {
|
||||
long id=Long.parseLong(request.containerBookingId());
|
||||
var node=nodes.containerActualFacts(List.of(id)).stream().findFirst().orElse(null);
|
||||
var plan=terminal.find(id);
|
||||
public List<Fact> read(List<FactRequest> requests,Map<Long,ContainerSource> sources) {
|
||||
var ids=requests.stream().map(r->Long.parseLong(r.containerBookingId())).distinct().toList();
|
||||
if(ids.isEmpty()) return List.of();
|
||||
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>();
|
||||
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>();
|
||||
@@ -53,9 +81,8 @@ public class AppointmentWorkTerminalFacts implements OriginalFacts {
|
||||
if(plan!=null && plan.getFleetSupplierId()!=null)
|
||||
responsibility=new Responsibility("SUPPLIER",plan.getFleetSupplierId().toString(),plan.getFleetSupplierName());
|
||||
// The initial arrangement/assignment establishes a baseline. Only later changes invalidate dependants.
|
||||
var history=terminal.histories(id);
|
||||
long reschedules=Math.max(0,history.stream().filter(h->"SCHEDULE_PICKUP".equals(h.action())).count()-1);
|
||||
long reassignments=Math.max(0,history.stream().filter(h->"SELECT_FLEET".equals(h.action())).count()-1);
|
||||
long reschedules=Math.max(0,(changes==null?0:changes.schedules())-1);
|
||||
long reassignments=Math.max(0,(changes==null?0:changes.fleets())-1);
|
||||
impact=reschedules+":"+reassignments;
|
||||
if(at==null) {
|
||||
if(node==null || node.arrivedDestinationPortAt()==null) blocks.add("实际到港后才能安排提柜");
|
||||
@@ -67,7 +94,7 @@ public class AppointmentWorkTerminalFacts implements OriginalFacts {
|
||||
case "EMPTY_RETURN" -> {
|
||||
at=node==null?null:node.emptyReturnedAt();
|
||||
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";}
|
||||
// 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()
|
||||
.orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"直送需求与原柜来源不一致"));
|
||||
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,"直送需求与实际装柜来源不一致"));
|
||||
at=row.actualAt();zone=row.timezone();businessNo=row.inboundCode();
|
||||
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 org.springframework.stereotype.Service;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
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 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;
|
||||
return rows.stream().map(row->new DirectCompletion(row.actualAt(),row.timezone()))
|
||||
.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) {
|
||||
var container=mapper.container(id);
|
||||
if(container==null || !"ACTIVE".equals(container.recordStatus())) return null;
|
||||
|
||||
@@ -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}
|
||||
@@ -4,6 +4,26 @@ info:
|
||||
version: 3.0.0
|
||||
description: 测量环节录入预计托数,审核通过后预生成全部 DP 托盘码并可合并打印。PDA 支持先扫箱或先扫托,同一国内作业仓可跨票共用开放托盘,实物装满后再封托。
|
||||
paths:
|
||||
/api/platform/v1/first-leg/pda-dropship/cartons/resolve:
|
||||
get:
|
||||
summary: 同仓跨客户识别箱码并预览原任务
|
||||
description: 只读,不领取或清点;返回 task 与 carton,确认时仍须领取原任务后按原 taskId 清点。拒绝其他作业员已领取任务及箱码歧义。
|
||||
x-required-authority: platform:firstleg:pda:view
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/WarehouseId'}
|
||||
- {name: boxCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 80}}
|
||||
responses:
|
||||
'200': {description: 原任务和箱内 SKU 预览}
|
||||
/api/platform/v1/first-leg/pda-dropship/pallets/resolve:
|
||||
get:
|
||||
summary: 查询当前仓库真实托盘状态与逐客户逐票组成
|
||||
description: 扫描不产生归托;返回 pallet 与 members,成员保留 taskId、inboundId、inboundCode、customerId、customerName、cartonCount。不存在或跨仓的托盘拒绝,已封托返回真实状态。
|
||||
x-required-authority: platform:firstleg:pda:view
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/WarehouseId'}
|
||||
- {name: palletNo, in: query, required: true, schema: {type: string, pattern: 'DP-[0-9]{6}-[0-9]{4}'}}
|
||||
responses:
|
||||
'200': {description: 实物托盘状态和成员汇总}
|
||||
/api/platform/v1/first-leg/pda-dropship/tasks/pending:
|
||||
get:
|
||||
summary: 查询待领取或本人已领取任务
|
||||
|
||||
@@ -355,7 +355,7 @@ paths:
|
||||
/internal/firstleg/v1/inbounds/unified-orders/workbench-filters:
|
||||
post:
|
||||
summary: 查询单一客户工作台履约筛选标识(Order内部)
|
||||
description: 只读返回命中状态、最新待确认费用及待上传资料的订单标识。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。
|
||||
description: 只读返回命中状态、最新待确认费用及待上传资料的订单标识。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。
|
||||
security: [{internalServiceToken: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -367,6 +367,13 @@ paths:
|
||||
properties:
|
||||
tenantId: {type: integer, format: int64, minimum: 1}
|
||||
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:
|
||||
'200':
|
||||
description: 按响应code判断,标识只供Order在自身客户范围内过滤、计数和分页
|
||||
|
||||
+15
-1
@@ -45,7 +45,7 @@ class InternalInboundOrderReferenceControllerTest {
|
||||
private InternalInboundOrderController controller;
|
||||
|
||||
@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("wrong", request)).isInstanceOf(BizException.class);
|
||||
org.mockito.Mockito.verifyNoInteractions(workbenchFilters);
|
||||
@@ -143,4 +143,18 @@ class InternalInboundOrderReferenceControllerTest {
|
||||
carrierId == 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");
|
||||
}
|
||||
|
||||
|
||||
@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("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_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);
|
||||
config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(AppointmentWorkSourceMapper.class);
|
||||
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());
|
||||
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());
|
||||
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 java.time.LocalDateTime;
|
||||
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.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@@ -19,7 +23,7 @@ class AppointmentWorkSourceServiceTest {
|
||||
}
|
||||
@Test void completeCursorManifestIncludesUnboundSourcesAndRejectsScopeDrift() {
|
||||
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);
|
||||
assertFalse(first.complete()); assertNotNull(first.nextCursor());
|
||||
assertNull(first.containers().get(0).departedAt());
|
||||
@@ -31,9 +35,11 @@ class AppointmentWorkSourceServiceTest {
|
||||
}
|
||||
@Test void sourceKeepsCustomerAndPhysicalInboundLineageWithoutDisplayingIdsAsNumbers() {
|
||||
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(facts.establishedFlow(1)).thenReturn("DIRECT");
|
||||
when(mapper.cargo(List.of(1L))).thenReturn(List.of(new AppointmentWorkSourceMapper.ContainerCargo(1,8,9,10,"ORDER-10","IN-8","PRIVATE","P","客户仓",12)));
|
||||
stubFlows("DIRECT");
|
||||
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());
|
||||
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());
|
||||
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() {
|
||||
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_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')");
|
||||
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'");
|
||||
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)");
|
||||
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");
|
||||
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'");
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+50
-1
@@ -24,7 +24,56 @@ class DropshipPalletizationServiceTest {
|
||||
private final FirstlegDocumentNumberGenerator numbers=mock(FirstlegDocumentNumberGenerator.class);
|
||||
private final DropshipPalletizationMapper mapper=mock(DropshipPalletizationMapper.class);
|
||||
private final DropshipPalletizationService service=new DropshipPalletizationService();
|
||||
DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); when(mapper.labelSkus(anyLong(),anyLong())).thenAnswer(call -> java.util.List.of(labelSku())); when(mapper.cartonSkus(anyLong())).thenAnswer(call -> java.util.List.of(cartonSku())); }
|
||||
private final com.baiyue.firstleg.repository.DropshipWorkspaceRepository workspace=mock(com.baiyue.firstleg.repository.DropshipWorkspaceRepository.class);
|
||||
/** 使用正式仓储入口的等价锁定查询,保留既有扫码测试的事实准备。 */
|
||||
DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); ReflectionTestUtils.setField(service,"workspaceRepository",workspace); when(workspace.lockPallet(anyString())).thenAnswer(call -> mapper.palletByNo(call.getArgument(0))); when(workspace.compatible(anyLong(),anyLong())).thenReturn(true); when(mapper.labelSkus(anyLong(),anyLong())).thenAnswer(call -> java.util.List.of(labelSku())); when(mapper.cartonSkus(anyLong())).thenAnswer(call -> java.util.List.of(cartonSku())); }
|
||||
|
||||
/** 跨客户预览不领取任务,不写入清点事实。 */
|
||||
@Test void workspaceResolvesPendingCustomerWithoutMutating(){
|
||||
ReflectionTestUtils.setField(service,"workspaceRepository",workspace);
|
||||
TaskRow other=task(); other.setTenantId(2L); other.setTaskStatus("PENDING"); other.setAssigneeId(null);
|
||||
when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L));
|
||||
when(mapper.task(10L,100L)).thenReturn(other);
|
||||
when(mapper.label(10L,"BOX-1")).thenReturn(label());
|
||||
when(mapper.palletCountByInbound(2L,30L)).thenReturn(other.getExpectedPalletCount());
|
||||
var result=service.resolveWorkspaceCarton(100L," BOX-1 ",7L);
|
||||
assertThat(result.task().tenantId()).isEqualTo(2L);
|
||||
assertThat(result.carton().canCount()).isTrue();
|
||||
verify(mapper,never()).claim(anyLong(),anyLong(),anyLong(),anyString());
|
||||
verify(mapper,never()).addCounted(anyLong(),anyLong(),anyInt(),anyInt());
|
||||
}
|
||||
|
||||
/** 不允许从跨票扫码接管其他作业员的任务。 */
|
||||
@Test void workspaceRejectsOtherOperatorsTaskAndAmbiguousBox(){
|
||||
ReflectionTestUtils.setField(service,"workspaceRepository",workspace);
|
||||
when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L));
|
||||
TaskRow other=task(); other.setAssigneeId(9L); when(mapper.task(10L,100L)).thenReturn(other);
|
||||
assertThatThrownBy(()->service.resolveWorkspaceCarton(100L,"BOX-1",7L)).isInstanceOf(BizException.class).hasMessageContaining("其他人员");
|
||||
when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L,11L));
|
||||
assertThatThrownBy(()->service.resolveWorkspaceCarton(100L,"BOX-1",7L)).isInstanceOf(BizException.class).hasMessageContaining("多个任务");
|
||||
}
|
||||
|
||||
/** 托盘预览必须返回已封托的真实状态,跨仓不存在时拒绝。 */
|
||||
@Test void workspacePalletNeverInventsOpenState(){
|
||||
ReflectionTestUtils.setField(service,"workspaceRepository",workspace);
|
||||
when(workspace.pallet(100L,"DP-260911-0001")).thenReturn(sealedPallet());
|
||||
when(workspace.members(20L)).thenReturn(java.util.List.of());
|
||||
when(mapper.cartons(20L)).thenReturn(java.util.List.of());
|
||||
assertThat(service.workspacePallet(100L,"DP-260911-0001").pallet().status()).isEqualTo("SEALED");
|
||||
assertThatThrownBy(()->service.workspacePallet(101L,"DP-260911-0001")).isInstanceOf(BizException.class).hasMessageContaining("未找到");
|
||||
}
|
||||
|
||||
/** 即使不同客户允许混托,运输渠道或目的仓不同仍须拒绝。 */
|
||||
@Test void incompatibleDestinationCannotBeMixed(){
|
||||
when(mapper.task(10L,100L)).thenReturn(task());
|
||||
when(mapper.palletCountByInbound(1L,30L)).thenReturn(2);
|
||||
when(mapper.label(10L,"BOX-1")).thenReturn(label());
|
||||
when(mapper.palletByNo("DP-260911-0001")).thenReturn(pallet());
|
||||
when(workspace.compatible(10L,20L)).thenReturn(false);
|
||||
assertThatThrownBy(()->service.countCarton(10L,new CountCartonRequest(100L,"BOX-1",java.util.List.of(new CountSkuLine(40L,"SKU-1",12,DiscrepancyAction.MATCH,null)),"DP-260911-0001","wrong-destination"),7L,"张三"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("渠道或目的仓不同");
|
||||
verify(mapper,never()).addCounted(anyLong(),anyLong(),anyInt(),anyInt());
|
||||
}
|
||||
|
||||
@Test void sharedPalletCountsDistinctSkuCodesWithoutMergingInboundDetails(){
|
||||
when(mapper.inboundCode(1L,30L)).thenReturn("IN-1");
|
||||
|
||||
+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 java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
import java.util.List;
|
||||
import com.baiyue.firstleg.portschedule.mapper.AppointmentWorkSourceMapper;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
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("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_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);
|
||||
config.addMapper(AppointmentWorkSourceMapper.class);config.addMapper(TerminalDrayageMapper.class);
|
||||
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
|
||||
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());
|
||||
@@ -29,6 +42,18 @@ class DirectDeliveryMapperTest {
|
||||
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(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"));
|
||||
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.terminaldrayage.mapper.*;
|
||||
import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.History;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import static org.mockito.Mockito.*;
|
||||
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 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 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() {
|
||||
var plan=new TerminalDrayageEntity();plan.setPickupAppointmentAt(at);plan.setFleetSupplierId(4L);plan.setFleetSupplierName("车队");
|
||||
when(terminal.find(1)).thenReturn(plan);
|
||||
when(terminal.histories(1)).thenReturn(List.of(history("SELECT_FLEET"),history("SCHEDULE_PICKUP")));
|
||||
var fact=adapter.read(request("PICKUP"),source());
|
||||
var plan=new TerminalDrayageEntity();plan.setContainerBookingId(1L);plan.setPickupAppointmentAt(at);plan.setFleetSupplierId(4L);plan.setFleetSupplierName("车队");
|
||||
when(terminal.findAll(List.of(1L))).thenReturn(List.of(plan));
|
||||
when(terminal.pickupChanges(List.of(1L))).thenReturn(List.of(new TerminalDrayageMapper.PickupChanges(1,1,1)));
|
||||
var fact=read(request("PICKUP"),source());
|
||||
assertEquals("0:0",fact.impactToken());assertEquals(at.toLocalDate(),fact.confirmed().date());
|
||||
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")));
|
||||
assertEquals("1:0",adapter.read(request("PICKUP"),source()).impactToken());
|
||||
var returnRequest=new FactRequest("W2","EMPTY_RETURN","DIRECT","1",null,null,null,List.of(),List.of(),List.of());
|
||||
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() {
|
||||
var plan=new TerminalDrayageEntity();plan.setFleetSupplierId(4L);plan.setFleetSupplierName("提柜承运人");
|
||||
when(terminal.find(1)).thenReturn(plan);
|
||||
var plan=new TerminalDrayageEntity();plan.setContainerBookingId(1L);plan.setFleetSupplierId(4L);plan.setFleetSupplierName("提柜承运人");
|
||||
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)));
|
||||
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());
|
||||
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(
|
||||
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())));
|
||||
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 fact=adapter.read(request,source);
|
||||
var fact=read(request,source);
|
||||
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.*;
|
||||
|
||||
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() {
|
||||
var mapper=mock(DirectDeliveryMapper.class);var guard=new UnloadingEvidenceGuard(mapper);
|
||||
var container=new DirectDeliveryMapper.Container(1,"ACTIVE",null);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Gateway 模块指引
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 职责与边界
|
||||
|
||||
- 负责路由、CORS、JWT/Redis 会话校验、平台/客户访问域隔离、请求标识和 Swagger 聚合。
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 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`,不得增加子域扫描配置。
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Integration Service 模块指引
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 职责与边界
|
||||
|
||||
- 负责百度 OCR、Amazon Logistics/SP-API、海外仓业务船司/船舶轨迹查询等第三方适配和能力开关;不保存业务主数据。
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 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`。
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
| 派送完成回传 | `delivery/service/DeliveryCompletedOutboxService.java`;签收资料保存并自动完成时在业务事务内按关联 `sourceInboundId` 分别写 `OVERSEAS_DELIVERY_COMPLETED` Outbox,同车不同 FBA 派送单互不代完成;不保留人工完成入口 |
|
||||
| 入仓后 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 |
|
||||
| 贴标申请、箱唛/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 可收货门禁 | `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 仓编码去重后按稳定顺序以英文逗号连接,预排与普通编辑请求均不接收路线;查询、预排与执行边界见“海外仓全流程开发路由” |
|
||||
|
||||
@@ -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>
|
||||
</dependencies>
|
||||
<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>
|
||||
|
||||
+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")
|
||||
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}")
|
||||
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
|
||||
""")
|
||||
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)
|
||||
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);
|
||||
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. */
|
||||
public Context inspect(AppointmentSourceService.Source source) {
|
||||
var row=mapper.latest(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);
|
||||
Integer total=row==null?source.packages():row.getPackageCount();
|
||||
var reasons=new ArrayList<>(source.blockReasons());
|
||||
|
||||
+46
-7
@@ -58,14 +58,42 @@ public class AppointmentSourceService {
|
||||
return response.data().warehouse().timezone();
|
||||
} 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) {
|
||||
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票真实货源");
|
||||
var keys=new LinkedHashSet<String>(); var orderIds=new LinkedHashSet<Long>();
|
||||
var bookingRefs=new java.util.ArrayList<AppointmentExternalSourceGateway.SourceRef>();
|
||||
for(var ref:requested) {
|
||||
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()));
|
||||
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("不支持的货源类型");
|
||||
@@ -77,17 +105,28 @@ public class AppointmentSourceService {
|
||||
if(booking.orderId()!=null) orderIds.add(booking.orderId());
|
||||
}
|
||||
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();
|
||||
if(!directOrders.isEmpty() && !external.resolveOrders(directOrders).isEmpty()) throw bad("该货物来自外接登记,请使用原登记来源继续预约");
|
||||
var directOrders=requested.stream().filter(ref->ref.sourceKey().startsWith("ORDER:")).map(ref->new AppointmentExternalSourceGateway.OrderRef(ref.customerId(),orderId(ref.sourceKey()))).distinct().toList();
|
||||
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 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 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 catalog=catalog();
|
||||
var result=requested.stream().map(ref->resolve(ref.sourceKey(),ref.customerId(),ref.destinationKey(),orderMap,bookingMap,gates,catalog,destinations)).toList();
|
||||
var catalog=scope.catalog();
|
||||
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>();
|
||||
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;
|
||||
}
|
||||
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.Transactional;
|
||||
|
||||
/** Original business commands invalidate the manifest atomically with their business write. */
|
||||
/** 原业务写入与工作清单失效保持在同一事务,不延后或绕过版本门禁。 */
|
||||
@Component
|
||||
public class AppointmentWorkChanges {
|
||||
private final AppointmentWorkMapper mapper;
|
||||
public AppointmentWorkChanges(AppointmentWorkMapper mapper) { this.mapper=mapper; }
|
||||
private final AppointmentWorkStore store;
|
||||
/** 复用工作事项持久层,统一记录守卫更新耗时。 */
|
||||
public AppointmentWorkChanges(AppointmentWorkStore store) { this.store=store; }
|
||||
/** 只标记需求变化,保留调用方原事务和异常语义。 */
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
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"));
|
||||
int version=integer(payload,"reviewVersion");
|
||||
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("原业务仍有阻断,请先处理原安排");
|
||||
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);
|
||||
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) {
|
||||
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>();
|
||||
|
||||
+6
@@ -29,6 +29,12 @@ public class AppointmentWorkCommitmentService {
|
||||
var row=mapper.commitment(key);
|
||||
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) {
|
||||
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());
|
||||
|
||||
+4
@@ -33,6 +33,10 @@ public class AppointmentWorkController {
|
||||
@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)));
|
||||
}
|
||||
@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}")
|
||||
public ApiResponse<WorkDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String workItemKey) {
|
||||
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,
|
||||
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,
|
||||
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();
|
||||
}
|
||||
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,
|
||||
@NotBlank String sourceKey,@NotBlank String destinationKey,@Positive int packageCount) { }
|
||||
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 requireReplay(String customerId,long dispatchId,List<Allocation> allocations);
|
||||
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 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.stereotype.Component;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
|
||||
|
||||
@Component
|
||||
public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway {
|
||||
private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(AppointmentWorkFactAdapter.class);
|
||||
private final AppointmentWorkFirstlegClient firstleg;
|
||||
private final List<BusinessProvider> providers;
|
||||
private final ObjectProvider<OwnerDirectory> directory;
|
||||
@@ -38,7 +40,10 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway {
|
||||
return response.data();
|
||||
} catch(RuntimeException error) { throw unavailable("柜次权威事实不可用",error); }
|
||||
}
|
||||
/** 原顺序读取全部事实;慢调用和失败仅附加无业务内容的局部统计。 */
|
||||
public Map<String,Fact> facts(List<AppointmentWorkState> states) {
|
||||
var timing=new FactTiming();boolean success=false;
|
||||
try {
|
||||
var result=new LinkedHashMap<String,Fact>();
|
||||
var remote=new ArrayList<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) {
|
||||
var requests=remote.subList(start,Math.min(start+200,remote.size())); requireToken();
|
||||
try { var response=firstleg.facts(token,requests);
|
||||
var requests=remote.subList(start,Math.min(start+200,remote.size())); timing.stage="FIRSTLEG"; requireToken();
|
||||
try { var response=timing.measure(timing.firstleg,requests.size(),()->firstleg.facts(token,requests));
|
||||
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); }
|
||||
}
|
||||
groups.forEach((provider,requests)->{
|
||||
for(int start=0; start<requests.size(); start+=200) {
|
||||
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); }
|
||||
}
|
||||
});
|
||||
return result;
|
||||
success=true;return result;
|
||||
} finally { timing.finish(success); }
|
||||
}
|
||||
public List<Requirement> requirements(ContainerSource source,Flow effectiveFlow) {
|
||||
var provider=requirements.getIfAvailable();
|
||||
if(provider==null) throw unavailable("原预约需求与批次清单适配尚未接入,不能生成不完整工作清单");
|
||||
var result=provider.requirements(source,effectiveFlow);
|
||||
if(result==null || result.isEmpty()) throw unavailable("原预约需求清单缺失,不能以空清单完成路线生成");
|
||||
return List.copyOf(result);
|
||||
/** 一次 facts 调用的计时,不保存原来源或返回值,不跨调用缓存。 */
|
||||
private static final class FactTiming {
|
||||
private final Span prepare=span(Stage.FACTS_PREPARE);
|
||||
private final long started=System.nanoTime();
|
||||
private final CallStatistics firstleg=new CallStatistics();
|
||||
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) {
|
||||
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) {
|
||||
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)
|
||||
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.planExists() && (fact.target()!=null || fact.confirmed()!=null || fact.planStatus()==PlanStatus.CONFIRMED)) throw unavailable("原计划存在性与计划结果冲突");
|
||||
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,
|
||||
String businessNo, String tripKey, List<String> readPermissions, List<String> actionPermissions,
|
||||
Action action, List<String> blockReasons, List<String> missingFields, List<BusinessReference> references,
|
||||
Responsibility responsibility) {
|
||||
public boolean arranged() { return planStatus == PlanStatus.CONFIRMED || planStatus == PlanStatus.NOT_REQUIRED || target != null; }
|
||||
Responsibility responsibility, String transportPlanImpactToken) {
|
||||
/** 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);
|
||||
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);
|
||||
Owner owner(String id, Actor actor);
|
||||
Responsibility supplier(String id);
|
||||
@@ -39,5 +41,7 @@ public interface AppointmentWorkFactGateway {
|
||||
/** IAM-backed active platform operators; display names supplied by callers are never trusted. */
|
||||
interface OwnerDirectory { Owner activePlatformOwner(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}") 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)")
|
||||
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}")
|
||||
@@ -64,4 +66,6 @@ public interface AppointmentWorkMapper {
|
||||
VALUES(#{id},#{workItemKey},#{operatorId},#{operatorName},#{action},#{summary},#{commandKey},#{requestHash},#{responseJson},#{occurredAt})
|
||||
""") 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} 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 WorkDetail(WorkItem item, List<WorkItem> relatedItems,
|
||||
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 RequirementDetails(boolean bookingRequired,String receivingRequirements,String reason) { }
|
||||
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 String ORDER_READ="platform:overseas-warehouse:last-mile-order:list";
|
||||
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) {
|
||||
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)));
|
||||
if(timezone==null) missing.add("跟进日期按业务默认时区America/Los_Angeles判断;作业时区待核实");
|
||||
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>();
|
||||
if(execution!=ExecutionStatus.COMPLETED && !state.isRetired()) {
|
||||
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.util.*;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
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.AppointmentWorkFactGateway.*;
|
||||
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; }
|
||||
record Snapshot(List<AppointmentWorkState> states,Map<String,Fact> facts,List<WorkItem> items) { }
|
||||
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();
|
||||
timing.close();timing=span(Stage.QUERY_DISCOVER);
|
||||
if(!Objects.equals(scopeVersion,facts.discover(null,1).scopeVersion())) throw conflict("已开船工作范围变化,请先完成服务端同步");
|
||||
timing.close();timing=span(Stage.QUERY_STORE);
|
||||
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);
|
||||
timing.close();timing=span(Stage.QUERY_PROJECT);
|
||||
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 byKey=items.stream().collect(Collectors.toMap(WorkItem::workItemKey,Function.identity()));
|
||||
var stateByKey=all.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity()));
|
||||
return new Snapshot(all,current,items.stream().map(i->withNext(i,next(i,byKey,stateByKey,new HashSet<>()))).toList());
|
||||
timing.close();timing=span(Stage.QUERY_NEXT);
|
||||
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) {
|
||||
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)) {
|
||||
var prior=items.get(key);
|
||||
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();
|
||||
}
|
||||
/** 完整快照之后单独统计汇总耗时,不改变待办筛选语义。 */
|
||||
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());
|
||||
});
|
||||
}
|
||||
/** 日历最终分组保持原顺序,单独记录快照后处理。 */
|
||||
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(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();
|
||||
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();
|
||||
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) {
|
||||
if(page<1 || size<1 || size>100 || keyword!=null && keyword.length()>128) throw bad("分页或搜索条件无效");
|
||||
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 || date.equals(i.calendarDate()))
|
||||
.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();
|
||||
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);
|
||||
});
|
||||
}
|
||||
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) {
|
||||
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);
|
||||
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("无权读取关联来源");
|
||||
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());
|
||||
@@ -84,11 +153,27 @@ public class AppointmentWorkQueryService {
|
||||
relatedKeys.addAll(effectiveKeys(relatedKeys,statesByKey));
|
||||
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());
|
||||
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,
|
||||
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); }
|
||||
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) {
|
||||
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 observedFingerprint;
|
||||
private String observedImpactToken;
|
||||
private String observedDependentImpactToken;
|
||||
private boolean observedArranged;
|
||||
private Responsibility responsibility;
|
||||
private ReturnHandoff returnHandoff;
|
||||
|
||||
+44
-3
@@ -1,23 +1,59 @@
|
||||
package com.baiyue.lastleg.appointmentwork;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
|
||||
|
||||
@Component
|
||||
public class AppointmentWorkStore {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(AppointmentWorkStore.class);
|
||||
public static final int MAX_ITEMS=10000;
|
||||
private final AppointmentWorkMapper mapper;
|
||||
private final ObjectMapper 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() {
|
||||
var guard=mapper.guard();
|
||||
observeTransaction();var guard=measure(Stage.GUARD_READ,mapper::guard);
|
||||
if(guard==null || !guard.isInitialized()) throw unavailable("完整工作范围尚未完成同步,不能显示零项");
|
||||
if(guard.getRequirementVersion()!=guard.getSynchronizedRequirementVersion()) throw conflict("预约批次或运输关联已变化,服务端正在协调完整工作清单");
|
||||
return guard.getScopeVersion();
|
||||
@@ -32,6 +68,11 @@ public class AppointmentWorkStore {
|
||||
public AppointmentWorkState get(String key) {
|
||||
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 save(AppointmentWorkState state) {
|
||||
state.setVersion(state.getVersion()+1);
|
||||
|
||||
+138
-21
@@ -2,45 +2,58 @@ package com.baiyue.lastleg.appointmentwork;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
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.AppointmentWorkFactGateway.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
|
||||
|
||||
@Service
|
||||
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 final AppointmentWorkStore store;
|
||||
private final AppointmentWorkFactGateway 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
|
||||
public SyncResult synchronize(SyncRequest request) {
|
||||
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();
|
||||
if(request.afterContainerBookingId()!=null && !Objects.equals(guard.getScanCursor(),request.afterContainerBookingId())) throw conflict("来源同步游标已变化,请从首批重新核对");
|
||||
timing.next(SyncPhase.DISCOVER);
|
||||
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()); }
|
||||
else if(!Objects.equals(guard.getScanScopeVersion(),page.scopeVersion())) 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>();
|
||||
for(var source:page.containers()) {
|
||||
validateSource(source);
|
||||
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(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++;
|
||||
} else created+=generate(source,source.establishedFlow(),existing,SYSTEM).size();
|
||||
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,known,requirementReader).size();
|
||||
} else {
|
||||
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()) {
|
||||
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);
|
||||
} else if(!Objects.equals(route.getSourceVersion(),source.sourceVersion())) {
|
||||
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 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);
|
||||
timing.next(SyncPhase.ACTIVE_STORE);
|
||||
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);
|
||||
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)); }
|
||||
store.checkpoint(guard);
|
||||
success=true;
|
||||
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) {
|
||||
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()) {
|
||||
if(requirement==null || requirement.requirementKey()==null || requirement.requirementKey().isBlank() || !seen.add(requirement.requirementKey()) || requirement.references()==null || requirement.replacesRequirementKeys()==null || requirement.sources()==null)
|
||||
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());
|
||||
}
|
||||
}
|
||||
/** Caller holds work guard and validates the route against current original facts. */
|
||||
/** 命令入口仍在调用方守卫事务中即时加载工作集。 */
|
||||
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);
|
||||
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);
|
||||
validateSource(source);
|
||||
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>();
|
||||
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()) {
|
||||
@@ -91,10 +165,17 @@ public class AppointmentWorkSyncService {
|
||||
for(var state:created) {
|
||||
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())));
|
||||
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);
|
||||
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.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) {
|
||||
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());
|
||||
@@ -102,6 +183,18 @@ public class AppointmentWorkSyncService {
|
||||
store.insert(state); store.history(state,actor,"GENERATED","根据已确认路线建立工作需求",null,null,null);
|
||||
}
|
||||
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())); });
|
||||
for(var old:existing) if(old.getRequirementKey()!=null && !"BATCH_REPLACED".equals(old.getRetirementReason()) && !activeKeys.contains(old.getWorkItemKey())) {
|
||||
String containerId=source.containerBookingId();
|
||||
@@ -116,6 +209,7 @@ public class AppointmentWorkSyncService {
|
||||
store.save(old);store.history(old,actor,"SOURCE_REMOVED","原排车已移除该柜实物,保留其他柜的当前关联",null,null,null);continue;
|
||||
}
|
||||
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();
|
||||
// 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.
|
||||
@@ -126,7 +220,9 @@ public class AppointmentWorkSyncService {
|
||||
}
|
||||
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) {
|
||||
var state=create(source,type,requirement,flow); var old=known.get(state.getWorkItemKey());
|
||||
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());
|
||||
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) {
|
||||
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.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());
|
||||
if(requirement.batchKey()!=null) state.setBatchKeys(List.of(requirement.batchKey())); }
|
||||
return state;
|
||||
@@ -166,17 +262,18 @@ public class AppointmentWorkSyncService {
|
||||
var fact=facts.get(state.getWorkItemKey()); if(fact==null) continue;
|
||||
if(fact!=null && state.getObservedImpactToken()!=null && !Objects.equals(state.getObservedImpactToken(),fact.impactToken()) && state.isObservedArranged()) {
|
||||
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) {
|
||||
if(state.isRetired()) 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);
|
||||
if(review) { state.setReviewRequired(true); state.setReviewVersion(state.getReviewVersion()+1); state.setReviewReason("原业务时间、货量或执行关联发生变化,请核对已有安排"); }
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -185,9 +282,29 @@ public class AppointmentWorkSyncService {
|
||||
var queue=new ArrayDeque<String>(); queue.add(key); var seen=new HashSet<String>();
|
||||
var byKey=states.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity()));
|
||||
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());
|
||||
}
|
||||
}
|
||||
/** 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) {
|
||||
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()));
|
||||
|
||||
+14
-1
@@ -21,7 +21,20 @@ public class AppointmentWorkSynchronizationJob {
|
||||
cursor=result.complete()?null:result.nextCursor();
|
||||
} catch(RuntimeException error) {
|
||||
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,
|
||||
@Valid @RequestBody CustomerOrderProgress.Request request) {
|
||||
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 CustomerVisitFileGateway files;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository deliveryExecution;
|
||||
|
||||
public CustomerVisitResponses.Page page(String scopedCustomerId, CustomerVisitRequests.Query query) {
|
||||
String customerId = customerScope(scopedCustomerId, query.customerId());
|
||||
@@ -76,6 +77,7 @@ public class CustomerVisitService {
|
||||
return detail(visit);
|
||||
}
|
||||
|
||||
/** 人工派送动作必须在真实出库后执行,取消统一走出库聚合命令。 */
|
||||
@Transactional
|
||||
public CustomerVisitResponses.Completed updateDeliveryStatus(String customerId, String operatorId,
|
||||
String deliveryNo,
|
||||
@@ -84,8 +86,14 @@ public class CustomerVisitService {
|
||||
if (!DeliveryStatusPolicy.isEditableTarget(target)) {
|
||||
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 (!com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard.matches(delivery, outbound))
|
||||
throw conflict("派送尚未实际出库,不能开始派送或拒签");
|
||||
if (target.equals(delivery.getStatus())) {
|
||||
CustomerVisit existing = visits.findByDelivery(customerId, delivery.getId());
|
||||
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);
|
||||
delivery.setUpdatedAt(now);
|
||||
delivery.setVersion(version(delivery) + 1);
|
||||
deliveryOrders.updateById(delivery);
|
||||
deliveryExecution.save(delivery);
|
||||
String result = "派送状态由 " + from + " 修改为 " + target;
|
||||
if (StringUtils.hasText(request.remark())) result += ",备注:" + trim(request.remark());
|
||||
recordOperation(delivery, operatorId, "UPDATE_DELIVERY_STATUS", result, now);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user