This commit is contained in:
2026-10-16 11:41:24 +08:00
parent 4e2ea89403
commit 5855529e97
59 changed files with 1077 additions and 110 deletions
+13
View File
@@ -106,6 +106,19 @@
- 装柜、船节点、卸柜、暂扣、派送、费用与资金按统一通信契约的逐事件表直接投递。保留本地 Outbox、消费幂等、重试与失败处置,不携带用户 JWT;旧 Bridge 源码已退出聚合和发布清单,退役待清理;实际停机仍须替代链验证与授权,不得据源码状态认定已部署。 - 装柜、船节点、卸柜、暂扣、派送、费用与资金按统一通信契约的逐事件表直接投递。保留本地 Outbox、消费幂等、重试与失败处置,不携带用户 JWT;旧 Bridge 源码已退出聚合和发布清单,退役待清理;实际停机仍须替代链验证与授权,不得据源码状态认定已部署。
- `lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。共用测试 MySQL 实例不改变服务数据归属。准确路由、ACK、撤销矩阵和切换门槛只维护在新的两份主契约中。 - `lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。共用测试 MySQL 实例不改变服务数据归属。准确路由、ACK、撤销矩阵和切换门槛只维护在新的两份主契约中。
## 数据库访问与中文代码注释(所有服务及公共模块强制)
- 本节适用于全部后端服务和公共模块;新增、修改的方法必须执行,触及的存量代码同步整改。登记本约束不代表存量代码已全部完成迁移和注释补齐。
- 生产代码的关系型数据库读写统一经 MyBatis/MyBatis-Plus Mapper 执行;Controller、Service、定时任务、消费者、编号生成器和公共基础组件不得直接使用 `JdbcTemplate`、`NamedParameterJdbcTemplate`、原生 JDBC/Connection/Statement 或其他通道执行 SQL。仅改成 Repository 包装 JDBC 不算整改,SQL 必须进入 Mapper;无数据库职责的模块不得为满足本条新增数据库访问。
- 正常业务数据库访问路径统一为 `Controller → Service → Repository → Mapper`,四层职责明确,不得省略 Repository,也不得由 Controller 直接调用 Repository/Mapper,或由 Service 直接调用 Mapper。定时任务、消费者等非 HTTP 入口从 Service 进入,同样经 Repository → Mapper 完成持久化访问。
- Controller 负责请求接收、参数校验、入口权限和响应转换;Service 负责业务规则、编排及业务事务;Repository 负责领域数据访问封装、查询组合与持久化模型转换,对 Service 提供有业务含义的数据操作;Mapper 负责 SQL 执行及数据库记录映射。Repository 不承载业务状态流转,不直接用 JDBC 执行 SQL;Service 不继承 MyBatis-Plus `ServiceImpl` 等方式绕过 Repository。
- 复杂查询、批量处理、原子计数、锁定读取、Outbox 和数据库监控查询同样经 Repository → Mapper,不以 SQL 特殊或实现方便为由跳层。公共模块需要数据库能力时,按现有模块边界定义 Repository/持久化接口,由所属服务的 Repository → Mapper 实现,禁止借此跨服务读库。
- 迁移数据库操作须保留客户隔离条件、事务传播、行锁、幂等和并发语义;`LAST_INSERT_ID()` 等连接级操作必须在同一事务连接完成,并明确关闭相关读取缓存,不能仅机械搬移 SQL。Mapper 注册及扫描仍遵守下文统一规范。
- 测试代码可用 `JdbcTemplate` 创建隔离测试表、准备数据及核对结果;被测生产路径仍必须调用实际 Mapper,不得把测试造数做法带入生产。数据库基线和迁移脚本按现有部署契约管理。
- 每个手写方法及构造器均须添加简短中文注释,通常 1—3 行说明业务用途;接口方法和 Mapper 方法优先使用中文 Javadoc。覆盖方法同样须有简短中文说明,实现中的关键规则仍须就地注释;自动生成的方法不手工逐个补注释。
- 核心业务、复杂 SQL、状态流转、资金与库存变更、权限隔离、事务、锁、幂等、并发、重试和补偿逻辑,须在关键代码前增加适量中文注释,解释业务规则、处理原因、约束边界及失败影响;不逐行翻译代码,不添加无信息量或与实现不符的注释。
- 新增和修改的代码说明注释统一使用中文;必要的技术标识、协议名、类名可保留原文,第三方许可证和生成标记保持原样。修改方法时同步更新相关存量英文或过期说明;审查时同时核对 Controller → Service → Repository → Mapper 分层及中文方法、核心逻辑注释。
## 契约、数据库与验证入口 ## 契约、数据库与验证入口
- 对外 Controller/API 新增、改名或删除时,同时核对调用端、统一 Gateway、OpenAPI 和权限;提交前运行 `node --test scripts/gateway-route-contract.test.mjs`,自动发现生产 Controller 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。 - 对外 Controller/API 新增、改名或删除时,同时核对调用端、统一 Gateway、OpenAPI 和权限;提交前运行 `node --test scripts/gateway-route-contract.test.mjs`,自动发现生产 Controller 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。
+5
View File
@@ -1,5 +1,10 @@
# Audit Service 模块指引 # Audit Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界 ## 职责边界
- Mapper 只由 `BaiyueAuditApplication.java` 对 `com.baiyue.audit` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。 - Mapper 只由 `BaiyueAuditApplication.java` 对 `com.baiyue.audit` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
+5
View File
@@ -1,5 +1,10 @@
# Auth Service 模块指引 # Auth Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界 ## 职责与边界
- Mapper 只由 `BaiyueAuthApplication.java` 对 `com.baiyue.auth` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。 - Mapper 只由 `BaiyueAuthApplication.java` 对 `com.baiyue.auth` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
+5
View File
@@ -1,5 +1,10 @@
# Common Audit 模块指引 # Common Audit 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界 ## 职责边界
- 负责业务审计事件采集、注解切面、敏感字段脱敏以及日志/HTTP 输出。 - 负责业务审计事件采集、注解切面、敏感字段脱敏以及日志/HTTP 输出。
+5
View File
@@ -1,5 +1,10 @@
# Common Core 模块指引 # Common Core 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界 ## 职责边界
- 提供不依赖具体业务域的返回模型、异常、基础实体和 MyBatis-Plus 通用配置。 - 提供不依赖具体业务域的返回模型、异常、基础实体和 MyBatis-Plus 通用配置。
+5
View File
@@ -1,5 +1,10 @@
# Common Event Contract 模块指引 # Common Event Contract 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界 ## 职责边界
- 维护 Firstleg、Lastleg、Finance 之间稳定、可版本化的业务事件契约。 - 维护 Firstleg、Lastleg、Finance 之间稳定、可版本化的业务事件契约。
+5
View File
@@ -1,5 +1,10 @@
# Common OpenAPI 模块指引 # Common OpenAPI 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界 ## 职责边界
- 为 Spring MVC 服务提供 SpringDoc/OpenAPI 自动配置和统一文档元数据。 - 为 Spring MVC 服务提供 SpringDoc/OpenAPI 自动配置和统一文档元数据。
+5
View File
@@ -1,5 +1,10 @@
# Common Security 模块指引 # Common Security 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界 ## 职责边界
- 提供业务服务通用的 JWT 解码、认证过滤、Redis Access Token 会话校验、认证用户上下文及 401/403 JSON 响应。 - 提供业务服务通用的 JWT 解码、认证过滤、Redis Access Token 会话校验、认证用户上下文及 401/403 JSON 响应。
+5
View File
@@ -1,5 +1,10 @@
# Common Web 模块指引 # Common Web 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界 ## 职责边界
- 提供 Servlet Web 通用能力:请求链路标识、上下文和统一异常响应。 - 提供 Servlet Web 通用能力:请求链路标识、上下文和统一异常响应。
@@ -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` 而中止,此项未通过;未为消除检查错误而创建替代业务文档。
+3 -1
View File
@@ -43,7 +43,7 @@
| 区域 | 完整入口 | 服务 schema | 表数 | | 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:| |---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 227 | | 国内 | `头程数据库统一增量-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` | 208 | | 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 209 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 - 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
@@ -159,3 +159,5 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
自营库存周转后端导出权限见 `selfop-turnover-export-permission.sql`,仅新增 `platform:self-operated-analytics:export` 并授予平台管理员;同步 IAM 自营权限源及国内统一基线。配套部署 Lastleg、File、Admin,File 注册 `SELFOP_INVENTORY_TURNOVER`(Nacos同名配置同步);无业务建表及采集配置变更。执行前须逐项授权。 自营库存周转后端导出权限见 `selfop-turnover-export-permission.sql`,仅新增 `platform:self-operated-analytics:export` 并授予平台管理员;同步 IAM 自营权限源及国内统一基线。配套部署 Lastleg、File、Admin,File 注册 `SELFOP_INVENTORY_TURNOVER`(Nacos同名配置同步);无业务建表及采集配置变更。执行前须逐项授权。
货物废弃通用库存与散箱字段迁移:`migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql`。新增库存仓位关联及箱唛快照,须先迁移再部署 Lastleg;不回填历史废弃单。 货物废弃通用库存与散箱字段迁移:`migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql`。新增库存仓位关联及箱唛快照,须先迁移再部署 Lastleg;不回填历史废弃单。
2026-10-16 拉取合并配套结构清单及执行边界见 [数据库变更汇总](20261016-数据库变更汇总.md),已有尾程库使用 `migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql`;空库初始化海外表数同步为 209。该增量可重复执行,尚未执行到任何实际数据库。
@@ -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;
@@ -3298,7 +3298,7 @@ CREATE TABLE ll_warehouse_transfer_order (
pickup_contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '提货联系电话', pickup_contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '提货联系电话',
package_count int NOT NULL COMMENT '箱数', package_count int NOT NULL COMMENT '箱数',
delivery_method varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓方式', delivery_method varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓方式',
estimated_arrival_at datetime(3) NOT NULL COMMENT '预计入仓时间', estimated_arrival_at datetime(3) NULL COMMENT '预计入仓时间,库存转运不填写',
estimated_ship_at datetime(3) DEFAULT NULL COMMENT '预计发货时间', estimated_ship_at datetime(3) DEFAULT NULL COMMENT '预计发货时间',
fba_shipment_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '可选FBA货件编号', fba_shipment_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '可选FBA货件编号',
tracking_no varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '物流跟踪号', tracking_no varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '物流跟踪号',
@@ -4564,4 +4564,11 @@ CREATE TABLE ll_appointment_work_history (
KEY idx_appointment_work_history (work_item_key,occurred_at) KEY idx_appointment_work_history (work_item_key,occurred_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 208 AS initialized_tables; -- 海外业务短单号分配时钟。
CREATE TABLE ll_compact_number_clock (
number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写',
last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒',
PRIMARY KEY (number_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 209 AS initialized_tables;
+5
View File
@@ -1,5 +1,10 @@
# File Service 模块指引 # File Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界 ## 职责与边界
- Mapper 只由 `FileServiceApplication.java` 对 `com.baiyue.file` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。 - Mapper 只由 `FileServiceApplication.java` 对 `com.baiyue.file` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
+5
View File
@@ -1,5 +1,10 @@
# Finance Service Instructions # Finance Service Instructions
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 模块定位 ## 模块定位
- 本模块是独立财务服务,拥有客户结算条款、财务费用来源、CNY 账单与明细、客户资金账户及充值申请、应收款月度统计、客户对账确认、收款及分配、成本及分摊、账单修订、单据编号、幂等记录和财务审计状态。 - 本模块是独立财务服务,拥有客户结算条款、财务费用来源、CNY 账单与明细、客户资金账户及充值申请、应收款月度统计、客户对账确认、收款及分配、成本及分摊、账单修订、单据编号、幂等记录和财务审计状态。
+5
View File
@@ -1,5 +1,10 @@
# Firstleg Service 模块指引 # Firstleg Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界 ## 职责与边界
- Mapper 统一显式使用 `@Mapper`,仅由 `BaiyueFirstlegApplication.java` 对 `com.baiyue.firstleg` 根包按 `annotationClass = Mapper.class` 扫描;禁止恢复业务子域 Mapper 包白名单。静态门禁见根 `scripts/mapper-scan-contract.mjs`,模块内编译门禁见 `MapperRegistrationArchitectureTest.java`、`BaiyueFirstlegApplicationContractTest.java`。 - Mapper 统一显式使用 `@Mapper`,仅由 `BaiyueFirstlegApplication.java` 对 `com.baiyue.firstleg` 根包按 `annotationClass = Mapper.class` 扫描;禁止恢复业务子域 Mapper 包白名单。静态门禁见根 `scripts/mapper-scan-contract.mjs`,模块内编译门禁见 `MapperRegistrationArchitectureTest.java`、`BaiyueFirstlegApplicationContractTest.java`。
+5
View File
@@ -1,5 +1,10 @@
# Gateway 模块指引 # Gateway 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界 ## 职责与边界
- 负责路由、CORS、JWT/Redis 会话校验、平台/客户访问域隔离、请求标识和 Swagger 聚合。 - 负责路由、CORS、JWT/Redis 会话校验、平台/客户访问域隔离、请求标识和 Swagger 聚合。
+5
View File
@@ -1,5 +1,10 @@
# IAM Service 模块指引 # IAM Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界 ## 职责与边界
- Mapper 只由 `BaiyueIamApplication.java` 对 `com.baiyue.iam` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。 - Mapper 只由 `BaiyueIamApplication.java` 对 `com.baiyue.iam` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
+5
View File
@@ -1,5 +1,10 @@
# Integration Service 模块指引 # Integration Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界 ## 职责与边界
- 负责百度 OCR、Amazon Logistics/SP-API、海外仓业务船司/船舶轨迹查询等第三方适配和能力开关;不保存业务主数据。 - 负责百度 OCR、Amazon Logistics/SP-API、海外仓业务船司/船舶轨迹查询等第三方适配和能力开关;不保存业务主数据。
+5
View File
@@ -1,5 +1,10 @@
# Lastleg Service 模块指引 # Lastleg Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界 ## 职责与边界
- Mapper 必须标注 `@Mapper`,仅由 `BaiyueLastlegApplication.java` 对 `com.baiyue.lastleg` 根包按 `annotationClass = Mapper.class` 扫描;禁止局部 `*MapperConfiguration` 和包白名单。验证:`scripts/mapper-scan-contract.mjs`、`MapperRegistrationArchitectureTest.java`。 - Mapper 必须标注 `@Mapper`,仅由 `BaiyueLastlegApplication.java` 对 `com.baiyue.lastleg` 根包按 `annotationClass = Mapper.class` 扫描;禁止局部 `*MapperConfiguration` 和包白名单。验证:`scripts/mapper-scan-contract.mjs`、`MapperRegistrationArchitectureTest.java`。
@@ -14,6 +14,26 @@ import java.util.List;
@Mapper @Mapper
public interface InventoryBalanceMapper extends BaseMapper<InventoryBalance> { public interface InventoryBalanceMapper extends BaseMapper<InventoryBalance> {
@Select("""
SELECT EXISTS(
SELECT 1 FROM ll_inbound_plan_carton c
JOIN ll_order o ON o.id=c.order_id AND o.customer_id=c.customer_id
JOIN ll_inbound_order i ON i.customer_id=o.customer_id AND i.inbound_code=o.inbound_code
WHERE c.customer_id=#{customerId} AND i.id=#{inboundOrderId}
AND o.overseas_warehouse_id=#{warehouseId} AND c.carton_mark=#{cartonMark}
UNION ALL
SELECT 1 FROM ll_warehouse_transfer_carton c
JOIN ll_warehouse_transfer_order t ON t.id=c.transfer_order_id AND t.customer_id=c.customer_id
WHERE c.customer_id=#{customerId} AND t.inbound_order_id=#{inboundOrderId}
AND t.operating_warehouse_id=#{warehouseId} AND c.status='COUNTED'
AND (c.carton_mark=#{cartonMark} OR c.customer_carton_mark=#{cartonMark})
)
""")
boolean cartonBelongsToInbound(@Param("customerId") String customerId,
@Param("warehouseId") String warehouseId,
@Param("inboundOrderId") Long inboundOrderId,
@Param("cartonMark") String cartonMark);
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " + @Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " +
"AND overseas_warehouse_id=#{overseasWarehouseId} " + "AND overseas_warehouse_id=#{overseasWarehouseId} " +
"AND destination_warehouse_code=#{destinationWarehouseCode} " + "AND destination_warehouse_code=#{destinationWarehouseCode} " +
@@ -34,6 +34,8 @@ public final class PalletOperationRequests {
@NotEmpty @Size(max = 20) List<@Valid TargetMove> targets, @NotEmpty @Size(max = 20) List<@Valid TargetMove> targets,
@Size(max = 500) String reason) { } @Size(max = 500) String reason) { }
public record MeasuredCartonBinding(@NotBlank @Size(max=255) String cartonMark,
@NotBlank @Size(max=255) String sourceCartonMark) {}
/** source为空时表示退货箱没有来源托盘,现场仅扫描箱唛后绑定目标托盘;actualSource为现场确认的原入仓库存行。 */ /** source为空时表示退货箱没有来源托盘,现场仅扫描箱唛后绑定目标托盘;actualSource为现场确认的原入仓库存行。 */
public record RelabelDisassemble(@NotBlank @Size(max = 128) String idempotencyKey, public record RelabelDisassemble(@NotBlank @Size(max = 128) String idempotencyKey,
@NotBlank @Size(max = 64) String relabelNo, @NotBlank @Size(max = 64) String relabelNo,
@@ -41,10 +43,15 @@ public final class PalletOperationRequests {
@Valid PalletExpectation target, @Valid PalletExpectation target,
@NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks, @NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks,
@Size(max = 500) String reason, @Size(max = 500) String reason,
@Valid MoveLine actualSource) { @Valid MoveLine actualSource,
@Size(max=500) List<@Valid MeasuredCartonBinding> measuredCartons) {
public RelabelDisassemble(String idempotencyKey, String relabelNo, PalletExpectation source,
PalletExpectation target, List<String> cartonMarks, String reason, MoveLine actualSource) {
this(idempotencyKey, relabelNo, source, target, cartonMarks, reason, actualSource, null);
}
public RelabelDisassemble(String idempotencyKey, String relabelNo, PalletExpectation source, public RelabelDisassemble(String idempotencyKey, String relabelNo, PalletExpectation source,
PalletExpectation target, List<String> cartonMarks, String reason) { PalletExpectation target, List<String> cartonMarks, String reason) {
this(idempotencyKey, relabelNo, source, target, cartonMarks, reason, null); this(idempotencyKey, relabelNo, source, target, cartonMarks, reason, null, null);
} }
} }
@@ -68,6 +68,7 @@ public class PdaPalletOperationService {
@Resource private RelabelApplicationMapper relabelApplications; @Resource private RelabelApplicationMapper relabelApplications;
@Resource private RelabelItemMapper relabelItems; @Resource private RelabelItemMapper relabelItems;
@Resource private RelabelPalletMutationService relabelPalletMutations; @Resource private RelabelPalletMutationService relabelPalletMutations;
@Resource private com.baiyue.lastleg.relabel.service.RelabelCartonMeasurementService cartonMeasurements;
@Resource private ReturnInventoryCartonMapper returnCartons; @Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private ReturnInventoryCartonItemMapper returnCartonItems; @Resource private ReturnInventoryCartonItemMapper returnCartonItems;
@Resource private ReturnInventoryBalanceMapper returnBalances; @Resource private ReturnInventoryBalanceMapper returnBalances;
@@ -307,7 +308,7 @@ public class PdaPalletOperationService {
Set<String> bound = boundRelabelMarks(application); Set<String> bound = boundRelabelMarks(application);
if (!marks.stream().allMatch(bound::contains)) { if (!marks.stream().allMatch(bound::contains)) {
if (marks.stream().anyMatch(bound::contains)) throw conflict("本批包含已绑定箱唛,请刷新后重试"); if (marks.stream().anyMatch(bound::contains)) throw conflict("本批包含已绑定箱唛,请刷新后重试");
recordRelabelBinding(identity, new RelabelMutation(application, byPosition, Map.of()), retained); recordRelabelBinding(identity, new RelabelMutation(application, byPosition, Map.of(), request.measuredCartons()), retained);
} }
var result = new PalletOperationResponses.Operation(IdWorker.getId(), "RELABEL_PALLET_DISASSEMBLE", request.idempotencyKey(), var result = new PalletOperationResponses.Operation(IdWorker.getId(), "RELABEL_PALLET_DISASSEMBLE", request.idempotencyKey(),
"COMPLETED", false, LocalDateTime.now(), List.of(content(identity.overseasWarehouseId(), source))); "COMPLETED", false, LocalDateTime.now(), List.of(content(identity.overseasWarehouseId(), source)));
@@ -351,7 +352,7 @@ public class PdaPalletOperationService {
requireRelabelDestination(identity.overseasWarehouseId(), selected, target); requireRelabelDestination(identity.overseasWarehouseId(), selected, target);
Plan base = plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false); Plan base = plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false);
Plan relabelPlan = new Plan(base.pallets(), base.moves(), base.changes(), Plan relabelPlan = new Plan(base.pallets(), base.moves(), base.changes(),
new RelabelMutation(application, itemsByPosition, actual == null ? Map.of() : anchors)); new RelabelMutation(application, itemsByPosition, actual == null ? Map.of() : anchors, request.measuredCartons()));
String reason = "换标拆托" + (request.reason() == null || request.reason().isBlank() String reason = "换标拆托" + (request.reason() == null || request.reason().isBlank()
? "" : ":" + request.reason().trim()); ? "" : ":" + request.reason().trim());
return execute(identity, "RELABEL_PALLET_DISASSEMBLE", request.idempotencyKey(), request, return execute(identity, "RELABEL_PALLET_DISASSEMBLE", request.idempotencyKey(), request,
@@ -840,7 +841,12 @@ public class PdaPalletOperationService {
record.setFromStatus(task.getStatus()); record.setToStatus(task.getStatus()); record.setFromStatus(task.getStatus()); record.setToStatus(task.getStatus());
record.setOperatorKey(identity.operatorKey()); record.setOperatorName(identity.operatorName()); record.setOperatorKey(identity.operatorKey()); record.setOperatorName(identity.operatorName());
record.setCreatedAt(LocalDateTime.now()); record.setCreatedAt(LocalDateTime.now());
var measured = cartonMeasurements.prepare(app,
mutation.itemsByPosition().values().stream().flatMap(List::stream).toList(),
mutation.itemsByPosition().keySet().stream().map(positions::selectById).filter(Objects::nonNull).findFirst().orElseThrow().getPalletId(),
targets.values().iterator().next().getPalletId(), targets.values().iterator().next().getPalletNo(), mutation.measuredCartons());
record.setDetailJson(write(Map.of("round", Objects.toString(task.getReviewedAt(), "INITIAL"), record.setDetailJson(write(Map.of("round", Objects.toString(task.getReviewedAt(), "INITIAL"),
"measuredCartons", measured,
"cartonMarks", mutation.itemsByPosition().values().stream().flatMap(List::stream).map(RelabelItem::getCartonMark).toList(), "cartonMarks", mutation.itemsByPosition().values().stream().flatMap(List::stream).map(RelabelItem::getCartonMark).toList(),
"targetPallets", targets.values().stream().map(InventoryPosition::getPalletNo).distinct().toList(), "targetPallets", targets.values().stream().map(InventoryPosition::getPalletNo).distinct().toList(),
"sourcePallets", mutation.itemsByPosition().keySet().stream().map(positions::selectById) "sourcePallets", mutation.itemsByPosition().keySet().stream().map(positions::selectById)
@@ -1122,6 +1128,11 @@ public class PdaPalletOperationService {
RelabelMutation relabelMutation) { } RelabelMutation relabelMutation) { }
private record RelabelMutation(RelabelApplication application, private record RelabelMutation(RelabelApplication application,
Map<Long, List<RelabelItem>> itemsByPosition, Map<Long, List<RelabelItem>> itemsByPosition,
Map<Long, InventoryPosition> anchors) { } Map<Long, InventoryPosition> anchors,
List<com.baiyue.lastleg.palletops.dto.PalletOperationRequests.MeasuredCartonBinding> measuredCartons) {
RelabelMutation(RelabelApplication application, Map<Long,List<RelabelItem>> itemsByPosition, Map<Long,InventoryPosition> anchors) {
this(application,itemsByPosition,anchors,null);
}
}
private enum InventoryBucket { AVAILABLE, HOLD } private enum InventoryBucket { AVAILABLE, HOLD }
} }
@@ -9,6 +9,23 @@ public interface RelabelPalletMeasurementMapper {
record Stock(Long id, Long palletId, String palletNo, long packages, long version) {} record Stock(Long id, Long palletId, String palletNo, long packages, long version) {}
record Original(Long palletId, String palletNo, int packages, BigDecimal lengthCm, record Original(Long palletId, String palletNo, int packages, BigDecimal lengthCm,
BigDecimal widthCm, BigDecimal heightCm, BigDecimal grossWeightKg, BigDecimal volumeCbm) {} BigDecimal widthCm, BigDecimal heightCm, BigDecimal grossWeightKg, BigDecimal volumeCbm) {}
record CartonSpec(Long palletId, BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm,
BigDecimal grossWeightKg, long cartons) {}
record Carton(Long id, String cartonMark, String customerCartonMark, Long palletId, String palletCode,
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, BigDecimal grossWeightKg) {}
@Select("SELECT c.id,c.carton_mark cartonMark,c.customer_carton_mark customerCartonMark,p.warehouse_pallet_id palletId,p.pallet_no palletCode," +
"c.length_cm lengthCm,c.width_cm widthCm,c.height_cm heightCm,c.gross_weight_kg grossWeightKg " +
"FROM ll_warehouse_transfer_carton c JOIN ll_warehouse_transfer_pallet p " +
"ON p.id=c.transfer_pallet_id AND p.customer_id=c.customer_id AND p.transfer_order_id=c.transfer_order_id " +
"WHERE c.customer_id=#{customerId} AND c.transfer_order_id=#{transferId} AND c.status='COUNTED' AND p.status='COMPLETED' ORDER BY c.id")
List<Carton> cartons(@Param("customerId") String customerId, @Param("transferId") Long transferId);
@Select("SELECT p.warehouse_pallet_id palletId,c.length_cm lengthCm,c.width_cm widthCm,c.height_cm heightCm," +
"c.gross_weight_kg grossWeightKg,COUNT(*) cartons FROM ll_warehouse_transfer_carton c " +
"JOIN ll_warehouse_transfer_pallet p ON p.id=c.transfer_pallet_id AND p.customer_id=c.customer_id AND p.transfer_order_id=c.transfer_order_id " +
"WHERE c.customer_id=#{customerId} AND c.transfer_order_id=#{transferId} AND c.status='COUNTED' AND p.status='COMPLETED' " +
"GROUP BY p.warehouse_pallet_id,c.length_cm,c.width_cm,c.height_cm,c.gross_weight_kg " +
"ORDER BY p.warehouse_pallet_id,c.length_cm,c.width_cm,c.height_cm,c.gross_weight_kg")
List<CartonSpec> cartonSpecs(@Param("customerId") String customerId, @Param("transferId") Long transferId);
@Select("SELECT id FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo}") @Select("SELECT id FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo}")
Long transfer(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); Long transfer(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
@Select("SELECT id,pallet_id palletId,pallet_no palletNo," + @Select("SELECT id,pallet_id palletId,pallet_no palletNo," +
@@ -80,6 +80,7 @@ public class RelabelApplicationService implements WalletResultHandler {
READY_FOR_OUTBOUND, "COMPLETED", "REJECTED", "CANCELLED"); READY_FOR_OUTBOUND, "COMPLETED", "REJECTED", "CANCELLED");
private static final Set<String> DELIVERY_METHODS = Set.of("TRUCK", "EXPRESS", "AMAZON_PICKUP", "CUSTOMER_PICKUP", "PICKUP"); private static final Set<String> DELIVERY_METHODS = Set.of("TRUCK", "EXPRESS", "AMAZON_PICKUP", "CUSTOMER_PICKUP", "PICKUP");
@Resource private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
@Resource private RelabelApplicationMapper applications; @Resource private RelabelApplicationMapper applications;
@Resource private RelabelItemMapper items; @Resource private RelabelItemMapper items;
@Resource private RelabelStatusHistoryMapper histories; @Resource private RelabelStatusHistoryMapper histories;
@@ -417,13 +418,13 @@ public class RelabelApplicationService implements WalletResultHandler {
} }
if (carton == null || !customerId.equals(carton.getCustomerId()) if (carton == null || !customerId.equals(carton.getCustomerId())
|| !request.operationWarehouseId().trim().equals(carton.getOverseasWarehouseId())) { || !request.operationWarehouseId().trim().equals(carton.getOverseasWarehouseId())) {
throw conflict("所选退货箱不属于当前客户和操作仓"); throw conflict("当前箱子不存在或不属于当前订单");
} }
if (!"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) { if (!"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) {
throw conflict("仓库唛头 " + carton.getCartonMark() + " 已不可用于换标"); throw conflict("仓库唛头 " + carton.getCartonMark() + " 已不可用于换标");
} }
if (!carton.getCartonMark().equals(item.cartonMark().trim())) if (!carton.getCartonMark().equals(item.cartonMark().trim()))
throw conflict("来源退货箱号与选择的库存不一致"); throw conflict("当前箱子不存在或不属于当前订单");
com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountGuard.requireConfirmed(carton); com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountGuard.requireConfirmed(carton);
final long sourceReturnCartonId = carton.getId(); final long sourceReturnCartonId = carton.getId();
Map<String, Long> availableSku = returnCartonItems.lockCarton(sourceReturnCartonId).stream() Map<String, Long> availableSku = returnCartonItems.lockCarton(sourceReturnCartonId).stream()
@@ -474,6 +475,10 @@ public class RelabelApplicationService implements WalletResultHandler {
|| !Objects.equals(balance.getInboundOrderId(), item.sourceInboundOrderId())) { || !Objects.equals(balance.getInboundOrderId(), item.sourceInboundOrderId())) {
throw conflict("Selected inventory does not belong to this customer, operation warehouse and inbound order"); throw conflict("Selected inventory does not belong to this customer, operation warehouse and inbound order");
} }
if (!inventoryBalances.cartonBelongsToInbound(customerId, request.operationWarehouseId().trim(),
item.sourceInboundOrderId(), item.cartonMark().trim())) {
throw conflict("当前箱子不存在或不属于当前订单");
}
if (value(position.getAvailablePackages()) < item.notifiedPackageCount()) { if (value(position.getAvailablePackages()) < item.notifiedPackageCount()) {
throw conflict("Available inventory changed for inbound " + balance.getInboundOrderNo()); throw conflict("Available inventory changed for inbound " + balance.getInboundOrderNo());
} }
@@ -482,7 +487,7 @@ public class RelabelApplicationService implements WalletResultHandler {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
long applicationId = IdWorker.getId(); long applicationId = IdWorker.getId();
String operationWarehouseId = request.operationWarehouseId().trim(); String operationWarehouseId = request.operationWarehouseId().trim();
String relabelNo = orderNoGenerator.next("RELABEL", operationWarehouseId, applicationId); String relabelNo = compactNumbers.next("TB", customerId);
BigDecimal chargeAmount = Objects.requireNonNullElse(request.chargeAmount(), BigDecimal.ZERO); BigDecimal chargeAmount = Objects.requireNonNullElse(request.chargeAmount(), BigDecimal.ZERO);
boolean requiresWallet = chargeAmount.signum() > 0; boolean requiresWallet = chargeAmount.signum() > 0;
int holdIndex = 0; int holdIndex = 0;
@@ -1362,7 +1367,7 @@ public class RelabelApplicationService implements WalletResultHandler {
} }
LastlegOrder order = new LastlegOrder(); LastlegOrder order = new LastlegOrder();
order.setId(IdWorker.getId()); order.setId(IdWorker.getId());
order.setOrderNo(orderNoGenerator.next("RELABEL", warehouseId, order.getId())); order.setOrderNo(relabelNo);
order.setCustomerId(customerId); order.setOrderType("RELABEL_APPLICATION"); order.setCustomerId(customerId); order.setOrderType("RELABEL_APPLICATION");
order.setInboundCode(null); order.setSourceRegion("CUSTOMER"); order.setInboundCode(null); order.setSourceRegion("CUSTOMER");
order.setSourceInboundId(relabelNo + ":" + warehouseId); order.setSourceInboundId(relabelNo + ":" + warehouseId);
@@ -0,0 +1,94 @@
package com.baiyue.lastleg.relabel.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baiyue.lastleg.relabel.entity.*;
import com.baiyue.lastleg.relabel.mapper.RelabelPalletMeasurementMapper;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
/** Replays saved physical box bindings; dimensions always come from measured source boxes. */
@Service
public class RelabelCartonMeasurementService {
@Resource private RelabelPalletMeasurementMapper mapper;
@Resource private PdaRelabelHistoryMapper histories;
@Resource private ObjectMapper json;
public record Move(String cartonMark, String sourceCartonMark, Long targetPalletId, String targetPalletCode) {}
public List<RelabelPalletMeasurementMapper.Carton> current(RelabelApplication app) {
Long transfer = mapper.transfer(app.getCustomerId(), app.getRelabelNo());
if (transfer == null) return List.of();
var boxes = new LinkedHashMap<String, RelabelPalletMeasurementMapper.Carton>();
for (var box : mapper.cartons(app.getCustomerId(), transfer)) boxes.put(box.cartonMark(), box);
if (boxes.isEmpty()) return List.of();
for (var history : records(app)) if ("PALLET_BIND".equals(history.getAction())) {
try {
for (var node : json.readTree(history.getDetailJson()).path("measuredCartons")) {
var move = json.treeToValue(node, Move.class);
var box = boxes.get(move.sourceCartonMark());
if (box == null) throw conflict("换标箱测量来源无法追溯");
boxes.put(box.cartonMark(), new RelabelPalletMeasurementMapper.Carton(box.id(), box.cartonMark(), box.customerCartonMark(),
move.targetPalletId(), move.targetPalletCode(), box.lengthCm(), box.widthCm(), box.heightCm(), box.grossWeightKg()));
}
} catch (com.fasterxml.jackson.core.JsonProcessingException e) { throw conflict("换标箱绑定记录无法读取"); }
}
return List.copyOf(boxes.values());
}
public List<Move> prepare(RelabelApplication app, List<RelabelItem> items, Long sourcePallet,
Long targetPallet, String targetCode, List<com.baiyue.lastleg.palletops.dto.PalletOperationRequests.MeasuredCartonBinding> requested) {
var boxes = current(app);
if (boxes.isEmpty()) return List.of();
var available = new ArrayList<>(boxes.stream().filter(box -> sourcePallet.equals(box.palletId())).toList());
var mappings = new HashMap<String,String>();
// A rebind retains the exact physical box selected at its first binding.
for (var history : records(app)) if ("PALLET_BIND".equals(history.getAction())) {
try { for (var node : json.readTree(history.getDetailJson()).path("measuredCartons"))
mappings.put(node.path("cartonMark").asText(), node.path("sourceCartonMark").asText());
} catch (com.fasterxml.jackson.core.JsonProcessingException e) { throw conflict("换标箱绑定记录无法读取"); }
}
var explicit = new HashMap<String,String>();
var selectedMarks = items.stream().map(RelabelItem::getCartonMark).toList();
available.removeIf(box -> mappings.entrySet().stream().anyMatch(entry -> box.cartonMark().equals(entry.getValue()) && !selectedMarks.contains(entry.getKey())));
for (var binding : requested == null ? List.<com.baiyue.lastleg.palletops.dto.PalletOperationRequests.MeasuredCartonBinding>of() : requested) {
if (binding == null || binding.cartonMark() == null || binding.sourceCartonMark() == null
|| items.stream().noneMatch(item -> binding.cartonMark().equals(item.getCartonMark()))
|| explicit.put(binding.cartonMark(),binding.sourceCartonMark()) != null)
throw conflict("实测箱选择无效或重复");
}
var result = new ArrayList<Move>();
for (var item : items) {
if (!Integer.valueOf(1).equals(item.getNotifiedPackageCount())) throw conflict("按箱测量的换标明细必须对应一个实测箱");
String prior = mappings.get(item.getCartonMark()), chosen = explicit.get(item.getCartonMark());
if (prior != null && chosen != null && !prior.equals(chosen)) throw conflict("改绑托盘不能替换已绑定的实测箱");
if (chosen == null) chosen = prior;
final String key = chosen;
var matched = available.stream().filter(box -> key != null ? key.equals(box.cartonMark())
: item.getCartonMark().equals(box.cartonMark()) || item.getCartonMark().equals(box.customerCartonMark())).toList();
RelabelPalletMeasurementMapper.Carton box = matched.size() == 1 ? matched.get(0) : null;
// Identical measurements may be allocated without a further scan; mixed specifications require an explicit choice.
if (box == null && chosen == null && !available.isEmpty() && available.stream().allMatch(b -> sameSpec(available.get(0), b))) box = available.get(0);
if (box == null) throw conflict("请选择本次换标对应的来源实测箱,箱规不同不能自动分摊体积和重量");
if (!positive(box.lengthCm()) || !positive(box.widthCm()) || !positive(box.heightCm()) || !positive(box.grossWeightKg()))
throw conflict("来源箱缺少有效实测尺寸或重量,请先完成箱测量");
available.remove(box);
result.add(new Move(item.getCartonMark(),box.cartonMark(),targetPallet,targetCode));
}
return result;
}
private boolean sameSpec(RelabelPalletMeasurementMapper.Carton a, RelabelPalletMeasurementMapper.Carton b) {
return equal(a.lengthCm(),b.lengthCm()) && equal(a.widthCm(),b.widthCm()) && equal(a.heightCm(),b.heightCm()) && equal(a.grossWeightKg(),b.grossWeightKg());
}
private boolean equal(java.math.BigDecimal a, java.math.BigDecimal b) { return a != null && b != null && a.compareTo(b) == 0; }
private boolean positive(java.math.BigDecimal value) { return value != null && value.signum() > 0; }
private List<PdaRelabelHistory> records(RelabelApplication app) {
return histories.selectList(new QueryWrapper<PdaRelabelHistory>().eq("customer_id",app.getCustomerId())
.eq("application_id",app.getId()).orderByAsc("created_at","id"));
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT,message); }
}
@@ -27,6 +27,7 @@ public class RelabelPalletMeasurementService {
@Resource private PdaRelabelHistoryMapper histories; @Resource private PdaRelabelHistoryMapper histories;
@Resource private PdaRelabelTaskMapper tasks; @Resource private PdaRelabelTaskMapper tasks;
@Resource private ObjectMapper json; @Resource private ObjectMapper json;
@Resource private RelabelCartonMeasurementService cartonMeasurements;
public record Measure(@NotBlank @Size(max=64) String relabelNo, @NotBlank @Size(max=128) String palletCode, public record Measure(@NotBlank @Size(max=64) String relabelNo, @NotBlank @Size(max=128) String palletCode,
@NotBlank @Size(max=128) String idempotencyKey, @NotBlank @Size(max=32768) String stockVersion, @NotBlank @Size(max=128) String idempotencyKey, @NotBlank @Size(max=32768) String stockVersion,
@NotNull @DecimalMin("0.01") @Digits(integer=6,fraction=2) BigDecimal lengthCm, @NotNull @DecimalMin("0.01") @Digits(integer=6,fraction=2) BigDecimal lengthCm,
@@ -35,7 +36,15 @@ public class RelabelPalletMeasurementService {
@NotNull @DecimalMin("0.01") @Digits(integer=9,fraction=3) BigDecimal grossWeightKg) {} @NotNull @DecimalMin("0.01") @Digits(integer=9,fraction=3) BigDecimal grossWeightKg) {}
public record Measurement(Long palletId, String palletCode, long packages, String stockVersion, public record Measurement(Long palletId, String palletCode, long packages, String stockVersion,
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, BigDecimal grossWeightKg, BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, BigDecimal grossWeightKg,
BigDecimal volumeCbm, boolean inherited) {} BigDecimal volumeCbm, boolean inherited, List<RelabelPalletMeasurementMapper.CartonSpec> sourceCartonSpecs,
boolean cartonAggregated, List<RelabelPalletMeasurementMapper.Carton> cartons) {
public Measurement(Long palletId, String palletCode, long packages, String stockVersion,
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, BigDecimal grossWeightKg,
BigDecimal volumeCbm, boolean inherited) {
this(palletId, palletCode, packages, stockVersion, lengthCm, widthCm, heightCm,
grossWeightKg, volumeCbm, inherited, List.of(), false, List.of());
}
}
public List<Measurement> list(PdaWarehouseIdentity identity, String relabelNo) { public List<Measurement> list(PdaWarehouseIdentity identity, String relabelNo) {
return snapshots(scoped(identity, relabelNo, false)); return snapshots(scoped(identity, relabelNo, false));
@@ -70,6 +79,7 @@ public class RelabelPalletMeasurementService {
var current = snapshots(app).stream().filter(p -> p.palletCode().equals(request.palletCode())).findFirst() var current = snapshots(app).stream().filter(p -> p.palletCode().equals(request.palletCode())).findFirst()
.orElseThrow(() -> conflict("托盘不属于本换标单或已经清空")); .orElseThrow(() -> conflict("托盘不属于本换标单或已经清空"));
if (!current.stockVersion().equals(request.stockVersion())) throw conflict("托盘库存已变化,请刷新后重新测量"); if (!current.stockVersion().equals(request.stockVersion())) throw conflict("托盘库存已变化,请刷新后重新测量");
if (current.cartonAggregated()) throw conflict("本托盘按实测箱自动汇总体积和重量,无需确认整托实测");
var measured = new Measurement(current.palletId(), current.palletCode(), current.packages(), current.stockVersion(), var measured = new Measurement(current.palletId(), current.palletCode(), current.packages(), current.stockVersion(),
request.lengthCm(), request.widthCm(), request.heightCm(), request.grossWeightKg(), request.lengthCm(), request.widthCm(), request.heightCm(), request.grossWeightKg(),
request.lengthCm().multiply(request.widthCm()).multiply(request.heightCm()).movePointLeft(6).setScale(6, RoundingMode.HALF_UP), false); request.lengthCm().multiply(request.widthCm()).multiply(request.heightCm()).movePointLeft(6).setScale(6, RoundingMode.HALF_UP), false);
@@ -85,23 +95,40 @@ public class RelabelPalletMeasurementService {
if (transferId == null) return List.of(); // Other relabel sources retain their own physical measurement contract. if (transferId == null) return List.of(); // Other relabel sources retain their own physical measurement contract.
var stock = mapper.stock(app.getCustomerId(), app.getOverseasWarehouseId()); var stock = mapper.stock(app.getCustomerId(), app.getOverseasWarehouseId());
var originals = mapper.originals(app.getCustomerId(), transferId); var originals = mapper.originals(app.getCustomerId(), transferId);
var cartonSpecs = mapper.cartonSpecs(app.getCustomerId(), transferId);
var involved = new HashSet<Long>(); originals.forEach(p -> involved.add(p.palletId())); var involved = new HashSet<Long>(); originals.forEach(p -> involved.add(p.palletId()));
var positions = items.selectList(new QueryWrapper<com.baiyue.lastleg.relabel.entity.RelabelItem>() var positions = items.selectList(new QueryWrapper<com.baiyue.lastleg.relabel.entity.RelabelItem>()
.eq("customer_id", app.getCustomerId()).eq("application_id", app.getId())); .eq("customer_id", app.getCustomerId()).eq("application_id", app.getId()));
var positionIds = positions.stream().map(i -> i.getSourceInventoryPositionId()).toList(); var positionIds = positions.stream().map(i -> i.getSourceInventoryPositionId()).toList();
stock.stream().filter(p -> positionIds.contains(p.id())).forEach(p -> involved.add(p.palletId())); stock.stream().filter(p -> positionIds.contains(p.id())).forEach(p -> involved.add(p.palletId()));
var records = histories(app); var records = histories(app);
var currentBoxes = cartonMeasurements.current(app);
var codes = new HashSet<String>(); var codes = new HashSet<String>();
for (var h : records) if ("PALLET_BIND".equals(h.getAction())) { for (var h : records) if ("PALLET_BIND".equals(h.getAction())) {
tree(h).path("sourcePallets").forEach(p -> codes.add(p.asText())); tree(h).path("sourcePallets").forEach(p -> codes.add(p.asText()));
tree(h).path("targetPallets").forEach(p -> codes.add(p.asText())); tree(h).path("targetPallets").forEach(p -> codes.add(p.asText()));
} }
stock.stream().filter(p -> codes.contains(p.palletNo())).forEach(p -> involved.add(p.palletId())); stock.stream().filter(p -> codes.contains(p.palletNo())).forEach(p -> involved.add(p.palletId()));
currentBoxes.forEach(box -> involved.add(box.palletId()));
var result = new ArrayList<Measurement>(); var result = new ArrayList<Measurement>();
for (Long pallet : involved.stream().filter(Objects::nonNull).sorted().toList()) { for (Long pallet : involved.stream().filter(Objects::nonNull).sorted().toList()) {
var rows = stock.stream().filter(p -> pallet.equals(p.palletId()) && p.packages() > 0).toList(); var rows = stock.stream().filter(p -> pallet.equals(p.palletId()) && p.packages() > 0).toList();
long count = rows.stream().mapToLong(p -> p.packages()).sum(); if (count == 0) continue; long count = rows.stream().mapToLong(p -> p.packages()).sum(); if (count == 0) continue;
String version = rows.stream().map(p -> p.id()+":"+p.version()+":"+p.packages()).collect(java.util.stream.Collectors.joining(",")); String version = rows.stream().map(p -> p.id()+":"+p.version()+":"+p.packages()).collect(java.util.stream.Collectors.joining(","));
if (!currentBoxes.isEmpty()) {
var boxes = currentBoxes.stream().filter(box -> pallet.equals(box.palletId())).toList();
boolean complete = boxes.size() == count && boxes.stream().allMatch(box -> positive(box.lengthCm())
&& positive(box.widthCm()) && positive(box.heightCm()) && positive(box.grossWeightKg()));
var specs = new LinkedHashMap<List<BigDecimal>,Long>();
if (complete) for (var box : boxes) specs.merge(List.of(box.lengthCm(),box.widthCm(),box.heightCm(),box.grossWeightKg()),1L,Long::sum);
var grouped = specs.entrySet().stream().map(entry -> new RelabelPalletMeasurementMapper.CartonSpec(pallet,
entry.getKey().get(0),entry.getKey().get(1),entry.getKey().get(2),entry.getKey().get(3),entry.getValue())).toList();
BigDecimal volume = complete ? boxes.stream().map(box -> box.lengthCm().multiply(box.widthCm()).multiply(box.heightCm()).movePointLeft(6))
.reduce(BigDecimal.ZERO,BigDecimal::add).setScale(6,RoundingMode.HALF_UP) : null;
BigDecimal weight = complete ? boxes.stream().map(RelabelPalletMeasurementMapper.Carton::grossWeightKg).reduce(BigDecimal.ZERO,BigDecimal::add) : null;
result.add(new Measurement(pallet,rows.get(0).palletNo(),count,version,null,null,null,weight,volume,true,grouped,true,boxes));
continue;
}
Measurement value = null; Measurement value = null;
for (var h : records) if ("PALLET_MEASURE".equals(h.getAction())) { for (var h : records) if ("PALLET_MEASURE".equals(h.getAction())) {
var measured = json.convertValue(tree(h).path("measurement"), Measurement.class); var measured = json.convertValue(tree(h).path("measurement"), Measurement.class);
@@ -110,7 +137,9 @@ public class RelabelPalletMeasurementService {
if (value == null && mapper.movements(app.getCustomerId(), pallet) == 0) { if (value == null && mapper.movements(app.getCustomerId(), pallet) == 0) {
var original = originals.stream().filter(p -> pallet.equals(p.palletId()) && p.packages() == count).findFirst().orElse(null); var original = originals.stream().filter(p -> pallet.equals(p.palletId()) && p.packages() == count).findFirst().orElse(null);
if (original != null) value = new Measurement(pallet, rows.get(0).palletNo(), count, version, if (original != null) value = new Measurement(pallet, rows.get(0).palletNo(), count, version,
original.lengthCm(), original.widthCm(), original.heightCm(), original.grossWeightKg(), original.volumeCbm(), true); positiveOrNull(original.lengthCm()), positiveOrNull(original.widthCm()), positiveOrNull(original.heightCm()),
original.grossWeightKg(), original.volumeCbm(), true,
cartonSpecs.stream().filter(spec -> pallet.equals(spec.palletId())).toList(), false, List.of());
} }
result.add(value == null ? new Measurement(pallet, rows.get(0).palletNo(), count, version, null,null,null,null,null,false) : value); result.add(value == null ? new Measurement(pallet, rows.get(0).palletNo(), count, version, null,null,null,null,null,false) : value);
} }
@@ -118,10 +147,28 @@ public class RelabelPalletMeasurementService {
} }
public void requireMeasured(RelabelApplication app) { public void requireMeasured(RelabelApplication app) {
for (var p : snapshots(app)) if (p.grossWeightKg() == null || p.volumeCbm() == null for (var p : snapshots(app)) if (p.grossWeightKg() == null || p.volumeCbm() == null
|| p.lengthCm() == null || p.widthCm() == null || p.heightCm() == null || !(positive(p.lengthCm()) && positive(p.widthCm()) && positive(p.heightCm())) && !completeSourceCartons(p)
|| p.grossWeightKg().signum() <= 0 || p.volumeCbm().signum() <= 0) || p.grossWeightKg().signum() <= 0 || p.volumeCbm().signum() <= 0)
throw conflict("托盘 " + p.palletCode() + " 拆合托后尚未确认实测尺寸和重量,请在 PDA 补测(含原托盘剩余货物)"); throw conflict("托盘 " + p.palletCode() + (p.cartonAggregated()
? " 实测箱数据不完整或与库存箱数不一致,请刷新核对来源箱"
: " 拆合托后尚未确认实测尺寸和重量,请在 PDA 补测(含原托盘剩余货物)"));
} }
private boolean completeSourceCartons(Measurement p) {
if (!p.inherited() || p.sourceCartonSpecs() == null || p.sourceCartonSpecs().isEmpty()) return false;
long count = 0; BigDecimal volume = BigDecimal.ZERO, weight = BigDecimal.ZERO;
for (var spec : p.sourceCartonSpecs()) {
if (spec.cartons() <= 0 || !positive(spec.lengthCm()) || !positive(spec.widthCm())
|| !positive(spec.heightCm()) || !positive(spec.grossWeightKg())) return false;
count += spec.cartons();
var boxes = BigDecimal.valueOf(spec.cartons());
volume = volume.add(spec.lengthCm().multiply(spec.widthCm()).multiply(spec.heightCm()).movePointLeft(6).multiply(boxes));
weight = weight.add(spec.grossWeightKg().multiply(boxes));
}
return count == p.packages() && volume.setScale(6, RoundingMode.HALF_UP).compareTo(p.volumeCbm()) == 0
&& weight.compareTo(p.grossWeightKg()) == 0;
}
private static boolean positive(BigDecimal value) { return value != null && value.signum() > 0; }
private static BigDecimal positiveOrNull(BigDecimal value) { return positive(value) ? value : null; }
private List<PdaRelabelHistory> histories(RelabelApplication app) { private List<PdaRelabelHistory> histories(RelabelApplication app) {
return histories.selectList(new QueryWrapper<PdaRelabelHistory>().eq("customer_id", app.getCustomerId()) return histories.selectList(new QueryWrapper<PdaRelabelHistory>().eq("customer_id", app.getCustomerId())
.eq("application_id", app.getId()).orderByAsc("created_at","id")); .eq("application_id", app.getId()).orderByAsc("created_at","id"));
@@ -537,7 +537,7 @@ public class ReturnInventoryService {
String cartonMark = text(requestedMark); String cartonMark = text(requestedMark);
long cartonId = IdWorker.getId(); long cartonId = IdWorker.getId();
// One durable barcode identifies a physical parcel, irrespective of SKU count. // One durable barcode identifies a physical parcel, irrespective of SKU count.
if ("PARCEL".equals(receipt.getUnitType())) cartonMark = "RP" + hash(List.of(receipt.getId())).substring(0, 24).toUpperCase(java.util.Locale.ROOT); if ("PARCEL".equals(receipt.getUnitType())) cartonMark = "RP-" + Long.toString(receipt.getId(), 36).toUpperCase(java.util.Locale.ROOT);
else if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId); else if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId);
if (cartons.findMark(cartonMark) != null) throw conflict("箱号已存在: " + cartonMark); if (cartons.findMark(cartonMark) != null) throw conflict("箱号已存在: " + cartonMark);
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
@@ -602,7 +602,8 @@ public class ReturnInventoryService {
} }
private String shortReturnMark(String returnNo, Long receiptId, String skuCode, long totalBoxes, long sequence) { private String shortReturnMark(String returnNo, Long receiptId, String skuCode, long totalBoxes, long sequence) {
String prefix = hash(java.util.Arrays.asList(returnNo, receiptId, skuCode)).substring(0, 16).toUpperCase(java.util.Locale.ROOT); String prefix = new java.math.BigInteger(hash(java.util.Arrays.asList(returnNo, receiptId, skuCode)).substring(0, 16), 16)
.toString(36).toUpperCase(java.util.Locale.ROOT);
return "RC" + prefix + "#" + totalBoxes + "-" + sequence; return "RC" + prefix + "#" + totalBoxes + "-" + sequence;
} }
@@ -1276,7 +1277,7 @@ public class ReturnInventoryService {
.map(ReturnInventoryRequests.PlanItem::skuCode).distinct().sorted().toList()).toList(); .map(ReturnInventoryRequests.PlanItem::skuCode).distinct().sorted().toList()).toList();
Map<List<String>, Long> totals = groups.stream().collect(java.util.stream.Collectors.groupingBy(group -> group, LinkedHashMap::new, java.util.stream.Collectors.counting())); Map<List<String>, Long> totals = groups.stream().collect(java.util.stream.Collectors.groupingBy(group -> group, LinkedHashMap::new, java.util.stream.Collectors.counting()));
Map<List<String>, String> prefixes = new HashMap<>(); Map<List<String>, Long> sequences = new HashMap<>(); Map<List<String>, String> prefixes = new HashMap<>(); Map<List<String>, Long> sequences = new HashMap<>();
return groups.stream().map(group -> prefixes.computeIfAbsent(group, ignored -> "RC" + java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(java.util.Locale.ROOT)) return groups.stream().map(group -> prefixes.computeIfAbsent(group, ignored -> LastlegOrderNoGenerator.compactCartonMark(IdWorker.getId()))
+ "#" + totals.get(group) + "-" + sequences.merge(group, 1L, Long::sum)).toList(); + "#" + totals.get(group) + "-" + sequences.merge(group, 1L, Long::sum)).toList();
} }
private String hash(Object request) { try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") private String hash(Object request) { try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
@@ -62,6 +62,7 @@ public class ReturnReceiptService {
public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED"; public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED";
public static final String COMPLETED = "COMPLETED"; public static final String COMPLETED = "COMPLETED";
@Resource private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
@Resource private ReturnOrderMapper orders; @Resource private ReturnOrderMapper orders;
@Resource private ReturnOrderItemMapper orderItems; @Resource private ReturnOrderItemMapper orderItems;
@Resource private ReturnReceiptMapper receipts; @Resource private ReturnReceiptMapper receipts;
@@ -99,7 +100,7 @@ public class ReturnReceiptService {
order.setOverseasWarehouseId(text(request.overseasWarehouseId())); order.setOverseasWarehouseId(text(request.overseasWarehouseId()));
order.setWarehouseContactName(text(request.warehouseContactName())); order.setWarehouseContactName(text(request.warehouseContactName()));
order.setWarehouseContactPhone(text(request.warehouseContactPhone())); order.setWarehouseContactPhone(text(request.warehouseContactPhone()));
order.setReturnNo(numbers.next("RETURN", order.getOverseasWarehouseId(), order.getId())); order.setReturnNo(compactNumbers.next("TT", customerId));
order.setCustomerReferenceNo(text(request.customerReferenceNo())); order.setNeedRelabel(request.needRelabel()); order.setStatus(PENDING_REVIEW); order.setCustomerReferenceNo(text(request.customerReferenceNo())); order.setNeedRelabel(request.needRelabel()); order.setStatus(PENDING_REVIEW);
order.setExpectedSkuTypes(skuCodes.size()); order.setExpectedQuantity(expected); order.setReceivedQuantity(0L); order.setExpectedSkuTypes(skuCodes.size()); order.setExpectedQuantity(expected); order.setReceivedQuantity(0L);
order.setRemark(text(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(requestHash); order.setRemark(text(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(requestHash);
@@ -429,10 +430,10 @@ public class ReturnReceiptService {
totalQuantity = Math.addExact(totalQuantity, requested.quantity()); totalQuantity = Math.addExact(totalQuantity, requested.quantity());
} }
long appId = IdWorker.getId(); LocalDateTime now = LocalDateTime.now(); long appId = IdWorker.getId(); LocalDateTime now = LocalDateTime.now();
String relabelNo = numbers.next("RELABEL", order.getOverseasWarehouseId(), appId); String relabelNo = compactNumbers.next("TT", customerId);
LastlegOrder tail = order.getLastlegOrderId() == null ? null : lastlegOrders.selectById(order.getLastlegOrderId()); LastlegOrder tail = order.getLastlegOrderId() == null ? null : lastlegOrders.selectById(order.getLastlegOrderId());
if (tail == null) { if (tail == null) {
tail = new LastlegOrder(); tail.setId(IdWorker.getId()); tail.setOrderNo(numbers.next("RELABEL", order.getOverseasWarehouseId(), tail.getId())); tail = new LastlegOrder(); tail.setId(IdWorker.getId()); tail.setOrderNo(relabelNo);
tail.setCustomerId(customerId); tail.setOrderType("RELABEL_APPLICATION"); tail.setSourceRegion("CUSTOMER"); tail.setCustomerId(customerId); tail.setOrderType("RELABEL_APPLICATION"); tail.setSourceRegion("CUSTOMER");
tail.setSourceInboundId("RETURN_RELABEL:" + relabelNo); tail.setSourceInboundId("RETURN_RELABEL:" + relabelNo);
tail.setSourceEventId("RETURN_RELABEL:" + relabelNo); tail.setSourceEventId("RETURN_RELABEL:" + relabelNo);
@@ -612,9 +613,9 @@ public class ReturnReceiptService {
if ("PARCEL".equals(unitType) && request.totalBoxCount() != null) throw badRequest("包裹签收不填写箱数"); if ("PARCEL".equals(unitType) && request.totalBoxCount() != null) throw badRequest("包裹签收不填写箱数");
if (unitCode == null && "PALLET".equals(unitType)) unitCode = text(request.palletCode()); if (unitCode == null && "PALLET".equals(unitType)) unitCode = text(request.palletCode());
if (unitCode == null && "BOX".equals(unitType)) if (unitCode == null && "BOX".equals(unitType))
unitCode = "RC" + hash(List.of(order.getReturnNo(), IdWorker.getId())).substring(0, 16).toUpperCase(java.util.Locale.ROOT) + "#1-1"; unitCode = LastlegOrderNoGenerator.compactCartonMark(IdWorker.getId());
if (unitCode == null && "PARCEL".equals(unitType)) { if (unitCode == null && "PARCEL".equals(unitType)) {
unitCode = "PK" + hash(List.of(order.getReturnNo(), IdWorker.getId())).substring(0, 16).toUpperCase(java.util.Locale.ROOT); unitCode = "PK-" + Long.toString(IdWorker.getId(), 36).toUpperCase(java.util.Locale.ROOT);
} }
if (unitCode == null) throw badRequest(("PALLET".equals(unitType) ? "托盘码" : "包裹码") + "不能为空"); if (unitCode == null) throw badRequest(("PALLET".equals(unitType) ? "托盘码" : "包裹码") + "不能为空");
if ("PALLET".equals(unitType) if ("PALLET".equals(unitType)
@@ -0,0 +1,59 @@
package com.baiyue.lastleg.service;
import net.sourceforge.pinyin4j.PinyinHelper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
/** Allocates suffix-free document numbers across all service instances. */
@Component
public class CompactBusinessNumberService {
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter FORMAT = DateTimeFormatter.ofPattern("yyMMddHHmmss", Locale.ROOT).withZone(ZONE);
private final JdbcTemplate jdbc;
private final ReturnOrderReferenceResolver customers;
public CompactBusinessNumberService(JdbcTemplate jdbc, ReturnOrderReferenceResolver customers) {
this.jdbc = jdbc;
this.customers = customers;
}
@Transactional
public String next(String prefix, String customerId) {
return allocate(prefix, initials(customers.customerName(customerId)), Instant.now());
}
String allocate(String prefix, String initials, Instant now) {
String key = prefix + "-" + initials;
long seconds = now.getEpochSecond();
// The row lock serializes identical prefixes/initials even across customers and JVMs.
jdbc.update("INSERT INTO ll_compact_number_clock(number_key,last_second) VALUES(?,LAST_INSERT_ID(?)) "
+ "ON DUPLICATE KEY UPDATE last_second=LAST_INSERT_ID(GREATEST(last_second+1,?))", key, seconds, seconds);
Long allocated = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
if (allocated == null || allocated < seconds) throw new IllegalStateException("业务编号生成失败");
return key + "-" + FORMAT.format(Instant.ofEpochSecond(allocated));
}
static String initials(String name) {
if (name == null || name.isBlank()) return "KH";
StringBuilder result = new StringBuilder();
boolean latinWord = false;
for (char c : name.trim().toCharArray()) {
if (result.length() >= 4) break;
if (Character.UnicodeScript.of(c) == Character.UnicodeScript.HAN) {
String[] pinyin = PinyinHelper.toHanyuPinyinStringArray(c);
if (pinyin != null && pinyin.length > 0) result.append(Character.toUpperCase(pinyin[0].charAt(0)));
latinWord = false;
} else if (c < 128 && Character.isLetterOrDigit(c)) {
if (!latinWord) result.append(Character.toUpperCase(c));
latinWord = true;
} else latinWord = false;
}
return result.isEmpty() ? "KH" : result.toString();
}
}
@@ -36,6 +36,7 @@ public class LastlegOrderNoGenerator {
public String next(String businessType, String warehouse, long uniqueId) { public String next(String businessType, String warehouse, long uniqueId) {
String code = businessType == null ? "" : businessType.trim().toUpperCase(Locale.ROOT); String code = businessType == null ? "" : businessType.trim().toUpperCase(Locale.ROOT);
if ("RETURN_CARTON".equals(code)) return compactCartonMark(uniqueId);
String returnPrefix = switch (code) { String returnPrefix = switch (code) {
case "RETURN" -> "RT"; case "RETURN" -> "RT";
case "RETURN_RECEIPT" -> "RR"; case "RETURN_RECEIPT" -> "RR";
@@ -65,6 +66,12 @@ public class LastlegOrderNoGenerator {
return "0".repeat(25 - encoded.length()) + encoded; return "0".repeat(25 - encoded.length()) + encoded;
} }
/** Preserve every snowflake ID bit; do not truncate or hash physical label identifiers. */
public static String compactCartonMark(long id) {
if (id <= 0) throw new IllegalArgumentException("Carton id must be positive");
return "BX-" + Long.toString(id, 36).toUpperCase(Locale.ROOT);
}
private String normalizeType(String value) { private String normalizeType(String value) {
String code = value == null ? "LMO" : value.trim().toUpperCase(Locale.ROOT); String code = value == null ? "LMO" : value.trim().toUpperCase(Locale.ROOT);
return switch (code) { return switch (code) {
@@ -65,6 +65,10 @@ public class ReturnOrderReferenceResolver {
} }
} }
public String customerName(String id) {
return customerReference(id).tenantName();
}
private ReturnCustomerReferenceClient.Customer customerReference(String id) { private ReturnCustomerReferenceClient.Customer customerReference(String id) {
try { try {
var result = customers.customers(internalToken, 1, 30, null, "ACTIVE", Long.parseLong(id)); var result = customers.customers(internalToken, 1, 30, null, "ACTIVE", Long.parseLong(id));
@@ -28,6 +28,14 @@ import java.util.List;
public class MerchantWarehouseTransferController { public class MerchantWarehouseTransferController {
@Resource private WarehouseTransferService service; @Resource private WarehouseTransferService service;
@GetMapping("/inventory-source-cartons")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_CREATE + "')")
public ApiResponse<List<WarehouseTransferResponses.SourceCarton>> sourceCartons(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String operatingWarehouseId,
@RequestParam String inboundCode) {
return ApiResponse.success(service.sourceCartons(customer(user), operatingWarehouseId, inboundCode));
}
@PostMapping @PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_CREATE + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_CREATE + "')")
public ApiResponse<WarehouseTransferResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<WarehouseTransferResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@@ -29,13 +29,13 @@ public final class WarehouseTransferRequests {
@Size(max = 32) String privateDestinationPostalCode, @Size(max = 32) String privateDestinationPostalCode,
@Size(max = 128) String privateDestinationContactName, @Size(max = 128) String privateDestinationContactName,
@Size(max = 64) String privateDestinationContactPhone, @Size(max = 64) String privateDestinationContactPhone,
@NotBlank @Size(max = 1000) String deliveryAddress, @Size(max = 1000) String deliveryAddress,
@Size(max = 128) String pickupContactName, @Size(max = 128) String pickupContactName,
@Size(max = 64) String pickupContactPhone, @Size(max = 64) String pickupContactPhone,
@Positive int packageCount, @Positive int packageCount,
@NotNull @DecimalMin(value = "0.0") BigDecimal volumeCbm, @NotNull @DecimalMin(value = "0.0") BigDecimal volumeCbm,
@NotBlank @Size(max = 64) String deliveryMethod, @Size(max = 64) String deliveryMethod,
@NotNull LocalDateTime estimatedArrivalAt, LocalDateTime estimatedArrivalAt,
LocalDateTime estimatedShipAt, LocalDateTime estimatedShipAt,
@Size(max = 128) String fbaShipmentNo, @Size(max = 128) String fbaShipmentNo,
@Size(max = 255) String trackingNo, @Size(max = 255) String trackingNo,
@@ -51,6 +51,14 @@ public final class WarehouseTransferRequests {
@NotEmpty @Size(max = 500) List<@Valid Item> items, @NotEmpty @Size(max = 500) List<@Valid Item> items,
@Size(max = 1000) List<@Valid RelabelItem> relabelItems @Size(max = 1000) List<@Valid RelabelItem> relabelItems
) { ) {
@jakarta.validation.constraints.AssertTrue(message="外来货物须填写入仓方式、地址和预计入仓时间;库存转运须选择FBA或私人仓")
public boolean isInboundModeValid() {
if (inboundCode != null && !inboundCode.isBlank())
return "FBA".equals(destinationType) || "PRIVATE".equals(destinationType)
|| destinationType == null && fbaWarehouseCode != null && !fbaWarehouseCode.isBlank();
return deliveryMethod != null && !deliveryMethod.isBlank() && deliveryAddress != null
&& !deliveryAddress.isBlank() && estimatedArrivalAt != null;
}
public Create(String inboundCode, List<Long> sourceLastlegOrderIds, String operatingWarehouseId, public Create(String inboundCode, List<Long> sourceLastlegOrderIds, String operatingWarehouseId,
String fbaWarehouseCode, String deliveryAddress, int packageCount, BigDecimal volumeCbm, String fbaWarehouseCode, String deliveryAddress, int packageCount, BigDecimal volumeCbm,
String deliveryMethod, LocalDateTime estimatedArrivalAt, String fbaShipmentNo, String deliveryMethod, LocalDateTime estimatedArrivalAt, String fbaShipmentNo,
@@ -6,6 +6,9 @@ import java.util.List;
public final class WarehouseTransferResponses { public final class WarehouseTransferResponses {
private WarehouseTransferResponses() {} private WarehouseTransferResponses() {}
public record SourceCarton(String cartonMark, String productNameZh, Long quantity,
String destinationWarehouseCode, String destinationWarehouseName, List<SourceCartonSku> items) {}
public record SourceCartonSku(String skuCode, Long quantity) {}
public record Item(Long id, String skuCode, String productNameZh, int packageCount, String shippingMark, Long quantity) {} public record Item(Long id, String skuCode, String productNameZh, int packageCount, String shippingMark, Long quantity) {}
public record Carton(String cartonMark, String customerCartonMark, int cartonIndex, int totalCartons, String status, public record Carton(String cartonMark, String customerCartonMark, int cartonIndex, int totalCartons, String status,
Long transferItemId, Long transferPalletId, BigDecimal length, BigDecimal width, BigDecimal height, Long transferItemId, Long transferPalletId, BigDecimal length, BigDecimal width, BigDecimal height,
@@ -3,6 +3,25 @@ import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
@Mapper public interface InboundSnapshotMapper { @Mapper public interface InboundSnapshotMapper {
record SourceCarton(String cartonMark, String skuCode, String productNameZh, Long quantity,
String destinationWarehouseCode, String destinationWarehouseName, String skuItemsJson) {}
@Select("""
SELECT c.carton_mark cartonMark,c.sku_code skuCode,NULL productNameZh,NULL quantity,
c.destination_warehouse_code destinationWarehouseCode,c.destination_warehouse_name destinationWarehouseName,NULL skuItemsJson
FROM ll_inbound_plan_carton c JOIN ll_order o ON o.id=c.order_id AND o.customer_id=c.customer_id
WHERE c.customer_id=#{customerId} AND o.inbound_code=#{inboundCode} AND o.overseas_warehouse_id=#{warehouseId}
UNION ALL
SELECT c.carton_mark,i.sku_code,i.product_name_zh,c.counted_quantity,
t.destination_warehouse_code,t.destination_warehouse_name,c.sku_items_json
FROM ll_warehouse_transfer_carton c
JOIN ll_warehouse_transfer_order t ON t.id=c.transfer_order_id AND t.customer_id=c.customer_id
JOIN ll_order o ON o.id=t.lastleg_order_id AND o.customer_id=t.customer_id
JOIN ll_warehouse_transfer_item i ON i.id=c.transfer_item_id AND i.customer_id=c.customer_id AND i.transfer_order_id=t.id
WHERE c.customer_id=#{customerId} AND o.inbound_code=#{inboundCode} AND t.operating_warehouse_id=#{warehouseId} AND c.status='COUNTED'
ORDER BY cartonMark
""")
java.util.List<SourceCarton> sourceCartons(@Param("customerId") String customerId,
@Param("warehouseId") String warehouseId, @Param("inboundCode") String inboundCode);
@Select("SELECT id FROM ll_inbound_order WHERE customer_id=#{customerId} AND (inbound_code=#{code} OR source_inbound_id=#{code}) LIMIT 1") @Select("SELECT id FROM ll_inbound_order WHERE customer_id=#{customerId} AND (inbound_code=#{code} OR source_inbound_id=#{code}) LIMIT 1")
Long findId(@Param("customerId") String customerId, @Param("code") String code); Long findId(@Param("customerId") String customerId, @Param("code") String code);
@Select("SELECT id FROM ll_inbound_order WHERE customer_id=#{customerId} AND source_inbound_id=#{sourceInboundId} LIMIT 1") @Select("SELECT id FROM ll_inbound_order WHERE customer_id=#{customerId} AND source_inbound_id=#{sourceInboundId} LIMIT 1")
@@ -88,6 +88,7 @@ import java.util.stream.Collectors;
@Service @Service
public class WarehouseTransferService { public class WarehouseTransferService {
@Resource private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
@Resource private com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory; @Resource private com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory;
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool; @Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool;
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPoolQueries; @Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPoolQueries;
@@ -140,14 +141,35 @@ public class WarehouseTransferService {
@Resource private InventoryCommandMapper inventoryCommands; @Resource private InventoryCommandMapper inventoryCommands;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
public List<WarehouseTransferResponses.SourceCarton> sourceCartons(String customerId, String warehouseId, String inboundCode) {
if (!StringUtils.hasText(warehouseId) || !StringUtils.hasText(inboundCode)) throw badRequest("请选择海外仓和库存来源单据");
return inboundSnapshots.sourceCartons(customerId, warehouseId.trim(), inboundCode.trim()).stream().map(row -> {
var carton = new WarehouseTransferCarton(); carton.setSkuItemsJson(row.skuItemsJson());
var contents = cartonContents(carton).stream().map(item -> new WarehouseTransferResponses.SourceCartonSku(item.skuCode(),item.quantity())).toList();
if (contents.isEmpty()) contents = List.of(new WarehouseTransferResponses.SourceCartonSku(row.skuCode(),row.quantity()));
return new WarehouseTransferResponses.SourceCarton(row.cartonMark(),row.productNameZh(),row.quantity(),
row.destinationWarehouseCode(),row.destinationWarehouseName(),contents);
}).toList();
}
private static LocalDateTime arrivalTime(WarehouseTransferRequests.Create request) {
return StringUtils.hasText(request.inboundCode()) ? null : request.estimatedArrivalAt();
}
private static String inboundMethod(WarehouseTransferRequests.Create request) {
return StringUtils.hasText(request.inboundCode()) ? "INVENTORY_TRANSFER" : request.deliveryMethod().trim();
}
private static String inboundAddress(WarehouseTransferRequests.Create request) {
return StringUtils.hasText(request.inboundCode()) ? "" : request.deliveryAddress().trim();
}
@Transactional @Transactional
public WarehouseTransferResponses.Detail create(String customerId, String operatorId, public WarehouseTransferResponses.Detail create(String customerId, String operatorId,
WarehouseTransferRequests.Create request) { WarehouseTransferRequests.Create request) {
if (!request.isInboundModeValid()) throw badRequest("外来货物须填写入仓信息,库存转运须选择FBA仓或私人仓");
int itemPackages = request.items().stream().mapToInt(WarehouseTransferRequests.Item::packageCount).sum(); int itemPackages = request.items().stream().mapToInt(WarehouseTransferRequests.Item::packageCount).sum();
if (itemPackages != request.packageCount()) { if (itemPackages != request.packageCount()) {
throw badRequest("Cargo item package total must equal transfer package count"); throw badRequest("Cargo item package total must equal transfer package count");
} }
validateRelabelRequest(request); validateRelabelRequest(customerId, request);
validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(), validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(),
request.privateDestinationAddress(), request.privateDestinationPostalCode(), request.privateDestinationAddress(), request.privateDestinationPostalCode(),
request.privateDestinationContactName(), request.privateDestinationContactPhone()); request.privateDestinationContactName(), request.privateDestinationContactPhone());
@@ -167,7 +189,7 @@ public class WarehouseTransferService {
} }
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
long transferId = IdWorker.getId(); long transferId = IdWorker.getId();
String transferNo = orderNoGenerator.next("TRANSFER", request.operatingWarehouseId(), transferId); String transferNo = compactNumbers.next("ZY", customerId);
String requestedInboundCode = trim(request.inboundCode()); String requestedInboundCode = trim(request.inboundCode());
List<Long> selectedIds = request.sourceLastlegOrderIds() == null List<Long> selectedIds = request.sourceLastlegOrderIds() == null
? List.of() : request.sourceLastlegOrderIds().stream().distinct().toList(); ? List.of() : request.sourceLastlegOrderIds().stream().distinct().toList();
@@ -210,13 +232,13 @@ public class WarehouseTransferService {
transfer.setOperatingWarehouseId(request.operatingWarehouseId().trim()); transfer.setOperatingWarehouseId(request.operatingWarehouseId().trim());
transfer.setFbaWarehouseCode(destinationCode(request)); transfer.setFbaWarehouseCode(destinationCode(request));
applyDestination(transfer, request); applyDestination(transfer, request);
transfer.setDeliveryAddress(request.deliveryAddress().trim()); transfer.setPackageCount(request.packageCount()); transfer.setDeliveryAddress(inboundAddress(request)); transfer.setPackageCount(request.packageCount());
transfer.setPickupContactName(trim(request.pickupContactName())); transfer.setPickupContactName(trim(request.pickupContactName()));
transfer.setPickupContactPhone(trim(request.pickupContactPhone())); transfer.setPickupContactPhone(trim(request.pickupContactPhone()));
transfer.setDeliveryMethod(request.deliveryMethod().trim()); transfer.setEstimatedArrivalAt(request.estimatedArrivalAt()); transfer.setDeliveryMethod(inboundMethod(request)); transfer.setEstimatedArrivalAt(arrivalTime(request));
transfer.setEstimatedShipAt(request.estimatedShipAt()); transfer.setEstimatedShipAt(request.estimatedShipAt());
transfer.setFbaShipmentNo(trim(request.fbaShipmentNo())); transfer.setFbaShipmentNo(trim(request.fbaShipmentNo()));
transfer.setTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim()) ? trim(request.trackingNo()) : null); transfer.setTrackingNo("CUSTOMER_DELIVERY".equals(inboundMethod(request)) ? trim(request.trackingNo()) : null);
transfer.setFbaBoxNos(trim(request.fbaBoxNos())); transfer.setRemark(trim(request.remark())); transfer.setFbaBoxNos(trim(request.fbaBoxNos())); transfer.setRemark(trim(request.remark()));
transfer.setNeedRelabel(request.needRelabel()); transfer.setStatus(DRAFT); transfer.setNeedRelabel(request.needRelabel()); transfer.setStatus(DRAFT);
transfer.setLabelDeadline(request.labelDeadline()); transfer.setLabelDeadline(request.labelDeadline());
@@ -278,11 +300,12 @@ public class WarehouseTransferService {
if (!DRAFT.equals(transfer.getStatus())) { if (!DRAFT.equals(transfer.getStatus())) {
throw conflict("Only a draft warehouse transfer order can be edited"); throw conflict("Only a draft warehouse transfer order can be edited");
} }
if (!request.isInboundModeValid()) throw badRequest("外来货物须填写入仓信息,库存转运须选择FBA仓或私人仓");
int itemPackages = request.items().stream().mapToInt(WarehouseTransferRequests.Item::packageCount).sum(); int itemPackages = request.items().stream().mapToInt(WarehouseTransferRequests.Item::packageCount).sum();
if (itemPackages != request.packageCount()) { if (itemPackages != request.packageCount()) {
throw badRequest("Cargo item package total must equal transfer package count"); throw badRequest("Cargo item package total must equal transfer package count");
} }
validateRelabelRequest(request); validateRelabelRequest(customerId, request);
validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(), validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(),
request.privateDestinationAddress(), request.privateDestinationPostalCode(), request.privateDestinationAddress(), request.privateDestinationPostalCode(),
request.privateDestinationContactName(), request.privateDestinationContactPhone()); request.privateDestinationContactName(), request.privateDestinationContactPhone());
@@ -293,15 +316,15 @@ public class WarehouseTransferService {
transfer.setOperatingWarehouseId(request.operatingWarehouseId().trim()); transfer.setOperatingWarehouseId(request.operatingWarehouseId().trim());
transfer.setFbaWarehouseCode(destinationCode(request)); transfer.setFbaWarehouseCode(destinationCode(request));
applyDestination(transfer, request); applyDestination(transfer, request);
transfer.setDeliveryAddress(request.deliveryAddress().trim()); transfer.setDeliveryAddress(inboundAddress(request));
transfer.setPackageCount(request.packageCount()); transfer.setPackageCount(request.packageCount());
transfer.setPickupContactName(trim(request.pickupContactName())); transfer.setPickupContactName(trim(request.pickupContactName()));
transfer.setPickupContactPhone(trim(request.pickupContactPhone())); transfer.setPickupContactPhone(trim(request.pickupContactPhone()));
transfer.setDeliveryMethod(request.deliveryMethod().trim()); transfer.setDeliveryMethod(inboundMethod(request));
transfer.setEstimatedArrivalAt(request.estimatedArrivalAt()); transfer.setEstimatedArrivalAt(arrivalTime(request));
transfer.setEstimatedShipAt(request.estimatedShipAt()); transfer.setEstimatedShipAt(request.estimatedShipAt());
transfer.setFbaShipmentNo(trim(request.fbaShipmentNo())); transfer.setFbaShipmentNo(trim(request.fbaShipmentNo()));
transfer.setTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim()) transfer.setTrackingNo("CUSTOMER_DELIVERY".equals(inboundMethod(request))
? trim(request.trackingNo()) : null); ? trim(request.trackingNo()) : null);
transfer.setFbaBoxNos(trim(request.fbaBoxNos())); transfer.setFbaBoxNos(trim(request.fbaBoxNos()));
transfer.setRemark(trim(request.remark())); transfer.setRemark(trim(request.remark()));
@@ -319,7 +342,7 @@ public class WarehouseTransferService {
order.setFbaWarehouseCode(destinationCode(request)); order.setFbaWarehouseCode(destinationCode(request));
order.setPackageCount(request.packageCount()); order.setPackageCount(request.packageCount());
order.setVolumeCbm(request.volumeCbm()); order.setVolumeCbm(request.volumeCbm());
order.setEstimatedWarehouseAt(request.estimatedArrivalAt()); order.setEstimatedWarehouseAt(arrivalTime(request));
order.setRelabelStatus(request.needRelabel() ? "PENDING_BINDING" : "NOT_REQUIRED"); order.setRelabelStatus(request.needRelabel() ? "PENDING_BINDING" : "NOT_REQUIRED");
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + DRAFT + ";RELABEL:" + order.getRelabelStatus()); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + DRAFT + ";RELABEL:" + order.getRelabelStatus());
order.setCustomerRemark(trim(request.remark())); order.setCustomerRemark(trim(request.remark()));
@@ -376,6 +399,19 @@ public class WarehouseTransferService {
if (PENDING_REVIEW.equals(transfer.getStatus())) return detail(customerId, transferNo); if (PENDING_REVIEW.equals(transfer.getStatus())) return detail(customerId, transferNo);
if (!DRAFT.equals(transfer.getStatus())) throw conflict("Only a draft warehouse transfer order can be submitted"); if (!DRAFT.equals(transfer.getStatus())) throw conflict("Only a draft warehouse transfer order can be submitted");
if (Boolean.TRUE.equals(transfer.getNeedRelabel())) {
Set<String> allowedMarks = !transfer.getTransferNo().equals(transfer.getInboundCode())
? inboundSnapshots.sourceCartons(customerId, transfer.getOperatingWarehouseId(), transfer.getInboundCode())
.stream().map(InboundSnapshotMapper.SourceCarton::cartonMark).collect(Collectors.toSet())
: transferCartons.listOrder(customerId, transfer.getId()).stream()
.flatMap(carton -> java.util.stream.Stream.of(carton.getCartonMark(), carton.getCustomerCartonMark()))
.filter(StringUtils::hasText).collect(Collectors.toSet());
for (var binding : relabelBindings.listOrder(customerId, transfer.getId())) {
if (!allowedMarks.contains(binding.getCustomerCartonMark()))
throw badRequest("当前箱子不存在或不属于当前订单");
}
}
LastlegOrder order = requireTailOrder(customerId, transfer); LastlegOrder order = requireTailOrder(customerId, transfer);
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
transfer.setStatus(PENDING_REVIEW); transfer.setStatus(PENDING_REVIEW);
@@ -859,7 +895,7 @@ public class WarehouseTransferService {
carton = new WarehouseTransferCarton(); carton.setId(IdWorker.getId()); carton = new WarehouseTransferCarton(); carton.setId(IdWorker.getId());
carton.setTransferOrderId(transfer.getId()); carton.setTransferItemId(input.transferItemId()); carton.setCustomerId(customerId); carton.setTransferOrderId(transfer.getId()); carton.setTransferItemId(input.transferItemId()); carton.setCustomerId(customerId);
carton.setCartonIndex(++nextIndex); carton.setTotalCartons(transfer.getPackageCount()); carton.setCartonIndex(++nextIndex); carton.setTotalCartons(transfer.getPackageCount());
carton.setCartonMark(transferNo + "#EX-" + nextIndex); carton.setCreatedAt(now); carton.setCartonMark(LastlegOrderNoGenerator.compactCartonMark(carton.getId())); carton.setCreatedAt(now);
} }
carton.setTransferPalletId(input.transferPalletId()); carton.setTransferPalletId(input.transferPalletId());
carton.setLengthCm(input.length()); carton.setWidthCm(input.width()); carton.setHeightCm(input.height()); carton.setLengthCm(input.length()); carton.setWidthCm(input.width()); carton.setHeightCm(input.height());
@@ -1712,7 +1748,7 @@ public class WarehouseTransferService {
} }
} }
private void validateRelabelRequest(WarehouseTransferRequests.Create request) { private void validateRelabelRequest(String customerId, WarehouseTransferRequests.Create request) {
if (!request.needRelabel()) { if (!request.needRelabel()) {
if (request.relabelItems() != null && !request.relabelItems().isEmpty()) { if (request.relabelItems() != null && !request.relabelItems().isEmpty()) {
throw badRequest("Relabel items require needRelabel=true"); throw badRequest("Relabel items require needRelabel=true");
@@ -1720,6 +1756,14 @@ public class WarehouseTransferService {
return; return;
} }
if (request.relabelItems() == null || request.relabelItems().isEmpty()) return; if (request.relabelItems() == null || request.relabelItems().isEmpty()) return;
Set<String> orderCartonMarks = StringUtils.hasText(request.inboundCode())
? inboundSnapshots.sourceCartons(customerId, request.operatingWarehouseId().trim(), request.inboundCode().trim())
.stream().map(InboundSnapshotMapper.SourceCarton::cartonMark)
.filter(StringUtils::hasText).map(String::trim).collect(Collectors.toSet())
: request.items().stream().flatMap(item -> splitMarks(item.shippingMark()).stream()).collect(Collectors.toSet());
if (!StringUtils.hasText(request.inboundCode()) && orderCartonMarks.isEmpty()) {
orderCartonMarks.addAll(splitMarks(request.fbaBoxNos()));
}
if (request.labelDeadline() == null || !StringUtils.hasText(request.relabelContactName()) if (request.labelDeadline() == null || !StringUtils.hasText(request.relabelContactName())
|| !StringUtils.hasText(request.relabelContactPhone())) { || !StringUtils.hasText(request.relabelContactPhone())) {
throw badRequest("Relabel deadline and contact are required"); throw badRequest("Relabel deadline and contact are required");
@@ -1754,6 +1798,7 @@ public class WarehouseTransferService {
throw badRequest("SKU relabel requires a new SKU"); throw badRequest("SKU relabel requires a new SKU");
} }
for (String mark : row.customerCartonMarks()) { for (String mark : row.customerCartonMarks()) {
if (!orderCartonMarks.contains(mark.trim())) throw badRequest("当前箱子不存在或不属于当前订单");
if (!marks.add(mark.trim().toUpperCase())) throw badRequest("Customer carton marks must be unique"); if (!marks.add(mark.trim().toUpperCase())) throw badRequest("Customer carton marks must be unique");
} }
} }
@@ -1969,7 +2014,7 @@ public class WarehouseTransferService {
private LastlegOrder createTailOrder(String customerId, String customerName, Long inboundOrderId, String inboundCode, String transferNo, private LastlegOrder createTailOrder(String customerId, String customerName, Long inboundOrderId, String inboundCode, String transferNo,
WarehouseTransferRequests.Create request, String requestHash, LocalDateTime now) { WarehouseTransferRequests.Create request, String requestHash, LocalDateTime now) {
LastlegOrder order = new LastlegOrder(); LastlegOrder order = new LastlegOrder();
order.setId(IdWorker.getId()); order.setOrderNo(orderNoGenerator.next("TRANSFER", request.operatingWarehouseId(), order.getId())); order.setCustomerId(customerId); order.setId(IdWorker.getId()); order.setOrderNo(transferNo); order.setCustomerId(customerId);
order.setCustomerName(customerName); order.setCustomerName(customerName);
order.setOrderType(ORDER_TYPE); order.setOrderType(ORDER_TYPE);
order.setInboundCode(inboundCode); order.setSourceRegion("CUSTOMER"); order.setInboundCode(inboundCode); order.setSourceRegion("CUSTOMER");
@@ -1979,7 +2024,7 @@ public class WarehouseTransferService {
order.setVolumeCbm(request.volumeCbm()); order.setVolumeCbm(request.volumeCbm());
order.setContainerNo(transferNo); order.setContainerNo(transferNo);
order.setPalletCount(0); order.setPickedPackageCount(0); order.setShippedPackageCount(0); order.setPalletCount(0); order.setPickedPackageCount(0); order.setShippedPackageCount(0);
order.setEstimatedWarehouseAt(request.estimatedArrivalAt()); order.setEstimatedWarehouseAt(arrivalTime(request));
order.setCurrentStatus(DRAFT); order.setCurrentStatus(DRAFT);
order.setRelabelStatus(request.needRelabel() ? "PENDING_BINDING" : "NOT_REQUIRED"); order.setRelabelStatus(request.needRelabel() ? "PENDING_BINDING" : "NOT_REQUIRED");
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + DRAFT + ";RELABEL:" + order.getRelabelStatus()); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + DRAFT + ";RELABEL:" + order.getRelabelStatus());
@@ -2007,7 +2052,7 @@ public class WarehouseTransferService {
String customerCartonMark, LocalDateTime now) { String customerCartonMark, LocalDateTime now) {
WarehouseTransferCarton carton = new WarehouseTransferCarton(); WarehouseTransferCarton carton = new WarehouseTransferCarton();
carton.setId(IdWorker.getId()); carton.setTransferOrderId(transferId); carton.setTransferItemId(transferItemId); carton.setId(IdWorker.getId()); carton.setTransferOrderId(transferId); carton.setTransferItemId(transferItemId);
carton.setCustomerId(customerId); carton.setCartonMark(transferNo + "#" + totalCartons + "-" + cartonIndex); carton.setCustomerId(customerId); carton.setCartonMark(LastlegOrderNoGenerator.compactCartonMark(carton.getId()));
carton.setCustomerCartonMark(trim(customerCartonMark)); carton.setCustomerCartonMark(trim(customerCartonMark));
carton.setCartonIndex(cartonIndex); carton.setTotalCartons(totalCartons); carton.setStatus("EXPECTED"); carton.setCartonIndex(cartonIndex); carton.setTotalCartons(totalCartons); carton.setStatus("EXPECTED");
carton.setCreatedAt(now); carton.setUpdatedAt(now); transferCartons.insert(carton); carton.setCreatedAt(now); carton.setUpdatedAt(now); transferCartons.insert(carton);
@@ -2020,7 +2065,7 @@ public class WarehouseTransferService {
detail.setOrderId(orderId); detail.setSourceInboundDetailId(transferNo + ":" + index); detail.setOrderId(orderId); detail.setSourceInboundDetailId(transferNo + ":" + index);
detail.setSkuCode(trim(source.skuCode())); detail.setProductNameZh(trim(source.productNameZh())); detail.setSkuCode(trim(source.skuCode())); detail.setProductNameZh(trim(source.productNameZh()));
detail.setPackageCount(source.packageCount()); detail.setDestinationWarehouseCode(destinationCode(request)); detail.setPackageCount(source.packageCount()); detail.setDestinationWarehouseCode(destinationCode(request));
detail.setFbaTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim()) detail.setFbaTrackingNo("CUSTOMER_DELIVERY".equals(inboundMethod(request))
? trim(request.trackingNo()) : null); ? trim(request.trackingNo()) : null);
detail.setShipmentNo(trim(request.fbaShipmentNo())); detail.setShipmentNo(trim(request.fbaShipmentNo()));
detail.setShippingMark(trim(source.shippingMark())); orderDetails.insert(detail); detail.setShippingMark(trim(source.shippingMark())); orderDetails.insert(detail);
@@ -2136,7 +2136,7 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order (
private_destination_address VARCHAR(1000) NULL COMMENT '私人仓完整地址', private_destination_postal_code VARCHAR(32) NULL COMMENT '私人仓邮编', private_destination_contact_name VARCHAR(128) NULL COMMENT '私人仓联系人', private_destination_address VARCHAR(1000) NULL COMMENT '私人仓完整地址', private_destination_postal_code VARCHAR(32) NULL COMMENT '私人仓邮编', private_destination_contact_name VARCHAR(128) NULL COMMENT '私人仓联系人',
private_destination_contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话', private_destination_contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话',
delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', pickup_contact_name VARCHAR(128) NULL COMMENT '提货联系人', pickup_contact_phone VARCHAR(64) NULL COMMENT '提货联系电话', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式', delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', pickup_contact_name VARCHAR(128) NULL COMMENT '提货联系人', pickup_contact_phone VARCHAR(64) NULL COMMENT '提货联系电话', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式',
estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计入仓时间', fba_shipment_no VARCHAR(128) NULL COMMENT '可选FBA货件编号', estimated_arrival_at DATETIME(3) NULL COMMENT '预计入仓时间,库存转运不填写', fba_shipment_no VARCHAR(128) NULL COMMENT '可选FBA货件编号',
tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注', tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注',
need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号', need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态',
@@ -2157,7 +2157,7 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order (
KEY idx_ll_wh_transfer_list (customer_id,status,created_at), KEY idx_ll_wh_transfer_inbound (customer_id,inbound_code) KEY idx_ll_wh_transfer_list (customer_id,status,created_at), KEY idx_ll_wh_transfer_inbound (customer_id,inbound_code)
) ENGINE=InnoDB COMMENT='客户转仓单'; ) ENGINE=InnoDB COMMENT='客户转仓单';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='fba_warehouse_code' AND is_nullable='NO'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码'', MODIFY COLUMN fba_shipment_no VARCHAR(128) NULL COMMENT ''可选FBA货件编号'', MODIFY COLUMN delivery_address VARCHAR(1000) NOT NULL COMMENT ''提货地址或中转仓收货地址'', MODIFY COLUMN delivery_method VARCHAR(64) NOT NULL COMMENT ''入仓方式'', MODIFY COLUMN estimated_arrival_at DATETIME(3) NOT NULL COMMENT ''预计入仓时间''', 'SELECT 1')); SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='fba_warehouse_code' AND is_nullable='NO'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码'', MODIFY COLUMN fba_shipment_no VARCHAR(128) NULL COMMENT ''可选FBA货件编号'', MODIFY COLUMN delivery_address VARCHAR(1000) NOT NULL COMMENT ''提货地址或中转仓收货地址'', MODIFY COLUMN delivery_method VARCHAR(64) NOT NULL COMMENT ''入仓方式'', MODIFY COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT ''预计入仓时间,库存转运不填写''', 'SELECT 1'));
PREPARE transfer_optional_fba_stmt FROM @ddl; EXECUTE transfer_optional_fba_stmt; DEALLOCATE PREPARE transfer_optional_fba_stmt; PREPARE transfer_optional_fba_stmt FROM @ddl; EXECUTE transfer_optional_fba_stmt; DEALLOCATE PREPARE transfer_optional_fba_stmt;
-- 兼容已存在的旧版转仓单表:CREATE TABLE IF NOT EXISTS 不会补充新增字段。 -- 兼容已存在的旧版转仓单表:CREATE TABLE IF NOT EXISTS 不会补充新增字段。
@@ -4227,3 +4227,13 @@ CREATE TABLE IF NOT EXISTS ll_appointment_work_history (
UNIQUE KEY uk_appointment_work_command (work_item_key,command_key), UNIQUE KEY uk_appointment_work_command (work_item_key,command_key),
KEY idx_appointment_work_history (work_item_key,occurred_at) KEY idx_appointment_work_history (work_item_key,occurred_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令';
-- 新单据短号:相同业务及客户名称缩写共享逻辑秒,避免同秒或多实例重号。
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='海外业务短单号分配时钟';
-- 库存转运无需再次预约入仓;目标库:lastleg-service 所属数据库。可重复执行。
SET @ddl = (SELECT 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 stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -1284,9 +1284,18 @@ paths:
responses: { '200': { description: 当前客户仓储转运单分页 }, '403': { description: 缺少仓储转运查看权限 } } responses: { '200': { description: 当前客户仓储转运单分页 }, '403': { description: 缺少仓储转运查看权限 } }
post: post:
summary: 客户创建仓储转运单 summary: 客户创建仓储转运单
description: 权限 merchant:overseas-warehouse:transfer:create;客户范围只取认证 Token。货物 items[].quantity 为 SKU 申报件数(正整数),items[].packageCount 为预计箱数,整单 packageCount 等于明细预计箱数合计;件数与箱数独立,实际签收箱数和每箱件数以 PDA 清点事实为准,支持短收、超收并记录差异原因。目的去向可暂未确定(destinationType 为 NONE 或不传),允许保存草稿、编辑和提交;入库确认后可在出库时绑定 FBA 仓或私人仓,也可选择入库库存发起贴标申请出库;普通转运出库前必须补齐目的仓。 description: 选择已有库存入仓码时,deliveryMethod、deliveryAddress和estimatedArrivalAt不需要提交;服务端保存库存转运标记并清空预计入仓时间,目的去向仅FBA或PRIVATE。外来货物仍须填写入仓方式、地址和预计入仓时间。 权限 merchant:overseas-warehouse:transfer:create;客户范围只取认证 Token。货物 items[].quantity 为 SKU 申报件数(正整数),items[].packageCount 为预计箱数,整单 packageCount 等于明细预计箱数合计;件数与箱数独立,实际签收箱数和每箱件数以 PDA 清点事实为准,支持短收、超收并记录差异原因。目的去向可暂未确定(destinationType 为 NONE 或不传),允许保存草稿、编辑和提交;入库确认后可在出库时绑定 FBA 仓或私人仓,也可选择入库库存发起贴标申请出库;普通转运出库前必须补齐目的仓。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
responses: { '200': { description: 创建后的仓储转运单 }, '403': { description: 缺少仓储转运创建权限 }, '409': { description: 幂等、库存或状态冲突 } } responses: { '200': { description: 创建后的仓储转运单 }, '403': { description: 缺少仓储转运创建权限 }, '409': { description: 幂等、库存或状态冲突 } }
/api/merchant/v1/last-leg/warehouse-transfers/inventory-source-cartons:
get:
summary: 客户查询库存来源单据的原始箱明细
description: 权限 merchant:overseas-warehouse:transfer:create;客户取认证会话。按仓库及入仓码查询真实原单箱快照,返回箱号、SKU子明细、已记录的件数和原目的仓;不存在逐箱快照时返回空数组,不按箱数伪造箱号,不宣称原单箱当前均可用。
security: [{ bearerAuth: [] }]
parameters:
- { name: operatingWarehouseId, in: query, required: true, schema: { type: string } }
- { name: inboundCode, in: query, required: true, schema: { type: string } }
responses: { '200': { description: 原单箱明细数组,cartonMark/productNameZh/quantity/destinationWarehouseCode/destinationWarehouseName/items;quantity未记录为null }, '403': { description: 缺少创建权限 } }
/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}: /api/merchant/v1/last-leg/warehouse-transfers/{transferNo}:
get: get:
summary: 客户查询自己的仓储转运单详情 summary: 客户查询自己的仓储转运单详情
@@ -220,13 +220,13 @@ paths:
/api/pda/v1/last-leg/pallet-operations/relabel-disassemble: /api/pda/v1/last-leg/pallet-operations/relabel-disassemble:
post: post:
summary: 按换标箱唛拆托并绑定目标托盘 summary: 按换标箱唛拆托并绑定目标托盘
description: PDA选择原入仓单库存行并扫描实际来源托盘,输入拆出箱数,核对本批cartonMarks后扫描目标托盘。传actualSource时以其数量和版本为准,同入仓余额及批次内原子调整本单冻结记账位置,再移动冻结库存和换标明细;不新增入库、不改变库存归属、不占用其他单据冻结量。每次一个来源库存行,可分批处理多个托盘。已有出库单时不允许此数量重分配。未传actualSource的箱唛模式仍要求已有来源仓位一致。普通库存整托全部换标允许目标与来源为同一托盘,校验整托数量和本单冻结归属后仅记录绑定、不搬移库存;部分换标必须使用新托盘。货物和包装不变时可继承原托盘测量,拆合托后新托盘和原托余货均须重新实测。 description: PDA选择原入仓单库存行并扫描实际来源托盘,输入拆出箱数,核对本批cartonMarks后扫描目标托盘。传actualSource时以其数量和版本为准,同入仓余额及批次内原子调整本单冻结记账位置,再移动冻结库存和换标明细;不新增入库、不改变库存归属、不占用其他单据冻结量。每次一个来源库存行,可分批处理多个托盘。已有出库单时不允许此数量重分配。未传actualSource的箱唛模式仍要求已有来源仓位一致。普通库存整托全部换标允许目标与来源为同一托盘,校验整托数量和本单冻结归属后仅记录绑定、不搬移库存;部分换标必须使用新托盘。按箱实测的仓储转运按实测箱追踪移托:来源扣除移出箱体积重量,目标增加同箱数据;同规格自动分配,混合箱规通过measuredCartons选择实际箱,改绑保留首次来源箱身份。其他整托测量来源仍按原规则补测。
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelPalletDisassemble' } } } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelPalletDisassemble' } } } }
responses: { '200': { description: 箱唛级拆托绑定结果 }, '409': { description: 箱唛、库存或托盘版本冲突 } } responses: { '200': { description: 箱唛级拆托绑定结果 }, '409': { description: 箱唛、库存或托盘版本冲突 } }
/api/pda/v1/last-leg/pallet-operations/relabel-pallet-measurements: /api/pda/v1/last-leg/pallet-operations/relabel-pallet-measurements:
post: post:
summary: 查询仓储转运换标涉及托盘的最终测量及库存版本 summary: 查询仓储转运换标涉及托盘的最终测量及库存版本
description: 权限 lastleg:pda:operate 及海外PDA角色;仅Token绑定仓。整托原托盘且没有拆合托流水、箱数未变时沿用原始测量;新托盘和拆合托后的原托盘余货需要补测。测量保存为PALLET_MEASURE作业历史,库存版本变化后失效。 description: 权限 lastleg:pda:operate 及海外PDA角色;仅Token绑定仓。按箱实测的仓储转运返回cartonAggregated=true,按当前实测箱自动汇总,不要求整托长宽高或人工确认;箱数与当前库存必须一致且每箱尺寸重量有效。其他整托测量来源沿用原始测量或补测。测量保存为PALLET_MEASURE作业历史,库存版本变化后失效。
requestBody: requestBody:
required: true required: true
content: content:
@@ -236,11 +236,11 @@ paths:
required: [relabelNo] required: [relabelNo]
properties: properties:
relabelNo: { type: string, maxLength: 64 } relabelNo: { type: string, maxLength: 64 }
responses: { '200': { description: 托盘业务码、箱数、stockVersion、长宽高cm、毛重kg、体积m³及inherited;其他来源换标返回空列表 }, '409': { description: 不属于当前仓 } } responses: { '200': { description: '托盘业务码、箱数、stockVersion、长宽高cm、毛重kg、体积m³及inherited;按箱测量的原托盘不虚构整托尺寸,长宽高为null,sourceCartonSpecs返回按箱规分组的lengthCm/widthCm/heightCm/grossWeightKg(每箱)/cartons;cartonAggregated表示按箱自动汇总,cartons返回当前托盘实测箱的cartonMark、customerCartonMark及尺寸重量,移托后自动重新汇总。其他来源换标返回空列表' }, '409': { description: 不属于当前仓 } }
/api/pda/v1/last-leg/pallet-operations/relabel-pallet-measure: /api/pda/v1/last-leg/pallet-operations/relabel-pallet-measure:
post: post:
summary: 确认仓储转运换标托盘最终实测 summary: 确认仓储转运换标托盘最终实测
description: 已认领的操作员可在处理中保存;待出库但未排车亦可补测。保存当前stockVersion及实测长宽高、毛重,服务端计算体积;相同幂等键异载荷拒绝,库存变化要求刷新。原托盘包装变化同样使用此接口重新测量。排车前核验所有相关非空托盘,包括原托盘余货。 description: 已认领的操作员可在处理中保存;待出库但未排车亦可补测。保存当前stockVersion及实测长宽高、毛重,服务端计算体积;相同幂等键异载荷拒绝,库存变化要求刷新。按箱实测托盘拒绝手动整托测量,直接使用箱数据。排车前核验所有相关非空托盘,包括原托盘余货。
requestBody: requestBody:
required: true required: true
content: content:
@@ -415,7 +415,7 @@ paths:
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete: /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete:
post: post:
summary: 提交现场任务到复核池并直接等待客户确认 summary: 提交现场任务到复核池并直接等待客户确认
description: 仓储转运换标须先确认所有涉及托盘的有效实测尺寸和重量,含拆托后原托盘剩余货物;手动完成也不能跳过实测校验。缺失或库存版本变化导致实测失效时,保持处理中并提示补测。 description: 按箱测量的仓储转运换标自动汇总各托盘剩余实测箱的体积重量,无需确认整托实测;箱数据不全或库存箱数不符时拒绝完成。其他整托来源仍校验有效实测尺寸和重量;手动完成也不能跳过校验。缺失或库存版本变化导致实测失效时,保持处理中并提示补测。
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelComplete' } } } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelComplete' } } } }
responses: { '200': { description: WAIT_REVIEW任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 托盘实测缺失或失效、箱数未核平、照片或版本冲突 } } responses: { '200': { description: WAIT_REVIEW任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 托盘实测缺失或失效、箱数未核平、照片或版本冲突 } }
@@ -1129,6 +1129,16 @@ components:
source: { $ref: '#/components/schemas/PalletExpectation' } source: { $ref: '#/components/schemas/PalletExpectation' }
target: { $ref: '#/components/schemas/PalletExpectation' } target: { $ref: '#/components/schemas/PalletExpectation' }
cartonMarks: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: string, maxLength: 255 } } cartonMarks: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: string, maxLength: 255 } }
measuredCartons:
type: array
maxItems: 500
description: 混合箱规选择实际移出箱;同规格可省略,改绑自动沿用首次绑定
items:
type: object
required: [cartonMark, sourceCartonMark]
properties:
cartonMark: { type: string, maxLength: 255, description: 本次换标明细箱唛 }
sourceCartonMark: { type: string, maxLength: 255, description: 来源实测箱的系统箱号 }
actualSource: { $ref: '#/components/schemas/PalletMoveLine' } actualSource: { $ref: '#/components/schemas/PalletMoveLine' }
reason: { type: string, maxLength: 500 } reason: { type: string, maxLength: 500 }
RelabelSource: RelabelSource:
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.inventory.mapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class InventoryCartonOwnershipTest {
@Test void realSqlRequiresMatchingCustomerWarehouseAndInboundForEachCarton() {
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var db = new JdbcTemplate(ds);
db.execute("CREATE TABLE ll_inbound_plan_carton(order_id BIGINT,customer_id VARCHAR,carton_mark VARCHAR)");
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,inbound_code VARCHAR,overseas_warehouse_id VARCHAR)");
db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR,inbound_code VARCHAR)");
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR,inbound_order_id BIGINT,operating_warehouse_id VARCHAR)");
db.execute("CREATE TABLE ll_warehouse_transfer_carton(transfer_order_id BIGINT,customer_id VARCHAR,carton_mark VARCHAR,customer_carton_mark VARCHAR,status VARCHAR)");
db.update("INSERT INTO ll_inbound_order VALUES(1,'C1','IN1'),(2,'C1','IN2')");
db.update("INSERT INTO ll_order VALUES(10,'C1','IN1','WH1'),(20,'C1','IN2','WH1')");
db.update("INSERT INTO ll_inbound_plan_carton VALUES(10,'C1','BOX1'),(20,'C1','OTHER-ORDER')");
db.update("INSERT INTO ll_warehouse_transfer_order VALUES(30,'C1',1,'WH1'),(40,'C2',1,'WH1')");
db.update("INSERT INTO ll_warehouse_transfer_carton VALUES(30,'C1','TRANSFER-BOX','CUSTOMER-BOX','COUNTED'),(30,'C1','UNCHECKED','CUSTOMER-UNCHECKED','NOT_RECEIVED'),(40,'C2','OTHER-CUSTOMER',NULL,'COUNTED')");
var config = new Configuration(new Environment("test", new JdbcTransactionFactory(), ds));
config.addMapper(InventoryBalanceMapper.class);
try (var session = new SqlSessionFactoryBuilder().build(config).openSession()) {
var mapper = session.getMapper(InventoryBalanceMapper.class);
for (String mark : new String[]{"BOX1", "TRANSFER-BOX", "CUSTOMER-BOX"}) {
assertTrue(mapper.cartonBelongsToInbound("C1", "WH1", 1L, mark));
assertFalse(mapper.cartonBelongsToInbound("C1", "WH2", 1L, mark));
assertFalse(mapper.cartonBelongsToInbound("C2", "WH1", 1L, mark));
assertFalse(mapper.cartonBelongsToInbound("C1", "WH1", 2L, mark));
}
for (String mark : new String[]{"MISSING", "OTHER-ORDER", "OTHER-CUSTOMER", "UNCHECKED"})
assertFalse(mapper.cartonBelongsToInbound("C1", "WH1", 1L, mark));
}
}
}
@@ -75,6 +75,7 @@ class PdaPalletOperationServiceTest {
@BeforeEach void setUp() { @BeforeEach void setUp() {
service = new PdaPalletOperationService(); service = new PdaPalletOperationService();
ReflectionTestUtils.setField(service, "cartonMeasurements", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.RelabelCartonMeasurementService.class));
ReflectionTestUtils.setField(service, "mapper", mapper); ReflectionTestUtils.setField(service, "mapper", mapper);
ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands); ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands);
ReflectionTestUtils.setField(service, "positions", positions); ReflectionTestUtils.setField(service, "positions", positions);
@@ -212,9 +212,13 @@ class RelabelApplicationServiceTest {
private RelabelApplicationService service; private RelabelApplicationService service;
private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.CompactBusinessNumberService.class);
service = new RelabelApplicationService(); service = new RelabelApplicationService();
ReflectionTestUtils.setField(service, "compactNumbers", compactNumbers);
org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030");
ReflectionTestUtils.setField(service, "bindingGuard", bindingGuard); ReflectionTestUtils.setField(service, "bindingGuard", bindingGuard);
ReflectionTestUtils.setField(service, "returnPackingTraces", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.class)); ReflectionTestUtils.setField(service, "returnPackingTraces", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.class));
ReflectionTestUtils.setField(service, "skuConversions", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.ReturnSkuConversionService.class)); ReflectionTestUtils.setField(service, "skuConversions", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.ReturnSkuConversionService.class));
@@ -251,11 +255,21 @@ class RelabelApplicationServiceTest {
balance.setOverseasWarehouseId("WH-1"); balance.setInboundOrderId(301L); balance.setInboundOrderNo("IN-1"); balance.setOverseasWarehouseId("WH-1"); balance.setInboundOrderId(301L); balance.setInboundOrderNo("IN-1");
balance.setInboundCode("CODE-1"); balance.setSkuCode("SKU-A"); balance.setProductNameZh("Chinese name"); balance.setInboundCode("CODE-1"); balance.setSkuCode("SKU-A"); balance.setProductNameZh("Chinese name");
lenient().when(inventoryBalances.selectById(any(Long.class))).thenReturn(balance); lenient().when(inventoryBalances.selectById(any(Long.class))).thenReturn(balance);
lenient().when(inventoryBalances.cartonBelongsToInbound(anyString(), anyString(), anyLong(), anyString())).thenReturn(true);
ReflectionTestUtils.setField(service, "feeSources", feeSources); ReflectionTestUtils.setField(service, "feeSources", feeSources);
lenient().when(feeSources.publishApproved(any(), any(), any())).thenReturn(new OverseasFeeSource()); lenient().when(feeSources.publishApproved(any(), any(), any())).thenReturn(new OverseasFeeSource());
} }
@Test
void rejectsCartonOutsideSelectedInboundBeforeCreatingOrHoldingInventory() {
when(inventoryBalances.cartonBelongsToInbound(eq("10001"), eq("WH-1"), eq(301L), anyString())).thenReturn(false);
var error = assertThrows(ResponseStatusException.class, () -> service.create("10001", "operator-1", request("bad-carton", 1, 1)));
assertEquals("当前箱子不存在或不属于当前订单", error.getReason());
verify(applications, never()).insert(any(RelabelApplication.class));
verify(inventoryCommands, never()).hold(any(), any(), any());
}
@Test @Test
void createBuildsPendingRelabelTailOrderItemsAndBothHistories() { void createBuildsPendingRelabelTailOrderItemsAndBothHistories() {
RelabelRequests.Create request = request("key-1", 10, 6); RelabelRequests.Create request = request("key-1", 10, 6);
@@ -286,7 +300,7 @@ class RelabelApplicationServiceTest {
assertEquals("PENDING_REVIEW", tailOrder.getValue().getCurrentStatus()); assertEquals("PENDING_REVIEW", tailOrder.getValue().getCurrentStatus());
assertEquals("RELABEL:PENDING_REVIEW", tailOrder.getValue().getSubOrderStatus()); assertEquals("RELABEL:PENDING_REVIEW", tailOrder.getValue().getSubOrderStatus());
assertEquals("CUSTOMER", tailOrder.getValue().getSourceRegion()); assertEquals("CUSTOMER", tailOrder.getValue().getSourceRegion());
assertTrue(tailOrder.getValue().getOrderNo().matches("RL-WH-\\d{8}-\\d{6}")); assertTrue(tailOrder.getValue().getOrderNo().matches("TB-CS-[0-9]{12}"));
assertNull(tailOrder.getValue().getInboundCode()); assertNull(tailOrder.getValue().getInboundCode());
assertEquals(application.getValue().getRelabelNo() + ":WH-1", tailOrder.getValue().getSourceInboundId()); assertEquals(application.getValue().getRelabelNo() + ":WH-1", tailOrder.getValue().getSourceInboundId());
assertEquals(tailOrder.getValue().getRelabelOrderNo(), application.getValue().getRelabelNo()); assertEquals(tailOrder.getValue().getRelabelOrderNo(), application.getValue().getRelabelNo());
@@ -0,0 +1,89 @@
package com.baiyue.lastleg.relabel.service;
import com.baiyue.lastleg.relabel.entity.*;
import com.baiyue.lastleg.relabel.mapper.*;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper;
import com.baiyue.lastleg.palletops.dto.PalletOperationRequests.MeasuredCartonBinding;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class RelabelCartonMeasurementServiceTest {
final RelabelCartonMeasurementService boxes = new RelabelCartonMeasurementService();
final RelabelPalletMeasurementService pallets = new RelabelPalletMeasurementService();
final RelabelPalletMeasurementMapper mapper = mock(RelabelPalletMeasurementMapper.class);
final PdaRelabelHistoryMapper histories = mock(PdaRelabelHistoryMapper.class);
final RelabelItemMapper items = mock(RelabelItemMapper.class);
final ObjectMapper json = new ObjectMapper();
final RelabelApplication app = new RelabelApplication();
final List<PdaRelabelHistory> records = new ArrayList<>();
@BeforeEach void setup() {
app.setId(1L); app.setCustomerId("7"); app.setRelabelNo("RL-1"); app.setOverseasWarehouseId("92");
for (Object service : List.of(boxes,pallets)) {
ReflectionTestUtils.setField(service,"mapper",mapper);
ReflectionTestUtils.setField(service,"histories",histories);
ReflectionTestUtils.setField(service,"json",json);
}
ReflectionTestUtils.setField(pallets,"items",items);
ReflectionTestUtils.setField(pallets,"cartonMeasurements",boxes);
when(mapper.transfer("7","RL-1")).thenReturn(10L);
when(histories.selectList(any())).thenAnswer(i -> List.copyOf(records));
when(mapper.originals("7",10L)).thenReturn(List.of());
when(items.selectList(any())).thenReturn(List.of());
when(mapper.cartons("7",10L)).thenReturn(List.of(box("BIG",100,60,50,50,"2.5"),box("SMALL1",100,50,40,40,"3.6"),box("SMALL2",100,50,40,40,"3.6")));
}
RelabelPalletMeasurementMapper.Carton box(String mark,long pallet,int l,int w,int h,String kg) {
return new RelabelPalletMeasurementMapper.Carton((long)mark.hashCode(),mark,null,pallet,"P"+pallet,
BigDecimal.valueOf(l),BigDecimal.valueOf(w),BigDecimal.valueOf(h),new BigDecimal(kg));
}
RelabelItem item(String mark) { var i=new RelabelItem(); i.setCartonMark(mark); i.setNotifiedPackageCount(1); return i; }
void save(List<RelabelCartonMeasurementService.Move> moves) throws Exception {
var h=new PdaRelabelHistory(); h.setAction("PALLET_BIND"); h.setDetailJson(json.writeValueAsString(Map.of("measuredCartons",moves))); records.add(h);
}
void stock(long source,long target,long third) {
when(mapper.stock("7","92")).thenReturn(List.of(new RelabelPalletMeasurementMapper.Stock(1L,100L,"P100",source,1),
new RelabelPalletMeasurementMapper.Stock(2L,200L,"P200",target,1),new RelabelPalletMeasurementMapper.Stock(3L,300L,"P300",third,1)));
}
void totals(long pallet,String cbm,String kg) {
var p=pallets.snapshots(app).stream().filter(row -> row.palletId()==pallet).findFirst().orElseThrow();
assertTrue(p.cartonAggregated()); assertNull(p.lengthCm());
assertEquals(0,new BigDecimal(cbm).compareTo(p.volumeCbm())); assertEquals(0,new BigDecimal(kg).compareTo(p.grossWeightKg()));
}
@Test void movingMixedBoxSubtractsExactMetricsAndRebindingMovesSameBoxOnce() throws Exception {
stock(3,0,0); totals(100,"0.31","9.7");
save(boxes.prepare(app,List.of(item("CUSTOMER")),100L,200L,"P200",List.of(new MeasuredCartonBinding("CUSTOMER","SMALL1"))));
stock(2,1,0); totals(100,"0.23","6.1"); totals(200,"0.08","3.6");
assertDoesNotThrow(() -> pallets.requireMeasured(app));
var rebind=boxes.prepare(app,List.of(item("CUSTOMER")),200L,300L,"P300",null);
assertEquals("SMALL1",rebind.get(0).sourceCartonMark()); save(rebind); save(rebind);
stock(2,0,1); totals(100,"0.23","6.1"); totals(300,"0.08","3.6");
assertEquals(3,boxes.current(app).size()); assertDoesNotThrow(() -> pallets.requireMeasured(app));
}
@Test void equalSpecificationsNeedNoExtraBoxSelection() throws Exception {
when(mapper.cartons("7",10L)).thenReturn(List.of(box("A",100,60,50,50,"2.5"),box("B",100,60,50,50,"2.50")));
save(boxes.prepare(app,List.of(item("CUSTOMER")),100L,200L,"P200",null));
stock(1,1,0); totals(100,"0.15","2.5"); totals(200,"0.15","2.5");
}
@Test void ambiguousForeignDuplicateAndAlreadyUsedBoxesAreRejected() throws Exception {
assertThrows(ResponseStatusException.class,()->boxes.prepare(app,List.of(item("X")),100L,200L,"P200",null));
assertThrows(ResponseStatusException.class,()->boxes.prepare(app,List.of(item("X")),100L,200L,"P200",List.of(new MeasuredCartonBinding("X","FOREIGN"))));
assertThrows(ResponseStatusException.class,()->boxes.prepare(app,List.of(item("X"),item("Y")),100L,200L,"P200",List.of(new MeasuredCartonBinding("X","BIG"),new MeasuredCartonBinding("Y","BIG"))));
save(boxes.prepare(app,List.of(item("X")),100L,200L,"P200",List.of(new MeasuredCartonBinding("X","BIG"))));
assertThrows(ResponseStatusException.class,()->boxes.prepare(app,List.of(item("Y")),200L,300L,"P300",List.of(new MeasuredCartonBinding("Y","BIG"))));
assertThrows(ResponseStatusException.class,()->boxes.prepare(app,List.of(item("X")),200L,300L,"P300",List.of(new MeasuredCartonBinding("X","SMALL1"))));
}
@Test void missingBoxMeasurementsOrUntrackedContentsCannotPassCompletion() {
when(mapper.cartons("7",10L)).thenReturn(List.of(box("A",100,0,50,50,"2.5")));
assertThrows(ResponseStatusException.class,()->boxes.prepare(app,List.of(item("A")),100L,200L,"P200",null));
stock(1,0,0); assertThrows(ResponseStatusException.class,()->pallets.requireMeasured(app));
when(mapper.cartons("7",10L)).thenReturn(List.of(box("A",100,60,50,50,"2.5")));
stock(2,0,0); assertThrows(ResponseStatusException.class,()->pallets.requireMeasured(app));
}
}
@@ -0,0 +1,44 @@
package com.baiyue.lastleg.relabel.service;
import com.baiyue.lastleg.relabel.mapper.RelabelPalletMeasurementMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.*;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
class RelabelPalletMeasurementMapperTest {
@Test void realSqlGroupsMeasuredCartonsAndKeepsCustomerTransferAndPalletBoundaries() {
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var db = new JdbcTemplate(ds);
db.execute("CREATE TABLE ll_warehouse_transfer_pallet(id BIGINT,customer_id VARCHAR,transfer_order_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR)");
db.execute("CREATE TABLE ll_warehouse_transfer_carton(customer_id VARCHAR,transfer_order_id BIGINT,transfer_pallet_id BIGINT,status VARCHAR,length_cm DECIMAL(12,2),width_cm DECIMAL(12,2),height_cm DECIMAL(12,2),gross_weight_kg DECIMAL(12,3))");
db.update("INSERT INTO ll_warehouse_transfer_pallet VALUES(1,'7',10,100,'COMPLETED'),(2,'8',10,200,'COMPLETED'),(3,'7',20,300,'COMPLETED'),(4,'7',10,400,'BOUND')");
db.update("INSERT INTO ll_warehouse_transfer_carton VALUES('7',10,1,'COUNTED',60,50,50,2.5),('7',10,1,'COUNTED',60,50,50,2.5),('7',10,1,'COUNTED',50,40,40,3.6),('7',10,1,'NOT_RECEIVED',60,50,50,2.5),('8',10,2,'COUNTED',60,50,50,2.5),('7',20,3,'COUNTED',60,50,50,2.5),('7',10,4,'COUNTED',60,50,50,2.5),('7',10,2,'COUNTED',60,50,50,2.5)");
db.execute("ALTER TABLE ll_warehouse_transfer_carton ADD id BIGINT GENERATED BY DEFAULT AS IDENTITY");
db.execute("ALTER TABLE ll_warehouse_transfer_carton ADD carton_mark VARCHAR DEFAULT 'BOX'");
db.execute("ALTER TABLE ll_warehouse_transfer_carton ADD customer_carton_mark VARCHAR DEFAULT 'CUSTOMER'");
db.execute("ALTER TABLE ll_warehouse_transfer_pallet ADD pallet_no VARCHAR DEFAULT 'P100'");
var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), ds));
configuration.addMapper(RelabelPalletMeasurementMapper.class);
try (var session = new SqlSessionFactoryBuilder().build(configuration).openSession()) {
var rows = session.getMapper(RelabelPalletMeasurementMapper.class).cartonSpecs("7", 10L);
assertThat(rows).hasSize(2).allSatisfy(row -> assertThat(row.palletId()).isEqualTo(100L));
assertThat(rows.get(0).lengthCm()).isEqualByComparingTo("50");
assertThat(rows.get(0).cartons()).isEqualTo(1);
assertThat(rows.get(1).lengthCm()).isEqualByComparingTo("60");
assertThat(rows.get(1).grossWeightKg()).isEqualByComparingTo("2.5");
assertThat(rows.get(1).cartons()).isEqualTo(2);
var boxes = session.getMapper(RelabelPalletMeasurementMapper.class).cartons("7", 10L);
assertThat(boxes).hasSize(3).allSatisfy(box -> {
assertThat(box.palletId()).isEqualTo(100L);
assertThat(box.palletCode()).isEqualTo("P100");
assertThat(box.cartonMark()).isEqualTo("BOX");
assertThat(box.customerCartonMark()).isEqualTo("CUSTOMER");
});
}
}
}
@@ -29,6 +29,7 @@ class RelabelPalletMeasurementServiceTest {
ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"applications",apps); ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"applications",apps);
ReflectionTestUtils.setField(service,"items",items); ReflectionTestUtils.setField(service,"histories",histories); ReflectionTestUtils.setField(service,"items",items); ReflectionTestUtils.setField(service,"histories",histories);
ReflectionTestUtils.setField(service,"tasks",tasks); ReflectionTestUtils.setField(service,"json",json); ReflectionTestUtils.setField(service,"tasks",tasks); ReflectionTestUtils.setField(service,"json",json);
ReflectionTestUtils.setField(service,"cartonMeasurements",mock(RelabelCartonMeasurementService.class));
app.setId(1L);app.setCustomerId("7");app.setRelabelNo("RL-1");app.setOverseasWarehouseId("92"); app.setId(1L);app.setCustomerId("7");app.setRelabelNo("RL-1");app.setOverseasWarehouseId("92");
app.setStatus("READY_FOR_OUTBOUND");app.setFulfillmentStatus("WAIT_PRE_DISPATCH"); app.setStatus("READY_FOR_OUTBOUND");app.setFulfillmentStatus("WAIT_PRE_DISPATCH");
when(apps.findAnyByRelabelNo("RL-1")).thenReturn(app);when(apps.lockAnyByRelabelNo("RL-1")).thenReturn(app); when(apps.findAnyByRelabelNo("RL-1")).thenReturn(app);when(apps.lockAnyByRelabelNo("RL-1")).thenReturn(app);
@@ -49,6 +50,45 @@ class RelabelPalletMeasurementServiceTest {
var p = service.snapshots(app).get(0);assertTrue(p.inherited()); assertEquals(new BigDecimal("80"),p.grossWeightKg()); var p = service.snapshots(app).get(0);assertTrue(p.inherited()); assertEquals(new BigDecimal("80"),p.grossWeightKg());
assertEquals(BigDecimal.ONE,p.volumeCbm());assertDoesNotThrow(() -> service.requireMeasured(app)); assertEquals(BigDecimal.ONE,p.volumeCbm());assertDoesNotThrow(() -> service.requireMeasured(app));
} }
@Test void cartonMeasuredSourceShowsRealBoxSpecsWithoutInventingPalletDimensions() {
cartonMeasuredSource();
var p = service.snapshots(app).get(0);
assertTrue(p.inherited()); assertNull(p.lengthCm()); assertNull(p.widthCm()); assertNull(p.heightCm());
assertEquals(1, p.sourceCartonSpecs().size());
assertEquals(new BigDecimal("60"), p.sourceCartonSpecs().get(0).lengthCm());
assertEquals(2, p.sourceCartonSpecs().get(0).cartons());
assertEquals(new BigDecimal("5.0"), p.grossWeightKg());
assertEquals(new BigDecimal("0.3"), p.volumeCbm());
assertDoesNotThrow(() -> service.requireMeasured(app));
}
@Test void incompleteOrMismatchedSourceCartonsCannotBypassMeasurement() {
cartonMeasuredSource();
for (var spec : List.of(
new RelabelPalletMeasurementMapper.CartonSpec(100L,new BigDecimal("60"),new BigDecimal("50"),new BigDecimal("50"),new BigDecimal("2.5"),1),
new RelabelPalletMeasurementMapper.CartonSpec(100L,new BigDecimal("61"),new BigDecimal("50"),new BigDecimal("50"),new BigDecimal("2.5"),2),
new RelabelPalletMeasurementMapper.CartonSpec(100L,new BigDecimal("60"),new BigDecimal("50"),new BigDecimal("50"),new BigDecimal("3"),2),
new RelabelPalletMeasurementMapper.CartonSpec(100L,BigDecimal.ZERO,new BigDecimal("50"),new BigDecimal("50"),new BigDecimal("2.5"),2))) {
when(mapper.cartonSpecs("7",10L)).thenReturn(List.of(spec));
assertThrows(ResponseStatusException.class, () -> service.requireMeasured(app));
}
when(mapper.cartonSpecs("7",10L)).thenReturn(List.of());
assertThrows(ResponseStatusException.class, () -> service.requireMeasured(app));
}
@Test void changedCartonMeasuredPalletMustBeRemeasuredInsteadOfReusingOldBoxSpecs() {
cartonMeasuredSource();
when(mapper.movements("7",100L)).thenReturn(1);
var p = service.snapshots(app).get(0);
assertFalse(p.inherited()); assertTrue(p.sourceCartonSpecs().isEmpty());
assertNull(p.volumeCbm());
assertThrows(ResponseStatusException.class, () -> service.requireMeasured(app));
}
private void cartonMeasuredSource() {
when(mapper.originals("7",10L)).thenReturn(List.of(new RelabelPalletMeasurementMapper.Original(100L,"OLD",2,
BigDecimal.ZERO,BigDecimal.ZERO,BigDecimal.ZERO,new BigDecimal("5.0"),new BigDecimal("0.3"))));
when(mapper.stock("7","92")).thenReturn(List.of(stock(1,100,"OLD",2,1)));
when(mapper.cartonSpecs("7",10L)).thenReturn(List.of(new RelabelPalletMeasurementMapper.CartonSpec(100L,
new BigDecimal("60"),new BigDecimal("50"),new BigDecimal("50"),new BigDecimal("2.5"),2)));
}
@Test void partialSplitRequiresBothNewPalletAndOriginalRemainderMeasurements() throws Exception { @Test void partialSplitRequiresBothNewPalletAndOriginalRemainderMeasurements() throws Exception {
split(); var values = service.snapshots(app);assertEquals(2,values.size()); split(); var values = service.snapshots(app);assertEquals(2,values.size());
assertTrue(values.stream().allMatch(p -> p.volumeCbm()==null && p.grossWeightKg()==null)); assertTrue(values.stream().allMatch(p -> p.volumeCbm()==null && p.grossWeightKg()==null));
@@ -184,7 +184,7 @@ class ReturnInventoryServiceTest {
var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setUnitType("PARCEL"); receipt.setUnitCode("PK-1"); var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setUnitType("PARCEL"); receipt.setUnitCode("PK-1");
var source = service.createReceiptCarton(completedOrder(), receipt, null, var source = service.createReceiptCarton(completedOrder(), receipt, null,
List.of(new ReturnInventoryService.ReceiptLine("A", "商品A", 3))); List.of(new ReturnInventoryService.ReceiptLine("A", "商品A", 3)));
assertThat(source.getCartonMark()).startsWith("RP"); assertThat(source.getCartonMark()).startsWith("RP-").hasSizeLessThanOrEqualTo(16);
assertThat(source.getSourceUnitType()).isEqualTo("PARCEL"); assertThat(source.getSourceUnitType()).isEqualTo("PARCEL");
verifyNoInteractions(numbers); verifyNoInteractions(numbers);
assertThat(source.getLabelStatus()).isEqualTo("CONFIRMED"); assertThat(source.getLabelStatus()).isEqualTo("CONFIRMED");
@@ -305,7 +305,7 @@ class ReturnInventoryServiceTest {
assertThat(created).hasSize(2); assertThat(created).hasSize(2);
assertThat(created).extracting(ReturnInventoryCarton::getCartonMark) assertThat(created).extracting(ReturnInventoryCarton::getCartonMark)
.allMatch(mark -> mark.matches("RC[A-F0-9]{16}#2-[12]")) .allMatch(mark -> mark.matches("RC[0-9A-Z]{1,13}#2-[12]"))
.doesNotHaveDuplicates(); .doesNotHaveDuplicates();
assertThat(created.get(0).getCartonMark()).endsWith("#2-1"); assertThat(created.get(0).getCartonMark()).endsWith("#2-1");
assertThat(created.get(1).getCartonMark()).endsWith("#2-2"); assertThat(created.get(1).getCartonMark()).endsWith("#2-2");
@@ -538,7 +538,7 @@ class ReturnInventoryServiceTest {
String a = ReflectionTestUtils.invokeMethod(service, "shortReturnMark", "RT-ORDER", 123L, "SAMEPREFIX-A", 2L, 1L); String a = ReflectionTestUtils.invokeMethod(service, "shortReturnMark", "RT-ORDER", 123L, "SAMEPREFIX-A", 2L, 1L);
String b = ReflectionTestUtils.invokeMethod(service, "shortReturnMark", "RT-ORDER", 123L, "SAMEPREFIX-B", 2L, 1L); String b = ReflectionTestUtils.invokeMethod(service, "shortReturnMark", "RT-ORDER", 123L, "SAMEPREFIX-B", 2L, 1L);
String second = ReflectionTestUtils.invokeMethod(service, "shortReturnMark", "RT-ORDER", 123L, "SAMEPREFIX-A", 2L, 2L); String second = ReflectionTestUtils.invokeMethod(service, "shortReturnMark", "RT-ORDER", 123L, "SAMEPREFIX-A", 2L, 2L);
assertThat(a).isNotEqualTo(b).matches("RC[A-F0-9]{16}#2-1"); assertThat(a).isNotEqualTo(b).matches("RC[0-9A-Z]{1,13}#2-1");
assertThat(second).isEqualTo(a.substring(0, a.lastIndexOf('-') + 1) + "2"); assertThat(second).isEqualTo(a.substring(0, a.lastIndexOf('-') + 1) + "2");
} }
@@ -121,9 +121,13 @@ class ReturnReceiptServiceTest {
ReturnReceiptService service; ReturnReceiptService service;
PdaWarehouseIdentity identity; PdaWarehouseIdentity identity;
private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.CompactBusinessNumberService.class);
service = new ReturnReceiptService(); service = new ReturnReceiptService();
ReflectionTestUtils.setField(service, "compactNumbers", compactNumbers);
org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030");
ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderItems", orderItems); ReflectionTestUtils.setField(service, "orderItems", orderItems);
ReflectionTestUtils.setField(service, "receipts", receipts); ReflectionTestUtils.setField(service, "receipts", receipts);
@@ -243,7 +247,7 @@ class ReturnReceiptServiceTest {
when(orderItems.lockOrder(1L)).thenReturn(List.of(item(11L, "SKU-A", 50, 50), item(12L, "SKU-B", 40, 40))); when(orderItems.lockOrder(1L)).thenReturn(List.of(item(11L, "SKU-A", 50, 50), item(12L, "SKU-B", 40, 40)));
when(fbaWarehouseValidator.required("200", "XLX6", null)).thenReturn( when(fbaWarehouseValidator.required("200", "XLX6", null)).thenReturn(
new FbaWarehouseValidator.VerifiedFbaWarehouse(200L, "XLX6", "FBA XLX6", "US", "CA")); new FbaWarehouseValidator.VerifiedFbaWarehouse(200L, "XLX6", "FBA XLX6", "US", "CA"));
when(numbers.next(eq("RELABEL"), eq("10"), anyLong())).thenReturn("RL-MULTI"); when(compactNumbers.next(eq("TT"), eq("customer-1"))).thenReturn("RL-MULTI");
when(lastlegOrders.selectById(99L)).thenReturn(tail); when(lastlegOrders.selectById(99L)).thenReturn(tail);
when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(first, second)); when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(first, second));
when(inventory.matchesReturnSource(any(), eq("RT-MULTI"))).thenCallRealMethod(); when(inventory.matchesReturnSource(any(), eq("RT-MULTI"))).thenCallRealMethod();
@@ -294,7 +298,7 @@ class ReturnReceiptServiceTest {
when(orderItems.lockOrder(1L)).thenReturn(List.of(item(11L, "SKU-A", 100, 100))); when(orderItems.lockOrder(1L)).thenReturn(List.of(item(11L, "SKU-A", 100, 100)));
when(fbaWarehouseValidator.required("200", "XLX6", null)).thenReturn( when(fbaWarehouseValidator.required("200", "XLX6", null)).thenReturn(
new FbaWarehouseValidator.VerifiedFbaWarehouse(200L, "XLX6", "FBA XLX6", "US", "CA")); new FbaWarehouseValidator.VerifiedFbaWarehouse(200L, "XLX6", "FBA XLX6", "US", "CA"));
when(numbers.next(eq("RELABEL"), eq("10"), anyLong())).thenReturn("RL-SECOND"); when(compactNumbers.next(eq("TT"), eq("customer-1"))).thenReturn("RL-SECOND");
when(lastlegOrders.selectById(99L)).thenReturn(tail); when(lastlegOrders.selectById(99L)).thenReturn(tail);
when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(carton)); when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(carton));
when(inventory.matchesReturnSource(any(), eq("RT-MULTI"))).thenCallRealMethod(); when(inventory.matchesReturnSource(any(), eq("RT-MULTI"))).thenCallRealMethod();
@@ -349,7 +353,7 @@ class ReturnReceiptServiceTest {
void createForecastPersistsCompleteTailOrderSourceAndReturnsItsBusinessNumber() { void createForecastPersistsCompleteTailOrderSourceAndReturnsItsBusinessNumber() {
AtomicReference<ReturnOrder> saved = new AtomicReference<>(); AtomicReference<ReturnOrder> saved = new AtomicReference<>();
when(orders.findKey("customer-1", "create-key")).thenReturn(null); when(orders.findKey("customer-1", "create-key")).thenReturn(null);
when(numbers.next(eq("RETURN"), eq("10"), anyLong())).thenReturn("RT-1"); when(compactNumbers.next(eq("TT"), eq("customer-1"))).thenReturn("RT-1");
when(orders.insert(any(ReturnOrder.class))).thenAnswer(invocation -> { saved.set(invocation.getArgument(0)); return 1; }); when(orders.insert(any(ReturnOrder.class))).thenAnswer(invocation -> { saved.set(invocation.getArgument(0)); return 1; });
when(orders.findNo("customer-1", "RT-1")).thenAnswer(invocation -> saved.get()); when(orders.findNo("customer-1", "RT-1")).thenAnswer(invocation -> saved.get());
when(orderItems.listOrder(anyLong())).thenReturn(List.of()); when(orderItems.listOrder(anyLong())).thenReturn(List.of());
@@ -0,0 +1,41 @@
package com.baiyue.lastleg.service;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class CompactBusinessNumberServiceTest {
@Test void usesCustomerInitialsAndShanghaiSecondsWithoutSequenceSuffix() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
var customers = mock(ReturnOrderReferenceResolver.class);
when(customers.customerName("42")).thenReturn("柏悦物流");
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class))
.thenAnswer(call -> Instant.now().getEpochSecond());
String number = new CompactBusinessNumberService(jdbc, customers).next("TB", "42");
assertTrue(number.matches("TB-BYWL-[0-9]{12}"), number);
assertEquals("AB", CompactBusinessNumberService.initials("Alpha Beta"));
}
@Test void usesDatabaseLogicalSecondAcrossInstancesAndClockRollback() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
var first = new CompactBusinessNumberService(jdbc, null);
var second = new CompactBusinessNumberService(jdbc, null);
Instant now = Instant.parse("2026-10-16T02:30:00Z");
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class))
.thenReturn(now.getEpochSecond(), now.getEpochSecond() + 1, now.getEpochSecond() + 2);
assertEquals("ZY-ABC-261016103000", first.allocate("ZY", "ABC", now));
assertEquals("ZY-ABC-261016103001", second.allocate("ZY", "ABC", now));
assertEquals("ZY-ABC-261016103002", first.allocate("ZY", "ABC", now.minusSeconds(60)));
}
@Test void physicalLabelKeepsEveryIdBitAndIsCompact() {
long id = 2087379674555461633L;
String mark = LastlegOrderNoGenerator.compactCartonMark(id);
assertTrue(mark.length() <= 16);
assertEquals(id, Long.parseLong(mark.substring(3), 36));
assertNotEquals(mark, LastlegOrderNoGenerator.compactCartonMark(id + 1_000_000));
assertEquals(mark, new LastlegOrderNoGenerator().next("RETURN_CARTON", "92", id));
}
}
@@ -0,0 +1,48 @@
package com.baiyue.lastleg.transfer.service;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.*;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
class TransferInventorySourceTest {
WarehouseTransferRequests.Create request(String inbound, String destination, String method, LocalDateTime arrival) {
return new WarehouseTransferRequests.Create(inbound,List.of(),"WH",destination,"ADDRESS",1,BigDecimal.ZERO,
method,arrival,null,null,null,null,false,"KEY",List.of(new WarehouseTransferRequests.Item("SKU","Product",1,null)));
}
@Test void inventoryDoesNotRequireRepeatedArrivalButExternalCargoStillDoes() {
assertTrue(request("IN","ONT8",null,null).isInboundModeValid());
assertFalse(request("IN",null,null,null).isInboundModeValid());
assertFalse(request(null,null,null,null).isInboundModeValid());
assertTrue(request(null,null,"CUSTOMER_DELIVERY",LocalDateTime.now()).isInboundModeValid());
}
@Test void originalBoxesIncludeBothSourcesButNeverAnotherCustomerWarehouseOrInbound() {
var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var db=new JdbcTemplate(ds);
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,inbound_code VARCHAR,overseas_warehouse_id VARCHAR)");
db.execute("CREATE TABLE ll_inbound_plan_carton(customer_id VARCHAR,order_id BIGINT,carton_mark VARCHAR,sku_code VARCHAR,destination_warehouse_code VARCHAR,destination_warehouse_name VARCHAR)");
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR,lastleg_order_id BIGINT,operating_warehouse_id VARCHAR,destination_warehouse_code VARCHAR,destination_warehouse_name VARCHAR)");
db.execute("CREATE TABLE ll_warehouse_transfer_item(id BIGINT,customer_id VARCHAR,transfer_order_id BIGINT,sku_code VARCHAR,product_name_zh VARCHAR)");
db.execute("CREATE TABLE ll_warehouse_transfer_carton(customer_id VARCHAR,transfer_order_id BIGINT,transfer_item_id BIGINT,carton_mark VARCHAR,counted_quantity BIGINT,sku_items_json VARCHAR,status VARCHAR)");
db.update("INSERT INTO ll_order VALUES(1,'A','IN','WH'),(2,'B','IN','WH'),(3,'A','IN','OTHER'),(4,'A','OTHER','WH')");
db.update("INSERT INTO ll_inbound_plan_carton VALUES('A',1,'FIRST','S','ONT8','Amazon'),('B',2,'FOREIGN','S','ONT8','Amazon'),('A',3,'WRONG-WH','S','ONT8','Amazon'),('A',4,'WRONG-IN','S','ONT8','Amazon'),('B',1,'FORGED','S','ONT8','Amazon')");
db.update("INSERT INTO ll_warehouse_transfer_order VALUES(10,'A',1,'WH','ONT8','Amazon')");
db.update("INSERT INTO ll_warehouse_transfer_item VALUES(11,'A',10,'SKU','Product')");
db.update("INSERT INTO ll_warehouse_transfer_carton VALUES('A',10,11,'MEASURED',20,NULL,'COUNTED'),('A',10,11,'EXPECTED',NULL,NULL,'EXPECTED'),('B',10,11,'FOREIGN2',20,NULL,'COUNTED')");
var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds)); config.addMapper(InboundSnapshotMapper.class);
try(var session=new SqlSessionFactoryBuilder().build(config).openSession()) {
var result=session.getMapper(InboundSnapshotMapper.class).sourceCartons("A","WH","IN");
assertEquals(List.of("FIRST","MEASURED"),result.stream().map(InboundSnapshotMapper.SourceCarton::cartonMark).toList());
assertEquals("ONT8",result.get(0).destinationWarehouseCode());
assertNull(result.get(0).quantity()); assertEquals(20L,result.get(1).quantity());
}
}
}
@@ -203,9 +203,13 @@ class WarehouseTransferServiceTest {
@Mock WarehouseTransferReceiptCoordinator receiptCoordinator; @Mock WarehouseTransferReceiptCoordinator receiptCoordinator;
@Mock com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory; @Mock com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory;
private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.CompactBusinessNumberService.class);
service = new WarehouseTransferService(); service = new WarehouseTransferService();
ReflectionTestUtils.setField(service, "compactNumbers", compactNumbers);
org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030");
ReflectionTestUtils.setField(service, "customerDirectory", customerDirectory); ReflectionTestUtils.setField(service, "customerDirectory", customerDirectory);
lenient().when(customerDirectory.find("TENANT-A")).thenReturn( lenient().when(customerDirectory.find("TENANT-A")).thenReturn(
new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer("TENANT-A", "C-A", "Customer A", null, null)); new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer("TENANT-A", "C-A", "Customer A", null, null));
@@ -416,7 +420,7 @@ class WarehouseTransferServiceTest {
assertEquals("Customer A", tailOrder.getValue().getCustomerName()); assertEquals("Customer A", tailOrder.getValue().getCustomerName());
assertEquals(WarehouseTransferService.DRAFT, tailOrder.getValue().getCurrentStatus()); assertEquals(WarehouseTransferService.DRAFT, tailOrder.getValue().getCurrentStatus());
assertEquals("WAREHOUSE_TRANSFER:DRAFT;RELABEL:PENDING_BINDING", tailOrder.getValue().getSubOrderStatus()); assertEquals("WAREHOUSE_TRANSFER:DRAFT;RELABEL:PENDING_BINDING", tailOrder.getValue().getSubOrderStatus());
assertTrue(tailOrder.getValue().getOrderNo().matches("WT-[0-9A-Z]{25}")); assertTrue(tailOrder.getValue().getOrderNo().matches("ZY-CS-[0-9]{12}"));
assertNull(tailOrder.getValue().getTransferWarehouseId()); assertNull(tailOrder.getValue().getTransferWarehouseId());
verify(sourceOrders).insert(any(WarehouseTransferSourceOrder.class)); verify(sourceOrders).insert(any(WarehouseTransferSourceOrder.class));
verify(relabelApplications, never()).insert(any(RelabelApplication.class)); verify(relabelApplications, never()).insert(any(RelabelApplication.class));
@@ -461,10 +465,45 @@ class WarehouseTransferServiceTest {
verifyNoInteractions(transfers); verifyNoInteractions(transfers);
} }
@Test
void transferRelabelRejectsUnknownAndForeignOrderCartonsBeforePersistence() {
var request = new WarehouseTransferRequests.Create(
"IN-1", List.of(11L), "WH-1", null, "FBA", null, "ONT8", null,
null, null, null, null, null, null, null, 1,
BigDecimal.ZERO, null, null, null, null, null, null, null, true,
LocalDateTime.of(2026, 10, 16, 10, 0), "Contact", "123", null, "TRUCK",
"carton-ownership", List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 1, null)),
List.of(new WarehouseTransferRequests.RelabelItem("CARTON_LABEL", List.of("FOREIGN-BOX"), "SKU-1", null, 1, "FBA-1", "ONT8", null)));
for (var cartons : List.of(List.<InboundSnapshotMapper.SourceCarton>of(),
List.of(new InboundSnapshotMapper.SourceCarton("CURRENT-BOX", "SKU-1", null, null, null, null, null)))) {
when(inboundSnapshots.sourceCartons("TENANT-A", "WH-1", "IN-1")).thenReturn(cartons);
var error = assertThrows(ResponseStatusException.class, () -> service.create("TENANT-A", "USER-1", request));
assertEquals("当前箱子不存在或不属于当前订单", error.getReason());
}
when(inboundSnapshots.sourceCartons("TENANT-A", "WH-1", "IN-1"))
.thenReturn(List.of(new InboundSnapshotMapper.SourceCarton("FOREIGN-BOX", "SKU-1", null, null, null, null, null)));
org.junit.jupiter.api.Assertions.assertDoesNotThrow(() ->
ReflectionTestUtils.invokeMethod(service, "validateRelabelRequest", "TENANT-A", request));
verifyNoInteractions(transfers);
}
@Test
void submittingOldDraftRechecksRelabelCartonOwnership() {
var transfer = transfer(WarehouseTransferService.DRAFT);
transfer.setNeedRelabel(true);
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
var binding = new com.baiyue.lastleg.transfer.entity.WarehouseTransferRelabelBinding();
binding.setCustomerCartonMark("FOREIGN-BOX");
when(relabelBindings.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(binding));
var error = assertThrows(ResponseStatusException.class, () -> service.submit("TENANT-A", "WTR-1", "USER-1"));
assertEquals("当前箱子不存在或不属于当前订单", error.getReason());
verify(transfers, never()).updateById(any(WarehouseTransferOrder.class));
}
@ParameterizedTest @ParameterizedTest
@NullSource @NullSource
@ValueSource(strings = {"NONE"}) @ValueSource(strings = {"NONE"})
void transferWithoutDestinationCanBeCreatedAndSubmitted(String destinationType) { void inventoryTransferRequiresKnownDestination(String destinationType) {
WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create( WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create(
"IN-1", List.of(11L), "WH-1", null, destinationType, null, null, null, "IN-1", List.of(11L), "WH-1", null, destinationType, null, null, null,
null, null, null, null, "123 Pickup Road", null, null, 5, null, null, null, null, "123 Pickup Road", null, null, 5,
@@ -473,53 +512,23 @@ class WarehouseTransferServiceTest {
null, null, null, null, null, "key-no-fba", null, null, null, null, null, "key-no-fba",
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1")), null); List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1")), null);
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L); var error = assertThrows(ResponseStatusException.class, () -> service.create("TENANT-A","USER-1",request));
when(orders.selectList(any())).thenReturn(List.of(historicalOrder())); assertEquals(400,error.getStatusCode().value());
WarehouseTransferOrder draft = transfer(WarehouseTransferService.DRAFT); verifyNoInteractions(transfers,orders);
draft.setFbaWarehouseCode(null);
when(transfers.findNo(eq("TENANT-A"), anyString())).thenReturn(draft);
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(draft);
when(orders.selectById(draft.getLastlegOrderId())).thenReturn(tailOrder());
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
service.create("TENANT-A", "USER-1", request);
var result = service.submit("TENANT-A", "WTR-1", "USER-1");
ArgumentCaptor<WarehouseTransferOrder> created = ArgumentCaptor.forClass(WarehouseTransferOrder.class);
verify(transfers).insert(created.capture());
assertNull(created.getValue().getDestinationType());
assertNull(created.getValue().getFbaWarehouseCode());
assertEquals(WarehouseTransferService.PENDING_REVIEW, result.status());
verify(relabelApplications, never()).insert(any(RelabelApplication.class));
} }
@Test @Test
void merchantCanClearDestinationWhenEditingDraft() { void inventoryDraftCannotClearDestination() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.DRAFT); var transfer = transfer(WarehouseTransferService.DRAFT);
transfer.setDestinationType("FBA"); when(transfers.lockNo("TENANT-A","WTR-1")).thenReturn(transfer);
transfer.setDestinationWarehouseCode("ONT8");
LastlegOrder order = tailOrder();
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(orders.selectById(transfer.getLastlegOrderId())).thenReturn(order);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create( WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create(
"IN-1", List.of(11L), "WH-1", null, "NONE", null, null, null, "IN-1", List.of(11L), "WH-1", null, "NONE", null, null, null,
null, null, null, null, "123 Pickup Road", null, null, 5, null, null, null, null, "123 Pickup Road", null, null, 5,
new BigDecimal("0.2500"), "WAREHOUSE_PICKUP", LocalDateTime.of(2026, 9, 12, 10, 0), new BigDecimal("0.2500"), "WAREHOUSE_PICKUP", LocalDateTime.of(2026, 9, 12, 10, 0),
null, null, null, null, null, false, null, null, null, null, null, "edit-no-fba", null, null, null, null, null, false, null, null, null, null, null, "edit-no-fba",
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1")), null); List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1")), null);
assertThrows(ResponseStatusException.class, () -> service.updateDraft("TENANT-A","WTR-1","USER-1",request));
var result = service.updateDraft("TENANT-A", "WTR-1", "USER-1", request); verify(transfers, never()).updateById(any(WarehouseTransferOrder.class));
assertEquals(WarehouseTransferService.DRAFT, result.status());
assertNull(transfer.getDestinationType());
assertNull(transfer.getDestinationWarehouseCode());
assertNull(transfer.getFbaWarehouseCode());
assertNull(order.getFbaWarehouseCode());
verify(transfers).updateById(transfer);
} }
@Test @Test
@@ -544,7 +553,8 @@ class WarehouseTransferServiceTest {
assertEquals("ONT8", transfer.getValue().getFbaWarehouseCode()); assertEquals("ONT8", transfer.getValue().getFbaWarehouseCode());
assertNull(transfer.getValue().getFbaShipmentNo()); assertNull(transfer.getValue().getFbaShipmentNo());
assertEquals("ONT8", tailOrder.getValue().getFbaWarehouseCode()); assertEquals("ONT8", tailOrder.getValue().getFbaWarehouseCode());
assertEquals("WAREHOUSE_PICKUP", transfer.getValue().getDeliveryMethod()); assertEquals("INVENTORY_TRANSFER", transfer.getValue().getDeliveryMethod());
assertNull(transfer.getValue().getEstimatedArrivalAt());
} }
@Test @Test
@@ -620,7 +630,7 @@ class WarehouseTransferServiceTest {
assertEquals(List.of(1, 2, 3, 4, 5), cartons.getAllValues().stream() assertEquals(List.of(1, 2, 3, 4, 5), cartons.getAllValues().stream()
.map(WarehouseTransferCarton::getCartonIndex).toList()); .map(WarehouseTransferCarton::getCartonIndex).toList());
assertTrue(cartons.getAllValues().stream().allMatch(value -> value.getCartonMark() assertTrue(cartons.getAllValues().stream().allMatch(value -> value.getCartonMark()
.matches("WT-[0-9A-Z]{25}#5-[1-5]"))); .matches("BX-[0-9A-Z]+")));
when(transfers.findKey("TENANT-A", request.idempotencyKey())).thenReturn(transfer.getValue()); when(transfers.findKey("TENANT-A", request.idempotencyKey())).thenReturn(transfer.getValue());
clearInvocations(customerDirectory, orders, inboundOrders); clearInvocations(customerDirectory, orders, inboundOrders);
service.create("TENANT-A", "USER-1", request); service.create("TENANT-A", "USER-1", request);
+5
View File
@@ -1,5 +1,10 @@
# Order Service 模块指引 # Order Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界 ## 职责与边界
- Mapper 只由 `BaiyueOrderApplication.java` 对 `com.baiyue.order` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。 - Mapper 只由 `BaiyueOrderApplication.java` 对 `com.baiyue.order` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
+5
View File
@@ -1,5 +1,10 @@
# Platform BOM 模块指引 # Platform BOM 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界 ## 职责边界
- 本模块只通过 `pom.xml` 的 `dependencyManagement` 发布 `baiyue-platform-bom`,统一管理本仓公共制品版本。 - 本模块只通过 `pom.xml` 的 `dependencyManagement` 发布 `baiyue-platform-bom`,统一管理本仓公共制品版本。
+5
View File
@@ -1,5 +1,10 @@
# Print Service 模块指引 # Print Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界 ## 职责边界
- 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]` 或 `documents[]`,模块在内存中生成一次性多页 PDF 并同步返回。 - 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]` 或 `documents[]`,模块在内存中生成一次性多页 PDF 并同步返回。
@@ -17,7 +17,7 @@ test('种子解析覆盖多行多值、函数括号及带逗号和引号的文
}) })
test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => { test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => {
for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 227], [overseas, overseasSchemas, domesticSchemas, 208]]) { for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 227], [overseas, overseasSchemas, domesticSchemas, 209]]) {
assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed) assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed)
assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed) assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed)
const body = sql.replace(/^--.*$/gm, '') const body = sql.replace(/^--.*$/gm, '')
+5
View File
@@ -1,5 +1,10 @@
# Overseas Warehouse Service 模块指引 # Overseas Warehouse Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界 ## 职责与边界
- Mapper 只由 `BaiyueWarehouseOverseasApplication.java` 对 `com.baiyue.warehouse` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。 - Mapper 只由 `BaiyueWarehouseOverseasApplication.java` 对 `com.baiyue.warehouse` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
@@ -18,8 +18,6 @@ import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import java.util.Set; import java.util.Set;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@Service @Service
@@ -448,11 +446,7 @@ public class WarehouseResourceService {
} }
private static String generatedPalletCode(String warehouseCode, long palletId) { private static String generatedPalletCode(String warehouseCode, long palletId) {
String compactWarehouseCode = warehouseCode.replaceAll("[^A-Za-z0-9]", "").toUpperCase(java.util.Locale.ROOT); return "TP-" + Long.toUnsignedString(palletId, 36).toUpperCase(java.util.Locale.ROOT);
if (compactWarehouseCode.length() > 9) compactWarehouseCode = compactWarehouseCode.substring(0, 9);
return "PLT-" + compactWarehouseCode + "-"
+ LocalDate.now().format(DateTimeFormatter.ofPattern("yyMMdd")) + "-"
+ Long.toUnsignedString(palletId, 36).toUpperCase(java.util.Locale.ROOT);
} }
private static String defaultLocationCode(String warehouseCode) { private static String defaultLocationCode(String warehouseCode) {
@@ -262,7 +262,7 @@ class WarehouseResourceServiceTest {
ArgumentCaptor<WarehousePalletEntity> captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); ArgumentCaptor<WarehousePalletEntity> captor = ArgumentCaptor.forClass(WarehousePalletEntity.class);
verify(repository).insertPallet(captor.capture()); verify(repository).insertPallet(captor.capture());
assertThat(captor.getValue().getPalletCode()).matches("PLT-USLAX01-[0-9]{6}-[0-9A-Z]+"); assertThat(captor.getValue().getPalletCode()).matches("TP-[0-9A-Z]+");
assertThat(captor.getValue().getPalletCode()).hasSizeLessThanOrEqualTo(34); assertThat(captor.getValue().getPalletCode()).hasSizeLessThanOrEqualTo(34);
assertThat(captor.getValue().getLocationId()).isEqualTo(30003L); assertThat(captor.getValue().getLocationId()).isEqualTo(30003L);
assertThat(captor.getValue().getId()).isNotNull(); assertThat(captor.getValue().getId()).isNotNull();
+5
View File
@@ -1,5 +1,10 @@
# Warehouse Service 模块指引 # Warehouse Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界 ## 职责与边界
- Mapper 只由 `BaiyueWarehouseApplication.java` 对 `com.baiyue.warehouse` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。 - Mapper 只由 `BaiyueWarehouseApplication.java` 对 `com.baiyue.warehouse` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。