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 源码已退出聚合和发布清单,退役待清理;实际停机仍须替代链验证与授权,不得据源码状态认定已部署。
- `lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。共用测试 MySQL 实例不改变服务数据归属。准确路由、ACK、撤销矩阵和切换门槛只维护在新的两份主契约中。
## 数据库访问与中文代码注释(所有服务及公共模块强制)
- 本节适用于全部后端服务和公共模块;新增、修改的方法必须执行,触及的存量代码同步整改。登记本约束不代表存量代码已全部完成迁移和注释补齐。
- 生产代码的关系型数据库读写统一经 MyBatis/MyBatis-Plus Mapper 执行;Controller、Service、定时任务、消费者、编号生成器和公共基础组件不得直接使用 `JdbcTemplate`、`NamedParameterJdbcTemplate`、原生 JDBC/Connection/Statement 或其他通道执行 SQL。仅改成 Repository 包装 JDBC 不算整改,SQL 必须进入 Mapper;无数据库职责的模块不得为满足本条新增数据库访问。
- 正常业务数据库访问路径统一为 `Controller → Service → Repository → Mapper`,四层职责明确,不得省略 Repository,也不得由 Controller 直接调用 Repository/Mapper,或由 Service 直接调用 Mapper。定时任务、消费者等非 HTTP 入口从 Service 进入,同样经 Repository → Mapper 完成持久化访问。
- Controller 负责请求接收、参数校验、入口权限和响应转换;Service 负责业务规则、编排及业务事务;Repository 负责领域数据访问封装、查询组合与持久化模型转换,对 Service 提供有业务含义的数据操作;Mapper 负责 SQL 执行及数据库记录映射。Repository 不承载业务状态流转,不直接用 JDBC 执行 SQL;Service 不继承 MyBatis-Plus `ServiceImpl` 等方式绕过 Repository。
- 复杂查询、批量处理、原子计数、锁定读取、Outbox 和数据库监控查询同样经 Repository → Mapper,不以 SQL 特殊或实现方便为由跳层。公共模块需要数据库能力时,按现有模块边界定义 Repository/持久化接口,由所属服务的 Repository → Mapper 实现,禁止借此跨服务读库。
- 迁移数据库操作须保留客户隔离条件、事务传播、行锁、幂等和并发语义;`LAST_INSERT_ID()` 等连接级操作必须在同一事务连接完成,并明确关闭相关读取缓存,不能仅机械搬移 SQL。Mapper 注册及扫描仍遵守下文统一规范。
- 测试代码可用 `JdbcTemplate` 创建隔离测试表、准备数据及核对结果;被测生产路径仍必须调用实际 Mapper,不得把测试造数做法带入生产。数据库基线和迁移脚本按现有部署契约管理。
- 每个手写方法及构造器均须添加简短中文注释,通常 1—3 行说明业务用途;接口方法和 Mapper 方法优先使用中文 Javadoc。覆盖方法同样须有简短中文说明,实现中的关键规则仍须就地注释;自动生成的方法不手工逐个补注释。
- 核心业务、复杂 SQL、状态流转、资金与库存变更、权限隔离、事务、锁、幂等、并发、重试和补偿逻辑,须在关键代码前增加适量中文注释,解释业务规则、处理原因、约束边界及失败影响;不逐行翻译代码,不添加无信息量或与实现不符的注释。
- 新增和修改的代码说明注释统一使用中文;必要的技术标识、协议名、类名可保留原文,第三方许可证和生成标记保持原样。修改方法时同步更新相关存量英文或过期说明;审查时同时核对 Controller → Service → Repository → Mapper 分层及中文方法、核心逻辑注释。
## 契约、数据库与验证入口
- 对外 Controller/API 新增、改名或删除时,同时核对调用端、统一 Gateway、OpenAPI 和权限;提交前运行 `node --test scripts/gateway-route-contract.test.mjs`,自动发现生产 Controller 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。
+5
View File
@@ -1,5 +1,10 @@
# Audit Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- Mapper 只由 `BaiyueAuditApplication.java` 对 `com.baiyue.audit` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
+5
View File
@@ -1,5 +1,10 @@
# Auth Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界
- Mapper 只由 `BaiyueAuthApplication.java` 对 `com.baiyue.auth` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
+5
View File
@@ -1,5 +1,10 @@
# Common Audit 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- 负责业务审计事件采集、注解切面、敏感字段脱敏以及日志/HTTP 输出。
+5
View File
@@ -1,5 +1,10 @@
# Common Core 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- 提供不依赖具体业务域的返回模型、异常、基础实体和 MyBatis-Plus 通用配置。
+5
View File
@@ -1,5 +1,10 @@
# Common Event Contract 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- 维护 Firstleg、Lastleg、Finance 之间稳定、可版本化的业务事件契约。
+5
View File
@@ -1,5 +1,10 @@
# Common OpenAPI 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- 为 Spring MVC 服务提供 SpringDoc/OpenAPI 自动配置和统一文档元数据。
+5
View File
@@ -1,5 +1,10 @@
# Common Security 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- 提供业务服务通用的 JWT 解码、认证过滤、Redis Access Token 会话校验、认证用户上下文及 401/403 JSON 响应。
+5
View File
@@ -1,5 +1,10 @@
# Common Web 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- 提供 Servlet Web 通用能力:请求链路标识、上下文和统一异常响应。
@@ -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 | 表数 |
|---|---|---|---:|
| 国内 | `头程数据库统一增量-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,经内部接口访问 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同名配置同步);无业务建表及采集配置变更。执行前须逐项授权。
货物废弃通用库存与散箱字段迁移:`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 '提货联系电话',
package_count int NOT NULL COMMENT '箱数',
delivery_method varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓方式',
estimated_arrival_at datetime(3) NOT NULL COMMENT '预计入仓时间',
estimated_arrival_at datetime(3) NULL COMMENT '预计入仓时间,库存转运不填写',
estimated_ship_at datetime(3) DEFAULT NULL COMMENT '预计发货时间',
fba_shipment_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '可选FBA货件编号',
tracking_no varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '物流跟踪号',
@@ -4564,4 +4564,11 @@ CREATE TABLE ll_appointment_work_history (
KEY idx_appointment_work_history (work_item_key,occurred_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 208 AS initialized_tables;
-- 海外业务短单号分配时钟。
CREATE TABLE ll_compact_number_clock (
number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写',
last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒',
PRIMARY KEY (number_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 209 AS initialized_tables;
+5
View File
@@ -1,5 +1,10 @@
# File Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界
- Mapper 只由 `FileServiceApplication.java` 对 `com.baiyue.file` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
+5
View File
@@ -1,5 +1,10 @@
# Finance Service Instructions
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 模块定位
- 本模块是独立财务服务,拥有客户结算条款、财务费用来源、CNY 账单与明细、客户资金账户及充值申请、应收款月度统计、客户对账确认、收款及分配、成本及分摊、账单修订、单据编号、幂等记录和财务审计状态。
+5
View File
@@ -1,5 +1,10 @@
# Firstleg Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界
- Mapper 统一显式使用 `@Mapper`,仅由 `BaiyueFirstlegApplication.java` 对 `com.baiyue.firstleg` 根包按 `annotationClass = Mapper.class` 扫描;禁止恢复业务子域 Mapper 包白名单。静态门禁见根 `scripts/mapper-scan-contract.mjs`,模块内编译门禁见 `MapperRegistrationArchitectureTest.java`、`BaiyueFirstlegApplicationContractTest.java`。
+5
View File
@@ -1,5 +1,10 @@
# Gateway 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界
- 负责路由、CORS、JWT/Redis 会话校验、平台/客户访问域隔离、请求标识和 Swagger 聚合。
+5
View File
@@ -1,5 +1,10 @@
# IAM Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界
- Mapper 只由 `BaiyueIamApplication.java` 对 `com.baiyue.iam` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
+5
View File
@@ -1,5 +1,10 @@
# Integration Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界
- 负责百度 OCR、Amazon Logistics/SP-API、海外仓业务船司/船舶轨迹查询等第三方适配和能力开关;不保存业务主数据。
+5
View File
@@ -1,5 +1,10 @@
# Lastleg Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界
- Mapper 必须标注 `@Mapper`,仅由 `BaiyueLastlegApplication.java` 对 `com.baiyue.lastleg` 根包按 `annotationClass = Mapper.class` 扫描;禁止局部 `*MapperConfiguration` 和包白名单。验证:`scripts/mapper-scan-contract.mjs`、`MapperRegistrationArchitectureTest.java`。
@@ -14,6 +14,26 @@ import java.util.List;
@Mapper
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} " +
"AND overseas_warehouse_id=#{overseasWarehouseId} " +
"AND destination_warehouse_code=#{destinationWarehouseCode} " +
@@ -34,6 +34,8 @@ public final class PalletOperationRequests {
@NotEmpty @Size(max = 20) List<@Valid TargetMove> targets,
@Size(max = 500) String reason) { }
public record MeasuredCartonBinding(@NotBlank @Size(max=255) String cartonMark,
@NotBlank @Size(max=255) String sourceCartonMark) {}
/** source为空时表示退货箱没有来源托盘,现场仅扫描箱唛后绑定目标托盘;actualSource为现场确认的原入仓库存行。 */
public record RelabelDisassemble(@NotBlank @Size(max = 128) String idempotencyKey,
@NotBlank @Size(max = 64) String relabelNo,
@@ -41,10 +43,15 @@ public final class PalletOperationRequests {
@Valid PalletExpectation target,
@NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks,
@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,
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 RelabelItemMapper relabelItems;
@Resource private RelabelPalletMutationService relabelPalletMutations;
@Resource private com.baiyue.lastleg.relabel.service.RelabelCartonMeasurementService cartonMeasurements;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
@Resource private ReturnInventoryBalanceMapper returnBalances;
@@ -307,7 +308,7 @@ public class PdaPalletOperationService {
Set<String> bound = boundRelabelMarks(application);
if (!marks.stream().allMatch(bound::contains)) {
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(),
"COMPLETED", false, LocalDateTime.now(), List.of(content(identity.overseasWarehouseId(), source)));
@@ -351,7 +352,7 @@ public class PdaPalletOperationService {
requireRelabelDestination(identity.overseasWarehouseId(), selected, target);
Plan base = plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false);
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()
? "" : ":" + request.reason().trim());
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.setOperatorKey(identity.operatorKey()); record.setOperatorName(identity.operatorName());
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"),
"measuredCartons", measured,
"cartonMarks", mutation.itemsByPosition().values().stream().flatMap(List::stream).map(RelabelItem::getCartonMark).toList(),
"targetPallets", targets.values().stream().map(InventoryPosition::getPalletNo).distinct().toList(),
"sourcePallets", mutation.itemsByPosition().keySet().stream().map(positions::selectById)
@@ -1122,6 +1128,11 @@ public class PdaPalletOperationService {
RelabelMutation relabelMutation) { }
private record RelabelMutation(RelabelApplication application,
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 }
}
@@ -9,6 +9,23 @@ public interface RelabelPalletMeasurementMapper {
record Stock(Long id, Long palletId, String palletNo, long packages, long version) {}
record Original(Long palletId, String palletNo, int packages, BigDecimal lengthCm,
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}")
Long transfer(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
@Select("SELECT id,pallet_id palletId,pallet_no palletNo," +
@@ -80,6 +80,7 @@ public class RelabelApplicationService implements WalletResultHandler {
READY_FOR_OUTBOUND, "COMPLETED", "REJECTED", "CANCELLED");
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 RelabelItemMapper items;
@Resource private RelabelStatusHistoryMapper histories;
@@ -417,13 +418,13 @@ public class RelabelApplicationService implements WalletResultHandler {
}
if (carton == null || !customerId.equals(carton.getCustomerId())
|| !request.operationWarehouseId().trim().equals(carton.getOverseasWarehouseId())) {
throw conflict("所选退货箱不属于当前客户和操作仓");
throw conflict("当前箱子不存在或不属于当前订单");
}
if (!"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) {
throw conflict("仓库唛头 " + carton.getCartonMark() + " 已不可用于换标");
}
if (!carton.getCartonMark().equals(item.cartonMark().trim()))
throw conflict("来源退货箱号与选择的库存不一致");
throw conflict("当前箱子不存在或不属于当前订单");
com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountGuard.requireConfirmed(carton);
final long sourceReturnCartonId = carton.getId();
Map<String, Long> availableSku = returnCartonItems.lockCarton(sourceReturnCartonId).stream()
@@ -474,6 +475,10 @@ public class RelabelApplicationService implements WalletResultHandler {
|| !Objects.equals(balance.getInboundOrderId(), item.sourceInboundOrderId())) {
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()) {
throw conflict("Available inventory changed for inbound " + balance.getInboundOrderNo());
}
@@ -482,7 +487,7 @@ public class RelabelApplicationService implements WalletResultHandler {
LocalDateTime now = LocalDateTime.now();
long applicationId = IdWorker.getId();
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);
boolean requiresWallet = chargeAmount.signum() > 0;
int holdIndex = 0;
@@ -1362,7 +1367,7 @@ public class RelabelApplicationService implements WalletResultHandler {
}
LastlegOrder order = new LastlegOrder();
order.setId(IdWorker.getId());
order.setOrderNo(orderNoGenerator.next("RELABEL", warehouseId, order.getId()));
order.setOrderNo(relabelNo);
order.setCustomerId(customerId); order.setOrderType("RELABEL_APPLICATION");
order.setInboundCode(null); order.setSourceRegion("CUSTOMER");
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 PdaRelabelTaskMapper tasks;
@Resource private ObjectMapper json;
@Resource private RelabelCartonMeasurementService cartonMeasurements;
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,
@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) {}
public record Measurement(Long palletId, String palletCode, long packages, String stockVersion,
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) {
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()
.orElseThrow(() -> 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(),
request.lengthCm(), request.widthCm(), request.heightCm(), request.grossWeightKg(),
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.
var stock = mapper.stock(app.getCustomerId(), app.getOverseasWarehouseId());
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 positions = items.selectList(new QueryWrapper<com.baiyue.lastleg.relabel.entity.RelabelItem>()
.eq("customer_id", app.getCustomerId()).eq("application_id", app.getId()));
var positionIds = positions.stream().map(i -> i.getSourceInventoryPositionId()).toList();
stock.stream().filter(p -> positionIds.contains(p.id())).forEach(p -> involved.add(p.palletId()));
var records = histories(app);
var currentBoxes = cartonMeasurements.current(app);
var codes = new HashSet<String>();
for (var h : records) if ("PALLET_BIND".equals(h.getAction())) {
tree(h).path("sourcePallets").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()));
currentBoxes.forEach(box -> involved.add(box.palletId()));
var result = new ArrayList<Measurement>();
for (Long pallet : involved.stream().filter(Objects::nonNull).sorted().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;
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;
for (var h : records) if ("PALLET_MEASURE".equals(h.getAction())) {
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) {
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,
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);
}
@@ -118,10 +147,28 @@ public class RelabelPalletMeasurementService {
}
public void requireMeasured(RelabelApplication app) {
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)
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) {
return histories.selectList(new QueryWrapper<PdaRelabelHistory>().eq("customer_id", app.getCustomerId())
.eq("application_id", app.getId()).orderByAsc("created_at","id"));
@@ -537,7 +537,7 @@ public class ReturnInventoryService {
String cartonMark = text(requestedMark);
long cartonId = IdWorker.getId();
// 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);
if (cartons.findMark(cartonMark) != null) throw conflict("箱号已存在: " + cartonMark);
LocalDateTime now = LocalDateTime.now();
@@ -602,7 +602,8 @@ public class ReturnInventoryService {
}
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;
}
@@ -1276,7 +1277,7 @@ public class ReturnInventoryService {
.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>, 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();
}
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 COMPLETED = "COMPLETED";
@Resource private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
@Resource private ReturnOrderMapper orders;
@Resource private ReturnOrderItemMapper orderItems;
@Resource private ReturnReceiptMapper receipts;
@@ -99,7 +100,7 @@ public class ReturnReceiptService {
order.setOverseasWarehouseId(text(request.overseasWarehouseId()));
order.setWarehouseContactName(text(request.warehouseContactName()));
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.setExpectedSkuTypes(skuCodes.size()); order.setExpectedQuantity(expected); order.setReceivedQuantity(0L);
order.setRemark(text(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(requestHash);
@@ -429,10 +430,10 @@ public class ReturnReceiptService {
totalQuantity = Math.addExact(totalQuantity, requested.quantity());
}
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());
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.setSourceInboundId("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 (unitCode == null && "PALLET".equals(unitType)) unitCode = text(request.palletCode());
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)) {
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 ("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) {
String code = businessType == null ? "" : businessType.trim().toUpperCase(Locale.ROOT);
if ("RETURN_CARTON".equals(code)) return compactCartonMark(uniqueId);
String returnPrefix = switch (code) {
case "RETURN" -> "RT";
case "RETURN_RECEIPT" -> "RR";
@@ -65,6 +66,12 @@ public class LastlegOrderNoGenerator {
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) {
String code = value == null ? "LMO" : value.trim().toUpperCase(Locale.ROOT);
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) {
try {
var result = customers.customers(internalToken, 1, 30, null, "ACTIVE", Long.parseLong(id));
@@ -28,6 +28,14 @@ import java.util.List;
public class MerchantWarehouseTransferController {
@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
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_CREATE + "')")
public ApiResponse<WarehouseTransferResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@@ -29,13 +29,13 @@ public final class WarehouseTransferRequests {
@Size(max = 32) String privateDestinationPostalCode,
@Size(max = 128) String privateDestinationContactName,
@Size(max = 64) String privateDestinationContactPhone,
@NotBlank @Size(max = 1000) String deliveryAddress,
@Size(max = 1000) String deliveryAddress,
@Size(max = 128) String pickupContactName,
@Size(max = 64) String pickupContactPhone,
@Positive int packageCount,
@NotNull @DecimalMin(value = "0.0") BigDecimal volumeCbm,
@NotBlank @Size(max = 64) String deliveryMethod,
@NotNull LocalDateTime estimatedArrivalAt,
@Size(max = 64) String deliveryMethod,
LocalDateTime estimatedArrivalAt,
LocalDateTime estimatedShipAt,
@Size(max = 128) String fbaShipmentNo,
@Size(max = 255) String trackingNo,
@@ -51,6 +51,14 @@ public final class WarehouseTransferRequests {
@NotEmpty @Size(max = 500) List<@Valid Item> items,
@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,
String fbaWarehouseCode, String deliveryAddress, int packageCount, BigDecimal volumeCbm,
String deliveryMethod, LocalDateTime estimatedArrivalAt, String fbaShipmentNo,
@@ -6,6 +6,9 @@ import java.util.List;
public final class 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 Carton(String cartonMark, String customerCartonMark, int cartonIndex, int totalCartons, String status,
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.Select;
@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")
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")
@@ -88,6 +88,7 @@ import java.util.stream.Collectors;
@Service
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.business.FleetPoolOrderBridge fleetPool;
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPoolQueries;
@@ -140,14 +141,35 @@ public class WarehouseTransferService {
@Resource private InventoryCommandMapper inventoryCommands;
@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
public WarehouseTransferResponses.Detail create(String customerId, String operatorId,
WarehouseTransferRequests.Create request) {
if (!request.isInboundModeValid()) throw badRequest("外来货物须填写入仓信息,库存转运须选择FBA仓或私人仓");
int itemPackages = request.items().stream().mapToInt(WarehouseTransferRequests.Item::packageCount).sum();
if (itemPackages != request.packageCount()) {
throw badRequest("Cargo item package total must equal transfer package count");
}
validateRelabelRequest(request);
validateRelabelRequest(customerId, request);
validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(),
request.privateDestinationAddress(), request.privateDestinationPostalCode(),
request.privateDestinationContactName(), request.privateDestinationContactPhone());
@@ -167,7 +189,7 @@ public class WarehouseTransferService {
}
LocalDateTime now = LocalDateTime.now();
long transferId = IdWorker.getId();
String transferNo = orderNoGenerator.next("TRANSFER", request.operatingWarehouseId(), transferId);
String transferNo = compactNumbers.next("ZY", customerId);
String requestedInboundCode = trim(request.inboundCode());
List<Long> selectedIds = request.sourceLastlegOrderIds() == null
? List.of() : request.sourceLastlegOrderIds().stream().distinct().toList();
@@ -210,13 +232,13 @@ public class WarehouseTransferService {
transfer.setOperatingWarehouseId(request.operatingWarehouseId().trim());
transfer.setFbaWarehouseCode(destinationCode(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.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.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.setNeedRelabel(request.needRelabel()); transfer.setStatus(DRAFT);
transfer.setLabelDeadline(request.labelDeadline());
@@ -278,11 +300,12 @@ public class WarehouseTransferService {
if (!DRAFT.equals(transfer.getStatus())) {
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();
if (itemPackages != request.packageCount()) {
throw badRequest("Cargo item package total must equal transfer package count");
}
validateRelabelRequest(request);
validateRelabelRequest(customerId, request);
validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(),
request.privateDestinationAddress(), request.privateDestinationPostalCode(),
request.privateDestinationContactName(), request.privateDestinationContactPhone());
@@ -293,15 +316,15 @@ public class WarehouseTransferService {
transfer.setOperatingWarehouseId(request.operatingWarehouseId().trim());
transfer.setFbaWarehouseCode(destinationCode(request));
applyDestination(transfer, request);
transfer.setDeliveryAddress(request.deliveryAddress().trim());
transfer.setDeliveryAddress(inboundAddress(request));
transfer.setPackageCount(request.packageCount());
transfer.setPickupContactName(trim(request.pickupContactName()));
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.setFbaShipmentNo(trim(request.fbaShipmentNo()));
transfer.setTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim())
transfer.setTrackingNo("CUSTOMER_DELIVERY".equals(inboundMethod(request))
? trim(request.trackingNo()) : null);
transfer.setFbaBoxNos(trim(request.fbaBoxNos()));
transfer.setRemark(trim(request.remark()));
@@ -319,7 +342,7 @@ public class WarehouseTransferService {
order.setFbaWarehouseCode(destinationCode(request));
order.setPackageCount(request.packageCount());
order.setVolumeCbm(request.volumeCbm());
order.setEstimatedWarehouseAt(request.estimatedArrivalAt());
order.setEstimatedWarehouseAt(arrivalTime(request));
order.setRelabelStatus(request.needRelabel() ? "PENDING_BINDING" : "NOT_REQUIRED");
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + DRAFT + ";RELABEL:" + order.getRelabelStatus());
order.setCustomerRemark(trim(request.remark()));
@@ -376,6 +399,19 @@ public class WarehouseTransferService {
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 (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);
LocalDateTime now = LocalDateTime.now();
transfer.setStatus(PENDING_REVIEW);
@@ -859,7 +895,7 @@ public class WarehouseTransferService {
carton = new WarehouseTransferCarton(); carton.setId(IdWorker.getId());
carton.setTransferOrderId(transfer.getId()); carton.setTransferItemId(input.transferItemId()); carton.setCustomerId(customerId);
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.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.relabelItems() != null && !request.relabelItems().isEmpty()) {
throw badRequest("Relabel items require needRelabel=true");
@@ -1720,6 +1756,14 @@ public class WarehouseTransferService {
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())
|| !StringUtils.hasText(request.relabelContactPhone())) {
throw badRequest("Relabel deadline and contact are required");
@@ -1754,6 +1798,7 @@ public class WarehouseTransferService {
throw badRequest("SKU relabel requires a new SKU");
}
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");
}
}
@@ -1969,7 +2014,7 @@ public class WarehouseTransferService {
private LastlegOrder createTailOrder(String customerId, String customerName, Long inboundOrderId, String inboundCode, String transferNo,
WarehouseTransferRequests.Create request, String requestHash, LocalDateTime now) {
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.setOrderType(ORDER_TYPE);
order.setInboundCode(inboundCode); order.setSourceRegion("CUSTOMER");
@@ -1979,7 +2024,7 @@ public class WarehouseTransferService {
order.setVolumeCbm(request.volumeCbm());
order.setContainerNo(transferNo);
order.setPalletCount(0); order.setPickedPackageCount(0); order.setShippedPackageCount(0);
order.setEstimatedWarehouseAt(request.estimatedArrivalAt());
order.setEstimatedWarehouseAt(arrivalTime(request));
order.setCurrentStatus(DRAFT);
order.setRelabelStatus(request.needRelabel() ? "PENDING_BINDING" : "NOT_REQUIRED");
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + DRAFT + ";RELABEL:" + order.getRelabelStatus());
@@ -2007,7 +2052,7 @@ public class WarehouseTransferService {
String customerCartonMark, LocalDateTime now) {
WarehouseTransferCarton carton = new WarehouseTransferCarton();
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.setCartonIndex(cartonIndex); carton.setTotalCartons(totalCartons); carton.setStatus("EXPECTED");
carton.setCreatedAt(now); carton.setUpdatedAt(now); transferCartons.insert(carton);
@@ -2020,7 +2065,7 @@ public class WarehouseTransferService {
detail.setOrderId(orderId); detail.setSourceInboundDetailId(transferNo + ":" + index);
detail.setSkuCode(trim(source.skuCode())); detail.setProductNameZh(trim(source.productNameZh()));
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);
detail.setShipmentNo(trim(request.fbaShipmentNo()));
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_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 '入仓方式',
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 '备注',
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 '业务状态',
@@ -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)
) 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;
-- 兼容已存在的旧版转仓单表: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),
KEY idx_appointment_work_history (work_item_key,occurred_at)
) 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: 缺少仓储转运查看权限 } }
post:
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: [] }]
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}:
get:
summary: 客户查询自己的仓储转运单详情
@@ -220,13 +220,13 @@ paths:
/api/pda/v1/last-leg/pallet-operations/relabel-disassemble:
post:
summary: 按换标箱唛拆托并绑定目标托盘
description: PDA选择原入仓单库存行并扫描实际来源托盘,输入拆出箱数,核对本批cartonMarks后扫描目标托盘。传actualSource时以其数量和版本为准,同入仓余额及批次内原子调整本单冻结记账位置,再移动冻结库存和换标明细;不新增入库、不改变库存归属、不占用其他单据冻结量。每次一个来源库存行,可分批处理多个托盘。已有出库单时不允许此数量重分配。未传actualSource的箱唛模式仍要求已有来源仓位一致。普通库存整托全部换标允许目标与来源为同一托盘,校验整托数量和本单冻结归属后仅记录绑定、不搬移库存;部分换标必须使用新托盘。货物和包装不变时可继承原托盘测量,拆合托后新托盘和原托余货均须重新实测。
description: PDA选择原入仓单库存行并扫描实际来源托盘,输入拆出箱数,核对本批cartonMarks后扫描目标托盘。传actualSource时以其数量和版本为准,同入仓余额及批次内原子调整本单冻结记账位置,再移动冻结库存和换标明细;不新增入库、不改变库存归属、不占用其他单据冻结量。每次一个来源库存行,可分批处理多个托盘。已有出库单时不允许此数量重分配。未传actualSource的箱唛模式仍要求已有来源仓位一致。普通库存整托全部换标允许目标与来源为同一托盘,校验整托数量和本单冻结归属后仅记录绑定、不搬移库存;部分换标必须使用新托盘。按箱实测的仓储转运按实测箱追踪移托:来源扣除移出箱体积重量,目标增加同箱数据;同规格自动分配,混合箱规通过measuredCartons选择实际箱,改绑保留首次来源箱身份。其他整托测量来源仍按原规则补测。
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelPalletDisassemble' } } } }
responses: { '200': { description: 箱唛级拆托绑定结果 }, '409': { description: 箱唛、库存或托盘版本冲突 } }
/api/pda/v1/last-leg/pallet-operations/relabel-pallet-measurements:
post:
summary: 查询仓储转运换标涉及托盘的最终测量及库存版本
description: 权限 lastleg:pda:operate 及海外PDA角色;仅Token绑定仓。整托原托盘且没有拆合托流水、箱数未变时沿用原始测量;新托盘和拆合托后的原托盘余货需要补测。测量保存为PALLET_MEASURE作业历史,库存版本变化后失效。
description: 权限 lastleg:pda:operate 及海外PDA角色;仅Token绑定仓。按箱实测的仓储转运返回cartonAggregated=true,按当前实测箱自动汇总,不要求整托长宽高或人工确认;箱数与当前库存必须一致且每箱尺寸重量有效。其他整托测量来源沿用原始测量或补测。测量保存为PALLET_MEASURE作业历史,库存版本变化后失效。
requestBody:
required: true
content:
@@ -236,11 +236,11 @@ paths:
required: [relabelNo]
properties:
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:
post:
summary: 确认仓储转运换标托盘最终实测
description: 已认领的操作员可在处理中保存;待出库但未排车亦可补测。保存当前stockVersion及实测长宽高、毛重,服务端计算体积;相同幂等键异载荷拒绝,库存变化要求刷新。原托盘包装变化同样使用此接口重新测量。排车前核验所有相关非空托盘,包括原托盘余货。
description: 已认领的操作员可在处理中保存;待出库但未排车亦可补测。保存当前stockVersion及实测长宽高、毛重,服务端计算体积;相同幂等键异载荷拒绝,库存变化要求刷新。按箱实测托盘拒绝手动整托测量,直接使用箱数据。排车前核验所有相关非空托盘,包括原托盘余货。
requestBody:
required: true
content:
@@ -415,7 +415,7 @@ paths:
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete:
post:
summary: 提交现场任务到复核池并直接等待客户确认
description: 仓储转运换标须先确认所有涉及托盘的有效实测尺寸和重量,含拆托后原托盘剩余货物;手动完成也不能跳过实测校验。缺失或库存版本变化导致实测失效时,保持处理中并提示补测。
description: 按箱测量的仓储转运换标自动汇总各托盘剩余实测箱的体积重量,无需确认整托实测;箱数据不全或库存箱数不符时拒绝完成。其他整托来源仍校验有效实测尺寸和重量;手动完成也不能跳过校验。缺失或库存版本变化导致实测失效时,保持处理中并提示补测。
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
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: 托盘实测缺失或失效、箱数未核平、照片或版本冲突 } }
@@ -1129,6 +1129,16 @@ components:
source: { $ref: '#/components/schemas/PalletExpectation' }
target: { $ref: '#/components/schemas/PalletExpectation' }
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' }
reason: { type: string, maxLength: 500 }
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() {
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, "inventoryCommands", inventoryCommands);
ReflectionTestUtils.setField(service, "positions", positions);
@@ -212,9 +212,13 @@ class RelabelApplicationServiceTest {
private RelabelApplicationService service;
private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
@BeforeEach
void setUp() {
compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.CompactBusinessNumberService.class);
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, "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));
@@ -251,11 +255,21 @@ class RelabelApplicationServiceTest {
balance.setOverseasWarehouseId("WH-1"); balance.setInboundOrderId(301L); balance.setInboundOrderNo("IN-1");
balance.setInboundCode("CODE-1"); balance.setSkuCode("SKU-A"); balance.setProductNameZh("Chinese name");
lenient().when(inventoryBalances.selectById(any(Long.class))).thenReturn(balance);
lenient().when(inventoryBalances.cartonBelongsToInbound(anyString(), anyString(), anyLong(), anyString())).thenReturn(true);
ReflectionTestUtils.setField(service, "feeSources", feeSources);
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
void createBuildsPendingRelabelTailOrderItemsAndBothHistories() {
RelabelRequests.Create request = request("key-1", 10, 6);
@@ -286,7 +300,7 @@ class RelabelApplicationServiceTest {
assertEquals("PENDING_REVIEW", tailOrder.getValue().getCurrentStatus());
assertEquals("RELABEL:PENDING_REVIEW", tailOrder.getValue().getSubOrderStatus());
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());
assertEquals(application.getValue().getRelabelNo() + ":WH-1", tailOrder.getValue().getSourceInboundId());
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,"items",items); ReflectionTestUtils.setField(service,"histories",histories);
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.setStatus("READY_FOR_OUTBOUND");app.setFulfillmentStatus("WAIT_PRE_DISPATCH");
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());
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 {
split(); var values = service.snapshots(app);assertEquals(2,values.size());
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 source = service.createReceiptCarton(completedOrder(), receipt, null,
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");
verifyNoInteractions(numbers);
assertThat(source.getLabelStatus()).isEqualTo("CONFIRMED");
@@ -305,7 +305,7 @@ class ReturnInventoryServiceTest {
assertThat(created).hasSize(2);
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();
assertThat(created.get(0).getCartonMark()).endsWith("#2-1");
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 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);
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");
}
@@ -121,9 +121,13 @@ class ReturnReceiptServiceTest {
ReturnReceiptService service;
PdaWarehouseIdentity identity;
private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
@BeforeEach
void setUp() {
compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.CompactBusinessNumberService.class);
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, "orderItems", orderItems);
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(fbaWarehouseValidator.required("200", "XLX6", null)).thenReturn(
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(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(first, second));
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(fbaWarehouseValidator.required("200", "XLX6", null)).thenReturn(
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(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(carton));
when(inventory.matchesReturnSource(any(), eq("RT-MULTI"))).thenCallRealMethod();
@@ -349,7 +353,7 @@ class ReturnReceiptServiceTest {
void createForecastPersistsCompleteTailOrderSourceAndReturnsItsBusinessNumber() {
AtomicReference<ReturnOrder> saved = new AtomicReference<>();
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.findNo("customer-1", "RT-1")).thenAnswer(invocation -> saved.get());
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 com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory;
private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
@BeforeEach
void setUp() {
compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.CompactBusinessNumberService.class);
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);
lenient().when(customerDirectory.find("TENANT-A")).thenReturn(
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(WarehouseTransferService.DRAFT, tailOrder.getValue().getCurrentStatus());
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());
verify(sourceOrders).insert(any(WarehouseTransferSourceOrder.class));
verify(relabelApplications, never()).insert(any(RelabelApplication.class));
@@ -461,10 +465,45 @@ class WarehouseTransferServiceTest {
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
@NullSource
@ValueSource(strings = {"NONE"})
void transferWithoutDestinationCanBeCreatedAndSubmitted(String destinationType) {
void inventoryTransferRequiresKnownDestination(String destinationType) {
WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create(
"IN-1", List.of(11L), "WH-1", null, destinationType, null, null, null,
null, null, null, null, "123 Pickup Road", null, null, 5,
@@ -473,53 +512,23 @@ class WarehouseTransferServiceTest {
null, null, null, null, null, "key-no-fba",
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1")), null);
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
when(orders.selectList(any())).thenReturn(List.of(historicalOrder()));
WarehouseTransferOrder draft = transfer(WarehouseTransferService.DRAFT);
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));
var error = assertThrows(ResponseStatusException.class, () -> service.create("TENANT-A","USER-1",request));
assertEquals(400,error.getStatusCode().value());
verifyNoInteractions(transfers,orders);
}
@Test
void merchantCanClearDestinationWhenEditingDraft() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.DRAFT);
transfer.setDestinationType("FBA");
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());
void inventoryDraftCannotClearDestination() {
var transfer = transfer(WarehouseTransferService.DRAFT);
when(transfers.lockNo("TENANT-A","WTR-1")).thenReturn(transfer);
WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create(
"IN-1", List.of(11L), "WH-1", null, "NONE", null, null, null,
null, null, null, null, "123 Pickup Road", null, null, 5,
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",
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1")), null);
var result = service.updateDraft("TENANT-A", "WTR-1", "USER-1", request);
assertEquals(WarehouseTransferService.DRAFT, result.status());
assertNull(transfer.getDestinationType());
assertNull(transfer.getDestinationWarehouseCode());
assertNull(transfer.getFbaWarehouseCode());
assertNull(order.getFbaWarehouseCode());
verify(transfers).updateById(transfer);
assertThrows(ResponseStatusException.class, () -> service.updateDraft("TENANT-A","WTR-1","USER-1",request));
verify(transfers, never()).updateById(any(WarehouseTransferOrder.class));
}
@Test
@@ -544,7 +553,8 @@ class WarehouseTransferServiceTest {
assertEquals("ONT8", transfer.getValue().getFbaWarehouseCode());
assertNull(transfer.getValue().getFbaShipmentNo());
assertEquals("ONT8", tailOrder.getValue().getFbaWarehouseCode());
assertEquals("WAREHOUSE_PICKUP", transfer.getValue().getDeliveryMethod());
assertEquals("INVENTORY_TRANSFER", transfer.getValue().getDeliveryMethod());
assertNull(transfer.getValue().getEstimatedArrivalAt());
}
@Test
@@ -620,7 +630,7 @@ class WarehouseTransferServiceTest {
assertEquals(List.of(1, 2, 3, 4, 5), cartons.getAllValues().stream()
.map(WarehouseTransferCarton::getCartonIndex).toList());
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());
clearInvocations(customerDirectory, orders, inboundOrders);
service.create("TENANT-A", "USER-1", request);
+5
View File
@@ -1,5 +1,10 @@
# 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`,不得增加子域扫描配置。
+5
View File
@@ -1,5 +1,10 @@
# Platform BOM 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- 本模块只通过 `pom.xml` 的 `dependencyManagement` 发布 `baiyue-platform-bom`,统一管理本仓公共制品版本。
+5
View File
@@ -1,5 +1,10 @@
# Print Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]` 或 `documents[]`,模块在内存中生成一次性多页 PDF 并同步返回。
@@ -17,7 +17,7 @@ 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([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed)
const body = sql.replace(/^--.*$/gm, '')
+5
View File
@@ -1,5 +1,10 @@
# 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`,不得增加子域扫描配置。
@@ -18,8 +18,6 @@ import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.Resource;
import java.util.Set;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import org.springframework.util.StringUtils;
@Service
@@ -448,11 +446,7 @@ public class WarehouseResourceService {
}
private static String generatedPalletCode(String warehouseCode, long palletId) {
String compactWarehouseCode = warehouseCode.replaceAll("[^A-Za-z0-9]", "").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);
return "TP-" + Long.toUnsignedString(palletId, 36).toUpperCase(java.util.Locale.ROOT);
}
private static String defaultLocationCode(String warehouseCode) {
@@ -262,7 +262,7 @@ class WarehouseResourceServiceTest {
ArgumentCaptor<WarehousePalletEntity> captor = ArgumentCaptor.forClass(WarehousePalletEntity.class);
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().getLocationId()).isEqualTo(30003L);
assertThat(captor.getValue().getId()).isNotNull();
+5
View File
@@ -1,5 +1,10 @@
# 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`,不得增加子域扫描配置。