diff --git a/AGENTS.md b/AGENTS.md index 3fddbd721..b28db77af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。 diff --git a/audit-service/AGENTS.md b/audit-service/AGENTS.md index 781ddd2ab..d266c053d 100644 --- a/audit-service/AGENTS.md +++ b/audit-service/AGENTS.md @@ -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`,不得增加子域扫描配置。 diff --git a/auth/AGENTS.md b/auth/AGENTS.md index 5decc247f..f6ff8ce7e 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -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`,不得增加子域扫描配置。 diff --git a/common-audit/AGENTS.md b/common-audit/AGENTS.md index e34c18bea..6298f658b 100644 --- a/common-audit/AGENTS.md +++ b/common-audit/AGENTS.md @@ -1,5 +1,10 @@ # Common Audit 模块指引 +## 数据库访问与中文注释约束 + +- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 +- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 + ## 职责边界 - 负责业务审计事件采集、注解切面、敏感字段脱敏以及日志/HTTP 输出。 diff --git a/common-core/AGENTS.md b/common-core/AGENTS.md index e725c4e21..6bb66fff6 100644 --- a/common-core/AGENTS.md +++ b/common-core/AGENTS.md @@ -1,5 +1,10 @@ # Common Core 模块指引 +## 数据库访问与中文注释约束 + +- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 +- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 + ## 职责边界 - 提供不依赖具体业务域的返回模型、异常、基础实体和 MyBatis-Plus 通用配置。 diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index ab5e442cc..46745428d 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -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 之间稳定、可版本化的业务事件契约。 diff --git a/common-openapi/AGENTS.md b/common-openapi/AGENTS.md index 41c12e6e7..fdffb1d7f 100644 --- a/common-openapi/AGENTS.md +++ b/common-openapi/AGENTS.md @@ -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 自动配置和统一文档元数据。 diff --git a/common-security/AGENTS.md b/common-security/AGENTS.md index cf49042bd..e9bd2a2da 100644 --- a/common-security/AGENTS.md +++ b/common-security/AGENTS.md @@ -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 响应。 diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index 3820a631b..16a878e89 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -1,5 +1,10 @@ # Common Web 模块指引 +## 数据库访问与中文注释约束 + +- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 +- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 + ## 职责边界 - 提供 Servlet Web 通用能力:请求链路标识、上下文和统一异常响应。 diff --git a/deploy/sql/20261016-数据库变更汇总.md b/deploy/sql/20261016-数据库变更汇总.md new file mode 100644 index 000000000..eb2172ede --- /dev/null +++ b/deploy/sql/20261016-数据库变更汇总.md @@ -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` 而中止,此项未通过;未为消除检查错误而创建替代业务文档。 diff --git a/deploy/sql/README.md b/deploy/sql/README.md index b128087a9..10ac3e7aa 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -45,7 +45,8 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving | 区域 | 完整入口 | 服务 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` | 213 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 214 | + - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 @@ -67,7 +68,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/213`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/214`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 @@ -161,3 +162,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。该增量可重复执行,尚未执行到任何实际数据库。 diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql new file mode 100644 index 000000000..54d74c3c1 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql @@ -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; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 9226b1b05..989491238 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -850,7 +850,7 @@ CREATE TABLE ll_finance_fee_source ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance'; -- V13 receiving coverage and pending changes; baseline only, no test data migration. -CREATE TABLE IF NOT EXISTS ll_fba_receiving_agreement ( +CREATE TABLE ll_fba_receiving_agreement ( agreement_key VARCHAR(64) NOT NULL COMMENT '收货预约独立业务键', version INT NOT NULL COMMENT '预约覆盖范围与确认资料版本', status VARCHAR(24) NOT NULL COMMENT '有效、取消或已替代状态', @@ -861,7 +861,7 @@ CREATE TABLE IF NOT EXISTS ll_fba_receiving_agreement ( UNIQUE KEY uk_fba_receiving_confirmation (destination_key, confirmation_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='独立于送货批次的收货预约'; -CREATE TABLE IF NOT EXISTS ll_fba_receiving_member ( +CREATE TABLE ll_fba_receiving_member ( agreement_key VARCHAR(64) NOT NULL COMMENT '所属收货预约业务键', batch_key VARCHAR(128) NOT NULL COMMENT '稳定送货批次键', customer_id VARCHAR(128) NOT NULL COMMENT '货物所属客户标识', @@ -871,7 +871,7 @@ CREATE TABLE IF NOT EXISTS ll_fba_receiving_member ( KEY idx_fba_receiving_batch (batch_key,customer_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收货预约当前覆盖关联及退出历史'; -CREATE TABLE IF NOT EXISTS ll_fba_receiving_change ( +CREATE TABLE ll_fba_receiving_change ( change_key VARCHAR(64) NOT NULL COMMENT '客户预约变化业务键', version INT NOT NULL COMMENT '变化续办版本', status VARCHAR(24) NOT NULL COMMENT '待仓库确认、已应用或已撤回状态', @@ -885,14 +885,14 @@ CREATE TABLE IF NOT EXISTS ll_fba_receiving_change ( KEY idx_fba_receiving_pending_batch (batch_key,status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='保留原安排且阻止误发车的客户变化'; -CREATE TABLE IF NOT EXISTS ll_fba_receiving_revision ( +CREATE TABLE ll_fba_receiving_revision ( agreement_key VARCHAR(64) NOT NULL COMMENT '收货预约业务键', version INT NOT NULL COMMENT '不可变覆盖与确认版本', snapshot_json LONGTEXT NOT NULL COMMENT '该版本完整客户覆盖、确认时段及回复依据', PRIMARY KEY (agreement_key,version) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收货预约覆盖与确认不可变历史'; -CREATE TABLE IF NOT EXISTS ll_fba_receiving_plan ( +CREATE TABLE ll_fba_receiving_plan ( batch_key VARCHAR(128) NOT NULL COMMENT '稳定送货批次键', customer_id VARCHAR(128) NOT NULL COMMENT '计划所属客户标识', snapshot_json LONGTEXT NOT NULL COMMENT '本客户未确认计划、目标窗口及业务时区,不含有效预约确认', @@ -3348,7 +3348,7 @@ CREATE TABLE ll_warehouse_transfer_order ( pickup_contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '提货联系电话', package_count int NOT NULL COMMENT '箱数', delivery_method varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓方式', - estimated_arrival_at datetime(3) NOT NULL COMMENT '预计入仓时间', + estimated_arrival_at datetime(3) NULL COMMENT '预计入仓时间,库存转运不填写', estimated_ship_at datetime(3) DEFAULT NULL COMMENT '预计发货时间', fba_shipment_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '可选FBA货件编号', tracking_no varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '物流跟踪号', @@ -4614,4 +4614,12 @@ 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, 213 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, 214 AS initialized_tables; + diff --git a/file/AGENTS.md b/file/AGENTS.md index 33f23e85c..cdfc798e0 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -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`,不得增加子域扫描配置。 diff --git a/finance/AGENTS.md b/finance/AGENTS.md index c59e2fdb1..8f055025b 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -1,5 +1,10 @@ # Finance Service Instructions +## 数据库访问与中文注释约束 + +- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 +- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 + ## 模块定位 - 本模块是独立财务服务,拥有客户结算条款、财务费用来源、CNY 账单与明细、客户资金账户及充值申请、应收款月度统计、客户对账确认、收款及分配、成本及分摊、账单修订、单据编号、幂等记录和财务审计状态。 diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 02754314e..fdbf7eb7c 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -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`。 diff --git a/gateway/AGENTS.md b/gateway/AGENTS.md index fc63cac7f..116d80555 100644 --- a/gateway/AGENTS.md +++ b/gateway/AGENTS.md @@ -1,5 +1,10 @@ # Gateway 模块指引 +## 数据库访问与中文注释约束 + +- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 +- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 + ## 职责与边界 - 负责路由、CORS、JWT/Redis 会话校验、平台/客户访问域隔离、请求标识和 Swagger 聚合。 diff --git a/iam/AGENTS.md b/iam/AGENTS.md index d72f2325f..d59414bd2 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -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`,不得增加子域扫描配置。 diff --git a/integration-service/AGENTS.md b/integration-service/AGENTS.md index a43a01575..34361a2c5 100644 --- a/integration-service/AGENTS.md +++ b/integration-service/AGENTS.md @@ -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、海外仓业务船司/船舶轨迹查询等第三方适配和能力开关;不保存业务主数据。 diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 6d6d78c30..6e6fbc6e6 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -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`。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java index 6f29a1004..8026eecc2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java @@ -14,6 +14,26 @@ import java.util.List; @Mapper public interface InventoryBalanceMapper extends BaseMapper { + @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} " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java index fd0d1e492..f53145fdd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java @@ -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 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 cartonMarks, String reason) { - this(idempotencyKey, relabelNo, source, target, cartonMarks, reason, null); + this(idempotencyKey, relabelNo, source, target, cartonMarks, reason, null, null); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java index 6c2f9a61d..94419194d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java @@ -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 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> itemsByPosition, - Map anchors) { } + Map anchors, + List measuredCartons) { + RelabelMutation(RelabelApplication application, Map> itemsByPosition, Map anchors) { + this(application,itemsByPosition,anchors,null); + } + } private enum InventoryBucket { AVAILABLE, HOLD } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelPalletMeasurementMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelPalletMeasurementMapper.java index ccd4f5e71..f2af956b3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelPalletMeasurementMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelPalletMeasurementMapper.java @@ -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 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 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," + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index cd13fa70a..17c6f9fcf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -80,6 +80,7 @@ public class RelabelApplicationService implements WalletResultHandler { READY_FOR_OUTBOUND, "COMPLETED", "REJECTED", "CANCELLED"); private static final Set 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 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); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelCartonMeasurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelCartonMeasurementService.java new file mode 100644 index 000000000..1096bb813 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelCartonMeasurementService.java @@ -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 current(RelabelApplication app) { + Long transfer = mapper.transfer(app.getCustomerId(), app.getRelabelNo()); + if (transfer == null) return List.of(); + var boxes = new LinkedHashMap(); + 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 prepare(RelabelApplication app, List items, Long sourcePallet, + Long targetPallet, String targetCode, List 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(); + // 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(); + 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.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(); + 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 records(RelabelApplication app) { + return histories.selectList(new QueryWrapper().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); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelPalletMeasurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelPalletMeasurementService.java index 1708beff3..4646fa63e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelPalletMeasurementService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelPalletMeasurementService.java @@ -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 sourceCartonSpecs, + boolean cartonAggregated, List 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 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(); originals.forEach(p -> involved.add(p.palletId())); var positions = items.selectList(new QueryWrapper() .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(); 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(); 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,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 histories(RelabelApplication app) { return histories.selectList(new QueryWrapper().eq("customer_id", app.getCustomerId()) .eq("application_id", app.getId()).orderByAsc("created_at","id")); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java index 1af1d5427..bbd80fb2b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java @@ -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, Long> totals = groups.stream().collect(java.util.stream.Collectors.groupingBy(group -> group, LinkedHashMap::new, java.util.stream.Collectors.counting())); Map, String> prefixes = new HashMap<>(); Map, 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") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java index 97edbaef4..4539015bd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java @@ -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) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/CompactBusinessNumberService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/CompactBusinessNumberService.java new file mode 100644 index 000000000..ed45087b1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/CompactBusinessNumberService.java @@ -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(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java index 9c6f8cb0f..f8ebba53f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java @@ -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) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolver.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolver.java index 919f9f544..5f049ce83 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolver.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolver.java @@ -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)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java index 56c1bb480..de3ef32c6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java @@ -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> 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 create(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java index ebdbcf71d..eb97fc7e1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java @@ -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 sourceLastlegOrderIds, String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress, int packageCount, BigDecimal volumeCbm, String deliveryMethod, LocalDateTime estimatedArrivalAt, String fbaShipmentNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java index ca60a8991..356c8d4ac 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java @@ -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 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, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java index 59e14637c..01a2cfa45 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java @@ -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 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") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferReturnSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferReturnSourceRepository.java new file mode 100644 index 000000000..7a7be18df --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferReturnSourceRepository.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.transfer.repository; + +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** 封装仓储转运所需的退货来源查询,保留客户和操作仓隔离。 */ +@Repository +public class TransferReturnSourceRepository { + @Resource private ReturnOrderMapper orders; + @Resource private ReturnInventoryCartonMapper cartons; + @Resource private ReturnInventoryCartonItemMapper items; + + /** 按当前客户和退货单号查询来源单据。 */ + public ReturnOrder findOrder(String customerId, String returnNo) { + return orders.findNo(customerId, returnNo); + } + + /** 查询当前仓内已完成清点且可用的退货箱,不影响其他页面的历史库存查询。 */ + public List availableCartons(String customerId, String warehouseId, String returnNo) { + return cartons.currentReturnCartons(customerId, warehouseId, returnNo).stream() + .filter(carton -> "AVAILABLE".equals(carton.getStatus()) && "CONFIRMED".equals(carton.getCountStatus()) + && carton.getRelabelApplicationId() == null) + .toList(); + } + + /** 批量读取已通过客户及仓库隔离校验的来源箱明细。 */ + public List cartonItems(List cartonIds) { + return cartonIds.isEmpty() ? List.of() : items.listCartons(cartonIds); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index 0b6df860c..19431bedb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -59,6 +59,10 @@ import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletItemMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferRelabelBindingMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferMeasurementPhotoMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder; +import com.baiyue.lastleg.transfer.repository.TransferReturnSourceRepository; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; @@ -88,6 +92,9 @@ import java.util.stream.Collectors; @Service public class WarehouseTransferService { + /** 退货单在转运来源中的展示编码,避免把退货单号误当成普通入仓码查询。 */ + public static final String RETURN_ORDER_PREFIX = "RETURN_ORDER:"; + @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; @@ -139,15 +146,59 @@ public class WarehouseTransferService { @Resource private InventoryCommandService inventory; @Resource private InventoryCommandMapper inventoryCommands; @Resource private ObjectMapper objectMapper; + @Resource private TransferReturnSourceRepository returnSources; + /** 查询来源单据当前可用的箱唛及箱内 SKU,兼容普通入仓与退货库存。 */ + public List sourceCartons(String customerId, String warehouseId, String inboundCode) { + if (!StringUtils.hasText(warehouseId) || !StringUtils.hasText(inboundCode)) throw badRequest("请选择海外仓和库存来源单据"); + String source = inboundCode.trim(); + if (isReturnOrderSource(source)) { + String returnNo = returnNoFromSource(source); + ReturnOrder order = returnSources.findOrder(customerId, returnNo); + if (order == null || !warehouseId.trim().equals(order.getOverseasWarehouseId())) { + throw notFound("退货单不存在或不属于当前操作仓"); + } + List cartons = returnSources.availableCartons(customerId, warehouseId.trim(), returnNo); + List cartonIds = cartons.stream().map(ReturnInventoryCarton::getId).filter(Objects::nonNull).toList(); + Map> items = cartonIds.isEmpty() ? Map.of() + : returnSources.cartonItems(cartonIds).stream().collect(Collectors.groupingBy(ReturnInventoryCartonItem::getCartonId)); + return cartons.stream().map(carton -> { + List rows = items.getOrDefault(carton.getId(), List.of()); + List contents = rows.stream() + .filter(item -> item.getQuantity() != null && item.getQuantity() > 0) + .map(item -> new WarehouseTransferResponses.SourceCartonSku(item.getSkuCode(), item.getQuantity())).toList(); + return new WarehouseTransferResponses.SourceCarton(carton.getCartonMark(), null, carton.getTotalQuantity(), + carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), contents); + }).toList(); + } + return inboundSnapshots.sourceCartons(customerId, warehouseId.trim(), source).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 +218,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 selectedIds = request.sourceLastlegOrderIds() == null ? List.of() : request.sourceLastlegOrderIds().stream().distinct().toList(); @@ -181,18 +232,35 @@ public class WarehouseTransferService { List selectedOrders; if (existingInventoryTransfer) { inboundCode = requestedInboundCode; - inboundOrderId = inboundSnapshots.findId(customerId, inboundCode); - if (inboundOrderId == null) throw notFound("Inbound order was not found for inboundCode " + inboundCode); + if (isReturnOrderSource(inboundCode)) { + ReturnOrder returnOrder = returnSources.findOrder(customerId, returnNoFromSource(inboundCode)); + if (returnOrder == null || !request.operatingWarehouseId().trim().equals(returnOrder.getOverseasWarehouseId()) + || returnOrder.getLastlegOrderId() == null) { + throw notFound("退货单不存在、操作仓不匹配或未关联尾程订单"); + } + LastlegOrder returnOrderTail = orders.selectById(returnOrder.getLastlegOrderId()); + if (returnOrderTail == null || !customerId.equals(returnOrderTail.getCustomerId())) { + throw notFound("退货单关联的尾程订单不存在"); + } + inboundOrderId = StringUtils.hasText(returnOrderTail.getSourceInboundId()) + ? inboundSnapshots.findBySourceInboundId(customerId, returnOrderTail.getSourceInboundId()) + : inboundSnapshots.findId(customerId, returnOrderTail.getInboundCode()); + if (inboundOrderId == null) throw notFound("退货单关联的入仓快照不存在"); + selectedOrders = List.of(returnOrderTail); + } else { + inboundOrderId = inboundSnapshots.findId(customerId, inboundCode); + if (inboundOrderId == null) throw notFound("Inbound order was not found for inboundCode " + inboundCode); + selectedOrders = selectedIds.isEmpty() + ? orders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId) + .eq(LastlegOrder::getInboundCode, inboundCode)) + : orders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, selectedIds)); + } // The merchant form may only submit the inventory inbound code. In that // case derive all eligible tail orders for the inbound instead of // rejecting a valid inventory transfer because source IDs were omitted. - selectedOrders = selectedIds.isEmpty() - ? orders.selectList(new LambdaQueryWrapper() - .eq(LastlegOrder::getCustomerId, customerId) - .eq(LastlegOrder::getInboundCode, inboundCode)) - : orders.selectList(new LambdaQueryWrapper() - .eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, selectedIds)); - if (selectedOrders.isEmpty() || (!selectedIds.isEmpty() && selectedOrders.size() != selectedIds.size())) { + if (selectedOrders.isEmpty() || (!selectedIds.isEmpty() && !isReturnOrderSource(inboundCode) && selectedOrders.size() != selectedIds.size())) { throw notFound("One or more selected customer orders do not exist in the current tenant"); } } else { @@ -210,13 +278,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 +346,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 +362,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 +388,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())); @@ -370,12 +439,26 @@ public class WarehouseTransferService { } @Transactional + /** 提交草稿前重新校验来源箱归属,防止草稿保存后库存已发生变化。 */ public WarehouseTransferResponses.Detail submit(String customerId, String transferNo, String operatorId) { WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); if (transfer == null) throw notFound("Warehouse transfer order not found"); 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 allowedMarks = !transfer.getTransferNo().equals(transfer.getInboundCode()) + ? sourceCartons(customerId, transfer.getOperatingWarehouseId(), transfer.getInboundCode()) + .stream().map(WarehouseTransferResponses.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 +942,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 +1795,8 @@ 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 +1804,14 @@ public class WarehouseTransferService { return; } if (request.relabelItems() == null || request.relabelItems().isEmpty()) return; + Set orderCartonMarks = StringUtils.hasText(request.inboundCode()) + ? sourceCartons(customerId, request.operatingWarehouseId().trim(), request.inboundCode().trim()) + .stream().map(WarehouseTransferResponses.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 +1846,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 +2062,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 +2072,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 +2100,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 +2113,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); @@ -2058,6 +2151,10 @@ public class WarehouseTransferService { return auth == null ? null : auth.getName(); } private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + /** 根据独立前缀识别退货来源,避免与普通入仓码混淆。 */ + private static boolean isReturnOrderSource(String value) { return StringUtils.hasText(value) && value.trim().startsWith(RETURN_ORDER_PREFIX); } + /** 从转运来源编码提取真实退货单号。 */ + private static String returnNoFromSource(String value) { return value.trim().substring(RETURN_ORDER_PREFIX.length()).trim(); } private static String firstText(String first, String second) { return StringUtils.hasText(first) ? first.trim() : trim(second); } private static long packages(Long value) { return value == null ? 0L : value; } private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 3ffe625ab..9685b16a9 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -2186,7 +2186,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 '业务状态', @@ -2207,7 +2207,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 不会补充新增字段。 @@ -4277,3 +4277,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; diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index abd10994c..cf682fd24 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -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: 客户查询自己的仓储转运单详情 diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index 8370848bd..425cb982d 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -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: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/mapper/InventoryCartonOwnershipTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/mapper/InventoryCartonOwnershipTest.java new file mode 100644 index 000000000..ae7a6470c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/mapper/InventoryCartonOwnershipTest.java @@ -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)); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java index ed36eedf3..9fbfc5254 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java @@ -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); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index 2776687fd..16bba7407 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -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()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelCartonMeasurementServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelCartonMeasurementServiceTest.java new file mode 100644 index 000000000..82e6988a2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelCartonMeasurementServiceTest.java @@ -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 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 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)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelPalletMeasurementMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelPalletMeasurementMapperTest.java new file mode 100644 index 000000000..2fe9e0711 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelPalletMeasurementMapperTest.java @@ -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"); + }); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelPalletMeasurementServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelPalletMeasurementServiceTest.java index 9d308c27e..d23417006 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelPalletMeasurementServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelPalletMeasurementServiceTest.java @@ -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)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java index 79e1b1b6c..811b5fe36 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java @@ -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"); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java index 55c7a3b05..d67a66ccc 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java @@ -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 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()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/CompactBusinessNumberServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/CompactBusinessNumberServiceTest.java new file mode 100644 index 000000000..07ff1c047 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/CompactBusinessNumberServiceTest.java @@ -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)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferInventorySourceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferInventorySourceTest.java new file mode 100644 index 000000000..8bc35b8ca --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferInventorySourceTest.java @@ -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()); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferReturnSourceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferReturnSourceTest.java new file mode 100644 index 000000000..f7532f5ff --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferReturnSourceTest.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.transfer.service; + +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper; +import com.baiyue.lastleg.transfer.repository.TransferReturnSourceRepository; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class TransferReturnSourceTest { + /** 来源查询应排除占用和未清点箱,并保留整箱内的全部 SKU。 */ + @Test + void returnsOnlyAvailableConfirmedCartonsWithContents() { + var orders = mock(ReturnOrderMapper.class); + var cartons = mock(ReturnInventoryCartonMapper.class); + var items = mock(ReturnInventoryCartonItemMapper.class); + var repository = new TransferReturnSourceRepository(); + ReflectionTestUtils.setField(repository, "orders", orders); + ReflectionTestUtils.setField(repository, "cartons", cartons); + ReflectionTestUtils.setField(repository, "items", items); + var service = new WarehouseTransferService(); + ReflectionTestUtils.setField(service, "returnSources", repository); + var order = new ReturnOrder(); + order.setOverseasWarehouseId("WH-1"); + when(orders.findNo("C-1", "RET-1")).thenReturn(order); + var available = carton(1L, "AVAILABLE", "CONFIRMED"); + when(cartons.currentReturnCartons("C-1", "WH-1", "RET-1")).thenReturn(List.of( + available, carton(2L, "OUTBOUND_PENDING", "CONFIRMED"), carton(3L, "AVAILABLE", "PENDING"))); + var sku = new ReturnInventoryCartonItem(); + sku.setCartonId(1L); sku.setSkuCode("SKU-1"); sku.setQuantity(3L); + when(items.listCartons(List.of(1L))).thenReturn(List.of(sku)); + + var result = service.sourceCartons("C-1", "WH-1", "RETURN_ORDER:RET-1"); + + assertEquals(1, result.size()); + assertEquals("BOX-1", result.get(0).cartonMark()); + assertEquals("SKU-1", result.get(0).items().get(0).skuCode()); + assertEquals(3L, result.get(0).items().get(0).quantity()); + assertThrows(ResponseStatusException.class, + () -> service.sourceCartons("C-1", "OTHER-WH", "RETURN_ORDER:RET-1")); + verify(cartons, never()).currentReturnCartons("C-1", "OTHER-WH", "RET-1"); + } + + /** 构造不同库存状态的退货箱以验证来源筛选边界。 */ + private static ReturnInventoryCarton carton(long id, String status, String countStatus) { + var carton = new ReturnInventoryCarton(); + carton.setId(id); carton.setCartonMark("BOX-" + id); carton.setTotalQuantity(3L); + carton.setStatus(status); carton.setCountStatus(countStatus); + return carton; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index fe604c7ab..916557722 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -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,47 @@ 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.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.setOperatingWarehouseId("WH-1"); + transfer.setInboundCode("IN-1"); + 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 +514,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 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 +555,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 +632,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); diff --git a/order/AGENTS.md b/order/AGENTS.md index 23ad5d926..490fde935 100644 --- a/order/AGENTS.md +++ b/order/AGENTS.md @@ -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`,不得增加子域扫描配置。 diff --git a/platform-bom/AGENTS.md b/platform-bom/AGENTS.md index 459558e7a..4b20ebd94 100644 --- a/platform-bom/AGENTS.md +++ b/platform-bom/AGENTS.md @@ -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`,统一管理本仓公共制品版本。 diff --git a/print/AGENTS.md b/print/AGENTS.md index 3e9af5ab7..912e3852f 100644 --- a/print/AGENTS.md +++ b/print/AGENTS.md @@ -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 并同步返回。 diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 201bad69f..d03ec8a05 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -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, 214]]) { 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, '') diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md index 5b5ae5124..a85755f74 100644 --- a/warehouse-overseas-service/AGENTS.md +++ b/warehouse-overseas-service/AGENTS.md @@ -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`,不得增加子域扫描配置。 diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java index 024636e81..f79428c25 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java @@ -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) { diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java index fa6f46a7b..64a7fe197 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java @@ -262,7 +262,7 @@ class WarehouseResourceServiceTest { ArgumentCaptor 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(); diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md index 09585a20f..51f8a6b40 100644 --- a/warehouse/AGENTS.md +++ b/warehouse/AGENTS.md @@ -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`,不得增加子域扫描配置。