Compare commits

...
Author SHA1 Message Date
ouyangxq a338d924c6 feat(file): validate platform dispatch vehicle and driver evidence 2026-10-19 23:38:00 +08:00
liuyihe e0677e7ae7 提交 2026-10-19 16:41:20 +08:00
liuyihe 44c76d8953 提交 2026-10-19 15:54:19 +08:00
ouyangxq eb167347f6 test(transfer): prepare source snapshot before foreign relabel ownership check 2026-10-19 15:19:09 +08:00
ouyangxq c8608d50ba merge: retain current overseas inventory fixes with looseboard demand 2026-10-19 15:11:45 +08:00
ouyangxq dff040b509 feat(looseboard): require route contacts and pickup delivery times 2026-10-19 15:11:15 +08:00
liuyihe 26a203b4eb 提交 2026-10-19 15:00:25 +08:00
ouyangxq 21128b895f feat(trucking): refine truck demand contacts vehicle and service times 2026-10-19 14:34:33 +08:00
ouyangxq fafcf75014 fix(appointments): exclude pickup trips from delivery calendar 2026-10-19 12:21:57 +08:00
ouyangxq aa195cef66 feat(appointments): add four-type calendar sources and align release contracts 2026-10-19 12:15:43 +08:00
liuyihe fe1b2a6a46 提交 2026-10-19 11:54:29 +08:00
liuyihe e8f6bb32dc 提交 2026-10-19 10:23:09 +08:00
ouyangxq 45fbd6eff8 fix(lastleg): reconfirm received bookings after releasing all own cargo 2026-10-18 19:53:35 +08:00
ouyangxq 2c330bf9df test(lastleg): derive merchant relabel scope from profile receipt 2026-10-18 19:29:35 +08:00
ouyangxq 4bef8577fa test(lastleg): verify dispatch repair with current-run HTTP evidence 2026-10-18 19:21:12 +08:00
ouyangxq db4354bda8 fix(lastleg): align return receipt fixtures and schema totals 2026-10-18 18:45:47 +08:00
ouyangxq aa33514ec6 fix(lastleg): repair dispatch cancellation and return execution contracts 2026-10-18 16:34:14 +08:00
ouyangxq 66a0beb653 test(lastleg): mark diagnostic fixture mapper for registration contract 2026-10-18 11:49:47 +08:00
ouyangxq 5cd108b39a chore(lastleg): instrument appointment latency from restored 662dead baseline 2026-10-18 11:43:10 +08:00
ouyangxq b0acbfbb34 perf(lastleg): batch appointment requirement generation reads 2026-10-17 12:54:39 +08:00
ouyangxq 662dead053 chore(lastleg): diagnose work fact synchronization phases 2026-10-17 11:37:57 +08:00
ouyangxq 2971d6f7f0 perf(lastleg): reuse warehouse directory within sync generation 2026-10-17 09:57:54 +08:00
ouyangxq 81e4123b10 perf(lastleg): limit transport reconciliation facts and trace sync phases 2026-10-17 09:11:00 +08:00
ouyangxq 732cf99007 perf(lastleg): reuse work items within synchronization batch 2026-10-17 08:30:11 +08:00
ouyangxq 5fd74e9c8c perf(lastleg): batch receiving candidates and trace slow work invalidation 2026-10-17 07:49:56 +08:00
ouyangxq af97cd17d0 Merge production prebuild test fixes into frozen release 2026-10-16 18:44:42 +08:00
ouyangxq 5f5133bd68 test(lastleg): separate stable carton marks from return numbers 2026-10-16 18:44:32 +08:00
ouyangxq 339c70bb56 test(lastleg): isolate SQL semantic tests in bounded CI 2026-10-16 18:41:25 +08:00
ouyangxq e4bf573146 test: integrate production preflight contract correction 2026-10-16 18:14:49 +08:00
ouyangxq 4348296daf test(lastleg): validate shared appointment contract references 2026-10-16 18:14:37 +08:00
liuyihe 9ea03eb540 提交 2026-10-16 17:43:07 +08:00
liuyihe c9186c37a5 提交 2026-10-16 16:56:33 +08:00
liuyihe 6211d4ae37 提交 2026-10-16 16:53:32 +08:00
ouyangxq fee0268391 fix(appointment): link customer work to original batch requirement 2026-10-16 16:38:33 +08:00
ouyangxq 38a1f1079a Merge remote-tracking branch 'origin/test' into test 2026-10-16 16:21:51 +08:00
ouyangxq 58685945b1 fix(appointment): exclude cancelled batches without active allocations 2026-10-16 16:21:51 +08:00
liuyihe ecded9e58b 提交 2026-10-16 16:18:39 +08:00
liuyihe 488c0318ff 提交 2026-10-16 16:18:28 +08:00
ouyangxq 000125c3ec chore(sql): normalize merged initialization ending 2026-10-16 15:45:18 +08:00
ouyangxq 0708bbad2e Merge remote-tracking branch 'origin/test' into test
# Conflicts:
#	deploy/sql/README.md
#	deploy/sql/海外区域统一增量-MySQL8.sql
2026-10-16 15:44:59 +08:00
ouyangxq 30f48bd926 feat(appointments): support scoped receiving and stable trip continuation 2026-10-16 15:43:04 +08:00
liuyihe 358feb5567 提交 2026-10-16 13:57:39 +08:00
liuyihe 5855529e97 提交 2026-10-16 11:41:24 +08:00
ouyangxq 4e2ea89403 fix(selfop): retry continuous outbound failures with bounded diagnostics 2026-10-16 11:05:37 +08:00
ouyangxq a3a5862f89 fix(delivery): use frozen relabel destination in SKU details 2026-10-15 21:48:18 +08:00
ouyangxq b8739eebf0 fix(lastleg): preserve return relabel delivery metadata and measurements 2026-10-15 21:40:37 +08:00
ouyangxq 607aaf74dc fix(lastleg): route all relabel pickups directly to outbound and verify final schemas 2026-10-15 21:05:34 +08:00
ouyangxq 4277601999 perf(firstleg): scope appointment fact source aggregation by container 2026-10-15 20:03:41 +08:00
ouyangxq ae33780dae fix(order): project exact customer fulfillment progress in detail 2026-10-15 19:46:34 +08:00
ouyangxq 097bec33b1 perf(lastleg): batch live appointment work facts and filter candidates 2026-10-15 19:10:57 +08:00
ouyangxq abf4e45e39 perf(firstleg): batch appointment source and execution facts 2026-10-15 19:08:18 +08:00
ouyangxq 2631bd3af4 fix(dispatch): use supported capacity for default draft groups 2026-10-15 18:45:05 +08:00
liuyihe cda5f3dc32 提交 2026-10-15 18:25:53 +08:00
ouyangxq caff693ad8 test(appointments): verify stable batches and current HTTP contracts 2026-10-15 18:13:52 +08:00
ouyangxq 204ae10737 fix(dispatch): persist and reuse the resolved draft group 2026-10-15 18:12:55 +08:00
ouyangxq 23ce486539 fix(firstleg): reuse source manifest within work fact batches 2026-10-15 17:52:56 +08:00
ouyangxq 994215d981 fix(appointment): keep source inspection in work transaction 2026-10-15 17:34:45 +08:00
ouyangxq 42679d4d54 fix(appointment): retain unbookable cargo as blocked work 2026-10-15 17:28:22 +08:00
ouyangxq 14b8cc625b fix(appointment): retain safe synchronization failure diagnostics 2026-10-15 17:18:09 +08:00
420 changed files with 19307 additions and 1579 deletions
+13
View File
@@ -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 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。
+5
View File
@@ -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`,不得增加子域扫描配置。
+5
View File
@@ -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`,不得增加子域扫描配置。
+5
View File
@@ -1,5 +1,10 @@
# Common Audit 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- 负责业务审计事件采集、注解切面、敏感字段脱敏以及日志/HTTP 输出。
+5
View File
@@ -1,5 +1,10 @@
# Common Core 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- 提供不依赖具体业务域的返回模型、异常、基础实体和 MyBatis-Plus 通用配置。
+5
View File
@@ -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 之间稳定、可版本化的业务事件契约。
+5
View File
@@ -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 自动配置和统一文档元数据。
+5
View File
@@ -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 响应。
+5
View File
@@ -1,5 +1,10 @@
# Common Web 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- 提供 Servlet Web 通用能力:请求链路标识、上下文和统一异常响应。
+7
View File
@@ -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
View File
@@ -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。该增量可重复执行,尚未执行到任何实际数据库。
@@ -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;
@@ -0,0 +1,2 @@
-- 对应数据库:baiyue_lastleg;可重复执行,不修改原箱号、SKU预报件数和实测数据。
ALTER TABLE ll_warehouse_transfer_carton MODIFY COLUMN transfer_item_id BIGINT NULL COMMENT '实际箱内首项SKU明细ID,预报补箱可为空';
+3 -3
View File
@@ -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
View File
@@ -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

+90
View File
@@ -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`
+5
View File
@@ -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,8 @@ 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),
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 +69,12 @@ enum BusinessFileCategory {
.orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型"));
}
/** 校验文件归属;跨客户排车证据固定为平台所有,不随货物客户变化。 */
void validateOwner(long tenantId) {
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,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();
}
}
+5
View File
@@ -1,5 +1,10 @@
# Finance Service Instructions
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 模块定位
- 本模块是独立财务服务,拥有客户结算条款、财务费用来源、CNY 账单与明细、客户资金账户及充值申请、应收款月度统计、客户对账确认、收款及分配、成本及分摊、账单修订、单据编号、幂等记录和财务审计状态。
+5
View File
@@ -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`。
@@ -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)
@@ -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 &lt;&gt; '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);
}
@@ -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);
}
@@ -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)));}
@@ -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;
}
}
@@ -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;
}
@@ -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) {
@@ -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()));
}
}
@@ -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) { }
@@ -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);
}
@@ -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);
@@ -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,
@@ -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());
}
}
@@ -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("实际提柜后才能记录直送卸货");
@@ -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);
}
}
@@ -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在自身客户范围内过滤、计数和分页
@@ -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();
}
}
}
}
@@ -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");
}
}
@@ -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());
}
}
}
@@ -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());
}
}
@@ -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();
}
}
@@ -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");
@@ -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);
}
}
@@ -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());
}
}
}
@@ -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();
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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);
+5
View File
@@ -1,5 +1,10 @@
# Gateway 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界
- 负责路由、CORS、JWT/Redis 会话校验、平台/客户访问域隔离、请求标识和 Swagger 聚合。
+5
View File
@@ -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`,不得增加子域扫描配置。
+5
View File
@@ -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、海外仓业务船司/船舶轨迹查询等第三方适配和能力开关;不保存业务主数据。
+5
View File
@@ -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`。
+1 -1
View File
@@ -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
View File
@@ -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>
@@ -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);
}
@@ -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());
@@ -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,
@@ -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();
}
}
@@ -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>();
@@ -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());
@@ -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)));
@@ -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()); }
@@ -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);
}
@@ -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());
@@ -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(); }
}
@@ -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);
}
}
@@ -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);
}
@@ -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 }
@@ -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("跟进日期已过");
@@ -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);
}
@@ -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;
@@ -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);
@@ -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()));
@@ -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);
}
}
@@ -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 注册。 */ }
}
}
@@ -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();}}}
}
}
@@ -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){ /* 诊断计数故障不能替换原结果。 */ }
}
}
}
@@ -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(); }
}
@@ -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;
};
}
}
@@ -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)));
}
}
@@ -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 &gt;= #{from} AND c.plannedAt &lt; #{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);
}
@@ -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) { }
}
@@ -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());
}
}
@@ -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();
}
}
@@ -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()));
}
}
@@ -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);
@@ -12,6 +12,7 @@ import java.util.List;
@Mapper
public interface DeliveryOperationMapper extends BaseMapper<DeliveryOperation> {
/** 普通库存流水与原箱回仓凭据共同证明实际退回,每个原箱只计一次箱数。 */
@Select("SELECT l.id AS ledger_id, di.id AS dispatch_item_id, oi.warehouse_pallet_id AS pallet_id, " +
"l.delta_packages AS packages, l.occurred_at AS received_at FROM ll_delivery_order d " +
"JOIN ll_exception_order e ON e.customer_id=d.customer_id AND e.delivery_order_id=d.id " +
@@ -27,7 +28,10 @@ public interface DeliveryOperationMapper extends BaseMapper<DeliveryOperation> {
"JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " +
"WHERE p.customer_id=d.customer_id AND t.dispatch_order_id=d.dispatch_order_id " +
"AND p.dispatch_item_id=di.id AND p.inventory_position_id=i.inventory_position_id AND a.order_id=e.last_mile_order_id) " +
"WHERE d.customer_id=#{customerId} AND d.id=#{deliveryOrderId} ORDER BY l.occurred_at,l.id")
"WHERE d.customer_id=#{customerId} AND d.id=#{deliveryOrderId} UNION ALL SELECT MIN(r.id),r.source_dispatch_item_id,oi.warehouse_pallet_id,SUM(r.packages),r.received_at " +
"FROM ll_return_carton_stock_receipt r JOIN ll_outbound_pallet_item oi ON oi.id=r.source_outbound_pallet_item_id AND oi.customer_id=r.customer_id " +
"WHERE r.customer_id=#{customerId} AND r.delivery_order_id=#{deliveryOrderId} " +
"GROUP BY r.receipt_no,r.source_dispatch_item_id,oi.warehouse_pallet_id,r.received_at ORDER BY received_at,ledger_id")
List<DeliveryReturnReceiptFact> returnReceipts(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId);
@Select("SELECT COUNT(*) FROM ll_exception_order WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} " +
@@ -4,7 +4,7 @@ import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** Reconstruct every selected inbound source, including shared pallets and already picked quantities. */
/** 还原整托实际入仓组成及本次已拣数量,不按展示计划过滤合托来源。 */
@Mapper
public interface DispatchFeeCargoMapper {
@Select("""
@@ -22,13 +22,10 @@ public interface DispatchFeeCargoMapper {
GROUP BY pk.dispatch_item_id,pk.inventory_position_id) picked
ON picked.dispatch_item_id=di.id AND picked.inventory_position_id=p.id
WHERE di.dispatch_order_id=#{dispatchId} AND di.customer_id=#{customerId}
AND (di.plan_pallet_id IS NULL OR EXISTS (
SELECT 1 FROM ll_inbound_plan_pallet_allocation a
JOIN ll_inbound_plan_line line ON line.id=a.line_id AND line.customer_id=di.customer_id
WHERE a.pallet_id=di.plan_pallet_id AND line.order_id=b.inbound_order_id))
GROUP BY di.id,di.warehouse_pallet_id,di.pallet_no,b.inbound_order_id,o.source_region,o.source_inbound_id,b.inbound_code,di.inbound_code
HAVING SUM(p.available_packages+COALESCE(picked.packages,0))>0
ORDER BY di.id,b.inbound_order_id
""")
/** 整托候选箱数来自物理库存,因此组成也必须包含该托所有实际来源。 */
List<DeliveryFeeCoverageModels.Cargo> list(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId);
}

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