生产更新:同步已提交的海外仓分析及业务修复(2026-10-16) #27

Merged
admin merged 138 commits from test into main 2026-10-16 18:02:59 +08:00
754 changed files with 54784 additions and 1579 deletions
+15
View File
@@ -78,6 +78,8 @@
| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` |
| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` |
| Lastleg 内部模块化与依赖基线 | `lastleg-service/` |
| 自营领星采集、经营统计、SKU 日销量 | `lastleg-service/selfoperated/` 查询与采集编排;领星签名/分页/外部接口查 `integration-service/`,不因采集页面任务同时读取两服务 |
| 跨客户同仓预约、预约跟进、到港日历 | `lastleg-service/inboundplan/`、`appointmentplanning/`;到港事实查 `firstleg/portschedule/` |
| 车辆、司机证照、车次、外接预订、车队报价、出车记录 | `lastleg-service/fleet/`;先读 `lastleg-service/AGENTS.md` 对应入口,客户排车关联再沿 `dispatch/fleet/` 实际调用 |
| PDF 标签、审计、业务事件直投 | `print/`、`audit-service/`、`common-event-contract/` |
@@ -104,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 通用能力:请求链路标识、上下文和统一异常响应。
+9
View File
@@ -141,6 +141,15 @@ baiyue:
max-rows: 200000
max-attempts: 3
batch-size: 200
SELFOP_INVENTORY_TURNOVER:
provider-service: lastleg-service
provider-path: /internal/exports/v1/SELFOP_INVENTORY_TURNOVER/batches
required-permission: platform:self-operated-analytics:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 100
OVERSEAS_INVENTORY_LEDGER:
provider-service: lastleg-service
provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_LEDGER/batches
+12
View File
@@ -218,6 +218,12 @@ services:
environment:
<<: *common-environment
SERVER_PORT: 9210
LINGXING_WMS_ENABLED: ${LINGXING_WMS_ENABLED:-false}
LINGXING_COLLECTION_MODE: ${SELFOP_COLLECTION_MODE:-OFF}
LINGXING_ENVIRONMENT_LABEL: ${DEPLOY_ENV:-test}
LINGXING_SOURCE_ACCOUNT: ${SELFOP_SOURCE_ACCOUNT:-lingxing-primary}
LINGXING_OMS_APP_KEY: ${LINGXING_OMS_APP_KEY:-}
LINGXING_OMS_APP_SECRET: ${LINGXING_OMS_APP_SECRET:-}
BAIDU_OCR_ENABLED: ${BAIDU_OCR_ENABLED:-false}
BAIDU_OCR_API_KEY: ${BAIDU_OCR_API_KEY:-}
BAIDU_OCR_SECRET_KEY: ${BAIDU_OCR_SECRET_KEY:-}
@@ -282,6 +288,12 @@ services:
environment:
<<: *common-environment
SERVER_PORT: 9303
SELFOP_EXECUTION_ENABLED: ${SELFOP_EXECUTION_ENABLED:-false}
SELFOP_COLLECTION_MODE: ${SELFOP_COLLECTION_MODE:-OFF}
SELFOP_SOURCE_ACCOUNT: ${SELFOP_SOURCE_ACCOUNT:-lingxing-primary}
SELFOP_DAILY_REQUEST_BUDGET: ${SELFOP_DAILY_REQUEST_BUDGET:-0}
SELFOP_MIN_REQUEST_INTERVAL_MS: ${SELFOP_MIN_REQUEST_INTERVAL_MS:-5000}
SELFOP_SALES_PUBLICATION_ENABLED: ${SELFOP_SALES_PUBLICATION_ENABLED:-false}
EVENT_CONNECT_TIMEOUT_MS: ${EVENT_CONNECT_TIMEOUT_MS:-3000}
EVENT_READ_TIMEOUT_MS: ${EVENT_READ_TIMEOUT_MS:-15000}
EVENT_LEASE_SECONDS: ${EVENT_LEASE_SECONDS:-60}
+10
View File
@@ -21,6 +21,16 @@ IMAGE_TAG=
FILE_UPLOADS_HOST_DIR=/data/firstleg/uploads
CORS_ALLOWED_ORIGINS=
INTERNAL_SERVICE_TOKEN=
# 领星唯一账号;凭据仅注入 integration-service。
LINGXING_OMS_APP_KEY=
LINGXING_OMS_APP_SECRET=
LINGXING_WMS_ENABLED=false
SELFOP_COLLECTION_MODE=OFF
SELFOP_SOURCE_ACCOUNT=lingxing-primary
# 未核准额度使用0,真实出口拒绝请求;有效模式仍需显式的独占交接证明与截止时间。
SELFOP_DAILY_REQUEST_BUDGET=0
SELFOP_MIN_REQUEST_INTERVAL_MS=5000
SELFOP_SALES_PUBLICATION_ENABLED=false
REDIS_PASSWORD=
AUTH_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
AUTH_DB_USERNAME=
@@ -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` 而中止,此项未通过;未为消除检查错误而创建替代业务文档。
+29 -4
View File
@@ -1,5 +1,9 @@
# 单测试环境数据库空库初始化
`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 日事实批次原子构建,完整性验证后方可发布。已有未构建汇总的销量版本须重新构建发布,不回填旧版本、不回退旧查询;空版本同样必须有就绪证据。源基线与海外空库基线同步。
`migrations/overseas/baiyue_lastleg/V21__procurement_fee_confirmation_origin.sql` 增加采购费用核定入口,区分业务节点与财务操作,用于独立新增费用、替代核定及其重试权限。同步源基线和海外统一空库基线;不回填测试存量,不表示已执行。
`migrations/overseas/baiyue_lastleg/V20__procurement_exception_control.sql` 增加采购事实更正/阻断说明、异常关闭及逐来源最终去向三表。服务端保持原收货和发运记录,关闭不产生库存、退款或退货运输;需与采购详情聚合和节点阻断校验的匹配版本发布。源基线和海外统一基线同步,本文件存在不表示已执行。
@@ -22,14 +26,27 @@
内部拼车需求池的当前结构在 Lastleg 源基线和海外统一空库基线同步维护,独立五项权限在 IAM 源种子和国内统一基线维护。已存在基础表的指定环境结构补齐使用 `migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql` 与 `migrations/cn/baiyue_iam/V4__fleet_capacity_offer_permissions.sql`;执行前确认三张新表、六个新增字段均未存在,并单独核对目标环境。随后 `V11__align_capacity_pool_collation.sql` 将预订与商业桥的客户关联字段显式统一为 `utf8mb4_unicode_ci`,与原订单域一致,避免依赖MySQL实例默认排序规则导致JOIN失败。它们不回填、删除或重置业务数据,不能重放整个空库基线。候选文件存在不表示已经执行。
平台自营领星统计十张 `ll_selfop_*` 表直接同步 Lastleg 源基线及海外统一空库基线;时间使用 UTC 存储及已确认北京时间业务日,数量使用 DECIMAL 保留 PCS 精度,维持独立发布版本。包含账号共享预算/冷却、验证根用途与硬预算、TEST BUSINESS 根会话持久预算、稳定任务号、阶段进度/心跳和旧版替代时间;任务的 `business_root_task_id` 标记共享根,只有根保存 `business_budget_json`,生产任务不使用该测试预算。库存 run 包含每日代表日期、选择规则及账号/仓库/日期唯一约束,代表库存与 profile 不适用销量副本淘汰。IAM 权限源为 `iam/src/main/resources/db/selfoperated-analytics-permissions.sql`,自营业务下经营总览、销量分析、库存分析、数据采集的菜单资源与独立读写权限同步国内统一基线,仅平台管理员默认授权。执行和部署状态只维护于工作区测试环境主契约;真实采集模式默认 OFF。
客户/商品资料基线为 `lastleg-service/src/main/resources/db/selfop-master-data.sql`,仅新增 `ll_selfop_owner`、`ll_selfop_product` 两表,并同步在 Lastleg 和海外统一空库基线。按来源账号、货主编码、SKU 和完整资料版本关联,不创建平台客户、收费对象或独立映射表;未知实测尺寸保持 NULL。对 TEST 已有库只审阅并授权这两张新表的 CREATE,不能重放统一初始化文件,也不重置采集数据。
历史库存还原基线为 `lastleg-service/src/main/resources/db/selfop-inventory-history.sql`,新增流水事实、历史日版本和日库存三表;同步 Lastleg 与海外空库基线。仅执行这三表 CREATE,不重放原始库存或销量表。匹配版本复用 `sync_task/run/stage` 的 `INVENTORY_FLOW_HISTORY`、`INVENTORY_HISTORY_BUILD` 与 `INVENTORY_FLOW/HISTORY_INPUT`,原库存快照不改写。完成三表并部署 Integration、Lastleg、Admin 后,才启用 `baiyue.self-operated-analytics.inventory-history-enabled=true`;默认关闭,历史目标30日优先再60日。保留当前账号出口、1200ms间隔和所有被历史版本引用的证据;现有销量副本淘汰不得删除这些引用。
预约工作事项新增结构由 `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` | 181 |
| 国内 | `头程数据库统一增量-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` | 214 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
@@ -51,7 +68,7 @@
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/181`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/214`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
## 平台管理员密码
@@ -67,7 +84,7 @@
| 所属库 | 保留内容 | 不包含 |
|---|---|---|
| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、417 个权限、598 条角色权限、7 个数据范围、客户编号起点;无人工完成订单权限 | 测试客户、固定客户账号、额外扩权 |
| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、419 个权限、600 条角色权限、7 个数据范围、客户编号起点;无人工完成订单权限 | 测试客户、固定客户账号、额外扩权 |
| Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 |
| Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 |
| File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 |
@@ -139,3 +156,11 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
本次整理使用隔离 MySQL 8.4,国内与海外分别在不存在对方区域 schema 的空库执行两轮,逐表比对整理前最终字段、默认值、索引、约束、注释及业务触发器。SHOW CREATE 的冗余字符集显示与运行态自增计数不视为结构差异;移除示例种子和强制改密是明确行为变更。这不代表已部署或完成真实 HTTP 验收。
码头车队邮件配置见 [Firstleg 车队邮件](../../firstleg/docs/terminal-drayage-mail.md)。
`migrations/overseas/baiyue_lastleg/V24__cargo_discard_photos.sql` 仅新增废弃照片关联表,按客户、申请和托盘关联文件及提交时间。与 Lastleg、File 和三端照片功能共同发布;不回填历史废弃单,不修改库存。脚本存在不表示目标环境已执行。
自营库存周转后端导出权限见 `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,21 @@
-- 仅 baiyue_iam,新增客户废弃确认按钮权限并授权默认客户管理员。
-- 自定义客户角色由管理员按需分配;不自动扩大其权限。
-- 可重复执行;授权和持久化安全版本在同一事务提交。
START TRANSACTION;
INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
VALUES (5306183,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:confirm','确认废弃完成',NULL,'POST',30,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),status='ACTIVE',deleted=0;
INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at)
SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p
ON p.tenant_id=0 AND p.app_code='MERCHANT' AND p.permission_code='merchant:overseas-warehouse:discard:confirm'
WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0;
INSERT INTO iam_security_subject_version(auth_user_id,permission_version)
SELECT DISTINCT u.auth_user_id,1 FROM iam_user u
JOIN iam_membership m ON m.user_id=u.id
JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=m.tenant_id
JOIN iam_role r ON r.id=mr.role_id
WHERE u.auth_user_id IS NOT NULL AND u.status='ACTIVE' AND u.deleted=0
AND m.status='ACTIVE' AND m.deleted=0 AND r.tenant_id=0 AND r.app_code='MERCHANT'
AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0
ON DUPLICATE KEY UPDATE permission_version=permission_version+1;
COMMIT;
@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS ll_selfop_warehouse_daily_sales (
sales_run_id BIGINT NOT NULL COMMENT '完整销量计算版本标识',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码',
business_date DATE NOT NULL COMMENT '已核准业务日',
observed_quantity DECIMAL(38,6) NULL COMMENT '该日PCS非负有效出库量之和,无有效行时为空',
invalid_row_count BIGINT NOT NULL COMMENT '该日数量为空或负数或单位非PCS的日事实行数',
source_row_count BIGINT NOT NULL COMMENT '该日参与仓库汇总的SKU日事实总行数',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
PRIMARY KEY (sales_run_id,source_account,warehouse_code,business_date),
CONSTRAINT chk_selfop_warehouse_daily_sales CHECK (source_row_count > 0 AND invalid_row_count >= 0 AND invalid_row_count <= source_row_count AND (observed_quantity IS NULL OR observed_quantity >= 0))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本按仓按日汇总,随SKU日事实批次原子构建';
SET @selfop_warehouse_daily_ready_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_selfop_sync_run' AND column_name='warehouse_daily_ready'),
'SELECT 1',
'ALTER TABLE ll_selfop_sync_run ADD COLUMN warehouse_daily_ready TINYINT NOT NULL DEFAULT 0 COMMENT ''仓库日汇总已完整构建并校验,含完整空版本''');
PREPARE selfop_warehouse_daily_ready_stmt FROM @selfop_warehouse_daily_ready_ddl;
EXECUTE selfop_warehouse_daily_ready_stmt;
DEALLOCATE PREPARE selfop_warehouse_daily_ready_stmt;
@@ -0,0 +1,44 @@
-- Region: OVERSEAS
-- Schema: baiyue_lastleg
-- Purpose: 统一退货进销存 UNION 文本列排序规则,去除查询转换需求
-- Preconditions: 八张退货表已存在;核对字段长度、可空性、默认值及唯一索引无目标规则冲突
-- Postconditions: 指定文本字段及表默认规则统一为 utf8mb4_unicode_ci;不修改数量、状态或业务标识
-- 不重放空库基线;本文件可重复执行。DDL 非事务,失败停止并核对后续执行范围。
ALTER TABLE ll_return_order
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID';
ALTER TABLE ll_return_order_item
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名';
ALTER TABLE ll_return_receipt
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN receipt_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收单号';
ALTER TABLE ll_return_receipt_item
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码';
ALTER TABLE ll_return_inventory_carton
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID',
MODIFY COLUMN carton_mark VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货箱号和箱唛编码';
ALTER TABLE ll_return_inventory_carton_item
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名';
ALTER TABLE ll_return_inventory_outbound_movement
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '库存所属客户ID和隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际出库海外仓ID',
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次出库SKU编码快照',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名快照',
MODIFY COLUMN outbound_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次退货出库业务单号';
ALTER TABLE ll_return_sku_conversion
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID',
MODIFY COLUMN old_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标前SKU',
MODIFY COLUMN new_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标后SKU',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名快照',
MODIFY COLUMN relabel_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标单号';
@@ -0,0 +1,16 @@
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,
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;
@@ -0,0 +1,24 @@
-- 货物废弃支持所有入库来源及散箱全量作业;不回填或修改历史单据。
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;
@@ -0,0 +1,13 @@
-- 整箱废弃后保留SKU明细,剩余数量允许归零,仍禁止负库存。
-- 原子替换约束,可重复执行;不修改业务记录。
SET @ddl = IF(EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton_item'
AND constraint_name='chk_ll_return_inventory_carton_item_quantity'
),
'ALTER TABLE ll_return_inventory_carton_item DROP CHECK chk_ll_return_inventory_carton_item_quantity, ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)',
'ALTER TABLE ll_return_inventory_carton_item ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,3 @@
-- 客户确认废弃完结时间,可重复执行;仅结构变更。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='customer_confirmed_at'), 'ALTER TABLE ll_cargo_discard_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认废弃完结时间''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -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,预报补箱可为空';
+52 -9
View File
@@ -1,9 +1,27 @@
-- TEST ONLY. Rebuild the explicitly authorized appointment header and item tables.
-- The new command-history table MUST NOT already exist; check this before executing any DROP.
-- If it exists, stop and inspect the prior outcome. Do not clear command history or blindly retry.
-- The command-history and cargo-verification tables MUST NOT already exist; check both before any DROP.
-- If either exists, stop and inspect the prior outcome. Do not clear history or blindly retry.
-- Requires explicit approval; never run against production or as automatic startup SQL.
-- Caller must verify the TEST connection before executing.
USE baiyue_lastleg;
DROP PROCEDURE IF EXISTS assert_test_appointment_reset_preconditions;
DELIMITER $$
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','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()
AND table_name IN ('ll_inbound_fba_appointment','ll_inbound_fba_appointment_item')) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Stop: expected both legacy appointment tables; inspect partial or new schema';
END IF;
END$$
DELIMITER ;
CALL assert_test_appointment_reset_preconditions();
DROP PROCEDURE assert_test_appointment_reset_preconditions;
DROP TABLE ll_inbound_fba_appointment_item;
DROP TABLE ll_inbound_fba_appointment;
CREATE TABLE ll_inbound_fba_appointment (
@@ -24,12 +42,14 @@ CREATE TABLE ll_inbound_fba_appointment (
appointment_remark VARCHAR(1000) NULL COMMENT '备注或取消原因',
fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA目的仓编码',
selected_count INT NOT NULL DEFAULT 0 COMMENT '真实货源票数',
replacement_nos_json LONGTEXT NULL COMMENT '分批或合并后的新预约业务号列表,非空时原批次只读',
replacement_keys_json LONGTEXT NULL COMMENT '替代批次不可变键列表,只用于内部业务关联',
repartition_reason VARCHAR(1000) NULL COMMENT '分批或合并原因,原安排及来源明细保留',
created_by VARCHAR(128) NOT NULL COMMENT '登录操作人',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '更新UTC时间',
PRIMARY KEY (id),
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预约安排与正式确认唯一主记录';
@@ -43,19 +63,42 @@ CREATE TABLE ll_inbound_fba_appointment_item (
order_id BIGINT NULL COMMENT '尚未形成尾程订单时为空',
inbound_code VARCHAR(128) NULL COMMENT '真实入仓码,不伪造',
fba_warehouse_code VARCHAR(128) NOT NULL COMMENT '目的FBA仓编码',
package_count INT NULL COMMENT '所选整票预报箱数,未知保持空',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '取消或失败后释放货源占用',
active_source_key VARCHAR(160) GENERATED ALWAYS AS (CASE WHEN active=1 THEN source_key ELSE NULL END) STORED COMMENT '有效货源唯一占用键',
active_order_id BIGINT GENERATED ALWAYS AS (CASE WHEN active=1 THEN order_id ELSE NULL END) STORED COMMENT '有效订单唯一占用键',
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 '当前批次数量分配有效;取消、未约成和完成均保留,仅替代时释放',
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),
UNIQUE KEY uk_ll_fba_active_source (active_source_key),
UNIQUE KEY uk_ll_fba_active_order (active_order_id),
KEY idx_ll_fba_active_source (customer_id,source_key,active),
KEY idx_ll_fba_active_order (customer_id,order_id,active),
KEY idx_ll_fba_item_source_lookup (source_key,customer_id),
KEY idx_ll_fba_item_customer_order (customer_id,order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入仓前后共用的预约真实货源';
CREATE TABLE ll_appointment_cargo_verification (
id BIGINT NOT NULL COMMENT '核实记录主键',
customer_id VARCHAR(128) NOT NULL COMMENT '原货源所属客户',
source_key VARCHAR(160) NOT NULL COMMENT '稳定ORDER或BOOKING原来源键',
destination_key VARCHAR(160) NOT NULL COMMENT '核实时的原目的地',
source_version VARCHAR(160) NULL COMMENT '核实时的原来源版本',
source_packages INT NULL COMMENT '核实时的原来源箱数,未知为空',
version INT NOT NULL COMMENT '同一来源的核实版本,从一递增',
package_count INT NULL COMMENT '独立核实箱数,不回写原订单',
shipment_references_json LONGTEXT NOT NULL COMMENT '本票货件号或追踪号列表,不回写原订单',
remark VARCHAR(1000) NULL COMMENT '核实说明',
operator_id VARCHAR(128) NOT NULL COMMENT '登录操作人',
operator_name VARCHAR(200) NULL COMMENT '操作人名称快照',
command_key VARCHAR(64) NOT NULL COMMENT '来源内幂等命令键',
request_hash CHAR(64) NOT NULL COMMENT '来源、操作人及请求摘要',
created_at DATETIME(3) NOT NULL COMMENT '核实UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_cargo_verification_version (customer_id,source_key,version),
UNIQUE KEY uk_ll_cargo_verification_command (customer_id,source_key,command_key),
CONSTRAINT chk_ll_cargo_verification_quantity CHECK (package_count IS NULL OR package_count > 0),
CONSTRAINT chk_ll_cargo_verification_version CHECK (version > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约货物核实资料及不可覆盖的版本历史';
CREATE TABLE ll_fba_appointment_command (
command_key VARCHAR(128) NOT NULL COMMENT '持久化幂等键',
request_hash CHAR(64) NOT NULL COMMENT '操作、操作者和请求摘要',
@@ -0,0 +1,11 @@
-- 自营库存周转后端导出权限;不建表,不修改销量、库存或采集任务。
USE baiyue_iam;
SET NAMES utf8mb4;
START TRANSACTION;
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES
(5118002,0,'PLATFORM','API',5118120,'platform:self-operated-analytics:export','导出平台自营库存周转','/api/platform/v1/files/export-tasks','POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),route_path=VALUES(route_path),http_method=VALUES(http_method),status='ACTIVE',deleted=0,updated_at=CURRENT_TIMESTAMP(3);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
SELECT 4000001,5118002,0,CURRENT_TIMESTAMP(3)
WHERE NOT EXISTS (SELECT 1 FROM iam_role_permission WHERE role_id=4000001 AND permission_id=5118002 AND tenant_id=0);
COMMIT;
@@ -2,7 +2,7 @@
-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。
-- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。
-- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。
-- DDL 隐式提交;失败后停止,不从中间续跑,也不把部分初始化当成成功。
-- DDL 隐式提交;失败后停止,不从中间续跑,也不把部分初始化当成成功。utf8mb4_0900_ai_ci
SET NAMES utf8mb4 COLLATE utf8mb4_0900_ai_ci;
SET SESSION sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';
@@ -1422,7 +1422,6 @@ CREATE TABLE fl_consumed_event (
CREATE TABLE fl_container_booking (
id bigint unsigned NOT NULL COMMENT '货柜预订ID(雪花ID)',
booking_no varchar(40) NOT NULL COMMENT '平台唯一货柜预订单号',
container_no varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '真实集装箱号权威快照;同一订舱内唯一,不同提单允许复用;确认前允许修订',
cabinet_no varchar(64) NOT NULL COMMENT '平台唯一内部柜号',
source_type varchar(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
@@ -1477,7 +1476,6 @@ CREATE TABLE fl_container_booking (
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜资料最后更新时间',
version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_container_booking_no (booking_no),
UNIQUE KEY uk_container_booking_cabinet (cabinet_no),
UNIQUE KEY uk_container_booking_ocean_container (ocean_booking_id,container_no),
KEY idx_container_booking_container_no (container_no,updated_at,id),
@@ -4104,6 +4102,27 @@ CREATE TABLE fl_split_operation (
CONSTRAINT fk_split_operation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='分柜操作幂等与审计索引';
-- Current schema for actual destination unloading; never an overseas warehouse receipt.
CREATE TABLE fl_direct_delivery_unloading (
container_booking_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL COMMENT 'Immutable physically loaded inbound source',
actual_at DATETIME(3) NOT NULL COMMENT 'Actual destination local time',
timezone VARCHAR(64) NOT NULL COMMENT 'Explicit destination IANA timezone',
received_by VARCHAR(128) NOT NULL COMMENT 'Actual recipient',
evidence_no VARCHAR(128) NOT NULL COMMENT 'Actual delivery receipt reference',
remark VARCHAR(1000) NULL,
version INT NOT NULL DEFAULT 1,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (container_booking_id,inbound_id),
UNIQUE KEY uk_direct_unloading_command (container_booking_id,idempotency_key),
CONSTRAINT fk_direct_unloading_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='直送目的仓真实卸货凭据';
CREATE TABLE fl_terminal_drayage (
id bigint unsigned NOT NULL COMMENT '目的港码头拖车聚合ID',
container_booking_id bigint unsigned NOT NULL COMMENT '唯一归属物理货柜ID',
@@ -5337,7 +5356,7 @@ CREATE TRIGGER trg_container_booking_before_update BEFORE UPDATE ON fl_container
SELECT MAX(plan_status) INTO current_plan_status
FROM baiyue_firstleg.fl_loading_plan WHERE container_booking_id = OLD.id;
IF current_plan_status IN ('LOADING', 'COMPLETED') AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
NOT (OLD.id <=> NEW.id)
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
OR NOT (OLD.channel_group_code <=> NEW.channel_group_code)
OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name)
@@ -7123,7 +7142,9 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306172,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306180,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306181,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306183,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:confirm','确认废弃完成',NULL,'POST',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306182,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306183,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306190,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306191,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306192,0,CURRENT_TIMESTAMP(3));
@@ -8315,4 +8336,33 @@ SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p
WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND UPPER(r.role_code) IN('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')
AND r.status='ACTIVE' AND r.deleted=0;
SELECT 'PASS' AS initialization_status, 'CN' AS region, 226 AS initialized_tables;
-- 自营业务菜单资源用于菜单管理;四个页面和所有只读接口统一由 list 授权,采集写权限独立。
USE baiyue_iam;
SET NAMES utf8mb4;
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES
(5118100,0,'PLATFORM','MENU',0,'platform:self-operated:menu','自营业务',NULL,NULL,1100,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118140,0,'PLATFORM','MENU',5118100,'platform:self-operated:overview:menu','经营总览','/self-operated/overview','GET',5,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118110,0,'PLATFORM','MENU',5118100,'platform:self-operated:sales:menu','销量分析','/self-operated/sales','GET',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118120,0,'PLATFORM','MENU',5118100,'platform:self-operated:inventory:menu','库存分析','/self-operated/inventory','GET',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118130,0,'PLATFORM','MENU',5118100,'platform:self-operated:collection:menu','数据采集','/self-operated/collection','GET',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118000,0,'PLATFORM','API',5118100,'platform:self-operated-analytics:list','读取平台自营出库销量与库存分析','/api/platform/v1/last-leg/self-operated-analytics','GET',40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118002,0,'PLATFORM','API',5118120,'platform:self-operated-analytics:export','导出平台自营库存周转','/api/platform/v1/files/export-tasks','POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118001,0,'PLATFORM','API',5118130,'platform:self-operated-analytics:sync','平台自营采集重试与重采集','/api/platform/v1/last-leg/self-operated-analytics/sync-tasks','POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES
(4000001,5118000,0,CURRENT_TIMESTAMP(3)),
(4000001,5118001,0,CURRENT_TIMESTAMP(3)),
(4000001,5118002,0,CURRENT_TIMESTAMP(3)),
(4000001,5118100,0,CURRENT_TIMESTAMP(3)),
(4000001,5118140,0,CURRENT_TIMESTAMP(3)),
(4000001,5118110,0,CURRENT_TIMESTAMP(3)),
(4000001,5118120,0,CURRENT_TIMESTAMP(3)),
(4000001,5118130,0,CURRENT_TIMESTAMP(3));
-- Appointment work independent write permissions (current baseline).
USE baiyue_iam;
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107283,0,'PLATFORM','BUTTON',5107280,'platform:appointment:manage-work','维护预约工作安排',NULL,'POST',743,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107283,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102213,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:direct-unload:confirm','登记直送目的仓实际卸货',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3));
SELECT 'PASS' AS initialization_status, 'CN' AS region, 227 AS initialized_tables;
+711 -37
View File
@@ -48,6 +48,7 @@ CREATE TABLE ll_cargo_discard_carton (
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
carton_id bigint NOT NULL COMMENT '箱唛记录ID',
carton_mark varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱唛',
source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_cargo_discard_carton (application_id,carton_id),
@@ -60,13 +61,16 @@ CREATE TABLE ll_cargo_discard_item (
application_id bigint NOT NULL COMMENT '申请单ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
order_id bigint DEFAULT NULL COMMENT '一件代发订单ID',
source_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON',
source_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY',
return_carton_id bigint DEFAULT NULL COMMENT '退货库存箱ID',
inbound_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓单号快照',
profile_id bigint DEFAULT NULL COMMENT '托盘档案ID',
sku_inventory_id bigint DEFAULT NULL COMMENT 'SKU库存记录ID',
sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
product_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品名称',
inventory_position_id BIGINT NULL COMMENT '废弃来源库存仓位ID',
carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组',
carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要逐箱核对本次所选箱子',
available_packages_snapshot int NOT NULL COMMENT '可用箱数快照',
discard_packages int NOT NULL COMMENT '申请废弃箱数',
completion_photo_file_ids text COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA完工照片文件ID JSON数组',
@@ -77,6 +81,7 @@ CREATE TABLE ll_cargo_discard_item (
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_cargo_discard_item (application_id,profile_id,sku_code),
UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code),
KEY idx_ll_cargo_discard_item_list (customer_id,application_id),
KEY idx_ll_cargo_discard_item_inbound (customer_id,inbound_code,sku_code)
,KEY idx_ll_cargo_discard_return_carton (customer_id,return_carton_id)
@@ -266,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 '预计到达时间',
@@ -844,6 +849,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 '系统预约业务单号,不是外部约号',
@@ -862,12 +918,14 @@ CREATE TABLE ll_inbound_fba_appointment (
appointment_remark VARCHAR(1000) NULL COMMENT '备注或取消原因',
fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA目的仓编码',
selected_count INT NOT NULL DEFAULT 0 COMMENT '真实货源票数',
replacement_nos_json LONGTEXT NULL COMMENT '分批或合并后的新预约业务号列表,非空时原批次只读',
replacement_keys_json LONGTEXT NULL COMMENT '替代批次不可变键列表,只用于内部业务关联',
repartition_reason VARCHAR(1000) NULL COMMENT '分批或合并原因,原安排及来源明细保留',
created_by VARCHAR(128) NOT NULL COMMENT '登录操作人',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '更新UTC时间',
PRIMARY KEY (id),
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预约安排与正式确认唯一主记录';
@@ -881,19 +939,42 @@ CREATE TABLE ll_inbound_fba_appointment_item (
order_id BIGINT NULL COMMENT '尚未形成尾程订单时为空',
inbound_code VARCHAR(128) NULL COMMENT '真实入仓码,不伪造',
fba_warehouse_code VARCHAR(128) NOT NULL COMMENT '目的FBA仓编码',
package_count INT NULL COMMENT '所选整票预报箱数,未知保持空',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '取消或失败后释放货源占用',
active_source_key VARCHAR(160) GENERATED ALWAYS AS (CASE WHEN active=1 THEN source_key ELSE NULL END) STORED COMMENT '有效货源唯一占用键',
active_order_id BIGINT GENERATED ALWAYS AS (CASE WHEN active=1 THEN order_id ELSE NULL END) STORED COMMENT '有效订单唯一占用键',
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 '当前批次数量分配有效;取消、未约成和完成均保留,仅替代时释放',
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),
UNIQUE KEY uk_ll_fba_active_source (active_source_key),
UNIQUE KEY uk_ll_fba_active_order (active_order_id),
KEY idx_ll_fba_active_source (customer_id,source_key,active),
KEY idx_ll_fba_active_order (customer_id,order_id,active),
KEY idx_ll_fba_item_source_lookup (source_key,customer_id),
KEY idx_ll_fba_item_customer_order (customer_id,order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入仓前后共用的预约真实货源';
CREATE TABLE ll_appointment_cargo_verification (
id BIGINT NOT NULL COMMENT '核实记录主键',
customer_id VARCHAR(128) NOT NULL COMMENT '原货源所属客户',
source_key VARCHAR(160) NOT NULL COMMENT '稳定ORDER或BOOKING原来源键',
destination_key VARCHAR(160) NOT NULL COMMENT '核实时的原目的地',
source_version VARCHAR(160) NULL COMMENT '核实时的原来源版本',
source_packages INT NULL COMMENT '核实时的原来源箱数,未知为空',
version INT NOT NULL COMMENT '同一来源的核实版本,从一递增',
package_count INT NULL COMMENT '独立核实箱数,不回写原订单',
shipment_references_json LONGTEXT NOT NULL COMMENT '本票货件号或追踪号列表,不回写原订单',
remark VARCHAR(1000) NULL COMMENT '核实说明',
operator_id VARCHAR(128) NOT NULL COMMENT '登录操作人',
operator_name VARCHAR(200) NULL COMMENT '操作人名称快照',
command_key VARCHAR(64) NOT NULL COMMENT '来源内幂等命令键',
request_hash CHAR(64) NOT NULL COMMENT '来源、操作人及请求摘要',
created_at DATETIME(3) NOT NULL COMMENT '核实UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_cargo_verification_version (customer_id,source_key,version),
UNIQUE KEY uk_ll_cargo_verification_command (customer_id,source_key,command_key),
CONSTRAINT chk_ll_cargo_verification_quantity CHECK (package_count IS NULL OR package_count > 0),
CONSTRAINT chk_ll_cargo_verification_version CHECK (version > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约货物核实资料及不可覆盖的版本历史';
CREATE TABLE ll_fba_appointment_command (
command_key VARCHAR(128) NOT NULL COMMENT '持久化幂等键',
request_hash CHAR(64) NOT NULL COMMENT '操作、操作者和请求摘要',
@@ -1072,7 +1153,7 @@ CREATE TABLE ll_inbound_plan_carton (
warehouse_pallet_id bigint DEFAULT NULL COMMENT '海外实物托盘ID',
warehouse_location_id bigint DEFAULT NULL COMMENT '海外库位ID',
pda_task_id bigint DEFAULT NULL COMMENT '首次扫码PDA任务ID',
status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、BULK_RECEIVED、AVAILABLE',
status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱唛状态:未绑定UNASSIGNED、已绑托ON_PALLET、整票收货BULK_RECEIVED、可用AVAILABLE、待废弃DISCARD_PENDING、已废弃DISCARDED',
version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本',
bound_at datetime(3) DEFAULT NULL COMMENT '绑定托盘时间',
received_at datetime(3) DEFAULT NULL COMMENT '完成入库时间',
@@ -1694,7 +1775,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',
@@ -1723,7 +1804,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',
@@ -2184,7 +2265,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 '客户上传面单文件名',
@@ -2373,13 +2454,13 @@ CREATE TABLE ll_relabel_status_history (
CREATE TABLE ll_return_inventory_outbound_movement (
id BIGINT NOT NULL COMMENT '退货出库SKU流水ID',
outbound_pick_id BIGINT NOT NULL COMMENT '同事务PDA出库拣货记录ID',
customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户ID和隔离键',
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '实际出库海外仓ID',
customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '库存所属客户ID和隔离键',
overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际出库海外仓ID',
carton_id BIGINT NOT NULL COMMENT '真实退货库存箱ID',
carton_mark VARCHAR(128) NOT NULL COMMENT '冻结的真实退货箱业务码',
outbound_no VARCHAR(64) NOT NULL COMMENT '本次退货出库业务单号',
sku_code VARCHAR(128) NOT NULL COMMENT '本次出库SKU编码快照', return_sku_quantity BIGINT NULL COMMENT '退货单SKU本来源计划出库件数快照,NULL为历史整箱出库',
product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照',
outbound_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次退货出库业务单号',
sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次出库SKU编码快照', return_sku_quantity BIGINT NULL COMMENT '退货单SKU本来源计划出库件数快照,NULL为历史整箱出库',
product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名快照',
quantity BIGINT NOT NULL COMMENT '本箱该SKU实际出库件数,始终为正',
total_before BIGINT NOT NULL COMMENT '客户仓SKU出库前实物总数量',
total_after BIGINT NOT NULL COMMENT '客户仓SKU出库后实物总数量',
@@ -2395,7 +2476,7 @@ CREATE TABLE ll_return_inventory_outbound_movement (
CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0
AND total_before-total_after=quantity AND available_before-available_after=quantity
AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)
) ENGINE=InnoDB COMMENT='不可变退货出库SKU库存流水';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='不可变退货出库SKU库存流水';
CREATE TABLE ll_return_inventory_balance (
id bigint NOT NULL COMMENT '退货库存余额ID',
@@ -2414,15 +2495,17 @@ CREATE TABLE ll_return_inventory_balance (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存按客户海外仓SKU汇总余额';
CREATE TABLE ll_return_inventory_carton (
original_packaging TINYINT(1) NULL COMMENT '是否使用原箱原包装',
label_status varchar(32) DEFAULT NULL COMMENT 'WAIT_LABEL待贴标 CONFIRMED已扫码确认标签',
new_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标后新箱唛',
id bigint NOT NULL COMMENT '退货库存箱ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID',
customer_id varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
overseas_warehouse_id varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID',
source_receipt_id bigint DEFAULT NULL COMMENT '来源PDA签收单ID',
source_batch_no text COLLATE utf8mb4_unicode_ci COMMENT '实际消耗来源签收批次号,多个批次逗号分隔',
source_return_no text COLLATE utf8mb4_unicode_ci COMMENT '实际消耗来源退货单号,多个单号逗号分隔',
pallet_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收时绑定的托盘码',
carton_mark varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货箱号和箱唛编码',
carton_mark varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货箱号和箱唛编码',
fba_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA箱唛',
destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码',
destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称',
@@ -2459,13 +2542,14 @@ CREATE TABLE ll_return_inventory_carton_item (
id bigint NOT NULL COMMENT '退货库存箱SKU明细ID',
carton_id bigint NOT NULL COMMENT '退货库存箱ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
product_name_zh varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名',
sku_code varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
product_name_zh varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名',
quantity bigint NOT NULL COMMENT '该SKU箱内数量',
relabelled_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '当前SKU已经换标的实物件数,不可二次换标',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_return_inventory_carton_item (carton_id,sku_code),
KEY idx_ll_return_inventory_carton_item_customer (customer_id,sku_code),
CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK ((quantity > 0)),
CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK ((quantity >= 0)),
packing_reserved_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '合箱计划预占件数'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存箱内SKU和数量';
@@ -2486,9 +2570,9 @@ CREATE TABLE ll_return_inventory_operation (
CREATE TABLE ll_return_order (
id bigint NOT NULL COMMENT '退货订单ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
customer_id varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
return_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货订单号',
overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID',
overseas_warehouse_id varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID',
warehouse_contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中转仓联系人快照',
warehouse_contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中转仓联系电话快照',
customer_reference_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户参考号',
@@ -2521,8 +2605,8 @@ CREATE TABLE ll_return_order_item (
id bigint NOT NULL COMMENT '退货预报明细ID',
return_order_id bigint NOT NULL COMMENT '退货订单ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
product_name_zh varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名',
sku_code varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
product_name_zh varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名',
expected_quantity bigint NOT NULL COMMENT 'SKU预报数量',
received_quantity bigint NOT NULL DEFAULT '0' COMMENT 'SKU累计实收数量',
created_at datetime(3) NOT NULL COMMENT '创建时间',
@@ -2537,7 +2621,7 @@ CREATE TABLE ll_return_receipt (
id bigint NOT NULL COMMENT 'PDA退货签收单ID',
return_order_id bigint NOT NULL COMMENT '退货订单ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
receipt_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收单号',
receipt_no varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收单号',
receipt_batch_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收批次号',
pallet_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次签收绑定的托盘码',
carton_mark varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收生成或绑定的退货箱号',
@@ -2564,7 +2648,9 @@ CREATE TABLE ll_return_receipt (
length DECIMAL(12,3) NULL COMMENT '实测尺寸cm',
width DECIMAL(12,3) NULL COMMENT '实测尺寸cm',
height DECIMAL(12,3) NULL COMMENT '实测尺寸cm',
weight DECIMAL(12,3) NULL COMMENT '实测重量kg'
weight DECIMAL(12,3) NULL COMMENT '实测重量kg',
original_packaging TINYINT(1) NULL COMMENT '是否使用原箱原包装',
measurement_source VARCHAR(32) NULL COMMENT '尺寸重量来源'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA退货实收签收事实';
CREATE TABLE ll_return_receipt_batch (
@@ -2588,7 +2674,7 @@ CREATE TABLE ll_return_receipt_item (
receipt_id bigint NOT NULL COMMENT 'PDA退货签收单ID',
return_order_item_id bigint NOT NULL COMMENT '退货预报明细ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
sku_code varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
received_quantity bigint NOT NULL COMMENT '本次SKU实收数量',
PRIMARY KEY (id),
KEY idx_ll_return_receipt_item_receipt (receipt_id),
@@ -3170,9 +3256,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 '客户原箱唛,用于换标现场绑定',
@@ -3213,6 +3311,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',
@@ -3249,7 +3348,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 '物流跟踪号',
@@ -3907,10 +4006,332 @@ CREATE TABLE ll_procurement_closure_item (
CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 181 AS initialized_tables;
USE baiyue_lastleg;
-- 平台自营领星统计:独立事实与不可变发布版本;仅空库初始化,不自动执行。
CREATE TABLE ll_selfop_outbound_discovery_scope (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
catalog_from_utc DATETIME(3) NULL COMMENT '账号完整目录连续覆盖UTC下界,不等同销量覆盖',
catalog_until_utc DATETIME(3) NULL COMMENT '账号完整目录连续覆盖UTC上界,不等同销量覆盖',
next_sequence BIGINT NOT NULL DEFAULT 1 COMMENT '新发现任务的单调调度序号;重试沿用,只安排任务,不判断对象内容的新旧',
next_observation_sequence BIGINT NOT NULL DEFAULT 1 COMMENT '每次实际出库详情请求前分配的单调采集代次;与 task 调度序号独立,重试请求也分配新代次',
active_task_id BIGINT NULL COMMENT '当前共享发现/详情补齐任务',
lease_owner VARCHAR(128) NULL COMMENT '账号发现执行者',
lease_until_utc DATETIME(3) NULL COMMENT '账号发现租约到期时刻',
fence_token BIGINT NOT NULL DEFAULT 0 COMMENT '防止过期发现进程继续写共享暂存或派发任务',
request_budget INT NOT NULL COMMENT '一次调度允许的目录、详情请求总预算,必须为正',
time_budget_seconds INT NOT NULL COMMENT '一次调度允许占用的执行时长,必须为正',
min_request_interval_ms INT NOT NULL COMMENT '同账号请求最小间隔,按已核准配额配置',
api_budget_json JSON NULL COMMENT '账号所有采集族共用的预算、统计日、保守预留及确认消耗、尝试许可和独占交接依据',
api_cooldown_until_utc DATETIME(3) NULL COMMENT '账号全部真实请求共用冷却UTC截止,重启与跨日不清除',
api_pause_reason VARCHAR(64) NULL COMMENT '账号停止或等待的固定原因码,不保存供应商原始错误或凭据',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_discovery_account (source_account),
CONSTRAINT chk_selfop_outbound_discovery_scope CHECK (request_budget > 0 AND time_budget_seconds > 0 AND min_request_interval_ms > 0 AND fence_token >= 0 AND next_sequence > 0 AND next_observation_sequence > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='账号共享出库目录与详情发现协调作用域';
CREATE TABLE ll_selfop_sync_scope (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
data_family VARCHAR(16) NOT NULL COMMENT 'OUTBOUND 或 INVENTORY;按账号+仓库+任务族互斥',
scope_json JSON NOT NULL COMMENT '本仓范围规则,customerMode=ALL_AUTHORIZED,库存为全部品质;当前账号全部授权货主均自营,不将当天出现的货主列表当白名单',
scope_hash CHAR(64) NOT NULL COMMENT '仓库、货主范围规则、品质、时区及单位口径的规范化摘要;不包含当天观测到的客户/SKU 集合',
business_timezone VARCHAR(64) NOT NULL COMMENT '本仓报表及调度时区,初始建议值为 Asia/Shanghai,各仓独立核准',
source_timezone VARCHAR(64) NULL COMMENT '本仓已核准的出库源时间时区',
schedule_cron VARCHAR(128) NOT NULL COMMENT '本仓、本任务族的调度表达式,按 business_timezone 执行',
stale_after_seconds INT NOT NULL COMMENT '本仓、本任务族数据过期阈值,必须为正;库存初始可采用 26 小时,后续按仓调整',
warehouse_policy_ref VARCHAR(128) NULL COMMENT '未来按仓告警/补货策略的稳定引用;一期为空,不建告警规则表',
initial_from_utc DATETIME(3) NULL COMMENT '首采固定UTC下界,按本仓业务时区减一个月',
initial_until_utc DATETIME(3) NULL COMMENT '首采固定UTC截止,重试不重新取当前时间',
discovery_from_utc DATETIME(3) NULL COMMENT '本仓已发布的连续销量覆盖UTC下界',
discovery_until_utc DATETIME(3) NULL COMMENT '本仓已发布的连续销量覆盖UTC上界,不含端点',
fact_revision BIGINT NOT NULL DEFAULT 0 COMMENT '出库事实归并修订号;内容实际变化才增加',
published_fact_revision BIGINT NOT NULL DEFAULT 0 COMMENT '本仓最后已发布销量版所使用的事实修订号',
rebuild_required TINYINT NOT NULL DEFAULT 0 COMMENT '事实变化待重建标记;跨仓修订同时置旧、新仓为 1',
invalidated_at_utc DATETIME(3) NULL COMMENT '最近一次因事实修订使当前版待重建的时刻',
published_run_id BIGINT NULL COMMENT '唯一对外可见版本,指向本任务族已发布执行批次',
lease_owner VARCHAR(128) NULL COMMENT '当前执行者令牌,不使用仅进程内锁',
lease_until_utc DATETIME(3) NULL COMMENT '租约到期时刻',
fence_token BIGINT NOT NULL DEFAULT 0 COMMENT '每次取得租约递增,事实提交和发布均校验',
known_order_cursor_id BIGINT NULL COMMENT '本仓已可靠处理的稳定订单内部标识游标',
known_order_round_max_id BIGINT NULL COMMENT '本轮已知单复查固定最大内部标识',
known_order_round_started_at_utc DATETIME(3) NULL COMMENT '本仓已知单复查轮次开始UTC时间',
known_order_round_finished_at_utc DATETIME(3) NULL COMMENT '本仓已知单完整轮转完成UTC时间',
history_round_from_utc DATETIME(3) NULL COMMENT '本仓历史核对固定UTC下界',
history_round_until_utc DATETIME(3) NULL COMMENT '本仓历史核对固定UTC上界',
history_slice_cursor_utc DATETIME(3) NULL COMMENT '本仓下一待核对历史切片UTC位置',
history_round_finished_at_utc DATETIME(3) NULL COMMENT '上一轮历史时间片全部完成时刻',
review_request_budget INT NOT NULL COMMENT '本仓单次复查最大请求数,必须为正',
review_time_budget_seconds INT NOT NULL COMMENT '本仓单次复查最大执行秒数,必须为正',
review_target_days INT NOT NULL DEFAULT 7 COMMENT '建议每 7 天轮转一遍,属于待配额验证的目标,不保证首次大量详情在单次调度内完成',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_scope (source_account,warehouse_code,data_family),
KEY idx_selfop_scope_lease (lease_until_utc),
CONSTRAINT chk_selfop_sync_scope CHECK (data_family IN ('OUTBOUND','INVENTORY') AND stale_after_seconds > 0 AND review_request_budget > 0 AND review_time_budget_seconds > 0 AND review_target_days > 0 AND fact_revision >= 0 AND published_fact_revision >= 0 AND fence_token >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='分仓任务族租约及发布指针';
CREATE TABLE ll_selfop_sync_task (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
task_no VARCHAR(64) NOT NULL COMMENT '稳定采集业务任务号,重试沿用,不展示内部数据库标识',
execution_purpose VARCHAR(16) NOT NULL COMMENT 'BUSINESS或VALIDATION;由服务端冻结用途,子任务继承',
validation_root_task_id BIGINT NULL COMMENT '验证会话唯一预算根任务,根引用自身,正式任务为空',
validation_budget_json JSON NULL COMMENT '验证根冻结硬预算、累计消耗、库存分仓页数、截止时刻及停止原因',
business_root_task_id BIGINT NULL COMMENT '测试人工正式采集共享预算根任务;生产任务为空',
business_budget_json JSON NULL COMMENT '仅测试正式采集根保存冻结请求上限、截止时间、消耗及停止原因',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
task_role VARCHAR(24) NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC',
scope_id BIGINT NULL COMMENT '分仓任务必须引用分仓作用域;仅账号共享发现为空',
warehouse_code VARCHAR(64) NULL COMMENT '来源仓库原始编码,按仓独立统计',
discovery_scope_id BIGINT NULL COMMENT '共享发现必填,引用账号发现协调器',
upstream_task_id BIGINT NULL COMMENT '出库分仓任务引用已持久化目录的共享发现切片;库存无此上游',
discovery_sequence BIGINT NULL COMMENT '共享任务及分仓子任务继承的调度顺序;重试保持不变,不用于事实覆盖判定',
observation_baseline BIGINT NULL COMMENT '共享任务建立时已分配的最大详情采集代次,分仓子任务继承;用于识别旧任务恢复期间出现的其他任务新观测',
request_key VARCHAR(128) NOT NULL COMMENT '共享发现包含切片/计划槽位;分仓任务包含仓库、任务族及切片或计划槽位;人工附请求幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求类型、查询范围、范围摘要的指纹;同键不同请求拒绝',
task_type VARCHAR(24) NOT NULL COMMENT '计划、补采、复核、重建与验证;INVENTORY_FLOW_HISTORY为流水片,INVENTORY_HISTORY_BUILD为本地历史还原',
planned_at_utc DATETIME(3) NULL COMMENT '计划时点;人工请求可空',
query_from_utc DATETIME(3) NULL COMMENT '出库查询区间UTC下界,库存不填',
query_until_utc DATETIME(3) NULL COMMENT '出库查询区间UTC上界,不含端点,库存不填',
query_scope_json JSON NOT NULL COMMENT '不变的账号、货主、仓库、品质和查询条件快照;按单补查在此记录单号',
routing_manifest_json JSON NULL COMMENT '共享切片目录完整标记、每仓对象键集合/数量和详情就绪状态;各对象记录 effectiveStageId,需新代次复查时记录 refreshTaskId,完成后指向新有效暂存;不改写旧载荷',
scope_hash CHAR(64) NOT NULL COMMENT '建任务时的业务范围摘要',
status VARCHAR(20) NOT NULL COMMENT 'WAITING_SOURCE、READY、RUNNING、RETRY_WAIT、SUCCEEDED、DEAD、SAMPLE_DONE;样本结束不等于正式成功',
apply_state VARCHAR(20) NOT NULL COMMENT '正式出库归并NOT_STARTED、APPLYING、BLOCKED、APPLIED;共享发现、库存、重建及验证为NOT_APPLICABLE',
affected_warehouse_codes_json JSON NULL COMMENT '本次归并可能改变的仓集合,包含原仓与新仓;首段事实写入前登记,扩大集合须先取得新增仓租约并锁 scope,不能写完再补',
apply_started_at_utc DATETIME(3) NULL COMMENT '首段事实提交UTC时间',
apply_completed_at_utc DATETIME(3) NULL COMMENT '全部必需事实归并完成UTC时间,失败不填',
attempt_count INT NOT NULL DEFAULT 0 COMMENT '已创建的执行尝试数',
next_retry_at_utc DATETIME(3) NULL COMMENT '有限退避后下一次可执行时刻',
requested_by VARCHAR(128) NULL COMMENT '人工请求的平台操作人,仅内部审计',
result_published_run_id BIGINT NULL COMMENT '正式成功任务实际发布或复用的版本;验证和共享发现为空',
progress_json JSON NULL COMMENT '与可靠检查点一起提交的有界阶段、单位、尝试和质量进度,不保存单据集合',
progress_updated_at_utc DATETIME(3) NULL COMMENT '最后可靠业务检查点UTC时间,与执行心跳分开',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_task_no (task_no),
UNIQUE KEY uk_selfop_task_request (source_account,request_key),
KEY idx_selfop_task_poll (status,next_retry_at_utc,id),
KEY idx_selfop_task_upstream (upstream_task_id,warehouse_code),
KEY idx_selfop_task_apply (apply_state,id),
KEY idx_selfop_task_scope_list (scope_id,created_at,id),
KEY idx_selfop_task_account_list (source_account,task_role,created_at,id),
KEY idx_selfop_task_business_root (business_root_task_id,status,id),
CONSTRAINT chk_selfop_business_budget CHECK ((business_root_task_id IS NULL AND business_budget_json IS NULL) OR (execution_purpose='BUSINESS' AND business_root_task_id IS NOT NULL AND ((business_root_task_id=id AND business_budget_json IS NOT NULL) OR (business_root_task_id<>id AND business_budget_json IS NULL)))),
CONSTRAINT chk_selfop_sync_task CHECK (attempt_count >= 0 AND execution_purpose IN ('BUSINESS','VALIDATION') AND ((execution_purpose = 'BUSINESS' AND validation_root_task_id IS NULL AND validation_budget_json IS NULL) OR (execution_purpose = 'VALIDATION' AND validation_root_task_id IS NOT NULL AND apply_state = 'NOT_APPLICABLE' AND result_published_run_id IS NULL AND status <> 'SUCCEEDED')) AND ((task_role = 'ACCOUNT_DISCOVERY' AND scope_id IS NULL AND warehouse_code IS NULL AND discovery_scope_id IS NOT NULL) OR (task_role = 'WAREHOUSE_SYNC' AND scope_id IS NOT NULL AND warehouse_code IS NOT NULL)))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='一次计划或人工请求';
CREATE TABLE ll_selfop_sync_run (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
task_id BIGINT NOT NULL COMMENT '本次执行请求',
execution_purpose VARCHAR(16) NOT NULL COMMENT '从逻辑任务冻结继承,验证尝试不能作为正式统计版本',
scope_id BIGINT NULL COMMENT '仅账号共享发现为空;分仓执行必须有作用域',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
warehouse_code VARCHAR(64) NULL COMMENT '来源仓库原始编码,按仓独立统计',
run_role VARCHAR(24) NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC',
upstream_task_id BIGINT NULL COMMENT '分仓出库计算引用的共享切片及其稳定暂存集合',
attempt_no INT NOT NULL COMMENT '同一请求从 1 起递增',
scope_hash CHAR(64) NOT NULL COMMENT '本版本的业务范围摘要',
scope_snapshot_json JSON NOT NULL COMMENT '分仓版只含本仓货主/品质、固定首采 [B,E0)、时区、调度与过期阈值;账号发现保存待路由仓集合,不冒充业务版',
observed_dimensions_json JSON NULL COMMENT '本次实际出现的仓库/货主集合及数量摘要,仅作采集证据;例如已观测 70 货主不等于固定授权范围',
fence_token BIGINT NOT NULL COMMENT '本尝试持有的栅栏版本',
base_published_run_id BIGINT NULL COMMENT '执行开始时的上一可见版本',
reference_sales_run_id BIGINT NULL COMMENT '库存构建开始时固定的本仓已发布销量版,用其不可变 SKU 维度生成出库已知商品占位;没有已发布销量版时为空,不读取半归并订单事实',
inventory_daily_sample_date DATE NULL COMMENT '北京时间当日首个合格计划库存发布代表日期,非代表为空',
inventory_sample_rule_version VARCHAR(32) NULL COMMENT '日库存代表选择规则 DAILY_SCHEDULED_FIRST_V1,与日标记原子保存',
status VARCHAR(24) NOT NULL COMMENT 'FETCHING、VALIDATED、ROUTED、FACTS_APPLIED、BUILDING、READY、PUBLISHED、REUSED、YIELDED、FAILED、ABORTED、SAMPLE_DONE;复用和验证不是版本头',
last_phase VARCHAR(24) NOT NULL COMMENT '失败/中止前所在阶段,支持判断是否已经归并事实',
fetch_started_at_utc DATETIME(3) NULL COMMENT '实际采集开始UTC时间',
fetch_finished_at_utc DATETIME(3) NULL COMMENT '实际采集完成UTC时间,未完成为空',
build_started_at_utc DATETIME(3) NULL COMMENT '实际开始有界候选构建UTC时间,用于构建耗时及首采合并间隔',
warehouse_daily_ready TINYINT NOT NULL DEFAULT 0 COMMENT '仓库日汇总已完整构建并校验,含完整空版本',
finished_at_utc DATETIME(3) NULL COMMENT '本次尝试终止UTC时间',
published_at_utc DATETIME(3) NULL COMMENT '实际原子切换发布指针UTC时间',
superseded_at_utc DATETIME(3) NULL COMMENT '被新发布版替代的首次UTC时间,同事务填写且重试不重置',
page_count INT NOT NULL DEFAULT 0 COMMENT '已完整获取页数',
source_total_count BIGINT NULL COMMENT '上游可靠返回的总数;未返回则为空',
unique_object_count BIGINT NOT NULL DEFAULT 0 COMMENT '规范化去重后对象数',
progress_snapshot_json JSON NULL COMMENT '尝试终止时冻结的有界可靠进度摘要,历史尝试不随逻辑任务改写',
heartbeat_at_utc DATETIME(3) NULL COMMENT '有效执行者最新UTC心跳,不代表业务检查点推进',
coverage_status VARCHAR(16) NOT NULL COMMENT 'UNKNOWN、PARTIAL、COMPLETE;分页完整与数值有效性分别判断',
covered_from_utc DATETIME(3) NULL COMMENT '本仓销量版本实际连续覆盖UTC下界',
covered_until_utc DATETIME(3) NULL COMMENT '本仓销量版本实际连续覆盖UTC上界,不含端点',
complete_day_from DATE NULL COMMENT '本仓完整自然日区间首日,包含',
complete_day_until DATE NULL COMMENT '本仓完整自然日区间末日,不含',
applied_fact_revision BIGINT NULL COMMENT '本次尝试归并后的事实修订号,库存为空',
calculated_fact_revision BIGINT NULL COMMENT '候选销量版本计算所依据的固定事实修订号',
error_code VARCHAR(64) NULL COMMENT '内部可诊断错误分类,不保存认证信息',
error_message VARCHAR(500) NULL COMMENT '脱敏后的固定错误摘要',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_run_attempt (task_id,attempt_no),
UNIQUE KEY uk_selfop_inventory_daily (source_account,warehouse_code,inventory_daily_sample_date),
KEY idx_selfop_run_scope (scope_id,id),
KEY idx_selfop_run_status (status,updated_at),
CONSTRAINT chk_selfop_inventory_daily CHECK ((inventory_daily_sample_date IS NULL AND inventory_sample_rule_version IS NULL) OR (inventory_daily_sample_date IS NOT NULL AND inventory_sample_rule_version IS NOT NULL AND inventory_sample_rule_version = 'DAILY_SCHEDULED_FIRST_V1' AND execution_purpose = 'BUSINESS' AND run_role = 'WAREHOUSE_SYNC' AND status = 'PUBLISHED' AND coverage_status = 'COMPLETE')),
CONSTRAINT chk_selfop_sync_run CHECK (attempt_no > 0 AND page_count >= 0 AND unique_object_count >= 0 AND execution_purpose IN ('BUSINESS','VALIDATION') AND (execution_purpose <> 'VALIDATION' OR status NOT IN ('PUBLISHED','REUSED')) AND (source_total_count IS NULL OR source_total_count >= 0) AND ((run_role = 'ACCOUNT_DISCOVERY' AND scope_id IS NULL AND warehouse_code IS NULL) OR (run_role = 'WAREHOUSE_SYNC' AND scope_id IS NOT NULL AND warehouse_code IS NOT NULL)))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='执行尝试、覆盖证明和版本头';
CREATE TABLE ll_selfop_sync_stage (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
run_id BIGINT NOT NULL COMMENT '首次写入该暂存对象的执行尝试',
stage_batch_id BIGINT NOT NULL COMMENT '出库取共享发现 task.id,便于断点续取;库存取本次 run.id,严禁跨尝试混页',
last_fetch_run_id BIGINT NOT NULL COMMENT '最后一次补齐该对象的执行尝试,保留重试来源',
observation_sequence BIGINT NULL COMMENT '该完整出库详情实际请求的采集代次;有效出库对象必填,目录待补对象和库存为空;不能填 task 调度序号',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
previous_warehouse_code VARCHAR(64) NULL COMMENT '路由时从已有单据识别的原仓库;归并前再校验,跨仓时通知两仓',
object_type VARCHAR(16) NOT NULL COMMENT 'OUTBOUND_ORDER 或 PRODUCT_STOCK',
object_key_hash CHAR(64) NOT NULL COMMENT '规范化业务键摘要;原业务键保留在载荷中并核对',
first_page_no INT NOT NULL COMMENT '首次发现页码',
observed_at_utc DATETIME(3) NOT NULL COMMENT '实际接收该对象的时刻',
content_hash CHAR(64) NOT NULL COMMENT '完整规范化对象摘要',
payload_json JSON NOT NULL COMMENT '目录阶段可先存订单头,详情齐后补完整 SKU;VALID 后不再修改;库存保存产品库存字段',
before_payload_json JSON NULL COMMENT '订单归并事务内记录归并前的完整规范化事实;新增单为空',
validation_status VARCHAR(16) NOT NULL COMMENT '暂存校验状态:PENDING待核验、VALID有效、INVALID异常',
validation_code VARCHAR(64) NULL COMMENT '字段缺失、冲突、未知时区等原因',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_stage_object (stage_batch_id,object_type,object_key_hash),
KEY idx_selfop_stage_history (object_type,object_key_hash,stage_batch_id),
KEY idx_selfop_stage_observation (object_type,object_key_hash,validation_status,observation_sequence),
KEY idx_selfop_stage_fact_observation (observation_sequence,warehouse_code,object_type,validation_status,run_id,observed_at_utc),
KEY idx_selfop_stage_route (stage_batch_id,warehouse_code,validation_status),
CONSTRAINT chk_selfop_sync_stage CHECK (first_page_no > 0 AND (object_type <> 'OUTBOUND_ORDER' OR validation_status <> 'VALID' OR observation_sequence IS NOT NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='待验证事实与修订证据';
CREATE TABLE ll_selfop_outbound_order (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
delivery_no VARCHAR(128) NOT NULL COMMENT 'OMP 发货单号,采用列表及详情同一身份',
source_no VARCHAR(128) NULL COMMENT 'WMS 来源出库单号,缺失不以内部 ID 回退展示',
source_status INT NOT NULL COMMENT '原始状态,100 已出库、99 已取消;不改造成内部状态码',
source_created_time VARCHAR(64) NULL COMMENT '来源创建时间原始字符串,不使用采集时间替代',
source_outbound_time VARCHAR(64) NULL COMMENT '来源实际出库时间原始字符串,不使用采集时间替代',
source_timezone VARCHAR(64) NULL COMMENT '解析采用的源时区',
outbound_at_utc DATETIME(3) NULL COMMENT '已核准的实际出库时间点;非出库状态可空',
business_date DATE NULL COMMENT '已核准业务时区下的出库日',
business_timezone VARCHAR(64) NOT NULL COMMENT '该事实当前归属仓所用业务时区,迁仓后重新确定业务日',
sales_eligible TINYINT NOT NULL DEFAULT 0 COMMENT '仅已出库、时间与全部数量有效时为 1',
revision_no BIGINT NOT NULL DEFAULT 1 COMMENT '该单内容变更次数,不等于拉取次数',
content_hash CHAR(64) NOT NULL COMMENT '订单头及完整 SKU 明细规范化摘要',
last_run_id BIGINT NOT NULL COMMENT '最后一次确认或修订的执行尝试',
last_observation_sequence BIGINT NOT NULL COMMENT '最后接受的完整详情采集代次;仅防旧观测重放,不按 task 调度序号拒绝较晚实际读取的事实,不冒充领星源版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_order (source_account,customer_code,delivery_no),
KEY idx_selfop_order_daily (source_account,warehouse_code,sales_eligible,business_date,id),
KEY idx_selfop_order_run (last_run_id),
CONSTRAINT chk_selfop_outbound_order CHECK (revision_no > 0 AND last_observation_sequence > 0 AND sales_eligible IN (0,1) AND (sales_eligible = 0 OR (source_status = 100 AND source_timezone IS NOT NULL AND outbound_at_utc IS NOT NULL AND business_date IS NOT NULL)))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='最后确认的单据事实';
CREATE TABLE ll_selfop_outbound_item (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
order_id BIGINT NOT NULL COMMENT '对应外部出库事实',
sku_code VARCHAR(128) NOT NULL COMMENT '原始 productSku',
product_name VARCHAR(255) NULL COMMENT '来源商品名称,仅作展示',
actual_quantity DECIMAL(20,6) NOT NULL COMMENT '详情 actualDeliveryQty,必须为非负有效数值',
quantity_unit VARCHAR(32) NOT NULL COMMENT '已核准业务单位;未确认单位不发布正式数量统计',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_order_sku (order_id,sku_code),
KEY idx_selfop_item_sku (sku_code,order_id),
CONSTRAINT chk_selfop_outbound_item CHECK (actual_quantity >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='整单替换的实际出库明细';
CREATE TABLE ll_selfop_inventory_snapshot (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
snapshot_run_id BIGINT NOT NULL COMMENT 'INVENTORY 执行尝试;发布后该批明细不可修改',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU',
product_quality SMALLINT NOT NULL COMMENT '来源品质,当前 0 正品、1 次品;全量均保存,不套用整箱库存',
product_name VARCHAR(255) NULL COMMENT '来源名称',
physical_quantity DECIMAL(20,6) NULL COMMENT '来源stkQty实物库存,未知为空',
available_quantity DECIMAL(20,6) NULL COMMENT '来源useStkQty可用库存,未知为空,不再次扣冻结',
frozen_quantity DECIMAL(20,6) NULL COMMENT '来源freezeQty冻结库存,未知为空',
transit_quantity DECIMAL(20,6) NULL COMMENT '来源transitQty在途数量,未知为空,不计可售库存',
source_unique_key VARCHAR(512) NULL COMMENT '原 uniqueKey4tenant,只作来源证据',
observed_at_utc DATETIME(3) NOT NULL COMMENT '所在页实际采集时间;缺行占位取本批采集完成时间',
quality_status VARCHAR(16) NOT NULL COMMENT 'VALID、UNKNOWN、INVALID;STALE 按批次年龄实时派生',
quality_reason VARCHAR(64) NULL COMMENT 'SOURCE_ROW_MISSING、FIELD_MISSING、NEGATIVE_QUANTITY 等',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_stock_snapshot (snapshot_run_id,source_account,customer_code,warehouse_code,sku_code,product_quality),
KEY idx_selfop_stock_lookup (source_account,customer_code,warehouse_code,sku_code,product_quality,snapshot_run_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='产品库存全量批次明细';
CREATE TABLE ll_selfop_sku_daily_sales (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
sales_run_id BIGINT NOT NULL COMMENT 'OUTBOUND 执行尝试,同时是计算版本',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU',
business_date DATE NOT NULL COMMENT '已核准业务日',
outbound_quantity DECIMAL(20,6) NOT NULL COMMENT '有效出库实际件数之和,非负',
outbound_order_count BIGINT NOT NULL COMMENT '该维度去重发货单数,非负',
quantity_unit VARCHAR(32) NOT NULL COMMENT '同一统计维度内一致的业务单位',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_daily_version (sales_run_id,source_account,customer_code,warehouse_code,sku_code,business_date),
KEY idx_selfop_daily_window_cover (sales_run_id,source_account,warehouse_code,business_date,customer_code,sku_code,outbound_quantity,outbound_order_count,quantity_unit),
KEY idx_selfop_daily_sku_cover (sales_run_id,source_account,warehouse_code,customer_code,sku_code,business_date,outbound_quantity,outbound_order_count,quantity_unit),
CONSTRAINT chk_selfop_sku_daily_sales CHECK (outbound_quantity >= 0 AND outbound_order_count >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本的稀疏日事实';
CREATE TABLE ll_selfop_warehouse_daily_sales (
sales_run_id BIGINT NOT NULL COMMENT '完整销量计算版本标识',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码',
business_date DATE NOT NULL COMMENT '已核准业务日',
observed_quantity DECIMAL(38,6) NULL COMMENT '该日PCS非负有效出库量之和,无有效行时为空',
invalid_row_count BIGINT NOT NULL COMMENT '该日数量为空或负数或单位非PCS的日事实行数',
source_row_count BIGINT NOT NULL COMMENT '该日参与仓库汇总的SKU日事实总行数',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
PRIMARY KEY (sales_run_id,source_account,warehouse_code,business_date),
CONSTRAINT chk_selfop_warehouse_daily_sales CHECK (source_row_count > 0 AND invalid_row_count >= 0 AND invalid_row_count <= source_row_count AND (observed_quantity IS NULL OR observed_quantity >= 0))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本按仓按日汇总,随SKU日事实批次原子构建';
CREATE TABLE ll_selfop_sku_profile (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
profile_run_id BIGINT NOT NULL COMMENT '本仓销量或库存候选版本;与对应 daily/snapshot 使用同一 run,发布后该版本全部维度行不可修改',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU',
product_name VARCHAR(255) NULL COMMENT '本版本确认的名称,不实时连接其他版本名称',
quantity_unit VARCHAR(32) NULL COMMENT '本版本已核准单位;未知或两版本单位冲突时不计算混合指标',
first_seen_at_utc DATETIME(3) NULL COMMENT '本证据族真实观测到本仓商品的最早接收时刻;缺行占位不能生成观察证据',
earliest_evidence_at_utc DATETIME(3) NULL COMMENT '库存版取真实库存首次观察时刻;销量版从该版有效事实重新求最早已核准出库时间,可随改期、取消或迁仓撤回/后移',
evidence_type VARCHAR(24) NOT NULL COMMENT 'INVENTORY_OBSERVATION、VALID_OUTBOUND、NONE;不得将销量证据复制成库存真实观察证据',
evidence_run_id BIGINT NULL COMMENT '本证据族真实依据的采集批次,无证据占位为空',
last_seen_run_id BIGINT NULL COMMENT '本证据族最近真实观测批次,无证据占位为空',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_profile_version (profile_run_id,source_account,customer_code,warehouse_code,sku_code),
KEY idx_selfop_profile_evidence (profile_run_id,earliest_evidence_at_utc,customer_code,sku_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='随发布版本固定的 SKU 维度与观察依据';
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 '已核对基准或首次变化前观察,原来源时间口径',
@@ -3928,7 +4349,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 '记录版本',
@@ -3948,3 +4369,256 @@ CREATE TABLE IF NOT EXISTS ll_appointment_impact_history (
CONSTRAINT fk_appointment_impact_history FOREIGN KEY (container_booking_id) REFERENCES ll_appointment_impact(container_booking_id),
CONSTRAINT chk_appointment_impact_action CHECK (action IN ('OBSERVED','CLAIM','RECORD','RESOLVE'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='船期观察及人工处理追加历史';
-- SKU 换标实物转换:先执行本增量,再部署服务。不会自动改写存量业务事实。
CREATE TABLE ll_return_sku_conversion (
id BIGINT NOT NULL COMMENT 'SKU库存转换记录ID',
customer_id 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 '操作海外仓ID',
application_id BIGINT NOT NULL COMMENT '换标申请ID',
relabel_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标单号',
item_id BIGINT NOT NULL COMMENT '换标箱级明细ID',
round_key VARCHAR(64) NOT NULL COMMENT '作业轮次,退回重做使用新轮次',
carton_id BIGINT NOT NULL COMMENT '实物来源包裹或箱ID',
carton_mark VARCHAR(128) NOT NULL COMMENT '完工时仓库识别码',
old_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标前SKU',
new_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标后SKU',
product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名快照',
quantity BIGINT NOT NULL COMMENT '实际完成转换件数',
status VARCHAR(32) NOT NULL COMMENT 'APPLIED待客户确认/CONFIRMED已确认/REVERSED退回冲回',
ownership_json LONGTEXT NOT NULL COMMENT '本次转换件数的来源退货单快照,供精确冲回',
completed_at DATETIME(3) NOT NULL COMMENT '实物SKU转换时间',
confirmed_at DATETIME(3) NULL COMMENT '客户确认时间',
reversed_at DATETIME(3) NULL COMMENT '退回冲回时间',
PRIMARY KEY(id),
UNIQUE KEY uk_return_sku_conversion_round(item_id,round_key,old_sku_code,new_sku_code),
KEY idx_return_sku_conversion_app(customer_id,application_id,status),
KEY idx_return_sku_conversion_carton(customer_id,carton_id),
CONSTRAINT chk_return_sku_conversion_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物SKU换标库存转换和冲回记录';
-- PDA cargo discard photo evidence
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 COMMENT='海外PDA货物废弃照片凭证';
-- 货物废弃支持所有入库来源及散箱全量作业;不回填或修改历史单据。
CREATE TABLE ll_selfop_owner (
id BIGINT NOT NULL COMMENT '主键',
run_id BIGINT NOT NULL COMMENT '完整资料采集版本',
source_account VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源账号',
customer_code VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主编码',
customer_name VARCHAR(255) NULL COMMENT '领星货主名称',
source_status VARCHAR(32) NULL COMMENT '来源启停状态',
observed_at_utc DATETIME(6) NOT NULL COMMENT '资料观测时间',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_owner_version (run_id,source_account,customer_code),
KEY idx_selfop_owner_lookup (source_account,customer_code,run_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星货主资料版本';
CREATE TABLE ll_selfop_product (
id BIGINT NOT NULL COMMENT '主键',
run_id BIGINT NOT NULL COMMENT '完整资料采集版本',
source_account VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源账号',
customer_code VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主编码',
sku_code VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '货主商品编码',
product_name VARCHAR(255) NULL COMMENT '商品名称',
declared_length DECIMAL(24,6) NULL COMMENT '商品资料长度',
declared_width DECIMAL(24,6) NULL COMMENT '商品资料宽度',
declared_height DECIMAL(24,6) NULL COMMENT '商品资料高度',
declared_size_unit VARCHAR(32) NULL COMMENT '商品资料尺寸单位',
measured_length DECIMAL(24,6) NULL COMMENT '仓库实测长度',
measured_width DECIMAL(24,6) NULL COMMENT '仓库实测宽度',
measured_height DECIMAL(24,6) NULL COMMENT '仓库实测高度',
measured_size_unit VARCHAR(32) NULL COMMENT '仓库实测尺寸单位',
observed_at_utc DATETIME(6) NOT NULL COMMENT '资料观测时间',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_product_version (run_id,source_account,customer_code,sku_code),
KEY idx_selfop_product_lookup (source_account,customer_code,sku_code,run_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星商品资料版本';
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
USE baiyue_lastleg;
-- SKU 申报件数独立于预计箱数;历史未申报件数保持 NULL,实收以 PDA 清点为准。
USE baiyue_lastleg;
-- 历史库存基线;执行前必须取得指定环境的建表授权。
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 '来源仓库编码',
customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码',
sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码',
source_flow_id VARCHAR(64) NOT NULL COMMENT '来源流水字符串标识',
product_quality INT NOT NULL COMMENT '来源品质,零正品一次品,其他未知',
cell_no VARCHAR(128) NULL COMMENT '来源库位编码',
inner_batch_no VARCHAR(128) NULL COMMENT '来源内部批次号',
bill_no VARCHAR(128) NULL COMMENT '来源业务单号',
source_no VARCHAR(128) NULL COMMENT '来源关联单号',
bill_type VARCHAR(32) NULL COMMENT '来源业务类型,未验证类型不得参与正式还原',
book_qty DECIMAL(20,6) NULL COMMENT '来源记账增减量,未知为空',
book_change_qty_raw VARCHAR(64) NULL COMMENT '来源带符号变动量原文',
delta_quantity DECIMAL(20,6) NULL COMMENT '解析后的有符号实物变动量',
close_quantity DECIMAL(20,6) NULL COMMENT '对应库位批次品质链的结存',
source_create_time VARCHAR(64) NULL COMMENT '来源北京时间原文',
event_at_utc DATETIME(3) NULL COMMENT '流水发生UTC时刻,无法解析时为空',
business_date DATE NULL COMMENT '北京时间业务日期',
quantity_unit VARCHAR(16) NULL COMMENT '已确认数量单位,未知为空',
content_hash CHAR(64) NOT NULL COMMENT '当前事实内容摘要',
revision_no BIGINT NOT NULL COMMENT '同来源流水单调增加的修订号',
stage_id BIGINT NOT NULL COMMENT '当前事实对应不可变暂存证据',
first_run_id BIGINT NOT NULL COMMENT '首次观察执行尝试',
last_run_id BIGINT NOT NULL COMMENT '最近确认执行尝试',
last_observed_at DATETIME(3) NOT NULL COMMENT '最近观察UTC时刻',
validation_status VARCHAR(16) NOT NULL COMMENT '字段资格,有效或未知',
validation_reason VARCHAR(64) NULL COMMENT '数量或身份待核验原因',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_flow_source (source_account,warehouse_code,source_flow_id),
KEY idx_selfop_flow_sku (source_account,warehouse_code,customer_code,sku_code,event_at_utc),
KEY idx_selfop_flow_day (source_account,warehouse_code,business_date),
CONSTRAINT chk_selfop_flow_revision CHECK (revision_no > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='自营历史库存流水最新事实,旧版本保留于暂存证据';
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 '来源仓库编码',
business_date DATE NOT NULL COMMENT '北京时间日末所属日期',
scope_hash CHAR(64) NOT NULL COMMENT '业务范围摘要',
rule_version VARCHAR(32) NOT NULL COMMENT '日末库存还原规则版本',
build_run_id BIGINT NOT NULL COMMENT '历史重建执行尝试',
publication_group_id BIGINT NOT NULL COMMENT '必须一起切换的发布组标识',
sales_run_id BIGINT NULL COMMENT '构建时固定的销量证据版本',
anchor_run_id BIGINT NOT NULL COMMENT '固定的独立库存锚点版本',
input_hash CHAR(64) NOT NULL COMMENT '冻结流水修订与覆盖及锚点输入摘要',
evidence_json JSON NOT NULL COMMENT '固定输入清单及不可变证据引用',
status VARCHAR(16) NOT NULL COMMENT '构建中、就绪、已发布、已替代或失败',
head_slot TINYINT NULL COMMENT '当前日头为一,非当前版本为空',
valid_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '合格商品数',
unknown_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '待核验商品数',
created_at DATETIME(3) NOT NULL COMMENT '版本创建UTC时刻',
published_at DATETIME(3) NULL COMMENT '发布组提交UTC时刻',
superseded_at DATETIME(3) NULL COMMENT '被新组替代的UTC时刻',
invalidated_at DATETIME(3) NULL COMMENT '已确认输入失效的UTC时刻',
invalidation_reason VARCHAR(64) NULL COMMENT '旧版本失效原因',
rebuild_run_id BIGINT NULL COMMENT '处理失效版本的重建尝试',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_history_head (source_account,warehouse_code,business_date,head_slot),
UNIQUE KEY uk_selfop_history_build_day (build_run_id,business_date),
KEY idx_selfop_history_group (publication_group_id,status),
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 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 '来源商品编码',
product_quality INT NOT NULL COMMENT '来源库存品质',
physical_quantity DECIMAL(20,6) NULL COMMENT '日末实物库存,未知为空,已确认零为零',
quality_status VARCHAR(16) NOT NULL COMMENT '日末资格,有效或未知',
quality_reason VARCHAR(64) NULL COMMENT '缺覆盖或锚点等未知原因',
anchor_reference BIGINT NULL COMMENT '独立库存锚点版本',
evidence_hash CHAR(64) NOT NULL COMMENT '该日计算证据摘要',
source VARCHAR(32) NOT NULL COMMENT '库存来源标记,流水还原',
PRIMARY KEY (day_version_id,customer_code,sku_code,product_quality),
KEY idx_selfop_daily_identity (customer_code,sku_code,day_version_id),
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 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 ll_appointment_work_commitment (
entity_key VARCHAR(160) NOT NULL COMMENT '原业务实体稳定键',
state_json LONGTEXT NOT NULL COMMENT '原业务命令产生的只读语义影响基线,不是第二份可编辑计划',
impact_version BIGINT NOT NULL DEFAULT 0 COMMENT '承诺影响单调递增版本',
PRIMARY KEY (entity_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工作事项承诺影响审计基线';
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 '是否完成首次完整来源扫描',
scope_version VARCHAR(128) NULL COMMENT '完整同步已确认的权威来源集合版本',
scan_scope_version VARCHAR(128) NULL COMMENT '当前扫描来源集合版本,防跨页漂移',
completed_at DATETIME(3) NULL COMMENT '最近完整同步UTC时间',
requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '原域命令在同一事务推进的需求版本',
synchronized_requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '完整扫描已消费需求版本',
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) VALUES (1,FALSE);
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 '工作命令乐观锁版本',
retired BOOLEAN NOT NULL DEFAULT FALSE COMMENT '已替代或已完成路线需求,不计活动工作',
state_json LONGTEXT NOT NULL COMMENT '工作关系责任跟进及影响基线,不保存可编辑业务执行事实',
PRIMARY KEY (work_item_key),
KEY idx_appointment_work_container (container_booking_id,retired)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项需求与关系';
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 '真实操作人员标识',
operator_name VARCHAR(128) NULL COMMENT '操作人显示名称',
action VARCHAR(48) NOT NULL COMMENT '工作命令或观察动作',
summary VARCHAR(2000) NOT NULL COMMENT '处理或责任变更依据',
command_key VARCHAR(64) NULL COMMENT '请求幂等键,观察记录为空',
request_hash VARCHAR(64) NULL COMMENT '包含操作者的规范请求摘要',
response_json LONGTEXT NULL COMMENT '原命令响应快照,重放不随当前状态改变',
occurred_at DATETIME(3) NOT NULL COMMENT '历史发生UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_appointment_work_command (work_item_key,command_key),
KEY idx_appointment_work_history (work_item_key,occurred_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令';
-- 海外业务短单号分配时钟。
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='海外业务短单号分配时钟';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 214 AS initialized_tables;
+2
View File
@@ -56,3 +56,5 @@
| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` |
散货托盘码与跨区传递:`firstleg/` 的 `LoadingPalletLabelService.java` 生成冻结标签并调用 Print 输出 PDF,`LoadingCompletedOutboxService.java` 传递装柜快照;`lastleg-service/` 通过 `LastlegSourcePalletMapper.java` 保存来源托盘,`InboundPlanService.java` 和 `ContainerInboundPlanConversionCoordinator.java` 消费。标签与资源预留的细节继续读对应服务指引。
平台自营销量、产品库存与采集:正式入口为 ../docs/自营业务/README.md;适配从 integration-service/AGENTS.md 进入,持久化、指标和平台运行查询从 lastleg-service/AGENTS.md 进入 selfoperated/analytics/,权限源为 iam/src/main/resources/db/selfoperated-analytics-permissions.sql。Admin 从 ../apps/admin-web/src/modules/self-operated/AGENTS.md 进入;与客户物流订单和仓储作业库存分开。
+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`,不得增加子域扫描配置。
@@ -31,6 +31,7 @@ enum BusinessFileCategory {
CONTAINER_DELIVERY_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_UNLOAD_PHOTO(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_CARGO_DISCARD_PHOTO(true, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_RELABEL_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_TRANSFER_MEASUREMENT_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_RELABEL_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
@@ -21,9 +21,11 @@ import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import java.util.Set;
@Service
public class ExportTaskService {
private static final String TURNOVER = "SELFOP_INVENTORY_TURNOVER";
@Resource private ExportTaskRepository repository;
@Resource private ExportStorage storage;
@Resource private ObjectMapper objectMapper;
@@ -34,6 +36,7 @@ public class ExportTaskService {
public ExportTaskResponse create(AuthenticatedUser user, CreateExportTaskRequest request) {
requirePlatform(user);
String type = request.exportType().trim().toUpperCase(Locale.ROOT);
if (TURNOVER.equals(type) && user.impersonated()) throw new BizException(ResultCode.FORBIDDEN, "平台自营导出不支持代登录");
String format = request.format().trim().toUpperCase(Locale.ROOT);
var definition = registry.require(type);
if (!"XLSX".equals(format)) throw new BizException(ResultCode.VALIDATION_FAILED, "当前仅支持 XLSX 格式");
@@ -135,6 +138,9 @@ public class ExportTaskService {
}
private void requireExportCostAccess(AuthenticatedUser user, FileExportTask task) {
if (TURNOVER.equals(task.getExportType()) && (user.impersonated() || !user.permissions().contains("platform:self-operated-analytics:export"))) {
throw new BizException(ResultCode.FORBIDDEN, "缺少平台自营导出权限");
}
if (!"TRIP_OPERATIONS_LIST".equals(task.getExportType())) return;
try {
if (objectMapper.readTree(task.getParametersJson()).path("costVisible").asBoolean(false)
@@ -172,11 +178,14 @@ public class ExportTaskService {
}
values.forEach((key, value) -> {
if (key == null || key.isBlank() || key.length() > 64) throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选键无效");
validateParameterValue(value, 1);
if (TURNOVER.equals(type) && "snapshotContext".equals(key)) {
if (!(value instanceof String text) || text.isBlank() || text.length() > 16000)
throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转查询版本无效,请刷新后重新导出");
} else validateParameterValue(value, 1);
});
try {
int maxBytes = ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST.equals(type)
? 512 * 1024 : 4096;
? 512 * 1024 : TURNOVER.equals(type) ? 64 * 1024 : 4096;
if (objectMapper.writeValueAsBytes(values).length > maxBytes) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"导出筛选条件不能超过 " + (maxBytes / 1024) + "KB");
@@ -210,6 +219,22 @@ public class ExportTaskService {
}
private void validateTypeParameters(String type, Map<String, Object> parameters) {
if (TURNOVER.equals(type)) {
Map<String,Object> values=parameters==null?Map.of():parameters;
if (!values.keySet().containsAll(Set.of("sourceAccount","warehouseCode","endDate","baseWindowDays","snapshotContext","columns","expectedTotal"))
|| !Set.of("sourceAccount","warehouseCode","endDate","baseWindowDays","groupBy","customerCodes","sku","focus","coverageDaysThreshold","turnoverStatus","sortBy","hideZeroInventory","snapshotContext","columns","expectedTotal").containsAll(values.keySet()))
throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转导出筛选无效");
if (!(values.get("columns") instanceof java.util.List<?> columns) || columns.isEmpty() || columns.size()>30
|| columns.stream().anyMatch(v -> !(v instanceof String)) || new java.util.HashSet<>(columns).size()!=columns.size())
throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转导出列无效");
if (values.containsKey("customerCodes") && (!(values.get("customerCodes") instanceof java.util.List<?> owners) || owners.size()>100 || owners.stream().anyMatch(v -> !(v instanceof String))))
throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转货主范围无效");
try {
long count=new java.math.BigDecimal(String.valueOf(values.get("expectedTotal"))).longValueExact();
if(count<0 || count>200000) throw new ArithmeticException();
} catch(RuntimeException e) { throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转导出条数无效"); }
return;
}
if (!ExportSchemaContracts.BUY_DECLARATION_ITEMS.equals(type)) return;
Map<String, Object> values = parameters == null ? Map.of() : parameters;
if (!values.keySet().equals(java.util.Set.of("loadingPlanId"))) {
+9
View File
@@ -224,6 +224,15 @@ baiyue:
max-rows: 200000
max-attempts: 3
batch-size: 200
SELFOP_INVENTORY_TURNOVER:
provider-service: lastleg-service
provider-path: /internal/exports/v1/SELFOP_INVENTORY_TURNOVER/batches
required-permission: platform:self-operated-analytics:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 100
OVERSEAS_INVENTORY_LEDGER:
provider-service: lastleg-service
provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_LEDGER/batches
@@ -0,0 +1,12 @@
openapi: 3.0.3
info:
title: Cargo discard photo storage ownership contract
version: 1.0.0
description: >-
Uses the existing internal business-file upload, metadata and content endpoints, protected
by X-Internal-Service-Token. businessType is OVERSEAS_CARGO_DISCARD_PHOTO; tenantId is a
positive customer ID and businessId is the positive cargo discard application ID.
File enforces composite ownership and non-empty JPG/PNG bytes with image signature and
10 MiB limit. Lastleg owns the pallet binding, submitted visibility and immutable confirmation.
No anonymous or public file endpoint is introduced.
paths: {}
@@ -0,0 +1,50 @@
openapi: 3.0.3
info:
title: 库存周转后台导出
version: 1.0.0
description: File 下载中心生成 XLSX,Lastleg 内部 Provider 按固定库存周转版本供数。浏览器不分页取数、不生成文件。
paths:
/api/platform/v1/files/export-tasks:
post:
summary: 提交库存周转 Excel 导出
description: |
仅平台、非代登录身份及 platform:self-operated-analytics:export 权限。
保留列表的 sourceAccount、warehouseCode、endDate、baseWindowDays、groupBy、customerCodes、sku、focus、coverageDaysThreshold、turnoverStatus、sortBy、hideZeroInventory、snapshotContext。
columns 为当前显示列的有序白名单键,服务端决定列名、格式并追加周转率证据列;expectedTotal 为已查询总条数,范围0至200000。
不接受 current/size/includeOverview。snapshotContext 最长16000字符,整组参数最大64KiB。空结果生成只有表头的文件。
后台每批最多100条,沿用固定上下文30分钟有效期;过期、服务重启或历史版本失效须刷新列表重新提交,不自动改用新版本。
版本或条数变化、分页缺失、超行数、生成失败时不提供部分文件。下载中心复用进度、临时失败退避重试、归属校验及过期规则。
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [exportType, format, taskName, parameters]
properties:
exportType: {type: string, enum: [SELFOP_INVENTORY_TURNOVER]}
format: {type: string, enum: [XLSX]}
taskName: {type: string, maxLength: 128}
parameters:
type: object
required: [sourceAccount, warehouseCode, endDate, baseWindowDays, snapshotContext, columns, expectedTotal]
properties:
snapshotContext: {type: string, minLength: 1, maxLength: 16000}
columns: {type: array, minItems: 1, maxItems: 30, uniqueItems: true, items: {type: string}}
expectedTotal: {type: integer, minimum: 0, maximum: 200000}
additionalProperties: true
responses:
'200': {description: ApiResponse 包装下载中心任务;业务错误遵循通用 code 契约}
'403': {description: 无平台自营导出权限或非平台身份}
/api/platform/v1/files/export-tasks/{taskId}/download:
get:
summary: 下载后台生成的库存周转 Excel
description: 校验任务归属、完成状态、有效期,以及当前非代登录身份和平台自营导出权限。
parameters:
- {name: taskId, in: path, required: true, schema: {type: string}}
responses:
'200':
description: XLSX 文件
content:
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
schema: {type: string, format: binary}
@@ -13,6 +13,7 @@ class LastlegExportDefinitionContractTest {
Map<?, ?> nacos = definitions(Path.of("../config/nacos/test/file-service.yml"));
Map<String, String> expected = Map.ofEntries(
Map.entry("LAST_MILE_ORDER_LIST", "platform:overseas-warehouse:last-mile-order:export"),
Map.entry("SELFOP_INVENTORY_TURNOVER", "platform:self-operated-analytics:export"),
Map.entry("OVERSEAS_INVENTORY_BALANCE", "platform:overseas-warehouse:inventory:export"),
Map.entry("OVERSEAS_INVENTORY_LEDGER", "platform:overseas-warehouse:inventory:export"),
Map.entry("OVERSEAS_QUOTE_LIST", "platform:overseas-master-data:quote:export"),
@@ -28,7 +29,7 @@ class LastlegExportDefinitionContractTest {
assertEquals("/internal/exports/v1/" + entry.getKey() + "/batches", definition.get("provider-path"));
assertEquals(entry.getValue(), definition.get("required-permission"));
assertEquals("PLATFORM", definition.get("app-scope"));
assertEquals(200, definition.get("batch-size"));
assertEquals(entry.getKey().equals("SELFOP_INVENTORY_TURNOVER") ? 100 : 200, definition.get("batch-size"));
var controlled = new ExportDefinitionRegistry.Definition();
controlled.setProviderService("lastleg-service");
controlled.setProviderPath(String.valueOf(definition.get("provider-path")));
@@ -0,0 +1,25 @@
package com.baiyue.file.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.*;
class CargoDiscardPhotoStorageTest {
@TempDir Path root;
@Test void discardEvidenceIsCustomerOwnedAndRequiresRealImageSignature() throws Exception {
var category = BusinessFileCategory.require("OVERSEAS_CARGO_DISCARD_PHOTO");
category.validateOwner(7);
assertThatThrownBy(() -> category.validateOwner(0)).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
assertThatThrownBy(() -> category.storageBusinessId("0")).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
var storage = new BusinessFileStorage(); ReflectionTestUtils.setField(storage, "configuredRoot", root); storage.initializeRoot();
byte[] bytes = {(byte)0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10};
var result = storage.store(7, "11", new MockMultipartFile("file", "discard.png", "image/png", bytes), category);
assertThat(storage.read(result.objectKey())).isEqualTo(bytes);
assertThatThrownBy(() -> storage.store(7, "11", new MockMultipartFile("file", "fake.png", "image/png", "fake".getBytes()), category)).hasMessageContaining("不是有效图片");
assertThatThrownBy(() -> storage.store(7, "11", new MockMultipartFile("file", "doc.pdf", "application/pdf", "%PDF-test".getBytes()), category)).hasMessageContaining("仅支持JPG");
assertThatThrownBy(() -> storage.store(7, "11", new MockMultipartFile("file", "large.png", "image/png", new byte[10*1024*1024+1]), category)).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
}
}
@@ -39,6 +39,23 @@ class ExportTaskServiceTest {
@MockBean private ExportStateService states;
@MockBean private ExportMetrics metrics;
@Resource private ExportTaskService service;
@Test void turnoverKeepsBoundedSignedContextAndRequiresIndependentExportPermission() {
var definition=definition();definition.setRequiredPermission("platform:self-operated-analytics:export");
when(registry.require("SELFOP_INVENTORY_TURNOVER")).thenReturn(definition);
when(states.createConstrained(any(FileExportTask.class))).thenAnswer(c->new ExportStateService.CreationResult(c.getArgument(0),true));
Map<String,Object> parameters=new LinkedHashMap<>();
parameters.put("sourceAccount","demo");parameters.put("warehouseCode","W");parameters.put("endDate","2026-10-14");parameters.put("baseWindowDays",30);
parameters.put("snapshotContext","x".repeat(15000));parameters.put("columns",List.of("sku","quantityTurnoverRate"));parameters.put("expectedTotal",205);
var request=new CreateExportTaskRequest("SELFOP_INVENTORY_TURNOVER","XLSX","库存周转",parameters);
assertThrows(BizException.class,()->service.create(user(1L,Set.of("platform:self-operated-analytics:list")),request));
assertEquals("PENDING",service.create(user(1L,Set.of("platform:self-operated-analytics:export")),request).status());
parameters.put("snapshotContext","x".repeat(16001));
assertThrows(BizException.class,()->service.create(user(1L,Set.of("platform:self-operated-analytics:export")),request));
parameters.put("snapshotContext","fixed");parameters.put("current",2);
assertThrows(BizException.class,()->service.create(user(1L,Set.of("platform:self-operated-analytics:export")),request));
parameters.remove("current");parameters.remove("columns");
assertThrows(BizException.class,()->service.create(user(1L,Set.of("platform:self-operated-analytics:export")),request));
}
@Test
void createsPersistedTaskOnlyWhenSourceExportPermissionIsPresent() {
when(registry.require("CUSTOMER_LIST")).thenReturn(definition());
@@ -94,6 +111,15 @@ class ExportTaskServiceTest {
assertEquals("9", service.get(user(1L, Set.of("platform:download:all")), 9L).id());
}
@Test void turnoverDownloadAndRetryRecheckExportPermissionEvenWithDownloadAll() {
var task=new FileExportTask();task.setId(9L);task.setTenantId(0L);task.setAppCode("PLATFORM");task.setRequestedBy(2L);
task.setExportType("SELFOP_INVENTORY_TURNOVER");task.setStatus("SUCCEEDED");when(repository.find(9L)).thenReturn(task);
var reader=user(1L,Set.of("platform:download:all","platform:self-operated-analytics:list"));
assertEquals(ResultCode.FORBIDDEN,assertThrows(BizException.class,()->service.download(reader,9L)).getResultCode());
assertEquals(ResultCode.FORBIDDEN,assertThrows(BizException.class,()->service.retry(reader,9L)).getResultCode());
verifyNoInteractions(storage);
}
@Test
void rejectsParametersDeeperThanTwoLevels() {
when(registry.require("CUSTOMER_LIST")).thenReturn(definition());
@@ -66,6 +66,24 @@ class GenericXlsxExportRunnerTest {
assertNull(requests.getAllValues().get(0).snapshotToken());assertEquals("snapshot-9",requests.getAllValues().get(1).snapshotToken());
}
@Test void turnoverWorkbookPreservesColumnOrderPercentUnknownAndLiteralSku() throws Exception {
var columns=List.of(new ExportColumn("quantityTurnoverRate","期间周转率",ExportColumnType.STRING,24),
new ExportColumn("sku","SKU",ExportColumnType.STRING,24),new ExportColumn("historyDays","合格历史库存天数",ExportColumnType.STRING,24));
var batch=new ExportBatchResponse(1,"sha256:turnover","fixed",columns,List.of(
Map.of("quantityTurnoverRate","125%","sku","=SUM(1,2)","historyDays","30/30"),
Map.of("quantityTurnoverRate","—","sku","未知商品","historyDays","29/30"),
Map.of("quantityTurnoverRate","0%","sku","零销量","historyDays","30/30")),"3",false,3L);
when(provider.batch(anyString(),eq("SELFOP_INVENTORY_TURNOVER"),any(),any())).thenReturn(ApiResponse.success(batch));
var task=new FileExportTask();task.setId(18L);task.setTenantId(0L);task.setAppCode("PLATFORM");task.setRequestedBy(7L);task.setDispatchVersion(1);
task.setExportType("SELFOP_INVENTORY_TURNOVER");task.setTaskName("库存周转");task.setParametersJson("{\"snapshotContext\":\"fixed\"}");
var definition=new ExportDefinitionRegistry.Definition();definition.setMaxRows(200000);definition.setBatchSize(100);
var result=runner.run(task,definition,new AtomicBoolean(),ignored->{});
try(var workbook=new XSSFWorkbook(storage.load(result.objectKey()).getInputStream())) {
var sheet=workbook.getSheetAt(0);assertEquals("期间周转率",sheet.getRow(0).getCell(0).getStringCellValue());
assertEquals("125%",sheet.getRow(1).getCell(0).getStringCellValue());assertEquals("'=SUM(1,2)",sheet.getRow(1).getCell(1).getStringCellValue());
assertEquals("—",sheet.getRow(2).getCell(0).getStringCellValue());assertEquals("0%",sheet.getRow(3).getCell(0).getStringCellValue());assertEquals(3,result.rows());
}
}
@Test void deletesTheCompletedFileWhenReadingItsFinalSizeFails() throws Exception {
var batch=new ExportBatchResponse(1,"sha256:test","snapshot-9",
List.of(new ExportColumn("text","文本",ExportColumnType.STRING,18)),
+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 账单与明细、客户资金账户及充值申请、应收款月度统计、客户对账确认、收款及分配、成本及分摊、账单修订、单据编号、幂等记录和财务审计状态。
+8 -1
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`。
@@ -54,7 +59,9 @@
| 海外派送完成回写 | 同一内部事件入口、消费者 `service/OverseasDeliveryCompletedConsumer.java`;按来源入仓单、订单、入仓码和 FBA 仓校验后推进当前入仓单 `LOADED→DELIVERED`,同一 Order 全部有效入仓单完成后才经 `OrderFulfillmentCompletionClient` 推进 Order `COMPLETED` |
| 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
| 海外仓业务国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/海外仓业务/船节点标准化契约.md` |
| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/OVERSEAS` 海外车队供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 |
| 已开船预约事项完整来源 | `portschedule/controller/InternalAppointmentWorkSourceController.java`、`service/AppointmentWorkSourceService.java`、`mapper/AppointmentWorkSourceMapper.java`;按完整来源版本游标提供所有实际开船或后续节点的有效货柜,保留无仓绑定与完成货柜;原始当地时间无时区时不得转UTC;契约 `src/main/resources/openapi/appointment-work-source-v1.yaml` |
| 直送目的仓实际卸货 | `terminaldrayage/controller/PlatformDirectDeliveryController.java`、`service/DirectDeliveryService.java`、`UnloadingEvidenceGuard.java`;按实装入仓来源逐票记录签收人与凭据,必须经 Lastleg 确认 DIRECT 路线且已提柜;不创建海外库存,全柜卸毕才可人工还空;契约 `src/main/resources/openapi/direct-delivery-v1.yaml`,基线 `src/main/resources/db/phase3-direct-delivery-schema.sql` |
| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/OVERSEAS` 海外车队供应商,依次选择车队、预约提柜、确认实际提柜,海外仓卸柜或全部直送目的仓实际卸货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 |
| 船节点交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 直投 Lastleg `/internal/lastleg/events`;消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java`,ACK/重试按 `../../docs/统一认证与服务通信契约.md` |
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 |
@@ -248,3 +248,10 @@ PriceBookQueryMapper -> PriceBookQueryReader + PriceBookQueryRows
- 回滚仅恢复原三文件及消费者类型引用;不涉及 URI、权限、JSON、SQL、表结构、业务状态或环境配置。
- 只读模型仍保留原 Lombok 可变 Bean 形态以维持 MyBatis 映射行为;本轮不顺带改造成不可变领域对象。
- 验证结果:七段 `@Select` SQL 文本逐块完全一致;Node 合同 5/5;`test-compile` 成功;价格簿、ArchUnit、根扫描及三个消费者切片定向测试 78/78;Firstleg 聚合全量 878 个测试,0 失败、0 错误、3 跳过,构建成功。
## 预约工作集成时的精确边界核对
`PlatformContainerShippingNodeController` 归船节点切片,`sailingcalendar` 归订舱船期切片;不再误归共享技术层。既有海外事件适配、实装费用重算和渠道目录读取在 `reviewedClassDependencies` 按具体调用者、目标类型和理由登记,不放开整个切片。
现存全局 service 包同时承载暂扣、装柜费用和订舱文档编排,形成 loading.bridge.service、loading.service、oceanbooking.service、service 四包 SCC。它是尚未拆分的既有包级债务,本轮未宣称消除;`reviewedPackageCycles` 只冻结这一精确集合,新增节点或第二个环仍失败,价格簿查询与客户统计仍必须处于所有环之外。合同有新增消费者与新增环的负向测试。预约新增提供端通过原域适配接口消费事实,不增加该包环。
@@ -3,6 +3,7 @@
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { pathToFileURL } from 'node:url';
const moduleRoot = path.resolve(import.meta.dirname, '..');
const sourceRoots = {
@@ -15,8 +16,8 @@ const sliceRules = [
['customer-statistics', /(?:^|\.)(?:customerstatistics)(?:\.|$)/],
['pricebook-query', /(?:^|\.)(?:pricebookquery)(?:\.|$)/],
['finance-integration', /(?:InternalFinance|FinanceFeeSource|FinanceCostSnapshot|FeePaymentVoucher|FeePaymentSnapshot|ContainerFeeSourceSnapshot)/],
['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException)/],
['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs|terminaldrayage)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/],
['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException|ContainerShippingNode)/],
['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs|terminaldrayage|sailingcalendar)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/],
['outbound', /(?:^|\.)outbound(?:\.|$)|(?:OutboundDispatch|ContainerTransport|OverseasContainerEvent)/],
['split-loading', /(?:^|\.)(?:split|loading)(?:\.|$)|(?:Loading|SmartLoading|Container(?:Booking|Overview|Service|Dtos)|PdaPick)/],
['measurement-pricing', /(?:^|\.)pricing(?:\.|$)|(?:Measurement|MeasureReview|Mojia|Pricing|PriceBook|PriceChannel|PriceOrigin|PriceWarehouse|Fba|Dimension|FeeItem|MerchantInboundBilling|InboundFee)/],
@@ -261,14 +262,28 @@ function renderDot(report) {
return lines.join('\n');
}
function checkContract(report, contractPath) {
const absolute = path.resolve(process.cwd(), contractPath);
const contract = JSON.parse(fs.readFileSync(absolute, 'utf8'));
export function contractViolations(report, contract) {
const allowed = new Set(contract.allowedSliceEdges ?? []);
const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`);
const additions = actual.filter(edge => !allowed.has(edge));
const violations = additions.map(edge => `new slice dependency: ${edge}`);
const violations = [];
const cycles=new Set((contract.reviewedPackageCycles ?? []).filter(c=>c.reason?.trim()
&& c.packages?.length>1 && c.packages.every(p=>!p.includes('*'))).map(c=>[...c.packages].sort().join('|')));
for (const cycle of report.packageSccs ?? []) {
if (!cycles.has([...cycle].sort().join('|'))) violations.push(`unreviewed package cycle: ${cycle.join(' -> ')}`);
}
const reviewed = new Set();
for (const edge of contract.reviewedClassDependencies ?? []) {
if (!edge.source || !edge.target || !edge.reason?.trim() || /[*]/.test(edge.source + edge.target)) {
violations.push('reviewed dependency requires exact classes and a rationale');
continue;
}
reviewed.add(`${edge.source}->${edge.target}`);
}
const classesByName = new Map(report.classes.map(entry => [entry.fqcn, entry]));
for (const edge of report.classDependencies) {
const from=classesByName.get(edge.source)?.slice, to=classesByName.get(edge.target)?.slice;
if (from && to && from!==to && !allowed.has(`${from}->${to}`) && !reviewed.has(`${edge.source}->${edge.target}`))
violations.push(`unreviewed slice dependency: ${from}->${to}: ${edge.source}->${edge.target}`);
}
for (const [fqcn, expectedFile] of Object.entries(contract.requiredClassLocations ?? {})) {
const actualClass = classesByName.get(fqcn);
if (!actualClass) violations.push(`required class missing: ${fqcn}`);
@@ -312,12 +327,19 @@ function checkContract(report, contractPath) {
violations.push(`${slice} incoming dependencies changed: ${incoming.join(', ')}`);
}
}
return violations;
}
function checkContract(report, contractPath) {
const contract=JSON.parse(fs.readFileSync(path.resolve(process.cwd(),contractPath),'utf8'));
const violations=contractViolations(report,contract);
if (violations.length > 0) {
process.stderr.write(`Firstleg dependency contract violations in ${contractPath}:\n${violations.map(item => `- ${item}`).join('\n')}\n`);
process.exitCode = 1;
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const options = parseArgs(process.argv.slice(2));
const report = buildReport(options.sourceSet);
if (options.check) checkContract(report, options.check);
@@ -328,3 +350,5 @@ const rendered = options.format === 'json'
: renderDot(report);
if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8');
else process.stdout.write(rendered);
}
@@ -1,5 +1,7 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { contractViolations } from './analyze-firstleg-dependencies.mjs';
import path from 'node:path';
import test from 'node:test';
@@ -7,6 +9,17 @@ const moduleRoot = path.resolve(import.meta.dirname, '..');
const analyzer = path.join(moduleRoot, 'scripts', 'analyze-firstleg-dependencies.mjs');
const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'firstleg-slice-dependencies.json');
test('reviewed original-domain calls do not open an entire slice or allow wildcard grants', () => {
const report=analyze(); const baseline=JSON.parse(readFileSync(contract,'utf8'));
assert.deepEqual(contractViolations(report,baseline),[]);
report.classDependencies.push({source:'com.baiyue.firstleg.service.SmartLoadingService',target:'com.baiyue.firstleg.domain.PriceBookChannelCatalog'});
assert.ok(contractViolations(report,baseline).some(message=>message.includes('SmartLoadingService->com.baiyue.firstleg.domain.PriceBookChannelCatalog')));
baseline.reviewedClassDependencies.push({source:'com.baiyue.firstleg.*',target:'com.baiyue.firstleg.service.FbaPricingService',reason:'invalid wildcard'});
assert.ok(contractViolations(report,baseline).some(message=>message.includes('exact classes')));
report.packageSccs.push(['com.baiyue.firstleg.service','com.baiyue.firstleg.pricebookquery.application']);
assert.ok(contractViolations(report,baseline).some(message=>message.includes('unreviewed package cycle')));
});
function analyze() {
return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'json'], {
cwd: moduleRoot,
@@ -53,7 +66,7 @@ test('customer statistics application layer only depends inward', () => {
assert.deepEqual(outwardDependencies, []);
});
test('price book query is a leaf slice and removes the package cycle', () => {
test('price book query remains a leaf slice outside reviewed pre-existing package cycles', () => {
const report = analyze();
const classes = report.classes.filter(entry => entry.slice === 'pricebook-query');
assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [
@@ -67,7 +80,9 @@ test('price book query is a leaf slice and removes the package cycle', () => {
assert.deepEqual(report.sliceEdges.filter(edge => edge.target === 'pricebook-query')
.map(edge => edge.source).sort(), ['booking-customs-trucking', 'measurement-pricing', 'split-loading']);
assert.equal(report.sliceSccs.some(component => component.includes('pricebook-query')), false);
assert.deepEqual(report.packageSccs, []);
assert.equal(report.packageSccs.some(cycle=>cycle.some(pkg=>pkg.startsWith('com.baiyue.firstleg.pricebookquery'))),false);
const reviewed=JSON.parse(readFileSync(contract,'utf8')).reviewedPackageCycles.map(c=>c.packages);
assert.deepEqual(report.packageSccs,reviewed);
});
test('price book query application and consumers respect ports', () => {
@@ -0,0 +1,12 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
@FeignClient(name="lastleg-service",contextId="appointmentWorkRouteClient",url="${LASTLEG_SERVICE_URL:${baiyue.clients.lastleg.url:}}")
public interface AppointmentWorkRouteClient {
record Route(String flow) { }
@GetMapping("/internal/lastleg/appointment-work/containers/{id}/route")
ApiResponse<Route> route(@RequestHeader("X-Internal-Service-Token") String token,@PathVariable("id") long id);
}
@@ -13,7 +13,7 @@ public final class ContainerOverviewDtos {
public record Overview(ContainerBookingEntityView container, Long loadingPlanId, PdaSummary pda,
List<Document> documents, ExceptionState exception, List<ExceptionHistory> exceptionHistory,
List<TimelineEntry> timeline) {}
public record ContainerBookingEntityView(Long id, String bookingNo, String cabinetNo, String containerNo,
public record ContainerBookingEntityView(Long id, String cabinetNo, String containerNo,
String status, Long warehouseId, String warehouseName, String voyageNo, String vesselName,
String billOfLadingNo, Long shippingCompanyId, String shippingCompanyName,
String departurePortCode, String departurePortName, String destinationPortCode,
@@ -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) {}
@@ -48,7 +48,7 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
if (size == 0) size = 20;
}
}
public record WorkbenchContainer(Long bookingId, String bookingNo, String cabinetNo, String containerNo,
public record WorkbenchContainer(Long bookingId, String cabinetNo, String containerNo,
Long warehouseId, String warehouseName, String channelGroupCode, String channelCode, String channelName, String containerType,
BigDecimal capacityCbm, BigDecimal maxWeightKg,
Long planId, String planNo, String planStatus, Integer version) {}
@@ -19,7 +19,6 @@ import java.time.LocalDateTime;
@TableName("fl_container_booking")
public class ContainerBookingEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String bookingNo;
private String containerNo;
private String cabinetNo;
private String sourceType;
@@ -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);
}
@@ -335,7 +335,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
@Select("""
<script>
SELECT b.id bookingId,b.booking_no bookingNo,b.cabinet_no cabinetNo,b.container_no containerNo,
SELECT b.id bookingId,b.cabinet_no cabinetNo,b.container_no containerNo,
b.warehouse_id warehouseId,b.warehouse_name warehouseName,b.channel_group_code channelGroupCode,b.channel_code channelCode,
b.channel_name channelName,b.container_type containerType,b.capacity_cbm capacityCbm,
b.max_weight_kg maxWeightKg,p.id planId,p.plan_no planNo,
@@ -0,0 +1,36 @@
package com.baiyue.firstleg.portschedule.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.portschedule.dto.AppointmentWorkSourceDtos.*;
import com.baiyue.firstleg.portschedule.service.AppointmentWorkSourceService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
@RestController
@RequestMapping("/internal/firstleg/appointment-work")
public class InternalAppointmentWorkSourceController {
private final AppointmentWorkSourceService service;
private final String token;
public InternalAppointmentWorkSourceController(AppointmentWorkSourceService service,
@Value("${baiyue.security.internal-service-token:}") String token) { this.service=service; this.token=token; }
@GetMapping("/containers")
public ApiResponse<SourcePage> discover(@RequestHeader(value="X-Internal-Service-Token",required=false) String supplied,
@RequestParam(required=false) String after,@RequestParam(defaultValue="200") int size) {
authorize(supplied); return ApiResponse.success(service.discover(after,size));
}
@GetMapping("/containers/{id}")
public ApiResponse<ContainerSource> container(@RequestHeader(value="X-Internal-Service-Token",required=false) String supplied,
@PathVariable long id) { authorize(supplied); return ApiResponse.success(service.container(id)); }
@PostMapping("/facts")
public ApiResponse<List<Fact>> facts(@RequestHeader(value="X-Internal-Service-Token",required=false) String supplied,
@RequestBody List<FactRequest> requests) { authorize(supplied); return ApiResponse.success(service.facts(requests)); }
private void authorize(String supplied) {
if(token==null || token.isBlank() || supplied==null || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),supplied.getBytes(StandardCharsets.UTF_8)))
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"内部身份无效");
}
}
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.portschedule.dto;
import java.time.*;
import java.util.List;
/** Internal wire values. Unzoned source timestamps are never converted using the server zone. */
public final class AppointmentWorkSourceDtos {
private AppointmentWorkSourceDtos() { }
public record Reference(String kind, String key, String businessNo, String label) { }
public record SourceReference(String customerId,String sourceKey,String destinationKey) { }
public record Requirement(String requirementKey, String batchKey, String batchNo, String destinationKind,
String originName, String destinationName, String timezone, List<Reference> references, List<String> replacesRequirementKeys,
List<SourceReference> sources) { }
public record ContainerSource(String containerBookingId, String containerNo, Instant departedAt,
LocalDateTime departedLocalAt, LocalDateTime sailedEvidenceLocalAt, String sourceVersion, String establishedFlow, String timezone, List<Requirement> requirements) { }
public record SourcePage(List<ContainerSource> containers, String nextCursor, boolean complete, String scopeVersion) { }
public record ContainerReference(String containerBookingId, String bookingNo, String containerNo) { }
public record FactRequest(String workItemKey, String type, String flow, String containerBookingId,
String requirementKey, String batchKey, String tripKey, List<String> batchKeys, List<ContainerReference> containers,List<SourceReference> sources) { }
public record Slot(LocalDate date, LocalTime startTime, LocalDate endDate, LocalTime endTime, String timezone) { }
public record Actual(Instant occurredAt, LocalDateTime localOccurredAt, String timezone) { }
public record Target(String kind, String containerBookingId) { }
public record Action(String code, String label, String targetWorkItemKey, Target target) { }
public record Responsibility(String kind, String id, String name) { }
public record Fact(String workItemKey, String fingerprint, String impactToken, boolean planExists, String planStatus,
String 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<Reference> references, Responsibility responsibility) { }
}
@@ -2,6 +2,6 @@ package com.baiyue.firstleg.portschedule.dto;
import java.time.LocalDateTime;
public record ArrivalCalendarEntry(Long containerBookingId, String bookingNo, String containerNo,
public record ArrivalCalendarEntry(Long containerBookingId, String cabinetNo, String containerNo,
String vesselName, String voyageNo, String destinationPortName, String destinationWarehouseCode,
LocalDateTime arrivalAt, String arrivalType) {}
@@ -0,0 +1,64 @@
package com.baiyue.firstleg.portschedule.mapper;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface AppointmentWorkSourceMapper {
record Source(long id, String cabinetNo, String containerNo, String departurePortName,
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 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,
b.version,MAX(f.id) fact_version,
(SELECT COUNT(*) FROM fl_direct_delivery_unloading d WHERE d.container_booking_id=b.id) delivery_version
FROM fl_container_booking b JOIN fl_carrier_schedule_node_fact f ON f.container_booking_id=b.id
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(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("""
<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 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 p.container_booking_id,i.inbound_id
</script>
""")
List<ContainerCargo> cargo(@Param("ids") List<Long> ids);
}
@@ -15,6 +15,15 @@ import java.util.List;
@Mapper
public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQueryEntity> {
@Select("""
SELECT container_booking_id FROM fl_carrier_schedule_node_fact
WHERE standard_node='PICKED_UP' AND actual_at IS NOT NULL
GROUP BY container_booking_id
HAVING MAX(actual_at)>=#{from} AND MAX(actual_at)<#{to}
ORDER BY MAX(actual_at),container_booking_id LIMIT 1001
""")
List<Long> selectPickupCalendarContainers(@Param("from") LocalDateTime from,@Param("to") LocalDateTime to);
@Select("""
<script>
SELECT * FROM fl_carrier_schedule_query
@@ -12,7 +12,7 @@ import org.apache.ibatis.annotations.Update;
@Mapper
public interface PortScheduleMapper extends BaseMapper<PortScheduleEntity> {
@Select("""
SELECT c.id containerBookingId,c.booking_no bookingNo,c.container_no containerNo,
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,
@@ -49,6 +49,9 @@ public class CarrierScheduleQueryRepository {
public List<ContainerActualFacts> containerActualFacts(List<Long> containerBookingIds) {
return queries.selectContainerActualFacts(containerBookingIds);
}
public List<Long> pickupCalendarContainers(LocalDateTime from, LocalDateTime to) {
return queries.selectPickupCalendarContainers(from, to);
}
public List<CarrierScheduleQueryEntity> due(LocalDateTime now, int limit) { return queries.selectDue(now, limit); }
public boolean claimLease(long id, String owner, LocalDateTime now, LocalDateTime leaseUntil) {
return queries.claimLease(id, owner, now, leaseUntil) == 1;
@@ -0,0 +1,90 @@
package com.baiyue.firstleg.portschedule.service;
import com.baiyue.firstleg.portschedule.dto.AppointmentWorkSourceDtos.*;
import com.baiyue.firstleg.portschedule.mapper.AppointmentWorkSourceMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.security.MessageDigest;
import java.util.*;
@Service
public class AppointmentWorkSourceService {
public interface OriginalFacts {
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;
private final ObjectMapper json;
public AppointmentWorkSourceService(AppointmentWorkSourceMapper mapper,OriginalFacts original,ObjectMapper json) {
this.mapper=mapper; this.original=original; this.json=json;
}
@Transactional(readOnly=true, isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public SourcePage discover(String after,int size) {
if(size<1 || size>200) throw bad("分页大小必须为1至200");
var rows=mapper.sources();
String scope=hash(rows); long last=0;
if(after!=null) {
String[] parts=after.split(":",-1);
if(parts.length!=2) throw bad("来源游标无效");
if(!scope.equals(parts[0])) throw new ResponseStatusException(HttpStatus.CONFLICT,"已开船来源已变化,请重新同步");
last=id(parts[1]);
}
final long cursor=last;
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(sources(selected),complete?null:scope+":"+selected.get(selected.size()-1).id(),complete,scope);
}
public ContainerSource container(long id) {
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 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,"所属客户"),
new Reference("ORDER",Long.toString(c.orderId()),c.orderNo(),"原订单"),
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)),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<>(); 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("事项身份或事实类型无效");
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)));}
catch(Exception error){throw new IllegalStateException("不能生成来源版本",error);}
}
private static long id(String value) {
try {long id=Long.parseLong(value); if(id>0)return id;} catch(RuntimeException ignored) { }
throw bad("柜次或游标无效");
}
private static ResponseStatusException bad(String message){return new ResponseStatusException(HttpStatus.BAD_REQUEST,message);}
}
@@ -68,6 +68,7 @@ public class CarrierScheduleQueryService {
@Resource private CarrierScheduleAdapterRegistry adapters;
@Resource private CarrierScheduleNodeOutboxService nodeOutbox;
@Resource private ObjectMapper objectMapper;
@Resource private EmptyReturnPrerequisite emptyReturnPrerequisite;
@Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}")
private String scheduleZone = "Asia/Shanghai";
private Clock clock = Clock.systemDefaultZone();
@@ -81,6 +82,14 @@ public class CarrierScheduleQueryService {
source.page(), source.size(), source.total(), source.pages());
}
public List<Long> pickupCalendarContainers(LocalDateTime from, LocalDateTime to) {
if (from == null || to == null || !to.isAfter(from)
|| java.time.Duration.between(from, to).compareTo(java.time.Duration.ofDays(42)) > 0) {
throw badRequest("提柜日历查询范围必须为1至42天");
}
return repository.pickupCalendarContainers(from, to);
}
public List<ContainerActualFacts> containerActualFacts(List<Long> containerBookingIds) {
if (containerBookingIds == null || containerBookingIds.isEmpty() || containerBookingIds.size() > 200) {
throw badRequest("containerBookingIds数量必须在1到200之间");
@@ -223,6 +232,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
requireManualActualAdvance(value, node, request.actualAt());
requireEmptyReturnEvidence(value.getContainerBookingId(), node, request.actualAt());
CarrierScheduleQueryEntity before = copy(value);
String providerEventId = sha256("MANUAL|" + id + '|' + key);
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
@@ -266,6 +276,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleQueryEntity query = found == null ? null : requiredLocked(found.getId());
String providerEventId = sha256("CONTAINER_MANUAL|" + containerBookingId + '|' + key);
String payloadHash = hash("CONTAINER_MANUAL_NODE", containerBookingId, request);
requireEmptyReturnEvidence(containerBookingId, node, request.actualAt());
CarrierScheduleNodeFactEntity fact = addNode(query == null ? null : query.getId(), containerBookingId,
"MANUAL", providerEventId,
node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(),
@@ -331,6 +342,11 @@ public class CarrierScheduleQueryService {
}
}
private void requireEmptyReturnEvidence(long containerBookingId,CarrierScheduleNode node,LocalDateTime actualAt) {
if(node==CarrierScheduleNode.EMPTY_RETURNED && actualAt!=null)
emptyReturnPrerequisite.requireUnloaded(containerBookingId,actualAt);
}
public boolean hasOutboundDepartureFact(long customerId, long oceanBookingId, long containerBookingId) {
CarrierScheduleQueryEntity query = repository.findByBusinessScope(customerId, oceanBookingId,
containerBookingId);
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.portschedule.service;
import java.time.LocalDateTime;
/** Actual unloading evidence owned by its original domain; not a work-item completion flag. */
public interface EmptyReturnPrerequisite {
LocalDateTime completedUnloading(long containerBookingId);
void requireUnloaded(long containerBookingId, LocalDateTime returnedAt);
}
@@ -42,7 +42,7 @@ public class ContainerOverviewService {
.toList();
PdaSummary pda = task == null ? null : pda(task);
List<TimelineEntry> timeline = timeline(id, task == null ? null : task.id(), exceptionHistory);
return new Overview(new ContainerBookingEntityView(booking.getId(), booking.getBookingNo(),
return new Overview(new ContainerBookingEntityView(booking.getId(),
booking.getCabinetNo(), booking.getContainerNo(), booking.getPlanStatus(), booking.getWarehouseId(),
booking.getWarehouseName(), booking.getVoyageNo(), booking.getVesselName(), booking.getBillOfLadingNo(),
booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getDeparturePortCode(),
@@ -63,7 +63,7 @@ public class ContainerService {
.map(ChannelRow::getChannelCode).anyMatch(channelGroup.channelCodes()::contains);
if (!activeGroup) throw conflict("所选物流渠道分组在当前仓库已失效,请重新选择");
ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(IdWorker.getId());
value.setBookingNo(documentNumbers.next("CB")); value.setCabinetNo(documentNumbers.nextCabinetNo());
value.setCabinetNo(documentNumbers.nextCabinetNo());
value.setSourceType("SIMULATION"); value.setRecordStatus("ACTIVE");
value.setChannelGroupCode(channelGroup.code()); value.setChannelCode(null); value.setChannelName(null);
value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName());
@@ -19,7 +19,7 @@ import java.util.Set;
public class FirstlegDocumentNumberGenerator {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
private static final Set<String> SUPPORTED_PREFIXES = Set.of("OB", "TA", "CB", "LP", "DP");
private static final Set<String> SUPPORTED_PREFIXES = Set.of("OB", "TA", "LP", "DP");
private static final long MAX_DAILY_SEQUENCE = 9_999L;
private final JdbcTemplate jdbc;
@@ -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,30 @@
package com.baiyue.firstleg.terminaldrayage.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.terminaldrayage.service.DirectDeliveryService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/platform/v1/terminal-drayages/{containerBookingId}/direct-deliveries")
public class PlatformDirectDeliveryController {
private final DirectDeliveryService service;
public PlatformDirectDeliveryController(DirectDeliveryService service){this.service=service;}
@GetMapping
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
public ApiResponse<List<DirectDeliveryService.View>> list(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long containerBookingId) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.list(containerBookingId));
}
@PostMapping("/{inboundId}")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:direct-unload:confirm')")
public ApiResponse<DirectDeliveryService.View> record(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long containerBookingId,
@PathVariable long inboundId,@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody DirectDeliveryService.Request request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.record(containerBookingId,inboundId,user.userId(),user.username(),key,request));
}
}
@@ -3,6 +3,6 @@ package com.baiyue.firstleg.terminaldrayage.dto;
import java.time.LocalDateTime;
/** Original pickup appointment wall time; the source does not establish a port time zone. */
public record PickupCalendarEntry(Long containerBookingId, String bookingNo, String containerNo,
public record PickupCalendarEntry(Long containerBookingId, String cabinetNo, String containerNo,
String destinationPortName, String destinationWarehouseCode, String fleetSupplierName,
LocalDateTime pickupAppointmentAt, LocalDateTime actualPickupAt) { }
@@ -0,0 +1,77 @@
package com.baiyue.firstleg.terminaldrayage.mapper;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface DirectDeliveryMapper {
record Delivery(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) { }
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);
@Select("SELECT id,record_status,unloading_completed_at warehouse_unloaded_at FROM fl_container_booking WHERE id=#{id}")
Container container(@Param("id") long id);
@Select("""
SELECT 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=#{id} AND p.plan_status='COMPLETED'
GROUP BY 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 i.inbound_id
""")
List<Delivery> deliveries(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_direct_delivery_unloading WHERE container_booking_id=#{id}")
int evidenceCount(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_direct_delivery_unloading WHERE container_booking_id=#{id} AND idempotency_key=#{key}")
int keyCount(@Param("id") long id,@Param("key") String key);
@Insert("""
INSERT INTO fl_direct_delivery_unloading(container_booking_id,inbound_id,actual_at,timezone,received_by,
evidence_no,remark,version,idempotency_key,request_hash,created_by,created_by_name)
VALUES(#{id},#{inboundId},#{actualAt},#{timezone},#{receivedBy},#{evidenceNo},#{remark},1,#{key},#{hash},#{operatorId},#{operatorName})
""")
int insert(@Param("id") long id,@Param("inboundId") long inboundId,@Param("actualAt") LocalDateTime actualAt,
@Param("timezone") String timezone,@Param("receivedBy") String receivedBy,@Param("evidenceNo") String evidenceNo,
@Param("remark") String remark,@Param("key") String key,@Param("hash") String hash,
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
}
@@ -15,7 +15,7 @@ import java.util.List;
@Mapper
public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity> {
@Select("""
SELECT c.id containerBookingId,c.booking_no bookingNo,c.container_no containerNo,
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
c.destination_port_name destinationPortName,c.destination_warehouse_code destinationWarehouseCode,
t.fleet_supplier_name fleetSupplierName,t.pickup_appointment_at pickupAppointmentAt,NULL actualPickupAt
FROM fl_terminal_drayage t JOIN fl_container_booking c ON c.id=t.container_booking_id
@@ -27,6 +27,21 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
List<com.baiyue.firstleg.terminaldrayage.dto.PickupCalendarEntry> calendar(
@Param("from") LocalDateTime from,@Param("to") LocalDateTime to,@Param("limit") int limit);
@Select("""
<script>
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
c.destination_port_name destinationPortName,c.destination_warehouse_code destinationWarehouseCode,
t.fleet_supplier_name fleetSupplierName,t.pickup_appointment_at pickupAppointmentAt,NULL actualPickupAt
FROM fl_container_booking c LEFT JOIN fl_terminal_drayage t ON t.container_booking_id=c.id
WHERE c.record_status='ACTIVE' AND c.id IN
<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
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')
ORDER BY c.id
</script>
""")
List<com.baiyue.firstleg.terminaldrayage.dto.PickupCalendarEntry> calendarByContainers(@Param("ids") List<Long> ids);
@Select("""
SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId,
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
@@ -99,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,127 @@
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;
import org.springframework.stereotype.Component;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.security.MessageDigest;
import java.util.*;
/** The terminal domain adapts its facts to a wire port; the work service never imports its persistence. */
@Component
public class AppointmentWorkTerminalFacts implements OriginalFacts {
private static final String READ="platform:firstleg:terminal-drayage:list";
private final TerminalDrayageMapper terminal;
private final DirectDeliveryMapper direct;
private final CarrierScheduleQueryService nodes;
private final UnloadingEvidenceGuard unloading;
private final ObjectMapper json;
public AppointmentWorkTerminalFacts(TerminalDrayageMapper terminal,DirectDeliveryMapper direct,
CarrierScheduleQueryService nodes,UnloadingEvidenceGuard unloading,ObjectMapper json) {
this.terminal=terminal;this.direct=direct;this.nodes=nodes;this.unloading=unloading;this.json=json;
}
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 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>();
LocalDateTime at=null; String zone=null; Slot confirmed=null; boolean exists=false;
String code=null,label=null,permission=null; Responsibility responsibility=null;
String businessNo=references.stream().filter(r->"CONTAINER".equals(r.kind())).map(Reference::businessNo).filter(Objects::nonNull).findFirst().orElse(null);
String impact="BASELINE";
switch(request.type()) {
case "PICKUP" -> {
at=node==null?null:node.pickedUpAt();
if(plan!=null && plan.getPickupAppointmentAt()!=null) {
var time=plan.getPickupAppointmentAt(); confirmed=new Slot(time.toLocalDate(),time.toLocalTime(),null,null,null); exists=true;
}
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.
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("实际到港后才能安排提柜");
else if(plan==null || plan.getFleetSupplierId()==null) {code="SAVE_PLAN";label="选择码头车队";permission="platform:firstleg:terminal-drayage:fleet:edit";}
else if(confirmed==null) {code="REQUEST_BOOKING";label="预约提柜";permission="platform:firstleg:terminal-drayage:pickup-appointment:edit";}
else {code="RECORD_EXECUTION";label="记录实际提柜";permission="platform:firstleg:port-schedule-query:manual";}
}
}
case "EMPTY_RETURN" -> {
at=node==null?null:node.emptyReturnedAt();
if(at==null && plan!=null) at=plan.getActualEmptyReturnAt();
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.
}
case "DELIVERY" -> {
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=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("实际提柜后才能记录直送卸货");
else if(at==null) {code="RECORD_EXECUTION";label="登记目的仓实际卸货";permission="platform:firstleg:terminal-drayage:direct-unload:confirm";}
}
default -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"不支持的Firstleg事实类型");
}
if((at!=null || confirmed!=null) && zone==null) missing.add("SOURCE_TIMEZONE");
Actual actual=at==null?null:new Actual(zone==null?null:at.atZone(ZoneId.of(zone)).toInstant(),at,zone);
Action action=code==null?null:new Action(code,label,null,new Target("FIRSTLEG_CONTAINER",source.containerBookingId()));
var fact=new Fact(request.workItemKey(),"pending",impact,exists,confirmed==null?"UNPLANNED":"CONFIRMED",
actual!=null?"COMPLETED":"PENDING",null,confirmed,actual,zone,businessNo,null,List.of(READ),
permission==null?List.of():List.of(permission),action,List.copyOf(blocks),List.copyOf(missing),List.copyOf(references),responsibility);
return new Fact(fact.workItemKey(),hash(fact),fact.impactToken(),fact.planExists(),fact.planStatus(),fact.executionStatus(),
fact.target(),fact.confirmed(),fact.actual(),fact.timezone(),fact.businessNo(),fact.tripKey(),fact.readPermissions(),
fact.actionPermissions(),fact.action(),fact.blockReasons(),fact.missingFields(),fact.references(),fact.responsibility());
}
private String hash(Object value) {
try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value)));}
catch(Exception error){throw new IllegalStateException(error);}
}
}
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.client.AppointmentWorkRouteClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
@Service
public class DirectDeliveryRouteGuard {
private final AppointmentWorkRouteClient client;
private final String token;
public DirectDeliveryRouteGuard(AppointmentWorkRouteClient client,@Value("${baiyue.security.internal-service-token:}") String token){this.client=client;this.token=token;}
public void requireDirect(long id) {
if(token==null || token.isBlank()) throw unavailable();
com.baiyue.common.core.api.ApiResponse<AppointmentWorkRouteClient.Route> response;
try {response=client.route(token,id);} catch(RuntimeException error){throw unavailable();}
if(response==null || !response.successful() || response.data()==null) throw unavailable();
if(!"DIRECT".equals(response.data().flow())) throw new ResponseStatusException(HttpStatus.CONFLICT,"请先在预约工作中确认整柜直送路线");
}
private ResponseStatusException unavailable(){return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"无法核实已确认路线,请稍后重试");}
}
@@ -0,0 +1,77 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.constraints.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.MessageDigest;
import java.time.*;
import java.util.*;
/** Actual direct delivery evidence. It does not call warehouse receipt or create stock. */
@Service
public class DirectDeliveryService {
public record Request(@Min(0) int expectedVersion,@NotNull LocalDateTime actualAt,@NotBlank String timezone,
@NotBlank @Size(max=128) String receivedBy,@NotBlank @Size(max=128) String evidenceNo,@Size(max=1000) String remark) { }
public record View(String requirementKey,String inboundCode,String orderNo,String destinationName,int packageCount,
int version,LocalDateTime actualAt,String timezone,String receivedBy,String evidenceNo,String remark) { }
private final DirectDeliveryMapper mapper;
private final CarrierScheduleQueryService nodes;
private final ObjectMapper json;
private final DirectDeliveryRouteGuard routes;
public DirectDeliveryService(DirectDeliveryMapper mapper,CarrierScheduleQueryService nodes,ObjectMapper json,DirectDeliveryRouteGuard routes) {
this.mapper=mapper;this.nodes=nodes;this.json=json;this.routes=routes;
}
public List<View> list(long id) {
var container=mapper.container(id);
if(container==null || !"ACTIVE".equals(container.recordStatus())) throw new BizException(ResultCode.NOT_FOUND,"货柜不存在");
return mapper.deliveries(id).stream().map(this::view).toList();
}
@Transactional
public View record(long id,long inboundId,long operatorId,String operatorName,String key,Request request) {
if(key==null || key.isBlank() || key.length()>128 || request==null) throw bad("请提供有效幂等键及卸货记录");
var container=mapper.lockContainer(id);
if(container==null || !"ACTIVE".equals(container.recordStatus())) throw new BizException(ResultCode.NOT_FOUND,"货柜不存在");
var current=mapper.deliveries(id).stream().filter(row->row.inboundId()==inboundId).findFirst()
.orElseThrow(()->bad("该入仓来源不在本柜实际装柜清单内"));
String hash=hash(request);
if(key.equals(current.idempotencyKey())) {
if(!hash.equals(current.requestHash())) throw conflict("同一幂等键对应的卸货内容已变化");
return view(current);
}
if(mapper.keyCount(id,key)>0) throw conflict("幂等键已被其他卸货记录使用");
if(current.version()!=request.expectedVersion() || current.actualAt()!=null) throw conflict("实际卸货已确认,请刷新核对");
if(container.warehouseUnloadedAt()!=null) throw conflict("已有海外仓卸柜事实,不能改记为直送");
routes.requireDirect(id);
ZoneId zone;
try {if(!ZoneId.getAvailableZoneIds().contains(request.timezone())) throw new IllegalArgumentException(); zone=ZoneId.of(request.timezone());} catch(RuntimeException e){throw bad("请选择有效目的地时区");}
if(request.actualAt()==null || zone.getRules().getValidOffsets(request.actualAt()).size()!=1)
throw bad("实际卸货时间缺失或处于夏令时歧义区间,请核对");
if(request.actualAt().atZone(zone).toInstant().isAfter(Instant.now())) throw bad("实际卸货时间不能在未来");
if(request.receivedBy()==null || request.receivedBy().isBlank() || request.evidenceNo()==null || request.evidenceNo().isBlank())
throw bad("请填写真实签收人及签收凭据号");
var facts=nodes.containerActualFacts(List.of(id));
var fact=facts.stream().filter(f->f.containerBookingId()==id).findFirst().orElse(null);
if(fact==null || fact.pickedUpAt()==null) throw conflict("实际提柜后才能记录直送卸货");
if(fact.emptyReturnedAt()!=null) throw conflict("货柜已实际还空,不能新增卸货事实");
// Pickup has no source zone. Its local clock cannot order a destination-zone actual;
// require the real pickup fact, without inventing an offset for it.
mapper.insert(id,inboundId,request.actualAt(),zone.getId(),request.receivedBy().trim(),request.evidenceNo().trim(),
request.remark(),key,hash,operatorId,operatorName);
return mapper.deliveries(id).stream().filter(row->row.inboundId()==inboundId).findFirst().map(this::view).orElseThrow();
}
private View view(DirectDeliveryMapper.Delivery row) {
return new View("INBOUND:"+row.inboundId(),row.inboundCode(),row.orderNo(),row.destinationName(),row.packageCount(),
row.version(),row.actualAt(),row.timezone(),row.receivedBy(),row.evidenceNo(),row.remark());
}
private String hash(Object value) {
try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value)));}
catch(Exception e){throw new IllegalStateException(e);}
}
private static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);}
private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);}
}
@@ -23,11 +23,18 @@ public class TerminalDrayageCalendarService {
@Value("${baiyue.security.internal-service-token}") String token) {this.mapper=mapper;this.warehouses=warehouses;this.shipping=shipping;this.token=token;}
public record Calendar(List<PickupCalendarEntry> records,boolean truncated,int unclassifiedCount,
String scopeBasis,String timeBasis) { }
@org.springframework.transaction.annotation.Transactional(readOnly=true)
public Calendar calendar(LocalDate from,LocalDate to) {
if(from==null || to==null || !to.isAfter(from) || ChronoUnit.DAYS.between(from,to)>42)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"日历查询范围必须为1至42天");
var rows=mapper.calendar(from.atStartOfDay(),to.atStartOfDay(),1001);
if(rows.isEmpty()) return result(List.of(),false,0);
var planned=mapper.calendar(from.atStartOfDay(),to.atStartOfDay(),1001);
var actualIds=shipping.pickupCalendarContainers(from.atStartOfDay(),to.atStartOfDay());
var candidates=new LinkedHashMap<Long,PickupCalendarEntry>();
planned.stream().limit(1000).forEach(row->candidates.put(row.containerBookingId(),row));
if(!actualIds.isEmpty()) mapper.calendarByContainers(actualIds.stream().limit(1000).toList())
.forEach(row->candidates.put(row.containerBookingId(),row));
boolean truncated=planned.size()>1000 || actualIds.size()>1000;
if(candidates.isEmpty()) return result(List.of(),truncated,0);
final Map<String,String> countries=new HashMap<>();
try {
var response=warehouses.catalog(token);
@@ -36,7 +43,7 @@ public class TerminalDrayageCalendarService {
countries.put(warehouse.warehouseCode(),warehouse.countryCode());
} catch(RuntimeException error) {throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"提柜目的仓范围暂不可核实",error);}
var records=new ArrayList<PickupCalendarEntry>();int unknown=0;
for(var row:rows.stream().limit(1000).toList()) {
for(var row:candidates.values()) {
var country=countries.get(row.destinationWarehouseCode());
if(country==null || country.isBlank()) unknown++;
else if("US".equals(country)) records.add(row);
@@ -47,9 +54,14 @@ public class TerminalDrayageCalendarService {
for(var fact:shipping.containerActualFacts(ids.subList(start,Math.min(start+200,ids.size()))))
if(fact.pickedUpAt()!=null) actual.put(fact.containerBookingId(),fact.pickedUpAt());
}
var enriched=records.stream().map(row->new PickupCalendarEntry(row.containerBookingId(),row.bookingNo(),row.containerNo(),
row.destinationPortName(),row.destinationWarehouseCode(),row.fleetSupplierName(),row.pickupAppointmentAt(),actual.get(row.containerBookingId()))).toList();
return result(enriched,rows.size()>1000,unknown);
var enriched=records.stream().map(row->new PickupCalendarEntry(row.containerBookingId(),row.cabinetNo(),row.containerNo(),
row.destinationPortName(),row.destinationWarehouseCode(),row.fleetSupplierName(),row.pickupAppointmentAt(),actual.get(row.containerBookingId())))
.filter(row->{var at=occurrence(row);return at!=null && !at.isBefore(from.atStartOfDay()) && at.isBefore(to.atStartOfDay());})
.sorted(Comparator.comparing(TerminalDrayageCalendarService::occurrence).thenComparing(PickupCalendarEntry::containerBookingId)).toList();
return result(enriched.stream().limit(1000).toList(),truncated || enriched.size()>1000,unknown);
}
private static java.time.LocalDateTime occurrence(PickupCalendarEntry row) {
return row.actualPickupAt()!=null?row.actualPickupAt():row.pickupAppointmentAt();
}
private Calendar result(List<PickupCalendarEntry> records,boolean truncated,int unknown) {
return new Calendar(List.copyOf(records),truncated,unknown,"US_DESTINATION_WAREHOUSE","SOURCE_LOCAL_UNSPECIFIED");
@@ -48,6 +48,7 @@ public class TerminalDrayageService {
private final CarrierScheduleQueryService carrierSchedules;
private final ObjectMapper objectMapper;
private final TerminalDrayageMailService mails;
private final UnloadingEvidenceGuard unloadingEvidence;
private Clock clock = Clock.systemDefaultZone();
public View detail(long containerBookingId) {
@@ -145,10 +146,10 @@ public class TerminalDrayageService {
TerminalDrayageEntity value = mapper.lock(containerBookingId);
requireVersion(value, request.expectedVersion());
LocalDateTime pickupAt = actualPickupAt(context, value);
if (context.base().unloadingCompletedAt() == null) throw conflict("海外卸柜收货完成后才能确认还柜");
LocalDateTime earliest = pickupAt != null && pickupAt.isAfter(context.base().unloadingCompletedAt())
? pickupAt : context.base().unloadingCompletedAt();
if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间");
if (completedUnloading(context) == null) throw conflict("海外仓卸柜或全部直送目的仓实际卸货完成后才能确认还柜");
LocalDateTime earliest = context.base().unloadingCompletedAt();
if (pickupAt != null && (earliest == null || pickupAt.isAfter(earliest))) earliest = pickupAt;
if (earliest != null && request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间");
if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认");
advanceNode(context, CarrierScheduleNode.EMPTY_RETURNED, request.actualAt(), action, key,
request.remark(), operatorId, operatorName);
@@ -229,23 +230,25 @@ public class TerminalDrayageService {
}
private void applyAuthoritativeDeadline(WorkContext context, TerminalDrayageEntity value) {
if (context.base().unloadingCompletedAt() != null) {
value.setEstimatedEmptyReturnAt(context.base().unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS));
if (completedUnloading(context) != null) {
value.setEstimatedEmptyReturnAt(returnDeadline(context));
}
}
private View view(WorkContext context, TerminalDrayageEntity value) {
String status = status(context, value);
LocalDateTime deadline = context.base().unloadingCompletedAt() == null ? null
: context.base().unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS);
LocalDateTime deadline = returnDeadline(context);
Duration remaining = deadline == null ? null : context.directCompletion() == null
? Duration.between(LocalDateTime.now(clock), deadline)
: Duration.between(clock.instant(), context.directCompletion().instant().plus(Duration.ofHours(EMPTY_RETURN_WARNING_HOURS)));
LocalDateTime returnedAt = actualEmptyReturnAt(context, value);
String warningStatus;
long overdueHours = 0;
if (returnedAt != null) warningStatus = "RESOLVED";
else if (deadline == null) warningStatus = "NOT_APPLICABLE";
else if (LocalDateTime.now(clock).isAfter(deadline)) {
else if (remaining.isNegative()) {
warningStatus = "OVERDUE";
overdueHours = Math.max(0, Duration.between(deadline, LocalDateTime.now(clock)).toHours());
overdueHours = Math.max(0, remaining.negated().toHours());
} else warningStatus = "PENDING";
Warning warning = new Warning(warningStatus, deadline, overdueHours, overdueHours / 24);
return new View(context.base().containerBookingId(), context.base().containerNo(), context.base().oceanBookingId(), status,
@@ -253,14 +256,14 @@ public class TerminalDrayageService {
value == null ? null : value.getFleetSupplierName(),
value == null ? null : value.getPickupAppointmentAt(),
actualPickupAt(context, value), deadline, returnedAt,
context.base().unloadingCompletedAt(), warning, allowedActions(context, value), context.facts(),
completedUnloading(context), warning, allowedActions(context, value), context.facts(),
value == null ? null : value.getUpdatedAt());
}
private List<String> allowedActions(WorkContext context, TerminalDrayageEntity value) {
List<String> result = new ArrayList<>();
if (value != null && value.getFleetSupplierId() != null && actualPickupAt(context, value) == null
&& actualEmptyReturnAt(context, value) == null && context.base().unloadingCompletedAt() == null) result.add("SEND_PICKUP_EMAIL");
&& actualEmptyReturnAt(context, value) == null && completedUnloading(context) == null) result.add("SEND_PICKUP_EMAIL");
CarrierScheduleNode current = currentNode(context);
if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
&& current.sequence() < CarrierScheduleNode.PICKED_UP.sequence()
@@ -268,14 +271,14 @@ public class TerminalDrayageService {
result.add("SELECT_FLEET");
if (value != null && value.getFleetSupplierId() != null) result.add("SCHEDULE_PICKUP");
}
if (context.base().unloadingCompletedAt() != null
if (completedUnloading(context) != null
&& actualEmptyReturnAt(context, value) == null) result.add("RECORD_EMPTY_RETURN");
return List.copyOf(result);
}
private String status(WorkContext context, TerminalDrayageEntity value) {
if (actualEmptyReturnAt(context, value) != null) return "EMPTY_RETURNED";
if (context.base().unloadingCompletedAt() != null) return "UNLOADED";
if (completedUnloading(context) != null) return "UNLOADED";
if (actualPickupAt(context, value) != null) return "PICKED_UP";
if (value != null && value.getPickupAppointmentAt() != null) return "PICKUP_SCHEDULED";
CarrierScheduleNode current = currentNode(context);
@@ -300,6 +303,14 @@ public class TerminalDrayageService {
return context.facts() == null ? null : node(context.facts().latestActualNode());
}
private LocalDateTime completedUnloading(WorkContext context) {
return context.completedUnloadingAt();
}
private LocalDateTime returnDeadline(WorkContext context) {
if(context.directCompletion()!=null) return context.directCompletion().deadline(EMPTY_RETURN_WARNING_HOURS);
return completedUnloading(context)==null?null:completedUnloading(context).plusHours(EMPTY_RETURN_WARNING_HOURS);
}
private LocalDateTime actualPickupAt(WorkContext context, TerminalDrayageEntity value) {
if (context.facts() != null && context.facts().pickedUpAt() != null) return context.facts().pickedUpAt();
return context.base().unloadingCompletedAt();
@@ -334,10 +345,12 @@ public class TerminalDrayageService {
private WorkContext context(Context base, ContainerActualFacts facts) {
if (base == null || !"ACTIVE".equals(base.recordStatus())) return null;
return new WorkContext(base, facts);
var direct=base.unloadingCompletedAt()==null?unloadingEvidence.directCompletion(base.containerBookingId()):null;
return new WorkContext(base, facts,base.unloadingCompletedAt()!=null?base.unloadingCompletedAt():direct==null?null:direct.localAt(),direct);
}
private record WorkContext(Context base, ContainerActualFacts facts) {}
private record WorkContext(Context base, ContainerActualFacts facts,LocalDateTime completedUnloadingAt,
UnloadingEvidenceGuard.DirectCompletion directCompletion) {}
private TerminalDrayageEntity requiredAggregate(long id) {
TerminalDrayageEntity value = mapper.lock(id);
@@ -0,0 +1,63 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.portschedule.service.EmptyReturnPrerequisite;
import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper;
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 {
private final DirectDeliveryMapper mapper;
public UnloadingEvidenceGuard(DirectDeliveryMapper mapper){this.mapper=mapper;}
public record DirectCompletion(LocalDateTime localAt,String timezone) {
public Instant instant(){return localAt.atZone(ZoneId.of(timezone)).toInstant();}
public LocalDateTime deadline(int hours){return instant().plus(Duration.ofHours(hours)).atZone(ZoneId.of(timezone)).toLocalDateTime();}
}
public DirectCompletion directCompletion(long 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;
if(container.warehouseUnloadedAt()!=null) return container.warehouseUnloadedAt();
var direct=directCompletion(id);
return direct==null?null:direct.localAt();
}
public void requireUnloaded(long id,LocalDateTime returnedAt) {
var container=mapper.lockContainer(id);
if(container==null) throw new BizException(ResultCode.NOT_FOUND,"货柜不存在");
LocalDateTime completed=completedUnloading(id);
if(completed==null) throw new BizException(ResultCode.CONFLICT,"海外仓卸柜或全部直送目的仓实际卸货完成后才能确认还柜");
// Original return nodes have no zone. Preserve the existing warehouse-local check,
// but do not compare that unzoned value with another destination's wall clock.
if(container.warehouseUnloadedAt()!=null && returnedAt.isBefore(completed))
throw new BizException(ResultCode.BAD_REQUEST,"实际还柜时间不能早于卸货完成时间");
}
}
@@ -94,7 +94,7 @@ BEGIN
SELECT MAX(plan_status) INTO current_plan_status
FROM fl_loading_plan WHERE container_booking_id = OLD.id;
IF current_plan_status IN ('LOADING', 'COMPLETED') AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
NOT (OLD.id <=> NEW.id)
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
OR NOT (OLD.channel_group_code <=> NEW.channel_group_code)
OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name)
@@ -0,0 +1,19 @@
-- Current schema for actual destination unloading; never an overseas warehouse receipt.
CREATE TABLE IF NOT EXISTS fl_direct_delivery_unloading (
container_booking_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL COMMENT 'Immutable physically loaded inbound source',
actual_at DATETIME(3) NOT NULL COMMENT 'Actual destination local time',
timezone VARCHAR(64) NOT NULL COMMENT 'Explicit destination IANA timezone',
received_by VARCHAR(128) NOT NULL COMMENT 'Actual recipient',
evidence_no VARCHAR(128) NOT NULL COMMENT 'Actual delivery receipt reference',
remark VARCHAR(1000) NULL,
version INT NOT NULL DEFAULT 1,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (container_booking_id,inbound_id),
UNIQUE KEY uk_direct_unloading_command (container_booking_id,idempotency_key),
CONSTRAINT fk_direct_unloading_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='直送目的仓真实卸货凭据';
@@ -35,7 +35,6 @@ CREATE TABLE IF NOT EXISTS fl_document_sequence (
CREATE TABLE IF NOT EXISTS fl_container_booking (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
container_no VARCHAR(64) NULL COMMENT '国内柜次真实集装箱号权威快照;ISO 6346标准格式;同一订舱内唯一,不同提单允许复用;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改',
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
@@ -90,7 +89,6 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜资料最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_container_booking_no (booking_no),
UNIQUE KEY uk_container_booking_cabinet (cabinet_no),
UNIQUE KEY uk_container_booking_ocean_container (ocean_booking_id,container_no),
KEY idx_container_booking_container_no (container_no,updated_at,id),
@@ -895,7 +893,7 @@ BEGIN
SELECT MAX(plan_status) INTO current_plan_status
FROM fl_loading_plan WHERE container_booking_id = OLD.id;
IF current_plan_status IN ('LOADING', 'COMPLETED') AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
NOT (OLD.id <=> NEW.id)
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
OR NOT (OLD.channel_code <=> NEW.channel_code)
OR NOT (OLD.channel_name <=> NEW.channel_name)
@@ -1542,9 +1540,9 @@ BEGIN
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_container_booking'
AND column_name IN ('booking_no', 'cabinet_no', 'warehouse_id', 'bill_of_lading_no',
AND column_name IN ('cabinet_no', 'warehouse_id', 'bill_of_lading_no',
'vessel_name', 'voyage_no', 'container_type', 'capacity_cbm',
'max_weight_kg', 'terminal_trucking_company_id', 'terminal_trucking_company')) <> 11 THEN
'max_weight_kg', 'terminal_trucking_company_id', 'terminal_trucking_company')) <> 10 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:货柜原型字段缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
@@ -0,0 +1,178 @@
openapi: 3.0.3
info: {title: Firstleg appointment work authoritative sources, version: 1.0.0}
security: [{InternalServiceToken: []}]
paths:
/internal/firstleg/appointment-work/containers:
get:
summary: Complete actual-sailed container scope
description: Includes missing warehouses, private destinations and stopped tracking tasks. An actual departure or later maritime milestone is required. GET never creates work. Scope-versioned cursors reject changes between pages with 409; consumers restart a full scan. Original local times without a source timezone are never converted to UTC.
parameters:
- {name: after, in: query, schema: {type: string, maxLength: 128}}
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 200}}
responses:
'200':
description: ApiResponse
content:
application/json:
schema:
type: object
properties:
data: {$ref: '#/components/schemas/SourcePage'}
'401': {description: Invalid internal service identity}
'409': {description: Scope changed; restart from the first page}
/internal/firstleg/appointment-work/containers/{id}:
get:
summary: Read one authoritative sailed container and physical cargo lineage
parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}]
responses:
'200':
description: ApiResponse
content:
application/json:
schema:
type: object
properties: {data: {$ref: '#/components/schemas/ContainerSource'}}
'401': {description: Invalid internal service identity}
'404': {description: No active actual-sailed source}
/internal/firstleg/appointment-work/facts:
post:
summary: Read pickup, direct unloading and empty-return facts; no writes
requestBody:
required: true
content:
application/json:
schema: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/FactRequest'}}
responses:
'200':
description: Exactly one result per requested work key, never a partial success
content:
application/json:
schema:
type: object
properties: {data: {type: array, items: {$ref: '#/components/schemas/Fact'}}}
'400': {description: Invalid type or duplicate identity}
'401': {description: Invalid internal service identity}
'409': {description: Direct requirement does not match physical cargo}
components:
securitySchemes:
InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token}
schemas:
Reference:
type: object
required: [kind, key, label]
properties:
kind: {type: string}
key: {type: string, description: Internal association only}
businessNo: {type: string, nullable: true}
label: {type: string}
SourceReference:
type: object
properties:
customerId: {type: string}
sourceKey: {type: string}
destinationKey: {type: string, nullable: true}
Requirement:
type: object
required: [requirementKey, references, replacesRequirementKeys, sources]
properties:
requirementKey: {type: string, description: 'Immutable INBOUND:<physical inbound id>'}
batchKey: {type: string, nullable: true}
batchNo: {type: string, nullable: true}
destinationKind: {type: string, nullable: true}
originName: {type: string, nullable: true}
destinationName: {type: string, nullable: true}
timezone: {type: string, nullable: true}
references: {type: array, items: {$ref: '#/components/schemas/Reference'}}
replacesRequirementKeys: {type: array, items: {type: string}}
sources: {type: array, items: {$ref: '#/components/schemas/SourceReference'}, description: Empty until Lastleg resolves its canonical source; domestic ORDER references are not tail-leg ORDER keys}
ContainerSource:
type: object
required: [containerBookingId, sourceVersion, establishedFlow, requirements]
properties:
containerBookingId: {type: string}
containerNo: {type: string, nullable: true}
departedAt: {type: string, format: date-time, nullable: true}
departedLocalAt: {type: string, nullable: true, description: Exact actual departure only; source local timestamp without offset}
sailedEvidenceLocalAt: {type: string, nullable: true, description: Actual departure-or-later node proving sailed eligibility; never presented as an actual departure time}
sourceVersion: {type: string}
establishedFlow: {type: string, enum: [UNKNOWN, DIRECT, TRANSFER]}
timezone: {type: string, nullable: true}
requirements: {type: array, items: {$ref: '#/components/schemas/Requirement'}}
SourcePage:
type: object
required: [containers, complete, scopeVersion]
properties:
containers: {type: array, items: {$ref: '#/components/schemas/ContainerSource'}}
nextCursor: {type: string, nullable: true}
complete: {type: boolean}
scopeVersion: {type: string}
ContainerReference:
type: object
properties:
containerBookingId: {type: string}
bookingNo: {type: string, nullable: true}
containerNo: {type: string, nullable: true}
FactRequest:
type: object
required: [workItemKey, type, flow, containerBookingId]
properties:
workItemKey: {type: string}
type: {type: string, enum: [PICKUP, EMPTY_RETURN, DELIVERY]}
flow: {type: string, enum: [TRANSFER, DIRECT, UNKNOWN]}
containerBookingId: {type: string}
requirementKey: {type: string, nullable: true}
batchKey: {type: string, nullable: true}
tripKey: {type: string, nullable: true}
batchKeys: {type: array, items: {type: string}}
containers: {type: array, items: {$ref: '#/components/schemas/ContainerReference'}}
sources: {type: array, items: {$ref: '#/components/schemas/SourceReference'}}
Slot:
type: object
properties:
date: {type: string, format: date}
startTime: {type: string, nullable: true}
endDate: {type: string, format: date, nullable: true}
endTime: {type: string, nullable: true}
timezone: {type: string, nullable: true, description: Null for unzoned original plans; source-local date is retained}
Actual:
type: object
description: At least one time is required. Unzoned source facts contain localOccurredAt only.
properties:
occurredAt: {type: string, format: date-time, nullable: true}
localOccurredAt: {type: string, nullable: true}
timezone: {type: string, nullable: true}
Fact:
type: object
required: [workItemKey, fingerprint, impactToken, planExists, planStatus, executionStatus, readPermissions, actionPermissions, blockReasons, missingFields, references]
properties:
workItemKey: {type: string}
fingerprint: {type: string}
impactToken: {type: string, description: First scheduling and first carrier assignment do not increase semantic impact}
planExists: {type: boolean}
planStatus: {type: string, enum: [UNPLANNED, TARGET_SET, AWAITING_REPLY, CONFIRMED, NOT_REQUIRED]}
executionStatus: {type: string, enum: [PENDING, IN_PROGRESS, COMPLETED]}
target: {$ref: '#/components/schemas/Slot'}
confirmed: {$ref: '#/components/schemas/Slot'}
actual: {$ref: '#/components/schemas/Actual'}
timezone: {type: string, nullable: true}
businessNo: {type: string, nullable: true}
tripKey: {type: string, nullable: true}
readPermissions: {type: array, items: {type: string}}
actionPermissions: {type: array, items: {type: string}}
blockReasons: {type: array, items: {type: string}}
missingFields: {type: array, items: {type: string}}
references: {type: array, items: {$ref: '#/components/schemas/Reference'}}
responsibility:
type: object
nullable: true
properties: {kind: {type: string}, id: {type: string, nullable: true}, name: {type: string}}
action:
type: object
nullable: true
properties:
code: {type: string}
label: {type: string}
targetWorkItemKey: {type: string, nullable: true}
target:
type: object
properties: {kind: {type: string, enum: [FIRSTLEG_CONTAINER]}, containerBookingId: {type: string}}
@@ -28,7 +28,7 @@ paths:
type: object
properties:
containerBookingId: {type: string}
bookingNo: {type: string}
cabinetNo: {type: string}
containerNo: {type: string, nullable: true}
vesselName: {type: string, nullable: true}
voyageNo: {type: string, nullable: true}
@@ -0,0 +1,70 @@
openapi: 3.0.3
info: {title: Direct destination unloading evidence, version: 1.0.0}
paths:
/api/platform/v1/terminal-drayages/{containerBookingId}/direct-deliveries:
get:
summary: Physical loaded inbound sources and immutable direct unloading evidence
x-permission: platform:firstleg:terminal-drayage:list
parameters: [{$ref: '#/components/parameters/Container'}]
responses:
'200':
description: ApiResponse
content:
application/json:
schema:
type: object
properties: {data: {type: array, items: {$ref: '#/components/schemas/View'}}}
'403': {description: Requires platform identity and list permission}
/api/platform/v1/terminal-drayages/{containerBookingId}/direct-deliveries/{inboundId}:
post:
summary: Record actual unloading for one complete physically loaded source
description: Locks the physical container. Requires Lastleg's persisted confirmed DIRECT route, actual pickup, a real recipient and receipt reference, an unambiguous IANA local time, and no warehouse unloading or empty-return fact. Route source failure returns 503. Exact retries replay; changed retries conflict. Does not create warehouse stock. Every loaded source must complete before manual empty return. Unzoned pickup/return timestamps are not compared to destination wall-clock times; cross-location chronology cannot be proven without the source zone. Direct completion and its 72-hour reminder use the latest evidence Instant.
x-permission: platform:firstleg:terminal-drayage:direct-unload:confirm
parameters:
- {$ref: '#/components/parameters/Container'}
- {name: inboundId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [expectedVersion, actualAt, timezone, receivedBy, evidenceNo]
properties:
expectedVersion: {type: integer, minimum: 0}
actualAt: {type: string, description: Local YYYY-MM-DDTHH:mm:ss without offset}
timezone: {type: string, description: Explicit destination IANA zone}
receivedBy: {type: string, minLength: 1, maxLength: 128}
evidenceNo: {type: string, minLength: 1, maxLength: 128}
remark: {type: string, maxLength: 1000, nullable: true}
responses:
'200':
description: ApiResponse
content:
application/json:
schema:
type: object
properties: {data: {$ref: '#/components/schemas/View'}}
'400': {description: Invalid source, time or evidence}
'403': {description: Requires platform identity and independent unloading permission}
'409': {description: Invalid business prerequisites, version or replay content}
'503': {description: Cannot verify authoritative route}
components:
parameters:
Container: {name: containerBookingId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
schemas:
View:
type: object
properties:
requirementKey: {type: string, description: 'INBOUND:<id>, association only, not a visible business number'}
inboundCode: {type: string, nullable: true}
orderNo: {type: string, nullable: true}
destinationName: {type: string, nullable: true}
packageCount: {type: integer}
version: {type: integer, description: Zero until first actual unloading}
actualAt: {type: string, nullable: true}
timezone: {type: string, nullable: true}
receivedBy: {type: string, nullable: true}
evidenceNo: {type: string, nullable: true}
remark: {type: string, nullable: true}
@@ -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在自身客户范围内过滤、计数和分页
@@ -2323,13 +2330,12 @@ components:
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}}
ContainerBooking:
type: object
required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, channelGroupCode, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version]
required: [id, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, channelGroupCode, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version]
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
currentPortScheduleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, readOnly: true, description: 当前运输读模型由 latestForDispatch 选中的船期记录 ID;无匹配时为空,仅用于定位关联船期,不作为业务编号展示}
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
planNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号;未创建计划时为空}
bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001, description: 平台内部货柜登记号}
containerNo: {type: string, maxLength: 64, nullable: true}
cabinetNo: {type: string, pattern: '^D[0-9]{4,}$', example: D0001, description: 平台全局连续且不按日期重置的稳定柜次号}
sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜}
@@ -2606,11 +2612,10 @@ components:
pages: {type: string, pattern: '^[0-9]+$'}
SmartLoadingWorkbenchContainer:
type: object
required: [bookingId, bookingNo, cabinetNo, warehouseId, warehouseName, channelGroupCode,
required: [bookingId, cabinetNo, warehouseId, warehouseName, channelGroupCode,
containerType, capacityCbm, maxWeightKg, planStatus, version]
properties:
bookingId: {type: string, pattern: '^[1-9][0-9]*$', description: containerBookingId 技术关联}
bookingNo: {type: string, description: 货柜登记业务号}
cabinetNo: {type: string, description: 稳定柜次业务号}
containerNo: {type: string, nullable: true, description: 真实集装箱号,允许在装柜前后补}
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
@@ -74,7 +74,7 @@ components:
exception: {$ref: '#/components/schemas/ExceptionState'}
exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}}
timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}}
Container: {type: object, properties: {id: {type: integer, format: int64}, bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001}, cabinetNo: {type: string, pattern: '^D[0-9]{4,}$', example: D0001}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, pickupCode: {type: string, pattern: '^[0-9]{1,64}$', nullable: true}, pickupCodeUpdatedBy: {type: integer, format: int64, nullable: true}, pickupCodeUpdatedByName: {type: string, nullable: true}, pickupCodeUpdatedAt: {type: string, format: date-time, nullable: true}, version: {type: integer, minimum: 0}}}
Container: {type: object, properties: {id: {type: integer, format: int64}, cabinetNo: {type: string, pattern: '^D[0-9]{4,}$', example: D0001}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, pickupCode: {type: string, pattern: '^[0-9]{1,64}$', nullable: true}, pickupCodeUpdatedBy: {type: integer, format: int64, nullable: true}, pickupCodeUpdatedByName: {type: string, nullable: true}, pickupCodeUpdatedAt: {type: string, format: date-time, nullable: true}, version: {type: integer, minimum: 0}}}
PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}}
PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}}
Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}}
@@ -4,7 +4,7 @@ paths:
/api/platform/v1/terminal-drayages/calendar:
get:
summary: 美国目的仓相关的提柜预约日历
description: 平台身份,同时要求预约列表和码头拖车列表权限。按原业务预约提柜日期查询,不将实际提柜或预计到仓替代预约时间。美国范围按启用目的仓国家确认,不代表已确认港口所在国。原字段未提供港口时区,timeBasis明确待核实,禁止当作UTC或套用收货仓时区。最多42天、读取1001条以检测截断;返回最多1000条中的美国范围记录,未知目的仓计入unclassifiedCount,不能将缺失数据当零。
description: 平台身份,同时要求预约列表和码头拖车列表权限。已提柜按船节点权威实际提柜日期、尚未提柜按原预约日期查询,改期不移动已执行事实;原预约与实际时间分别返回,不以预计到仓替代。美国范围按启用目的仓国家确认,不代表已确认港口所在国。原字段未提供港口时区,timeBasis明确待核实,禁止当作UTC或套用收货仓时区。最多42天,原预约和船节点实际日期候选分别读取1001条检测截断,经各自所属服务读取并按柜去重;返回最多1000条美国范围记录,未知目的仓计入unclassifiedCount,不能将缺失数据当零。
x-permissions: [platform:appointment:list, 'platform:firstleg:terminal-drayage:list']
parameters:
- {name: from, in: query, required: true, schema: {type: string, format: date}, description: 包含的原业务当地日期}
@@ -33,12 +33,12 @@ paths:
type: object
properties:
containerBookingId: {type: string, description: 仅用于来源详情定位}
bookingNo: {type: string}
cabinetNo: {type: string}
containerNo: {type: string, nullable: true}
destinationPortName: {type: string, nullable: true}
destinationWarehouseCode: {type: string, nullable: true}
fleetSupplierName: {type: string, nullable: true}
pickupAppointmentAt: {type: string, description: 原业务当地时间,无时区偏移,不是UTC时间点}
pickupAppointmentAt: {type: string, nullable: true, description: 原业务当地预约时间,无时区偏移;仅有实际提柜事实时可为空}
actualPickupAt: {type: string, nullable: true}
'400': {description: 日期范围非法}
'403': {description: 非平台身份或权限不足}
@@ -82,8 +82,8 @@ paths:
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:
post:
summary: 海外卸柜收货完成后确认实际还柜并推进EMPTY_RETURNED节点
description: 以海外卸柜完成事实开放还柜;无需已有车队、预约或实际提柜记录。无拖车记录时提交 expectedVersion=0,由命令创建记录,不补造提柜时间。实际还柜时间不得早于卸柜完成时间及已有实际提柜时间。
summary: 海外仓卸柜或全部直送目的仓实际卸货完成后确认实际还柜并推进EMPTY_RETURNED节点
description: 海外仓卸柜或全部直送目的仓实际卸货完成后开放还柜。无需已有车队或预约;海外仓卸柜可无实际提柜记录。无拖车记录时提交 expectedVersion=0,不补造提柜时间。保留原海外仓卸柜及已有船节点实际提柜的当地时间先后校验;直送目的仓时区不能套用于缺少时区的还柜时间,不比较不同地点的当地钟面值。
x-permission: platform:firstleg:terminal-drayage:empty-return:confirm
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}}
@@ -173,7 +173,7 @@ components:
required: [status, overdueHours, overdueDays]
properties:
status: {type: string, enum: [NOT_APPLICABLE, PENDING, OVERDUE, RESOLVED]}
deadlineAt: {type: string, format: date-time, nullable: true, description: 卸柜完成时间加72小时}
deadlineAt: {type: string, format: date-time, nullable: true, description: 原海外仓卸柜时间加72小时;直送按最晚真实卸货Instant加72小时后保留该目的地当地时间}
overdueHours: {type: integer, format: int64, minimum: 0}
overdueDays: {type: integer, format: int64, minimum: 0}
TerminalDrayageView:
@@ -189,7 +189,7 @@ components:
fleetSupplierName: {type: string, nullable: true}
pickupAppointmentAt: {type: string, format: date-time, nullable: true}
actualPickupAt: {type: string, format: date-time, nullable: true, description: 优先使用实际船节点提柜时间;缺失时在海外PDA确认整柜拆柜完成后使用卸柜完成时间}
estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: 服务端权威值,卸柜完成时间加72小时}
estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: 服务端权威值;直送72小时按绝对时长计算,时区不明的原海外仓事实保留原规则}
actualEmptyReturnAt: {type: string, format: date-time, nullable: true}
unloadingCompletedAt: {type: string, format: date-time, nullable: true}
emptyReturnWarning: {$ref: '#/components/schemas/Warning'}
@@ -128,7 +128,7 @@ class ContainerControllerContractTest {
.contains("required: [oceanBookingId, requirementId, expectedVersion]")
.contains("containerNo: {type: string, nullable: true")
.doesNotContain("required: [oceanBookingId, requirementId, containerNo, expectedVersion]")
.contains("required: [id, bookingNo, cabinetNo, sourceType, recordStatus")
.contains("required: [id, cabinetNo, sourceType, recordStatus")
.contains("sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]")
.contains("recordStatus: {type: string, enum: [ACTIVE, MERGED]")
.contains("mergedIntoContainerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true");
@@ -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");
}
}
@@ -0,0 +1,42 @@
package com.baiyue.firstleg.portschedule.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.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class AppointmentWorkSourceMapperTest {
@Test void actualSailedScopeHasNoWarehouseDestinationOrOpenQueryFilter() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
try(var c=ds.getConnection();var s=c.createStatement()) {
s.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR,departure_port_name VARCHAR,destination_warehouse_name VARCHAR,version INT,record_status VARCHAR)");
s.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP)");
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),(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 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(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());
}
}
}
@@ -0,0 +1,37 @@
package com.baiyue.firstleg.portschedule.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.assertEquals;
class ArrivalCalendarMapperTest {
@Test
void readsTheExistingCabinetNumberWithoutASecondContainerNumberColumn() throws Exception {
var ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:arrival-cabinet-number;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,record_status VARCHAR)");
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("INSERT INTO fl_container_booking VALUES(1,'D0007','TEST-CONT','Vessel','Voyage','Port'," +
"'US-W',NULL,'2026-11-25 08:00:00','ACTIVE')");
}
var config = new MybatisConfiguration();
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
config.addMapper(PortScheduleMapper.class);
try (var session = new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var rows = session.getMapper(PortScheduleMapper.class).arrivalCalendar(
LocalDateTime.parse("2026-11-01T00:00:00"), LocalDateTime.parse("2026-12-01T00:00:00"));
assertEquals(1, rows.size());
assertEquals("D0007", rows.get(0).cabinetNo());
assertEquals(1L, rows.get(0).containerBookingId());
}
}
}
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.portschedule.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 java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class PickupActualCalendarMapperTest {
@Test void selectsCanonicalActualPickupOnceInAHalfOpenRangeAndDetectsTruncation() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
try(var connection=ds.getConnection();var sql=connection.createStatement()) {
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP)");
sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(1,'PICKED_UP','2026-09-30 23:59:59'),(2,'PICKED_UP','2026-10-01 00:00:00'),(3,'PICKED_UP','2026-10-31 23:59:59'),(4,'PICKED_UP','2026-11-01 00:00:00'),(5,'ARRIVED_DESTINATION_PORT','2026-10-10'),(6,'PICKED_UP',NULL)");
sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(2,'PICKED_UP','2026-10-01 00:00:00'),(4,'PICKED_UP','2026-10-10')");
}
var config=new MybatisConfiguration();config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(CarrierScheduleQueryMapper.class);
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var mapper=session.getMapper(CarrierScheduleQueryMapper.class);
var from=LocalDateTime.parse("2026-10-01T00:00:00");var to=LocalDateTime.parse("2026-11-01T00:00:00");
assertEquals(List.of(2L,3L),mapper.selectPickupCalendarContainers(from,to));
try(var connection=ds.getConnection();var sql=connection.prepareStatement("INSERT INTO fl_carrier_schedule_node_fact VALUES(?,'PICKED_UP','2026-10-15')")) {
for(long id=10;id<1012;id++){sql.setLong(1,id);sql.addBatch();}sql.executeBatch();
}
session.clearCache();
assertEquals(1001,mapper.selectPickupCalendarContainers(from,to).size());
}
}
}
@@ -0,0 +1,92 @@
package com.baiyue.firstleg.portschedule.service;
import com.baiyue.firstleg.portschedule.mapper.AppointmentWorkSourceMapper;
import com.baiyue.firstleg.portschedule.controller.InternalAppointmentWorkSourceController;
import com.fasterxml.jackson.databind.ObjectMapper;
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.*;
class AppointmentWorkSourceServiceTest {
private final AppointmentWorkSourceMapper mapper=mock(AppointmentWorkSourceMapper.class);
private final AppointmentWorkSourceService.OriginalFacts facts=mock(AppointmentWorkSourceService.OriginalFacts.class);
private final AppointmentWorkSourceService service=new AppointmentWorkSourceService(mapper,facts,new ObjectMapper().findAndRegisterModules());
private AppointmentWorkSourceMapper.Source source(long id,int version) {
return new AppointmentWorkSourceMapper.Source(id,"D000"+id,null,"上海",null,LocalDateTime.parse("2026-10-01T12:00:00"),LocalDateTime.parse("2026-10-01T12:00:00"),version,10L,0L);
}
@Test void completeCursorManifestIncludesUnboundSourcesAndRejectsScopeDrift() {
when(mapper.sources()).thenReturn(List.of(source(1,1),source(2,1)));
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());
assertEquals(LocalDateTime.parse("2026-10-01T12:00:00"),first.containers().get(0).departedLocalAt());
var next=service.discover(first.nextCursor(),1);
assertTrue(next.complete());assertEquals("2",next.containers().get(0).containerBookingId());
when(mapper.sources()).thenReturn(List.of(source(1,2),source(2,1)));
assertEquals(409,assertThrows(ResponseStatusException.class,()->service.discover(first.nextCursor(),1)).getStatusCode().value());
}
@Test void sourceKeepsCustomerAndPhysicalInboundLineageWithoutDisplayingIdsAsNumbers() {
when(mapper.sources()).thenReturn(List.of(source(1,1)));
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));
}
@Test void internalControllerFailsClosedBeforeReadingSources() {
var guarded=new InternalAppointmentWorkSourceController(service,"secret");
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());
}
}
@@ -53,6 +53,7 @@ class CarrierScheduleQueryServiceTest {
@Mock private CarrierScheduleQueryRepository repository;
@Mock private PortScheduleRepository portSchedules;
@Mock private CarrierScheduleNodeOutboxService nodeOutbox;
@Mock private EmptyReturnPrerequisite emptyReturnPrerequisite;
@InjectMocks private CarrierScheduleQueryService service;
private CarrierScheduleAdapterRegistry adapters;
@@ -84,6 +85,19 @@ class CarrierScheduleQueryServiceTest {
verify(repository).containerActualFacts(List.of(20L, 21L));
}
@Test
void containerManualEmptyReturnCannotBypassRealUnloadingEvidence() {
org.mockito.Mockito.doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,"未卸货"))
.when(emptyReturnPrerequisite).requireUnloaded(20L,NOW);
assertThatThrownBy(()->service.addContainerManualNode(20L,7L,"admin","empty-no-unload",
new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest(
"EMPTY_RETURNED",NOW,null,NOW,null,null,"还柜")))
.isInstanceOf(BizException.class).hasMessage("未卸货");
verifyNoNodePublication();
}
private void verifyNoNodePublication() { org.mockito.Mockito.verifyNoInteractions(nodeOutbox); }
@Test
void rejectsInvalidActualFactBatchBeforeRepositoryAccess() {
assertThatThrownBy(() -> service.containerActualFacts(null))
@@ -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();
}
}
@@ -80,7 +80,7 @@ class Phase3FinalUnifiedSqlContractTest {
@Test
void initializationPassIsUniqueAndFinal() throws Exception {
String sql = sql();
int expectedTables = 226;
int expectedTables = 227;
long actualTables = java.util.regex.Pattern.compile("(?m)^CREATE TABLE\\s+")
.matcher(sql).results().count();
assertThat(actualTables).isEqualTo(expectedTables);
@@ -13,6 +13,7 @@ import org.mockito.MockitoAnnotations;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -25,6 +26,7 @@ import static org.mockito.Mockito.when;
class SailingCalendarServiceTest {
@Mock private SailingCalendarRepository repository;
@InjectMocks private SailingCalendarService service;
private final LocalDateTime copiedDeparture = LocalDateTime.now(ZoneId.of("Asia/Shanghai")).plusDays(14);
@BeforeEach
void setUp() {
@@ -33,8 +35,8 @@ class SailingCalendarServiceTest {
@Test
void replaysAnExistingCopyInsteadOfReturningAnInternalError() {
SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", LocalDateTime.of(2026, 10, 5, 10, 0));
SailingEntity existing = sailing(2L, "SL-COPY", "COPY", "QA-20261005", LocalDateTime.of(2026, 10, 12, 10, 0));
SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", copiedDeparture.minusDays(7));
SailingEntity existing = sailing(2L, "SL-COPY", "COPY", "QA-20261005", copiedDeparture);
SailingHistoryEntity history = new SailingHistoryEntity();
history.setAction("COPY"); history.setReason("复制自SL-SOURCE"); history.setSailingId(2L);
when(repository.sailing(1L)).thenReturn(source);
@@ -45,7 +47,7 @@ class SailingCalendarServiceTest {
when(repository.bookingSummary(any(SailingEntity.class))).thenReturn(null);
var result = service.copy(1L, 7L, "admin",
new CopyRequest(existing.getEtdAt(), LocalDateTime.of(2026, 10, 9, 18, 0)));
new CopyRequest(existing.getEtdAt(), copiedDeparture.minusDays(3)));
assertThat(result.sailing().id()).isEqualTo(2L);
verify(repository, never()).insert(any(SailingEntity.class));
@@ -53,13 +55,13 @@ class SailingCalendarServiceTest {
@Test
void reportsBusinessConflictForAnUnrelatedExistingSailing() {
SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", LocalDateTime.of(2026, 10, 5, 10, 0));
SailingEntity existing = sailing(2L, "SL-OTHER", "TEMPLATE", "QA-20261005", LocalDateTime.of(2026, 10, 12, 10, 0));
SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", copiedDeparture.minusDays(7));
SailingEntity existing = sailing(2L, "SL-OTHER", "TEMPLATE", "QA-20261005", copiedDeparture);
when(repository.sailing(1L)).thenReturn(source);
when(repository.sailingByBusinessKey(8L, "MATSON", "CNNGB", "USLGB", existing.getEtdAt(), "QA-20261005")).thenReturn(existing);
assertThatThrownBy(() -> service.copy(1L, 7L, "admin",
new CopyRequest(existing.getEtdAt(), LocalDateTime.of(2026, 10, 9, 18, 0))))
new CopyRequest(existing.getEtdAt(), copiedDeparture.minusDays(3))))
.isInstanceOf(BizException.class)
.hasMessageContaining("已存在航次 QA-20261005");
verify(repository, never()).insert(any(SailingEntity.class));
@@ -67,11 +69,11 @@ class SailingCalendarServiceTest {
@Test
void rejectsCutoffAtOrAfterCopiedDeparture() {
SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", LocalDateTime.of(2026, 10, 5, 10, 0));
SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", copiedDeparture.minusDays(7));
when(repository.sailing(1L)).thenReturn(source);
assertThatThrownBy(() -> service.copy(1L, 7L, "admin",
new CopyRequest(LocalDateTime.of(2026, 10, 12, 10, 0), LocalDateTime.of(2026, 10, 12, 10, 0))))
new CopyRequest(copiedDeparture, copiedDeparture)))
.isInstanceOf(BizException.class)
.hasMessageContaining("收货截止必须早于开船");
verify(repository, never()).insert(any(SailingEntity.class));
@@ -52,7 +52,6 @@ class ContainerServiceTest {
@BeforeEach
void setUpWarehouse() {
when(documentNumbers.next("CB")).thenReturn("CB-260818-0001");
when(documentNumbers.nextCabinetNo()).thenReturn("D0001");
when(smartLoadingService.createForContainer(anyLong(), anyLong(), anyString())).thenAnswer(invocation ->
new com.baiyue.firstleg.dto.SmartLoadingDtos.Plan(501L, "LP-260818-0001", "EDITING",
@@ -76,8 +75,9 @@ class ContainerServiceTest {
verify(repository).insert(captor.capture());
ContainerBookingEntity saved = captor.getValue();
assertThat(created).isSameAs(saved);
assertThat(saved.getBookingNo()).isEqualTo("CB-260818-0001");
assertThat(saved.getCabinetNo()).isEqualTo("D0001");
verify(documentNumbers).nextCabinetNo();
verify(documentNumbers, never()).next("CB");
assertThat(saved.getContainerNo()).isNull();
assertThat(saved.getOceanBookingId()).isNull();
assertThat(saved.getChannelGroupCode()).isEqualTo("MATSON_CLX");
@@ -41,7 +41,7 @@ class FirstlegDocumentNumberGeneratorTest {
assertThat(generator.next("OB")).isEqualTo("OB-260818-0001");
assertThat(generator.next("TA")).isEqualTo("TA-260818-0001");
assertThat(generator.next("CB")).isEqualTo("CB-260818-0001");
assertThatThrownBy(() -> generator.next("CB")).isInstanceOf(IllegalArgumentException.class);
assertThat(generator.next("LP")).isEqualTo("LP-260818-0001");
assertThat(generator.nextCabinetNo()).isEqualTo("D0001");
@@ -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);
}
}
@@ -627,7 +627,7 @@ class SmartLoadingServiceTest {
@Test
void pagesWorkbenchContainersWithTheSameGlobalFilters() {
WorkbenchContainer container = new WorkbenchContainer(99L, "CB-1", "CAB-1", null,
WorkbenchContainer container = new WorkbenchContainer(99L, "D0001", null,
100L, "义乌仓", "MATSON_CLX", null, null, "40HQ",
new BigDecimal("68"), new BigDecimal("19500"),
1L, "LP-1", "EDITING", 2);
@@ -71,10 +71,10 @@ class TaillegRealProducerAcceptanceTest {
}
private void seedCompletedLoadingPlan() {
jdbc.update("INSERT IGNORE INTO fl_container_booking(id,booking_no,container_no,cabinet_no,warehouse_id," +
jdbc.update("INSERT IGNORE INTO fl_container_booking(id,container_no,cabinet_no,warehouse_id," +
"warehouse_name,customer_id,customer_name,estimated_port_arrival_at,container_type,created_by,updated_by) " +
"VALUES(?,?,?,?,?,?,?,?,?,?,1,1)",
BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓",
"VALUES(?,?,?,?,?,?,?,?,?,1,1)",
BOOKING, MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓",
CUSTOMER, "真实验收客户", java.time.LocalDateTime.of(2026, 9, 30, 12, 0), "40HQ");
jdbc.update("INSERT IGNORE INTO fl_loading_plan(id,plan_no,container_booking_id,plan_status,created_by," +
"updated_by,version) VALUES(?,?,?,'LOADING',1,1,0)", PLAN, MARKER + "-PLAN", BOOKING);
@@ -51,7 +51,8 @@ class TerminalDrayageOpenApiContractTest {
"emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays",
"船节点跟踪任务尚未建立也返回WAITING_FOR_ARRIVAL空详情",
"允许读取空邮件列表,不以船节点跟踪任务是否已经建立作为货柜存在性条件");
assertThat(yaml).contains("description: 卸柜完成时间加72小时");
assertThat(yaml).contains("原海外仓卸柜时间加72小时", "直送按最晚真实卸货Instant加72小时",
"直送72小时按绝对时长计算", "不比较不同地点的当地钟面值");
assertThat(yaml).doesNotContain("/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:",
"platform:firstleg:terminal-drayage:pickup:confirm", "RECORD_PICKUP");
}

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