diff --git a/AGENTS.md b/AGENTS.md index 35f4819ff..b28db77af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,8 @@ | 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | | 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | | Lastleg 内部模块化与依赖基线 | `lastleg-service/` | +| 自营领星采集、经营统计、SKU 日销量 | `lastleg-service/selfoperated/` 查询与采集编排;领星签名/分页/外部接口查 `integration-service/`,不因采集页面任务同时读取两服务 | +| 跨客户同仓预约、预约跟进、到港日历 | `lastleg-service/inboundplan/`、`appointmentplanning/`;到港事实查 `firstleg/portschedule/` | | 车辆、司机证照、车次、外接预订、车队报价、出车记录 | `lastleg-service/fleet/`;先读 `lastleg-service/AGENTS.md` 对应入口,客户排车关联再沿 `dispatch/fleet/` 实际调用 | | PDF 标签、审计、业务事件直投 | `print/`、`audit-service/`、`common-event-contract/` | @@ -104,6 +106,19 @@ - 装柜、船节点、卸柜、暂扣、派送、费用与资金按统一通信契约的逐事件表直接投递。保留本地 Outbox、消费幂等、重试与失败处置,不携带用户 JWT;旧 Bridge 源码已退出聚合和发布清单,退役待清理;实际停机仍须替代链验证与授权,不得据源码状态认定已部署。 - `lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。共用测试 MySQL 实例不改变服务数据归属。准确路由、ACK、撤销矩阵和切换门槛只维护在新的两份主契约中。 +## 数据库访问与中文代码注释(所有服务及公共模块强制) + +- 本节适用于全部后端服务和公共模块;新增、修改的方法必须执行,触及的存量代码同步整改。登记本约束不代表存量代码已全部完成迁移和注释补齐。 +- 生产代码的关系型数据库读写统一经 MyBatis/MyBatis-Plus Mapper 执行;Controller、Service、定时任务、消费者、编号生成器和公共基础组件不得直接使用 `JdbcTemplate`、`NamedParameterJdbcTemplate`、原生 JDBC/Connection/Statement 或其他通道执行 SQL。仅改成 Repository 包装 JDBC 不算整改,SQL 必须进入 Mapper;无数据库职责的模块不得为满足本条新增数据库访问。 +- 正常业务数据库访问路径统一为 `Controller → Service → Repository → Mapper`,四层职责明确,不得省略 Repository,也不得由 Controller 直接调用 Repository/Mapper,或由 Service 直接调用 Mapper。定时任务、消费者等非 HTTP 入口从 Service 进入,同样经 Repository → Mapper 完成持久化访问。 +- Controller 负责请求接收、参数校验、入口权限和响应转换;Service 负责业务规则、编排及业务事务;Repository 负责领域数据访问封装、查询组合与持久化模型转换,对 Service 提供有业务含义的数据操作;Mapper 负责 SQL 执行及数据库记录映射。Repository 不承载业务状态流转,不直接用 JDBC 执行 SQL;Service 不继承 MyBatis-Plus `ServiceImpl` 等方式绕过 Repository。 +- 复杂查询、批量处理、原子计数、锁定读取、Outbox 和数据库监控查询同样经 Repository → Mapper,不以 SQL 特殊或实现方便为由跳层。公共模块需要数据库能力时,按现有模块边界定义 Repository/持久化接口,由所属服务的 Repository → Mapper 实现,禁止借此跨服务读库。 +- 迁移数据库操作须保留客户隔离条件、事务传播、行锁、幂等和并发语义;`LAST_INSERT_ID()` 等连接级操作必须在同一事务连接完成,并明确关闭相关读取缓存,不能仅机械搬移 SQL。Mapper 注册及扫描仍遵守下文统一规范。 +- 测试代码可用 `JdbcTemplate` 创建隔离测试表、准备数据及核对结果;被测生产路径仍必须调用实际 Mapper,不得把测试造数做法带入生产。数据库基线和迁移脚本按现有部署契约管理。 +- 每个手写方法及构造器均须添加简短中文注释,通常 1—3 行说明业务用途;接口方法和 Mapper 方法优先使用中文 Javadoc。覆盖方法同样须有简短中文说明,实现中的关键规则仍须就地注释;自动生成的方法不手工逐个补注释。 +- 核心业务、复杂 SQL、状态流转、资金与库存变更、权限隔离、事务、锁、幂等、并发、重试和补偿逻辑,须在关键代码前增加适量中文注释,解释业务规则、处理原因、约束边界及失败影响;不逐行翻译代码,不添加无信息量或与实现不符的注释。 +- 新增和修改的代码说明注释统一使用中文;必要的技术标识、协议名、类名可保留原文,第三方许可证和生成标记保持原样。修改方法时同步更新相关存量英文或过期说明;审查时同时核对 Controller → Service → Repository → Mapper 分层及中文方法、核心逻辑注释。 + ## 契约、数据库与验证入口 - 对外 Controller/API 新增、改名或删除时,同时核对调用端、统一 Gateway、OpenAPI 和权限;提交前运行 `node --test scripts/gateway-route-contract.test.mjs`,自动发现生产 Controller 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。 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/config/nacos/test/file-service.yml b/config/nacos/test/file-service.yml index 48155dcdb..740fdf618 100644 --- a/config/nacos/test/file-service.yml +++ b/config/nacos/test/file-service.yml @@ -141,6 +141,15 @@ baiyue: max-rows: 200000 max-attempts: 3 batch-size: 200 + SELFOP_INVENTORY_TURNOVER: + provider-service: lastleg-service + provider-path: /internal/exports/v1/SELFOP_INVENTORY_TURNOVER/batches + required-permission: platform:self-operated-analytics:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 100 OVERSEAS_INVENTORY_LEDGER: provider-service: lastleg-service provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_LEDGER/batches diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 56f4febb3..a32c3906f 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -218,6 +218,12 @@ services: environment: <<: *common-environment SERVER_PORT: 9210 + LINGXING_WMS_ENABLED: ${LINGXING_WMS_ENABLED:-false} + LINGXING_COLLECTION_MODE: ${SELFOP_COLLECTION_MODE:-OFF} + LINGXING_ENVIRONMENT_LABEL: ${DEPLOY_ENV:-test} + LINGXING_SOURCE_ACCOUNT: ${SELFOP_SOURCE_ACCOUNT:-lingxing-primary} + LINGXING_OMS_APP_KEY: ${LINGXING_OMS_APP_KEY:-} + LINGXING_OMS_APP_SECRET: ${LINGXING_OMS_APP_SECRET:-} BAIDU_OCR_ENABLED: ${BAIDU_OCR_ENABLED:-false} BAIDU_OCR_API_KEY: ${BAIDU_OCR_API_KEY:-} BAIDU_OCR_SECRET_KEY: ${BAIDU_OCR_SECRET_KEY:-} @@ -282,6 +288,12 @@ services: environment: <<: *common-environment SERVER_PORT: 9303 + SELFOP_EXECUTION_ENABLED: ${SELFOP_EXECUTION_ENABLED:-false} + SELFOP_COLLECTION_MODE: ${SELFOP_COLLECTION_MODE:-OFF} + SELFOP_SOURCE_ACCOUNT: ${SELFOP_SOURCE_ACCOUNT:-lingxing-primary} + SELFOP_DAILY_REQUEST_BUDGET: ${SELFOP_DAILY_REQUEST_BUDGET:-0} + SELFOP_MIN_REQUEST_INTERVAL_MS: ${SELFOP_MIN_REQUEST_INTERVAL_MS:-5000} + SELFOP_SALES_PUBLICATION_ENABLED: ${SELFOP_SALES_PUBLICATION_ENABLED:-false} EVENT_CONNECT_TIMEOUT_MS: ${EVENT_CONNECT_TIMEOUT_MS:-3000} EVENT_READ_TIMEOUT_MS: ${EVENT_READ_TIMEOUT_MS:-15000} EVENT_LEASE_SECONDS: ${EVENT_LEASE_SECONDS:-60} diff --git a/deploy/env/.env.test.example b/deploy/env/.env.test.example index 13dcc01d5..4af28c729 100644 --- a/deploy/env/.env.test.example +++ b/deploy/env/.env.test.example @@ -21,6 +21,16 @@ IMAGE_TAG= FILE_UPLOADS_HOST_DIR=/data/firstleg/uploads CORS_ALLOWED_ORIGINS= INTERNAL_SERVICE_TOKEN= +# 领星唯一账号;凭据仅注入 integration-service。 +LINGXING_OMS_APP_KEY= +LINGXING_OMS_APP_SECRET= +LINGXING_WMS_ENABLED=false +SELFOP_COLLECTION_MODE=OFF +SELFOP_SOURCE_ACCOUNT=lingxing-primary +# 未核准额度使用0,真实出口拒绝请求;有效模式仍需显式的独占交接证明与截止时间。 +SELFOP_DAILY_REQUEST_BUDGET=0 +SELFOP_MIN_REQUEST_INTERVAL_MS=5000 +SELFOP_SALES_PUBLICATION_ENABLED=false REDIS_PASSWORD= AUTH_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai AUTH_DB_USERNAME= 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 909f61a79..10ac3e7aa 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,9 @@ # 单测试环境数据库空库初始化 +`migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql` 将退货进销存 UNION 涉及的八张表、22 个文本字段及表默认规则显式统一为 `utf8mb4_unicode_ci`,同步 Lastleg 两份源基线与海外统一空库基线。执行前核对字段长度、默认值、可空性及目标规则下唯一索引无冲突;只修改规则,不回填或删除数据,不重放空库基线。DDL 非事务,失败时停止并复查实际已完成项;可按最终定义重跑。脚本存在不表示测试库已执行,执行后用原始计数/分页查询验证,无需发布查询转换代码。 + +`migrations/overseas/baiyue_lastleg/V22__selfop_warehouse_daily_sales.sql` 新增自营仓库每日汇总表及销量版本 `warehouse_daily_ready` 标记。与匹配 Lastleg 一起发布;汇总随 SKU 日事实批次原子构建,完整性验证后方可发布。已有未构建汇总的销量版本须重新构建发布,不回填旧版本、不回退旧查询;空版本同样必须有就绪证据。源基线与海外空库基线同步。 + `migrations/overseas/baiyue_lastleg/V21__procurement_fee_confirmation_origin.sql` 增加采购费用核定入口,区分业务节点与财务操作,用于独立新增费用、替代核定及其重试权限。同步源基线和海外统一空库基线;不回填测试存量,不表示已执行。 `migrations/overseas/baiyue_lastleg/V20__procurement_exception_control.sql` 增加采购事实更正/阻断说明、异常关闭及逐来源最终去向三表。服务端保持原收货和发运记录,关闭不产生库存、退款或退货运输;需与采购详情聚合和节点阻断校验的匹配版本发布。源基线和海外统一基线同步,本文件存在不表示已执行。 @@ -22,14 +26,27 @@ 内部拼车需求池的当前结构在 Lastleg 源基线和海外统一空库基线同步维护,独立五项权限在 IAM 源种子和国内统一基线维护。已存在基础表的指定环境结构补齐使用 `migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql` 与 `migrations/cn/baiyue_iam/V4__fleet_capacity_offer_permissions.sql`;执行前确认三张新表、六个新增字段均未存在,并单独核对目标环境。随后 `V11__align_capacity_pool_collation.sql` 将预订与商业桥的客户关联字段显式统一为 `utf8mb4_unicode_ci`,与原订单域一致,避免依赖MySQL实例默认排序规则导致JOIN失败。它们不回填、删除或重置业务数据,不能重放整个空库基线。候选文件存在不表示已经执行。 +平台自营领星统计十张 `ll_selfop_*` 表直接同步 Lastleg 源基线及海外统一空库基线;时间使用 UTC 存储及已确认北京时间业务日,数量使用 DECIMAL 保留 PCS 精度,维持独立发布版本。包含账号共享预算/冷却、验证根用途与硬预算、TEST BUSINESS 根会话持久预算、稳定任务号、阶段进度/心跳和旧版替代时间;任务的 `business_root_task_id` 标记共享根,只有根保存 `business_budget_json`,生产任务不使用该测试预算。库存 run 包含每日代表日期、选择规则及账号/仓库/日期唯一约束,代表库存与 profile 不适用销量副本淘汰。IAM 权限源为 `iam/src/main/resources/db/selfoperated-analytics-permissions.sql`,自营业务下经营总览、销量分析、库存分析、数据采集的菜单资源与独立读写权限同步国内统一基线,仅平台管理员默认授权。执行和部署状态只维护于工作区测试环境主契约;真实采集模式默认 OFF。 + +客户/商品资料基线为 `lastleg-service/src/main/resources/db/selfop-master-data.sql`,仅新增 `ll_selfop_owner`、`ll_selfop_product` 两表,并同步在 Lastleg 和海外统一空库基线。按来源账号、货主编码、SKU 和完整资料版本关联,不创建平台客户、收费对象或独立映射表;未知实测尺寸保持 NULL。对 TEST 已有库只审阅并授权这两张新表的 CREATE,不能重放统一初始化文件,也不重置采集数据。 + +历史库存还原基线为 `lastleg-service/src/main/resources/db/selfop-inventory-history.sql`,新增流水事实、历史日版本和日库存三表;同步 Lastleg 与海外空库基线。仅执行这三表 CREATE,不重放原始库存或销量表。匹配版本复用 `sync_task/run/stage` 的 `INVENTORY_FLOW_HISTORY`、`INVENTORY_HISTORY_BUILD` 与 `INVENTORY_FLOW/HISTORY_INPUT`,原库存快照不改写。完成三表并部署 Integration、Lastleg、Admin 后,才启用 `baiyue.self-operated-analytics.inventory-history-enabled=true`;默认关闭,历史目标30日优先再60日。保留当前账号出口、1200ms间隔和所有被历史版本引用的证据;现有销量副本淘汰不得删除这些引用。 + +预约工作事项新增结构由 `lastleg-service/src/main/resources/db/appointment-work.sql` 提供五张表:完整范围协调锁、工作关系、不可变历史、承诺影响版本与逐票批次实物分配,已同步 Lastleg 和海外空库初始化入口;对已有 TEST 仅审阅并授权这五表 CREATE 及协调锁初始行。直送目的仓实际卸货独立表由 `firstleg/src/main/resources/db/phase3-direct-delivery-schema.sql` 提供并同步国内空库入口,不产生海外库存。两项独立写权限见 `iam/src/main/resources/db/appointment-work-permissions.sql`,先校验精确权限身份再写入;默认只授权平台管理员。旧预约表的有损重建只允许单独审阅 `reset-test-fba-appointments.sql`;若历史、核实或工作/分配表已存在,前置检查会在 DROP 前拒绝,不能重放或擅自扩大重置范围。以上只维护脚本,不代表已执行或已部署。 + +换标直接出库的实物体积快照在 `ll_outbound_order.volume_cbm`、`ll_outbound_pallet_item.volume_cbm`、`ll_delivery_order.total_volume_cbm` 三列均允许 NULL,表示来源尚未确认;已有实盘尺寸按箱计算,不能用默认 0 代替未知,也不按 SKU 件数重复累计。两份初始化基线同步此定义,已有 TEST 只能在核对结构和授权后精确调整这三列,不重放空库文件或回填历史行。 + ## 唯一入口与区域边界 +V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving.sql` 提供四表:独立收货预约、当前/历史客户成员、待确认变化和不可变覆盖版本;同步 Lastleg 与海外空库基线。有效外部约号唯一性归收货预约的目的仓与约号,批次头表不再独立占用约号。部署前必须核对四表及旧头表唯一索引;本批只准备基线,不提供测试存量回填或自动重置。旧 `reset-test-fba-appointments.sql` 已拒绝存在这些新表的环境,不能用于重放 V13 初始化。实际 SQL 与部署须逐项授权。 + 两个统一 SQL 文件仍只用于空库初始化,名称中的“增量”不表示可向已有业务库重放。已有生产库的受控结构补齐单独放在 `migrations/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。 | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| -| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 181 | +| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 227 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 214 | + - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 @@ -51,7 +68,7 @@ 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/181`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/214`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 @@ -67,7 +84,7 @@ | 所属库 | 保留内容 | 不包含 | |---|---|---| -| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、417 个权限、598 条角色权限、7 个数据范围、客户编号起点;无人工完成订单权限 | 测试客户、固定客户账号、额外扩权 | +| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、419 个权限、600 条角色权限、7 个数据范围、客户编号起点;无人工完成订单权限 | 测试客户、固定客户账号、额外扩权 | | Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 | | Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 | | File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 | @@ -139,3 +156,11 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test 本次整理使用隔离 MySQL 8.4,国内与海外分别在不存在对方区域 schema 的空库执行两轮,逐表比对整理前最终字段、默认值、索引、约束、注释及业务触发器。SHOW CREATE 的冗余字符集显示与运行态自增计数不视为结构差异;移除示例种子和强制改密是明确行为变更。这不代表已部署或完成真实 HTTP 验收。 码头车队邮件配置见 [Firstleg 车队邮件](../../firstleg/docs/terminal-drayage-mail.md)。 + +`migrations/overseas/baiyue_lastleg/V24__cargo_discard_photos.sql` 仅新增废弃照片关联表,按客户、申请和托盘关联文件及提交时间。与 Lastleg、File 和三端照片功能共同发布;不回填历史废弃单,不修改库存。脚本存在不表示目标环境已执行。 + +自营库存周转后端导出权限见 `selfop-turnover-export-permission.sql`,仅新增 `platform:self-operated-analytics:export` 并授予平台管理员;同步 IAM 自营权限源及国内统一基线。配套部署 Lastleg、File、Admin,File 注册 `SELFOP_INVENTORY_TURNOVER`(Nacos同名配置同步);无业务建表及采集配置变更。执行前须逐项授权。 + +货物废弃通用库存与散箱字段迁移:`migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql`。新增库存仓位关联及箱唛快照,须先迁移再部署 Lastleg;不回填历史废弃单。 + +2026-10-16 拉取合并配套结构清单及执行边界见 [数据库变更汇总](20261016-数据库变更汇总.md),已有尾程库使用 `migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql`;空库初始化海外表数同步为 209。该增量可重复执行,尚未执行到任何实际数据库。 diff --git a/deploy/sql/migrations/cn/baiyue_iam/V6__cargo_discard_customer_confirmation_permission.sql b/deploy/sql/migrations/cn/baiyue_iam/V6__cargo_discard_customer_confirmation_permission.sql new file mode 100644 index 000000000..b4952fe4f --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_iam/V6__cargo_discard_customer_confirmation_permission.sql @@ -0,0 +1,21 @@ +-- 仅 baiyue_iam,新增客户废弃确认按钮权限并授权默认客户管理员。 +-- 自定义客户角色由管理员按需分配;不自动扩大其权限。 +-- 可重复执行;授权和持久化安全版本在同一事务提交。 +START TRANSACTION; +INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) +VALUES (5306183,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:confirm','确认废弃完成',NULL,'POST',30,'ACTIVE',1,0) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),status='ACTIVE',deleted=0; +INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at) +SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p + ON p.tenant_id=0 AND p.app_code='MERCHANT' AND p.permission_code='merchant:overseas-warehouse:discard:confirm' +WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0; +INSERT INTO iam_security_subject_version(auth_user_id,permission_version) +SELECT DISTINCT u.auth_user_id,1 FROM iam_user u +JOIN iam_membership m ON m.user_id=u.id +JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=m.tenant_id +JOIN iam_role r ON r.id=mr.role_id +WHERE u.auth_user_id IS NOT NULL AND u.status='ACTIVE' AND u.deleted=0 + AND m.status='ACTIVE' AND m.deleted=0 AND r.tenant_id=0 AND r.app_code='MERCHANT' + AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 +ON DUPLICATE KEY UPDATE permission_version=permission_version+1; +COMMIT; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V22__selfop_warehouse_daily_sales.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V22__selfop_warehouse_daily_sales.sql new file mode 100644 index 000000000..fc0c9f2b4 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V22__selfop_warehouse_daily_sales.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS ll_selfop_warehouse_daily_sales ( + sales_run_id BIGINT NOT NULL COMMENT '完整销量计算版本标识', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码', + business_date DATE NOT NULL COMMENT '已核准业务日', + observed_quantity DECIMAL(38,6) NULL COMMENT '该日PCS非负有效出库量之和,无有效行时为空', + invalid_row_count BIGINT NOT NULL COMMENT '该日数量为空或负数或单位非PCS的日事实行数', + source_row_count BIGINT NOT NULL COMMENT '该日参与仓库汇总的SKU日事实总行数', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + PRIMARY KEY (sales_run_id,source_account,warehouse_code,business_date), + CONSTRAINT chk_selfop_warehouse_daily_sales CHECK (source_row_count > 0 AND invalid_row_count >= 0 AND invalid_row_count <= source_row_count AND (observed_quantity IS NULL OR observed_quantity >= 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本按仓按日汇总,随SKU日事实批次原子构建'; + +SET @selfop_warehouse_daily_ready_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_selfop_sync_run' AND column_name='warehouse_daily_ready'), + 'SELECT 1', + 'ALTER TABLE ll_selfop_sync_run ADD COLUMN warehouse_daily_ready TINYINT NOT NULL DEFAULT 0 COMMENT ''仓库日汇总已完整构建并校验,含完整空版本'''); +PREPARE selfop_warehouse_daily_ready_stmt FROM @selfop_warehouse_daily_ready_ddl; +EXECUTE selfop_warehouse_daily_ready_stmt; +DEALLOCATE PREPARE selfop_warehouse_daily_ready_stmt; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql new file mode 100644 index 000000000..588c40a39 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql @@ -0,0 +1,44 @@ +-- Region: OVERSEAS +-- Schema: baiyue_lastleg +-- Purpose: 统一退货进销存 UNION 文本列排序规则,去除查询转换需求 +-- Preconditions: 八张退货表已存在;核对字段长度、可空性、默认值及唯一索引无目标规则冲突 +-- Postconditions: 指定文本字段及表默认规则统一为 utf8mb4_unicode_ci;不修改数量、状态或业务标识 +-- 不重放空库基线;本文件可重复执行。DDL 非事务,失败停止并核对后续执行范围。 +ALTER TABLE ll_return_order + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID'; +ALTER TABLE ll_return_order_item + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', + MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名'; +ALTER TABLE ll_return_receipt + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + MODIFY COLUMN receipt_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收单号'; +ALTER TABLE ll_return_receipt_item + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码'; +ALTER TABLE ll_return_inventory_carton + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID', + MODIFY COLUMN carton_mark VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货箱号和箱唛编码'; +ALTER TABLE ll_return_inventory_carton_item + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', + MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名'; +ALTER TABLE ll_return_inventory_outbound_movement + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '库存所属客户ID和隔离键', + MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际出库海外仓ID', + MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次出库SKU编码快照', + MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名快照', + MODIFY COLUMN outbound_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次退货出库业务单号'; +ALTER TABLE ll_return_sku_conversion + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID', + MODIFY COLUMN old_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标前SKU', + MODIFY COLUMN new_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标后SKU', + MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名快照', + MODIFY COLUMN relabel_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标单号'; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V24__cargo_discard_photos.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V24__cargo_discard_photos.sql new file mode 100644 index 000000000..1373e87ce --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V24__cargo_discard_photos.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo ( + id BIGINT NOT NULL, + customer_id VARCHAR(64) NOT NULL, + application_id BIGINT NOT NULL, + pallet_no VARCHAR(128) NOT NULL, + file_id BIGINT NOT NULL, + file_name VARCHAR(255) NOT NULL, + content_type VARCHAR(128) NOT NULL, + size_bytes BIGINT NOT NULL, + operator_id VARCHAR(64) NOT NULL, + created_at DATETIME(6) NOT NULL, + submitted_at DATETIME(6) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_cargo_discard_photo_file (file_id), + KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql new file mode 100644 index 000000000..4e79aa929 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql @@ -0,0 +1,24 @@ +-- 货物废弃支持所有入库来源及散箱全量作业;不回填或修改历史单据。 +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='inventory_position_id'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN inventory_position_id BIGINT NULL COMMENT ''废弃来源库存仓位ID''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_marks_json'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_marks_json TEXT NULL COMMENT ''本次废弃所选真实箱唛快照JSON数组''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='uk_ll_discard_position_sku'), + 'ALTER TABLE ll_cargo_discard_item ADD UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code)', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +ALTER TABLE ll_cargo_discard_item MODIFY COLUMN source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY'; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_scan_required'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要逐箱核对本次所选箱子''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +ALTER TABLE ll_cargo_discard_item MODIFY COLUMN carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组'; +ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '箱唛状态:未绑定UNASSIGNED、已绑托ON_PALLET、整票收货BULK_RECEIVED、可用AVAILABLE、待废弃DISCARD_PENDING、已废弃DISCARDED'; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='source_type'), + 'ALTER TABLE ll_cargo_discard_carton ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V26__allow_zero_return_carton_item_quantity.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V26__allow_zero_return_carton_item_quantity.sql new file mode 100644 index 000000000..7f5a22c6b --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V26__allow_zero_return_carton_item_quantity.sql @@ -0,0 +1,13 @@ +-- 整箱废弃后保留SKU明细,剩余数量允许归零,仍禁止负库存。 +-- 原子替换约束,可重复执行;不修改业务记录。 +SET @ddl = IF(EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton_item' + AND constraint_name='chk_ll_return_inventory_carton_item_quantity' +), + 'ALTER TABLE ll_return_inventory_carton_item DROP CHECK chk_ll_return_inventory_carton_item_quantity, ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)', + 'ALTER TABLE ll_return_inventory_carton_item ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V27__cargo_discard_customer_confirmation.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V27__cargo_discard_customer_confirmation.sql new file mode 100644 index 000000000..c8109e3c2 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V27__cargo_discard_customer_confirmation.sql @@ -0,0 +1,3 @@ +-- 客户确认废弃完结时间,可重复执行;仅结构变更。 +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='customer_confirmed_at'), 'ALTER TABLE ll_cargo_discard_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认废弃完结时间''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; 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/migrations/overseas/baiyue_lastleg/V29__allow_unbound_transfer_forecast_cartons.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V29__allow_unbound_transfer_forecast_cartons.sql new file mode 100644 index 000000000..5f26df5f5 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V29__allow_unbound_transfer_forecast_cartons.sql @@ -0,0 +1,2 @@ +-- 对应数据库:baiyue_lastleg;可重复执行,不修改原箱号、SKU预报件数和实测数据。 +ALTER TABLE ll_warehouse_transfer_carton MODIFY COLUMN transfer_item_id BIGINT NULL COMMENT '实际箱内首项SKU明细ID,预报补箱可为空'; diff --git a/deploy/sql/reset-test-fba-appointments.sql b/deploy/sql/reset-test-fba-appointments.sql index b49fb888a..647947332 100644 --- a/deploy/sql/reset-test-fba-appointments.sql +++ b/deploy/sql/reset-test-fba-appointments.sql @@ -1,9 +1,27 @@ -- TEST ONLY. Rebuild the explicitly authorized appointment header and item tables. --- The new command-history table MUST NOT already exist; check this before executing any DROP. --- If it exists, stop and inspect the prior outcome. Do not clear command history or blindly retry. +-- The command-history and cargo-verification tables MUST NOT already exist; check both before any DROP. +-- If either exists, stop and inspect the prior outcome. Do not clear history or blindly retry. -- Requires explicit approval; never run against production or as automatic startup SQL. -- Caller must verify the TEST connection before executing. USE baiyue_lastleg; +DROP PROCEDURE IF EXISTS assert_test_appointment_reset_preconditions; +DELIMITER $$ +CREATE PROCEDURE assert_test_appointment_reset_preconditions() +BEGIN + IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() + AND table_name IN ('ll_fba_appointment_command','ll_appointment_cargo_verification', + 'll_appointment_work','ll_dispatch_appointment_allocation','ll_fba_receiving_agreement', + 'll_fba_receiving_member','ll_fba_receiving_change','ll_fba_receiving_revision','ll_fba_receiving_plan')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Stop: appointment history or work/allocation schema already exists; inspect before any reset'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() + AND table_name IN ('ll_inbound_fba_appointment','ll_inbound_fba_appointment_item')) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Stop: expected both legacy appointment tables; inspect partial or new schema'; + END IF; +END$$ +DELIMITER ; +CALL assert_test_appointment_reset_preconditions(); +DROP PROCEDURE assert_test_appointment_reset_preconditions; DROP TABLE ll_inbound_fba_appointment_item; DROP TABLE ll_inbound_fba_appointment; CREATE TABLE ll_inbound_fba_appointment ( @@ -24,12 +42,14 @@ CREATE TABLE ll_inbound_fba_appointment ( appointment_remark VARCHAR(1000) NULL COMMENT '备注或取消原因', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA目的仓编码', selected_count INT NOT NULL DEFAULT 0 COMMENT '真实货源票数', + replacement_nos_json LONGTEXT NULL COMMENT '分批或合并后的新预约业务号列表,非空时原批次只读', + replacement_keys_json LONGTEXT NULL COMMENT '替代批次不可变键列表,只用于内部业务关联', + repartition_reason VARCHAR(1000) NULL COMMENT '分批或合并原因,原安排及来源明细保留', created_by VARCHAR(128) NOT NULL COMMENT '登录操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '更新UTC时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_fba_appointment_no (appointment_no), - UNIQUE KEY uk_ll_fba_external_confirmation (fba_warehouse_code,confirmation_no), KEY idx_ll_fba_appointment_list (appointment_at,appointment_status,fba_warehouse_code), KEY idx_ll_fba_appointment_planned (planned_at,appointment_status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA预约安排与正式确认唯一主记录'; @@ -43,19 +63,42 @@ CREATE TABLE ll_inbound_fba_appointment_item ( order_id BIGINT NULL COMMENT '尚未形成尾程订单时为空', inbound_code VARCHAR(128) NULL COMMENT '真实入仓码,不伪造', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT '目的FBA仓编码', - package_count INT NULL COMMENT '所选整票预报箱数,未知保持空', - active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '取消或失败后释放货源占用', - active_source_key VARCHAR(160) GENERATED ALWAYS AS (CASE WHEN active=1 THEN source_key ELSE NULL END) STORED COMMENT '有效货源唯一占用键', - active_order_id BIGINT GENERATED ALWAYS AS (CASE WHEN active=1 THEN order_id ELSE NULL END) STORED COMMENT '有效订单唯一占用键', + package_count INT NULL COMMENT '本批分配箱数,未知保持空;同一原来源总量守恒', + cargo_verification_version INT NULL COMMENT '采用的预约货物核实版本,未核实时为空', + shipment_references_json LONGTEXT NULL COMMENT '本次采用的货件或追踪号核实快照', + active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前批次数量分配有效;取消、未约成和完成均保留,仅替代时释放', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_fba_item_source (appointment_id,source_key), - UNIQUE KEY uk_ll_fba_active_source (active_source_key), - UNIQUE KEY uk_ll_fba_active_order (active_order_id), + KEY idx_ll_fba_active_source (customer_id,source_key,active), + KEY idx_ll_fba_active_order (customer_id,order_id,active), KEY idx_ll_fba_item_source_lookup (source_key,customer_id), KEY idx_ll_fba_item_customer_order (customer_id,order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入仓前后共用的预约真实货源'; +CREATE TABLE ll_appointment_cargo_verification ( + id BIGINT NOT NULL COMMENT '核实记录主键', + customer_id VARCHAR(128) NOT NULL COMMENT '原货源所属客户', + source_key VARCHAR(160) NOT NULL COMMENT '稳定ORDER或BOOKING原来源键', + destination_key VARCHAR(160) NOT NULL COMMENT '核实时的原目的地', + source_version VARCHAR(160) NULL COMMENT '核实时的原来源版本', + source_packages INT NULL COMMENT '核实时的原来源箱数,未知为空', + version INT NOT NULL COMMENT '同一来源的核实版本,从一递增', + package_count INT NULL COMMENT '独立核实箱数,不回写原订单', + shipment_references_json LONGTEXT NOT NULL COMMENT '本票货件号或追踪号列表,不回写原订单', + remark VARCHAR(1000) NULL COMMENT '核实说明', + operator_id VARCHAR(128) NOT NULL COMMENT '登录操作人', + operator_name VARCHAR(200) NULL COMMENT '操作人名称快照', + command_key VARCHAR(64) NOT NULL COMMENT '来源内幂等命令键', + request_hash CHAR(64) NOT NULL COMMENT '来源、操作人及请求摘要', + created_at DATETIME(3) NOT NULL COMMENT '核实UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_cargo_verification_version (customer_id,source_key,version), + UNIQUE KEY uk_ll_cargo_verification_command (customer_id,source_key,command_key), + CONSTRAINT chk_ll_cargo_verification_quantity CHECK (package_count IS NULL OR package_count > 0), + CONSTRAINT chk_ll_cargo_verification_version CHECK (version > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约货物核实资料及不可覆盖的版本历史'; + CREATE TABLE ll_fba_appointment_command ( command_key VARCHAR(128) NOT NULL COMMENT '持久化幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作、操作者和请求摘要', diff --git a/deploy/sql/selfop-turnover-export-permission.sql b/deploy/sql/selfop-turnover-export-permission.sql new file mode 100644 index 000000000..a1a03d44c --- /dev/null +++ b/deploy/sql/selfop-turnover-export-permission.sql @@ -0,0 +1,11 @@ +-- 自营库存周转后端导出权限;不建表,不修改销量、库存或采集任务。 +USE baiyue_iam; +SET NAMES utf8mb4; +START TRANSACTION; +INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES + (5118002,0,'PLATFORM','API',5118120,'platform:self-operated-analytics:export','导出平台自营库存周转','/api/platform/v1/files/export-tasks','POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),route_path=VALUES(route_path),http_method=VALUES(http_method),status='ACTIVE',deleted=0,updated_at=CURRENT_TIMESTAMP(3); +INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) +SELECT 4000001,5118002,0,CURRENT_TIMESTAMP(3) +WHERE NOT EXISTS (SELECT 1 FROM iam_role_permission WHERE role_id=4000001 AND permission_id=5118002 AND tenant_id=0); +COMMIT; diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index cd4b64281..d58001da3 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -2,7 +2,7 @@ -- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。 -- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。 -- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。 --- DDL 隐式提交;失败后停止,不从中间续跑,也不把部分初始化当成成功。 +-- DDL 隐式提交;失败后停止,不从中间续跑,也不把部分初始化当成成功。utf8mb4_0900_ai_ci SET NAMES utf8mb4 COLLATE utf8mb4_0900_ai_ci; SET SESSION sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'; @@ -1422,7 +1422,6 @@ CREATE TABLE fl_consumed_event ( CREATE TABLE fl_container_booking ( id bigint unsigned NOT NULL COMMENT '货柜预订ID(雪花ID)', - booking_no varchar(40) NOT NULL COMMENT '平台唯一货柜预订单号', container_no varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '真实集装箱号权威快照;同一订舱内唯一,不同提单允许复用;确认前允许修订', cabinet_no varchar(64) NOT NULL COMMENT '平台唯一内部柜号', source_type varchar(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜', @@ -1477,7 +1476,6 @@ CREATE TABLE fl_container_booking ( updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜资料最后更新时间', version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', PRIMARY KEY (id), - UNIQUE KEY uk_container_booking_no (booking_no), UNIQUE KEY uk_container_booking_cabinet (cabinet_no), UNIQUE KEY uk_container_booking_ocean_container (ocean_booking_id,container_no), KEY idx_container_booking_container_no (container_no,updated_at,id), @@ -4104,6 +4102,27 @@ CREATE TABLE fl_split_operation ( CONSTRAINT fk_split_operation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='分柜操作幂等与审计索引'; +-- Current schema for actual destination unloading; never an overseas warehouse receipt. +CREATE TABLE fl_direct_delivery_unloading ( + container_booking_id BIGINT UNSIGNED NOT NULL, + inbound_id BIGINT UNSIGNED NOT NULL COMMENT 'Immutable physically loaded inbound source', + actual_at DATETIME(3) NOT NULL COMMENT 'Actual destination local time', + timezone VARCHAR(64) NOT NULL COMMENT 'Explicit destination IANA timezone', + received_by VARCHAR(128) NOT NULL COMMENT 'Actual recipient', + evidence_no VARCHAR(128) NOT NULL COMMENT 'Actual delivery receipt reference', + remark VARCHAR(1000) NULL, + version INT NOT NULL DEFAULT 1, + idempotency_key VARCHAR(128) NOT NULL, + request_hash CHAR(64) NOT NULL, + created_by BIGINT UNSIGNED NOT NULL, + created_by_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (container_booking_id,inbound_id), + UNIQUE KEY uk_direct_unloading_command (container_booking_id,idempotency_key), + CONSTRAINT fk_direct_unloading_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='直送目的仓真实卸货凭据'; + + CREATE TABLE fl_terminal_drayage ( id bigint unsigned NOT NULL COMMENT '目的港码头拖车聚合ID', container_booking_id bigint unsigned NOT NULL COMMENT '唯一归属物理货柜ID', @@ -5337,7 +5356,7 @@ CREATE TRIGGER trg_container_booking_before_update BEFORE UPDATE ON fl_container SELECT MAX(plan_status) INTO current_plan_status FROM baiyue_firstleg.fl_loading_plan WHERE container_booking_id = OLD.id; IF current_plan_status IN ('LOADING', 'COMPLETED') AND ( - NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) + NOT (OLD.id <=> NEW.id) OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.channel_group_code <=> NEW.channel_group_code) OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name) @@ -7123,7 +7142,9 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306172,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306180,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306181,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306183,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:confirm','确认废弃完成',NULL,'POST',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306182,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306183,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306190,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306191,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306192,0,CURRENT_TIMESTAMP(3)); @@ -8315,4 +8336,33 @@ SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND UPPER(r.role_code) IN('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN') AND r.status='ACTIVE' AND r.deleted=0; -SELECT 'PASS' AS initialization_status, 'CN' AS region, 226 AS initialized_tables; +-- 自营业务菜单资源用于菜单管理;四个页面和所有只读接口统一由 list 授权,采集写权限独立。 +USE baiyue_iam; +SET NAMES utf8mb4; +INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES + (5118100,0,'PLATFORM','MENU',0,'platform:self-operated:menu','自营业务',NULL,NULL,1100,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118140,0,'PLATFORM','MENU',5118100,'platform:self-operated:overview:menu','经营总览','/self-operated/overview','GET',5,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118110,0,'PLATFORM','MENU',5118100,'platform:self-operated:sales:menu','销量分析','/self-operated/sales','GET',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118120,0,'PLATFORM','MENU',5118100,'platform:self-operated:inventory:menu','库存分析','/self-operated/inventory','GET',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118130,0,'PLATFORM','MENU',5118100,'platform:self-operated:collection:menu','数据采集','/self-operated/collection','GET',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118000,0,'PLATFORM','API',5118100,'platform:self-operated-analytics:list','读取平台自营出库销量与库存分析','/api/platform/v1/last-leg/self-operated-analytics','GET',40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118002,0,'PLATFORM','API',5118120,'platform:self-operated-analytics:export','导出平台自营库存周转','/api/platform/v1/files/export-tasks','POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118001,0,'PLATFORM','API',5118130,'platform:self-operated-analytics:sync','平台自营采集重试与重采集','/api/platform/v1/last-leg/self-operated-analytics/sync-tasks','POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES + (4000001,5118000,0,CURRENT_TIMESTAMP(3)), + (4000001,5118001,0,CURRENT_TIMESTAMP(3)), + (4000001,5118002,0,CURRENT_TIMESTAMP(3)), + (4000001,5118100,0,CURRENT_TIMESTAMP(3)), + (4000001,5118140,0,CURRENT_TIMESTAMP(3)), + (4000001,5118110,0,CURRENT_TIMESTAMP(3)), + (4000001,5118120,0,CURRENT_TIMESTAMP(3)), + (4000001,5118130,0,CURRENT_TIMESTAMP(3)); + +-- Appointment work independent write permissions (current baseline). +USE baiyue_iam; +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107283,0,'PLATFORM','BUTTON',5107280,'platform:appointment:manage-work','维护预约工作安排',NULL,'POST',743,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107283,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102213,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:direct-unload:confirm','登记直送目的仓实际卸货',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3)); + +SELECT 'PASS' AS initialization_status, 'CN' AS region, 227 AS initialized_tables; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 8ec8d2b43..970c1e4a6 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -48,6 +48,7 @@ CREATE TABLE ll_cargo_discard_carton ( customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', carton_id bigint NOT NULL COMMENT '箱唛记录ID', carton_mark varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱唛', + source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_cargo_discard_carton (application_id,carton_id), @@ -60,13 +61,16 @@ CREATE TABLE ll_cargo_discard_item ( application_id bigint NOT NULL COMMENT '申请单ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', order_id bigint DEFAULT NULL COMMENT '一件代发订单ID', - source_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON', + source_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY', return_carton_id bigint DEFAULT NULL COMMENT '退货库存箱ID', inbound_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓单号快照', profile_id bigint DEFAULT NULL COMMENT '托盘档案ID', sku_inventory_id bigint DEFAULT NULL COMMENT 'SKU库存记录ID', sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', product_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品名称', + inventory_position_id BIGINT NULL COMMENT '废弃来源库存仓位ID', + carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组', + carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要逐箱核对本次所选箱子', available_packages_snapshot int NOT NULL COMMENT '可用箱数快照', discard_packages int NOT NULL COMMENT '申请废弃箱数', completion_photo_file_ids text COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA完工照片文件ID JSON数组', @@ -77,6 +81,7 @@ CREATE TABLE ll_cargo_discard_item ( created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_cargo_discard_item (application_id,profile_id,sku_code), + UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code), KEY idx_ll_cargo_discard_item_list (customer_id,application_id), KEY idx_ll_cargo_discard_item_inbound (customer_id,inbound_code,sku_code) ,KEY idx_ll_cargo_discard_return_carton (customer_id,return_carton_id) @@ -266,7 +271,7 @@ CREATE TABLE ll_delivery_order ( package_count int NOT NULL DEFAULT '0' COMMENT '派送箱数', pallet_count int NOT NULL DEFAULT '0' COMMENT '派送托盘数', total_weight_kg DECIMAL(18,4) NULL COMMENT '总重量(千克),任一来源未确认时为空', - total_volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '总体积(立方米)', + total_volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米),来源未确认时为空', planned_delivery_at datetime(3) DEFAULT NULL COMMENT '预计派送时间', dispatched_at datetime(3) DEFAULT NULL COMMENT '实际派送时间', expected_arrival_at datetime(3) DEFAULT NULL COMMENT '预计到达时间', @@ -844,6 +849,57 @@ CREATE TABLE ll_finance_fee_source ( CONSTRAINT chk_ll_finance_fee_status CHECK ((source_status in (_utf8mb4'ACTIVE',_utf8mb4'SUPERSEDED',_utf8mb4'VOIDED'))) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance'; +-- V13 receiving coverage and pending changes; baseline only, no test data migration. +CREATE TABLE ll_fba_receiving_agreement ( + agreement_key VARCHAR(64) NOT NULL COMMENT '收货预约独立业务键', + version INT NOT NULL COMMENT '预约覆盖范围与确认资料版本', + status VARCHAR(24) NOT NULL COMMENT '有效、取消或已替代状态', + destination_key VARCHAR(128) NOT NULL COMMENT '权威目的仓关联键', + confirmation_no VARCHAR(128) NULL COMMENT '当前有效外部约号,失效后清空以释放覆盖', + snapshot_json LONGTEXT NOT NULL COMMENT '客户逐来源覆盖、确认时区及仓库依据快照', + PRIMARY KEY (agreement_key), + UNIQUE KEY uk_fba_receiving_confirmation (destination_key, confirmation_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='独立于送货批次的收货预约'; + +CREATE TABLE ll_fba_receiving_member ( + agreement_key VARCHAR(64) NOT NULL COMMENT '所属收货预约业务键', + batch_key VARCHAR(128) NOT NULL COMMENT '稳定送货批次键', + customer_id VARCHAR(128) NOT NULL COMMENT '货物所属客户标识', + active_ref VARCHAR(260) NULL COMMENT '有效批次与客户唯一引用,退出后清空且历史保留', + PRIMARY KEY (agreement_key,batch_key,customer_id), + UNIQUE KEY uk_fba_receiving_active_member (active_ref), + KEY idx_fba_receiving_batch (batch_key,customer_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收货预约当前覆盖关联及退出历史'; + +CREATE TABLE ll_fba_receiving_change ( + change_key VARCHAR(64) NOT NULL COMMENT '客户预约变化业务键', + version INT NOT NULL COMMENT '变化续办版本', + status VARCHAR(24) NOT NULL COMMENT '待仓库确认、已应用或已撤回状态', + batch_key VARCHAR(128) NOT NULL COMMENT '受影响原送货批次键', + customer_id VARCHAR(128) NOT NULL COMMENT '本次变化所属客户', + snapshot_json LONGTEXT NOT NULL COMMENT '原覆盖快照、逐来源拟保留量、负责人及跟进资料', + result_json LONGTEXT NULL COMMENT '确认后的实际批次去向及逐步骤结果', + active_ref VARCHAR(260) NULL COMMENT '同批同客户唯一未解决变化引用', + PRIMARY KEY (change_key), + UNIQUE KEY uk_fba_receiving_pending (active_ref), + KEY idx_fba_receiving_pending_batch (batch_key,status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='保留原安排且阻止误发车的客户变化'; + +CREATE TABLE ll_fba_receiving_revision ( + agreement_key VARCHAR(64) NOT NULL COMMENT '收货预约业务键', + version INT NOT NULL COMMENT '不可变覆盖与确认版本', + snapshot_json LONGTEXT NOT NULL COMMENT '该版本完整客户覆盖、确认时段及回复依据', + PRIMARY KEY (agreement_key,version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收货预约覆盖与确认不可变历史'; + +CREATE TABLE ll_fba_receiving_plan ( + batch_key VARCHAR(128) NOT NULL COMMENT '稳定送货批次键', + customer_id VARCHAR(128) NOT NULL COMMENT '计划所属客户标识', + snapshot_json LONGTEXT NOT NULL COMMENT '本客户未确认计划、目标窗口及业务时区,不含有效预约确认', + PRIMARY KEY (batch_key,customer_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='按批次及客户保存的未确认收货计划'; + + CREATE TABLE ll_inbound_fba_appointment ( id BIGINT NOT NULL COMMENT '主键ID', appointment_no VARCHAR(64) NOT NULL COMMENT '系统预约业务单号,不是外部约号', @@ -862,12 +918,14 @@ CREATE TABLE ll_inbound_fba_appointment ( appointment_remark VARCHAR(1000) NULL COMMENT '备注或取消原因', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA目的仓编码', selected_count INT NOT NULL DEFAULT 0 COMMENT '真实货源票数', + replacement_nos_json LONGTEXT NULL COMMENT '分批或合并后的新预约业务号列表,非空时原批次只读', + replacement_keys_json LONGTEXT NULL COMMENT '替代批次不可变键列表,只用于内部业务关联', + repartition_reason VARCHAR(1000) NULL COMMENT '分批或合并原因,原安排及来源明细保留', created_by VARCHAR(128) NOT NULL COMMENT '登录操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '更新UTC时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_fba_appointment_no (appointment_no), - UNIQUE KEY uk_ll_fba_external_confirmation (fba_warehouse_code,confirmation_no), KEY idx_ll_fba_appointment_list (appointment_at,appointment_status,fba_warehouse_code), KEY idx_ll_fba_appointment_planned (planned_at,appointment_status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA预约安排与正式确认唯一主记录'; @@ -881,19 +939,42 @@ CREATE TABLE ll_inbound_fba_appointment_item ( order_id BIGINT NULL COMMENT '尚未形成尾程订单时为空', inbound_code VARCHAR(128) NULL COMMENT '真实入仓码,不伪造', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT '目的FBA仓编码', - package_count INT NULL COMMENT '所选整票预报箱数,未知保持空', - active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '取消或失败后释放货源占用', - active_source_key VARCHAR(160) GENERATED ALWAYS AS (CASE WHEN active=1 THEN source_key ELSE NULL END) STORED COMMENT '有效货源唯一占用键', - active_order_id BIGINT GENERATED ALWAYS AS (CASE WHEN active=1 THEN order_id ELSE NULL END) STORED COMMENT '有效订单唯一占用键', + package_count INT NULL COMMENT '本批分配箱数,未知保持空;同一原来源总量守恒', + cargo_verification_version INT NULL COMMENT '采用的预约货物核实版本,未核实时为空', + shipment_references_json LONGTEXT NULL COMMENT '本次采用的货件或追踪号核实快照', + active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前批次数量分配有效;取消、未约成和完成均保留,仅替代时释放', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_fba_item_source (appointment_id,source_key), - UNIQUE KEY uk_ll_fba_active_source (active_source_key), - UNIQUE KEY uk_ll_fba_active_order (active_order_id), + KEY idx_ll_fba_active_source (customer_id,source_key,active), + KEY idx_ll_fba_active_order (customer_id,order_id,active), KEY idx_ll_fba_item_source_lookup (source_key,customer_id), KEY idx_ll_fba_item_customer_order (customer_id,order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入仓前后共用的预约真实货源'; +CREATE TABLE ll_appointment_cargo_verification ( + id BIGINT NOT NULL COMMENT '核实记录主键', + customer_id VARCHAR(128) NOT NULL COMMENT '原货源所属客户', + source_key VARCHAR(160) NOT NULL COMMENT '稳定ORDER或BOOKING原来源键', + destination_key VARCHAR(160) NOT NULL COMMENT '核实时的原目的地', + source_version VARCHAR(160) NULL COMMENT '核实时的原来源版本', + source_packages INT NULL COMMENT '核实时的原来源箱数,未知为空', + version INT NOT NULL COMMENT '同一来源的核实版本,从一递增', + package_count INT NULL COMMENT '独立核实箱数,不回写原订单', + shipment_references_json LONGTEXT NOT NULL COMMENT '本票货件号或追踪号列表,不回写原订单', + remark VARCHAR(1000) NULL COMMENT '核实说明', + operator_id VARCHAR(128) NOT NULL COMMENT '登录操作人', + operator_name VARCHAR(200) NULL COMMENT '操作人名称快照', + command_key VARCHAR(64) NOT NULL COMMENT '来源内幂等命令键', + request_hash CHAR(64) NOT NULL COMMENT '来源、操作人及请求摘要', + created_at DATETIME(3) NOT NULL COMMENT '核实UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_cargo_verification_version (customer_id,source_key,version), + UNIQUE KEY uk_ll_cargo_verification_command (customer_id,source_key,command_key), + CONSTRAINT chk_ll_cargo_verification_quantity CHECK (package_count IS NULL OR package_count > 0), + CONSTRAINT chk_ll_cargo_verification_version CHECK (version > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约货物核实资料及不可覆盖的版本历史'; + CREATE TABLE ll_fba_appointment_command ( command_key VARCHAR(128) NOT NULL COMMENT '持久化幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作、操作者和请求摘要', @@ -1072,7 +1153,7 @@ CREATE TABLE ll_inbound_plan_carton ( warehouse_pallet_id bigint DEFAULT NULL COMMENT '海外实物托盘ID', warehouse_location_id bigint DEFAULT NULL COMMENT '海外库位ID', pda_task_id bigint DEFAULT NULL COMMENT '首次扫码PDA任务ID', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、BULK_RECEIVED、AVAILABLE', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱唛状态:未绑定UNASSIGNED、已绑托ON_PALLET、整票收货BULK_RECEIVED、可用AVAILABLE、待废弃DISCARD_PENDING、已废弃DISCARDED', version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本', bound_at datetime(3) DEFAULT NULL COMMENT '绑定托盘时间', received_at datetime(3) DEFAULT NULL COMMENT '完成入库时间', @@ -1694,7 +1775,7 @@ CREATE TABLE ll_outbound_order ( pallet_count int NOT NULL DEFAULT '0' COMMENT '托盘数', gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空', volume_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)', - volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)', + volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米),来源未确认时为空', fee_coverage_json JSON NULL COMMENT '本次发运引用的原费用版本、实际货物及明确服务覆盖确认快照', idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', @@ -1723,7 +1804,7 @@ CREATE TABLE ll_outbound_pallet_item ( package_count int NOT NULL DEFAULT '0' COMMENT '箱数', gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空', volume_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)', - volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)', + volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米),来源未确认时为空', picked_package_count int NOT NULL DEFAULT '0' COMMENT '已拣箱数', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发SKU', @@ -2184,7 +2265,7 @@ CREATE TABLE ll_relabel_application ( label_deadline datetime(3) NOT NULL COMMENT '换标截止时间', contact_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人姓名', contact_phone varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人电话', - delivery_method varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填', + delivery_method varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送方式:TRUCK平台卡车、EXPRESS快递、PICKUP自提、AMAZON_PICKUP亚马逊自提、CUSTOMER_PICKUP客户自提;仅平台卡车进入预排车', print_shipping_label tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否需要打印面单', shipping_label_file_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户上传面单文件ID', shipping_label_file_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户上传面单文件名', @@ -2373,13 +2454,13 @@ CREATE TABLE ll_relabel_status_history ( CREATE TABLE ll_return_inventory_outbound_movement ( id BIGINT NOT NULL COMMENT '退货出库SKU流水ID', outbound_pick_id BIGINT NOT NULL COMMENT '同事务PDA出库拣货记录ID', - customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户ID和隔离键', - overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '实际出库海外仓ID', + customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '库存所属客户ID和隔离键', + overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际出库海外仓ID', carton_id BIGINT NOT NULL COMMENT '真实退货库存箱ID', carton_mark VARCHAR(128) NOT NULL COMMENT '冻结的真实退货箱业务码', - outbound_no VARCHAR(64) NOT NULL COMMENT '本次退货出库业务单号', - sku_code VARCHAR(128) NOT NULL COMMENT '本次出库SKU编码快照', return_sku_quantity BIGINT NULL COMMENT '退货单SKU本来源计划出库件数快照,NULL为历史整箱出库', - product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照', + outbound_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次退货出库业务单号', + sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次出库SKU编码快照', return_sku_quantity BIGINT NULL COMMENT '退货单SKU本来源计划出库件数快照,NULL为历史整箱出库', + product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名快照', quantity BIGINT NOT NULL COMMENT '本箱该SKU实际出库件数,始终为正', total_before BIGINT NOT NULL COMMENT '客户仓SKU出库前实物总数量', total_after BIGINT NOT NULL COMMENT '客户仓SKU出库后实物总数量', @@ -2395,7 +2476,7 @@ CREATE TABLE ll_return_inventory_outbound_movement ( CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after) -) ENGINE=InnoDB COMMENT='不可变退货出库SKU库存流水'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='不可变退货出库SKU库存流水'; CREATE TABLE ll_return_inventory_balance ( id bigint NOT NULL COMMENT '退货库存余额ID', @@ -2414,15 +2495,17 @@ CREATE TABLE ll_return_inventory_balance ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存按客户海外仓SKU汇总余额'; CREATE TABLE ll_return_inventory_carton ( + original_packaging TINYINT(1) NULL COMMENT '是否使用原箱原包装', + label_status varchar(32) DEFAULT NULL COMMENT 'WAIT_LABEL待贴标 CONFIRMED已扫码确认标签', new_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标后新箱唛', id bigint NOT NULL COMMENT '退货库存箱ID', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', - overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID', + customer_id varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + overseas_warehouse_id varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID', source_receipt_id bigint DEFAULT NULL COMMENT '来源PDA签收单ID', source_batch_no text COLLATE utf8mb4_unicode_ci COMMENT '实际消耗来源签收批次号,多个批次逗号分隔', source_return_no text COLLATE utf8mb4_unicode_ci COMMENT '实际消耗来源退货单号,多个单号逗号分隔', pallet_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收时绑定的托盘码', - carton_mark varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货箱号和箱唛编码', + carton_mark varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货箱号和箱唛编码', fba_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA箱唛', destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码', destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称', @@ -2459,13 +2542,14 @@ CREATE TABLE ll_return_inventory_carton_item ( id bigint NOT NULL COMMENT '退货库存箱SKU明细ID', carton_id bigint NOT NULL COMMENT '退货库存箱ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', - sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', - product_name_zh varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名', + sku_code varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', + product_name_zh varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名', quantity bigint NOT NULL COMMENT '该SKU箱内数量', + relabelled_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '当前SKU已经换标的实物件数,不可二次换标', PRIMARY KEY (id), UNIQUE KEY uk_ll_return_inventory_carton_item (carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer (customer_id,sku_code), - CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK ((quantity > 0)), + CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK ((quantity >= 0)), packing_reserved_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '合箱计划预占件数' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存箱内SKU和数量'; @@ -2486,9 +2570,9 @@ CREATE TABLE ll_return_inventory_operation ( CREATE TABLE ll_return_order ( id bigint NOT NULL COMMENT '退货订单ID', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + customer_id varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', return_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货订单号', - overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID', + overseas_warehouse_id varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID', warehouse_contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中转仓联系人快照', warehouse_contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中转仓联系电话快照', customer_reference_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户参考号', @@ -2521,8 +2605,8 @@ CREATE TABLE ll_return_order_item ( id bigint NOT NULL COMMENT '退货预报明细ID', return_order_id bigint NOT NULL COMMENT '退货订单ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', - sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', - product_name_zh varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名', + sku_code varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', + product_name_zh varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名', expected_quantity bigint NOT NULL COMMENT 'SKU预报数量', received_quantity bigint NOT NULL DEFAULT '0' COMMENT 'SKU累计实收数量', created_at datetime(3) NOT NULL COMMENT '创建时间', @@ -2537,7 +2621,7 @@ CREATE TABLE ll_return_receipt ( id bigint NOT NULL COMMENT 'PDA退货签收单ID', return_order_id bigint NOT NULL COMMENT '退货订单ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', - receipt_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收单号', + receipt_no varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收单号', receipt_batch_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收批次号', pallet_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次签收绑定的托盘码', carton_mark varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收生成或绑定的退货箱号', @@ -2564,7 +2648,9 @@ CREATE TABLE ll_return_receipt ( length DECIMAL(12,3) NULL COMMENT '实测尺寸cm', width DECIMAL(12,3) NULL COMMENT '实测尺寸cm', height DECIMAL(12,3) NULL COMMENT '实测尺寸cm', - weight DECIMAL(12,3) NULL COMMENT '实测重量kg' + weight DECIMAL(12,3) NULL COMMENT '实测重量kg', + original_packaging TINYINT(1) NULL COMMENT '是否使用原箱原包装', + measurement_source VARCHAR(32) NULL COMMENT '尺寸重量来源' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA退货实收签收事实'; CREATE TABLE ll_return_receipt_batch ( @@ -2588,7 +2674,7 @@ CREATE TABLE ll_return_receipt_item ( receipt_id bigint NOT NULL COMMENT 'PDA退货签收单ID', return_order_item_id bigint NOT NULL COMMENT '退货预报明细ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', - sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', + sku_code varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', received_quantity bigint NOT NULL COMMENT '本次SKU实收数量', PRIMARY KEY (id), KEY idx_ll_return_receipt_item_receipt (receipt_id), @@ -3170,9 +3256,21 @@ CREATE TABLE ll_wallet_event_inbox ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='跨区客户资金结果幂等收件箱'; CREATE TABLE ll_warehouse_transfer_carton ( + length_cm DECIMAL(12,2) NULL COMMENT '箱子实测长度厘米', + width_cm DECIMAL(12,2) NULL COMMENT '箱子实测宽度厘米', + height_cm DECIMAL(12,2) NULL COMMENT '箱子实测高度厘米', + gross_weight_kg DECIMAL(12,2) NULL COMMENT '箱子实测重量千克', + counted_quantity BIGINT NULL COMMENT '箱内SKU实点件数', + count_idempotency_key VARCHAR(255) NULL COMMENT '箱内清点幂等键', + count_request_hash VARCHAR(64) NULL COMMENT '箱内清点请求摘要', + counted_by VARCHAR(128) NULL COMMENT '箱内清点操作人', + counted_at DATETIME(3) NULL COMMENT '箱内清点完成时间', + discrepancy_reason VARCHAR(1000) NULL COMMENT '实收与预报箱数差异原因', + transfer_pallet_id BIGINT NULL COMMENT '签收箱所属转运托盘事实ID', + sku_items_json JSON NULL COMMENT '实收箱内SKU及逐项件数明细', id bigint NOT NULL COMMENT '预报箱主键', transfer_order_id bigint NOT NULL COMMENT '转运单ID', - transfer_item_id bigint NOT NULL COMMENT '转运申报明细ID', + transfer_item_id bigint DEFAULT NULL COMMENT '转运申报明细ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', carton_mark varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '系统生成的一箱一码箱唛', customer_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户原箱唛,用于换标现场绑定', @@ -3213,6 +3311,7 @@ CREATE TABLE ll_warehouse_transfer_inventory_allocation ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转仓单来源库存分配记录'; CREATE TABLE ll_warehouse_transfer_item ( + declared_quantity BIGINT NULL COMMENT 'SKU申报件数,非预计或实收箱数', id bigint NOT NULL COMMENT '主键ID', transfer_order_id bigint NOT NULL COMMENT '转仓单ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', @@ -3249,7 +3348,7 @@ CREATE TABLE ll_warehouse_transfer_order ( pickup_contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '提货联系电话', package_count int NOT NULL COMMENT '箱数', delivery_method varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓方式', - estimated_arrival_at datetime(3) NOT NULL COMMENT '预计入仓时间', + estimated_arrival_at datetime(3) NULL COMMENT '预计入仓时间,库存转运不填写', estimated_ship_at datetime(3) DEFAULT NULL COMMENT '预计发货时间', fba_shipment_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '可选FBA货件编号', tracking_no varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '物流跟踪号', @@ -3907,10 +4006,332 @@ CREATE TABLE ll_procurement_closure_item ( CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 181 AS initialized_tables; +USE baiyue_lastleg; + +-- 平台自营领星统计:独立事实与不可变发布版本;仅空库初始化,不自动执行。 +CREATE TABLE ll_selfop_outbound_discovery_scope ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + catalog_from_utc DATETIME(3) NULL COMMENT '账号完整目录连续覆盖UTC下界,不等同销量覆盖', + catalog_until_utc DATETIME(3) NULL COMMENT '账号完整目录连续覆盖UTC上界,不等同销量覆盖', + next_sequence BIGINT NOT NULL DEFAULT 1 COMMENT '新发现任务的单调调度序号;重试沿用,只安排任务,不判断对象内容的新旧', + next_observation_sequence BIGINT NOT NULL DEFAULT 1 COMMENT '每次实际出库详情请求前分配的单调采集代次;与 task 调度序号独立,重试请求也分配新代次', + active_task_id BIGINT NULL COMMENT '当前共享发现/详情补齐任务', + lease_owner VARCHAR(128) NULL COMMENT '账号发现执行者', + lease_until_utc DATETIME(3) NULL COMMENT '账号发现租约到期时刻', + fence_token BIGINT NOT NULL DEFAULT 0 COMMENT '防止过期发现进程继续写共享暂存或派发任务', + request_budget INT NOT NULL COMMENT '一次调度允许的目录、详情请求总预算,必须为正', + time_budget_seconds INT NOT NULL COMMENT '一次调度允许占用的执行时长,必须为正', + min_request_interval_ms INT NOT NULL COMMENT '同账号请求最小间隔,按已核准配额配置', + api_budget_json JSON NULL COMMENT '账号所有采集族共用的预算、统计日、保守预留及确认消耗、尝试许可和独占交接依据', + api_cooldown_until_utc DATETIME(3) NULL COMMENT '账号全部真实请求共用冷却UTC截止,重启与跨日不清除', + api_pause_reason VARCHAR(64) NULL COMMENT '账号停止或等待的固定原因码,不保存供应商原始错误或凭据', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_discovery_account (source_account), + CONSTRAINT chk_selfop_outbound_discovery_scope CHECK (request_budget > 0 AND time_budget_seconds > 0 AND min_request_interval_ms > 0 AND fence_token >= 0 AND next_sequence > 0 AND next_observation_sequence > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='账号共享出库目录与详情发现协调作用域'; + +CREATE TABLE ll_selfop_sync_scope ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + data_family VARCHAR(16) NOT NULL COMMENT 'OUTBOUND 或 INVENTORY;按账号+仓库+任务族互斥', + scope_json JSON NOT NULL COMMENT '本仓范围规则,customerMode=ALL_AUTHORIZED,库存为全部品质;当前账号全部授权货主均自营,不将当天出现的货主列表当白名单', + scope_hash CHAR(64) NOT NULL COMMENT '仓库、货主范围规则、品质、时区及单位口径的规范化摘要;不包含当天观测到的客户/SKU 集合', + business_timezone VARCHAR(64) NOT NULL COMMENT '本仓报表及调度时区,初始建议值为 Asia/Shanghai,各仓独立核准', + source_timezone VARCHAR(64) NULL COMMENT '本仓已核准的出库源时间时区', + schedule_cron VARCHAR(128) NOT NULL COMMENT '本仓、本任务族的调度表达式,按 business_timezone 执行', + stale_after_seconds INT NOT NULL COMMENT '本仓、本任务族数据过期阈值,必须为正;库存初始可采用 26 小时,后续按仓调整', + warehouse_policy_ref VARCHAR(128) NULL COMMENT '未来按仓告警/补货策略的稳定引用;一期为空,不建告警规则表', + initial_from_utc DATETIME(3) NULL COMMENT '首采固定UTC下界,按本仓业务时区减一个月', + initial_until_utc DATETIME(3) NULL COMMENT '首采固定UTC截止,重试不重新取当前时间', + discovery_from_utc DATETIME(3) NULL COMMENT '本仓已发布的连续销量覆盖UTC下界', + discovery_until_utc DATETIME(3) NULL COMMENT '本仓已发布的连续销量覆盖UTC上界,不含端点', + fact_revision BIGINT NOT NULL DEFAULT 0 COMMENT '出库事实归并修订号;内容实际变化才增加', + published_fact_revision BIGINT NOT NULL DEFAULT 0 COMMENT '本仓最后已发布销量版所使用的事实修订号', + rebuild_required TINYINT NOT NULL DEFAULT 0 COMMENT '事实变化待重建标记;跨仓修订同时置旧、新仓为 1', + invalidated_at_utc DATETIME(3) NULL COMMENT '最近一次因事实修订使当前版待重建的时刻', + published_run_id BIGINT NULL COMMENT '唯一对外可见版本,指向本任务族已发布执行批次', + lease_owner VARCHAR(128) NULL COMMENT '当前执行者令牌,不使用仅进程内锁', + lease_until_utc DATETIME(3) NULL COMMENT '租约到期时刻', + fence_token BIGINT NOT NULL DEFAULT 0 COMMENT '每次取得租约递增,事实提交和发布均校验', + known_order_cursor_id BIGINT NULL COMMENT '本仓已可靠处理的稳定订单内部标识游标', + known_order_round_max_id BIGINT NULL COMMENT '本轮已知单复查固定最大内部标识', + known_order_round_started_at_utc DATETIME(3) NULL COMMENT '本仓已知单复查轮次开始UTC时间', + known_order_round_finished_at_utc DATETIME(3) NULL COMMENT '本仓已知单完整轮转完成UTC时间', + history_round_from_utc DATETIME(3) NULL COMMENT '本仓历史核对固定UTC下界', + history_round_until_utc DATETIME(3) NULL COMMENT '本仓历史核对固定UTC上界', + history_slice_cursor_utc DATETIME(3) NULL COMMENT '本仓下一待核对历史切片UTC位置', + history_round_finished_at_utc DATETIME(3) NULL COMMENT '上一轮历史时间片全部完成时刻', + review_request_budget INT NOT NULL COMMENT '本仓单次复查最大请求数,必须为正', + review_time_budget_seconds INT NOT NULL COMMENT '本仓单次复查最大执行秒数,必须为正', + review_target_days INT NOT NULL DEFAULT 7 COMMENT '建议每 7 天轮转一遍,属于待配额验证的目标,不保证首次大量详情在单次调度内完成', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_scope (source_account,warehouse_code,data_family), + KEY idx_selfop_scope_lease (lease_until_utc), + CONSTRAINT chk_selfop_sync_scope CHECK (data_family IN ('OUTBOUND','INVENTORY') AND stale_after_seconds > 0 AND review_request_budget > 0 AND review_time_budget_seconds > 0 AND review_target_days > 0 AND fact_revision >= 0 AND published_fact_revision >= 0 AND fence_token >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='分仓任务族租约及发布指针'; + +CREATE TABLE ll_selfop_sync_task ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + task_no VARCHAR(64) NOT NULL COMMENT '稳定采集业务任务号,重试沿用,不展示内部数据库标识', + execution_purpose VARCHAR(16) NOT NULL COMMENT 'BUSINESS或VALIDATION;由服务端冻结用途,子任务继承', + validation_root_task_id BIGINT NULL COMMENT '验证会话唯一预算根任务,根引用自身,正式任务为空', + validation_budget_json JSON NULL COMMENT '验证根冻结硬预算、累计消耗、库存分仓页数、截止时刻及停止原因', + business_root_task_id BIGINT NULL COMMENT '测试人工正式采集共享预算根任务;生产任务为空', + business_budget_json JSON NULL COMMENT '仅测试正式采集根保存冻结请求上限、截止时间、消耗及停止原因', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + task_role VARCHAR(24) NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC', + scope_id BIGINT NULL COMMENT '分仓任务必须引用分仓作用域;仅账号共享发现为空', + warehouse_code VARCHAR(64) NULL COMMENT '来源仓库原始编码,按仓独立统计', + discovery_scope_id BIGINT NULL COMMENT '共享发现必填,引用账号发现协调器', + upstream_task_id BIGINT NULL COMMENT '出库分仓任务引用已持久化目录的共享发现切片;库存无此上游', + discovery_sequence BIGINT NULL COMMENT '共享任务及分仓子任务继承的调度顺序;重试保持不变,不用于事实覆盖判定', + observation_baseline BIGINT NULL COMMENT '共享任务建立时已分配的最大详情采集代次,分仓子任务继承;用于识别旧任务恢复期间出现的其他任务新观测', + request_key VARCHAR(128) NOT NULL COMMENT '共享发现包含切片/计划槽位;分仓任务包含仓库、任务族及切片或计划槽位;人工附请求幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求类型、查询范围、范围摘要的指纹;同键不同请求拒绝', + task_type VARCHAR(24) NOT NULL COMMENT '计划、补采、复核、重建与验证;INVENTORY_FLOW_HISTORY为流水片,INVENTORY_HISTORY_BUILD为本地历史还原', + planned_at_utc DATETIME(3) NULL COMMENT '计划时点;人工请求可空', + query_from_utc DATETIME(3) NULL COMMENT '出库查询区间UTC下界,库存不填', + query_until_utc DATETIME(3) NULL COMMENT '出库查询区间UTC上界,不含端点,库存不填', + query_scope_json JSON NOT NULL COMMENT '不变的账号、货主、仓库、品质和查询条件快照;按单补查在此记录单号', + routing_manifest_json JSON NULL COMMENT '共享切片目录完整标记、每仓对象键集合/数量和详情就绪状态;各对象记录 effectiveStageId,需新代次复查时记录 refreshTaskId,完成后指向新有效暂存;不改写旧载荷', + scope_hash CHAR(64) NOT NULL COMMENT '建任务时的业务范围摘要', + status VARCHAR(20) NOT NULL COMMENT 'WAITING_SOURCE、READY、RUNNING、RETRY_WAIT、SUCCEEDED、DEAD、SAMPLE_DONE;样本结束不等于正式成功', + apply_state VARCHAR(20) NOT NULL COMMENT '正式出库归并NOT_STARTED、APPLYING、BLOCKED、APPLIED;共享发现、库存、重建及验证为NOT_APPLICABLE', + affected_warehouse_codes_json JSON NULL COMMENT '本次归并可能改变的仓集合,包含原仓与新仓;首段事实写入前登记,扩大集合须先取得新增仓租约并锁 scope,不能写完再补', + apply_started_at_utc DATETIME(3) NULL COMMENT '首段事实提交UTC时间', + apply_completed_at_utc DATETIME(3) NULL COMMENT '全部必需事实归并完成UTC时间,失败不填', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '已创建的执行尝试数', + next_retry_at_utc DATETIME(3) NULL COMMENT '有限退避后下一次可执行时刻', + requested_by VARCHAR(128) NULL COMMENT '人工请求的平台操作人,仅内部审计', + result_published_run_id BIGINT NULL COMMENT '正式成功任务实际发布或复用的版本;验证和共享发现为空', + progress_json JSON NULL COMMENT '与可靠检查点一起提交的有界阶段、单位、尝试和质量进度,不保存单据集合', + progress_updated_at_utc DATETIME(3) NULL COMMENT '最后可靠业务检查点UTC时间,与执行心跳分开', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_task_no (task_no), + UNIQUE KEY uk_selfop_task_request (source_account,request_key), + KEY idx_selfop_task_poll (status,next_retry_at_utc,id), + KEY idx_selfop_task_upstream (upstream_task_id,warehouse_code), + KEY idx_selfop_task_apply (apply_state,id), + KEY idx_selfop_task_scope_list (scope_id,created_at,id), + KEY idx_selfop_task_account_list (source_account,task_role,created_at,id), + KEY idx_selfop_task_business_root (business_root_task_id,status,id), + CONSTRAINT chk_selfop_business_budget CHECK ((business_root_task_id IS NULL AND business_budget_json IS NULL) OR (execution_purpose='BUSINESS' AND business_root_task_id IS NOT NULL AND ((business_root_task_id=id AND business_budget_json IS NOT NULL) OR (business_root_task_id<>id AND business_budget_json IS NULL)))), + CONSTRAINT chk_selfop_sync_task CHECK (attempt_count >= 0 AND execution_purpose IN ('BUSINESS','VALIDATION') AND ((execution_purpose = 'BUSINESS' AND validation_root_task_id IS NULL AND validation_budget_json IS NULL) OR (execution_purpose = 'VALIDATION' AND validation_root_task_id IS NOT NULL AND apply_state = 'NOT_APPLICABLE' AND result_published_run_id IS NULL AND status <> 'SUCCEEDED')) AND ((task_role = 'ACCOUNT_DISCOVERY' AND scope_id IS NULL AND warehouse_code IS NULL AND discovery_scope_id IS NOT NULL) OR (task_role = 'WAREHOUSE_SYNC' AND scope_id IS NOT NULL AND warehouse_code IS NOT NULL))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='一次计划或人工请求'; + +CREATE TABLE ll_selfop_sync_run ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + task_id BIGINT NOT NULL COMMENT '本次执行请求', + execution_purpose VARCHAR(16) NOT NULL COMMENT '从逻辑任务冻结继承,验证尝试不能作为正式统计版本', + scope_id BIGINT NULL COMMENT '仅账号共享发现为空;分仓执行必须有作用域', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + warehouse_code VARCHAR(64) NULL COMMENT '来源仓库原始编码,按仓独立统计', + run_role VARCHAR(24) NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC', + upstream_task_id BIGINT NULL COMMENT '分仓出库计算引用的共享切片及其稳定暂存集合', + attempt_no INT NOT NULL COMMENT '同一请求从 1 起递增', + scope_hash CHAR(64) NOT NULL COMMENT '本版本的业务范围摘要', + scope_snapshot_json JSON NOT NULL COMMENT '分仓版只含本仓货主/品质、固定首采 [B,E0)、时区、调度与过期阈值;账号发现保存待路由仓集合,不冒充业务版', + observed_dimensions_json JSON NULL COMMENT '本次实际出现的仓库/货主集合及数量摘要,仅作采集证据;例如已观测 70 货主不等于固定授权范围', + fence_token BIGINT NOT NULL COMMENT '本尝试持有的栅栏版本', + base_published_run_id BIGINT NULL COMMENT '执行开始时的上一可见版本', + reference_sales_run_id BIGINT NULL COMMENT '库存构建开始时固定的本仓已发布销量版,用其不可变 SKU 维度生成出库已知商品占位;没有已发布销量版时为空,不读取半归并订单事实', + inventory_daily_sample_date DATE NULL COMMENT '北京时间当日首个合格计划库存发布代表日期,非代表为空', + inventory_sample_rule_version VARCHAR(32) NULL COMMENT '日库存代表选择规则 DAILY_SCHEDULED_FIRST_V1,与日标记原子保存', + status VARCHAR(24) NOT NULL COMMENT 'FETCHING、VALIDATED、ROUTED、FACTS_APPLIED、BUILDING、READY、PUBLISHED、REUSED、YIELDED、FAILED、ABORTED、SAMPLE_DONE;复用和验证不是版本头', + last_phase VARCHAR(24) NOT NULL COMMENT '失败/中止前所在阶段,支持判断是否已经归并事实', + fetch_started_at_utc DATETIME(3) NULL COMMENT '实际采集开始UTC时间', + fetch_finished_at_utc DATETIME(3) NULL COMMENT '实际采集完成UTC时间,未完成为空', + build_started_at_utc DATETIME(3) NULL COMMENT '实际开始有界候选构建UTC时间,用于构建耗时及首采合并间隔', + warehouse_daily_ready TINYINT NOT NULL DEFAULT 0 COMMENT '仓库日汇总已完整构建并校验,含完整空版本', + finished_at_utc DATETIME(3) NULL COMMENT '本次尝试终止UTC时间', + published_at_utc DATETIME(3) NULL COMMENT '实际原子切换发布指针UTC时间', + superseded_at_utc DATETIME(3) NULL COMMENT '被新发布版替代的首次UTC时间,同事务填写且重试不重置', + page_count INT NOT NULL DEFAULT 0 COMMENT '已完整获取页数', + source_total_count BIGINT NULL COMMENT '上游可靠返回的总数;未返回则为空', + unique_object_count BIGINT NOT NULL DEFAULT 0 COMMENT '规范化去重后对象数', + progress_snapshot_json JSON NULL COMMENT '尝试终止时冻结的有界可靠进度摘要,历史尝试不随逻辑任务改写', + heartbeat_at_utc DATETIME(3) NULL COMMENT '有效执行者最新UTC心跳,不代表业务检查点推进', + coverage_status VARCHAR(16) NOT NULL COMMENT 'UNKNOWN、PARTIAL、COMPLETE;分页完整与数值有效性分别判断', + covered_from_utc DATETIME(3) NULL COMMENT '本仓销量版本实际连续覆盖UTC下界', + covered_until_utc DATETIME(3) NULL COMMENT '本仓销量版本实际连续覆盖UTC上界,不含端点', + complete_day_from DATE NULL COMMENT '本仓完整自然日区间首日,包含', + complete_day_until DATE NULL COMMENT '本仓完整自然日区间末日,不含', + applied_fact_revision BIGINT NULL COMMENT '本次尝试归并后的事实修订号,库存为空', + calculated_fact_revision BIGINT NULL COMMENT '候选销量版本计算所依据的固定事实修订号', + error_code VARCHAR(64) NULL COMMENT '内部可诊断错误分类,不保存认证信息', + error_message VARCHAR(500) NULL COMMENT '脱敏后的固定错误摘要', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_run_attempt (task_id,attempt_no), + UNIQUE KEY uk_selfop_inventory_daily (source_account,warehouse_code,inventory_daily_sample_date), + KEY idx_selfop_run_scope (scope_id,id), + KEY idx_selfop_run_status (status,updated_at), + CONSTRAINT chk_selfop_inventory_daily CHECK ((inventory_daily_sample_date IS NULL AND inventory_sample_rule_version IS NULL) OR (inventory_daily_sample_date IS NOT NULL AND inventory_sample_rule_version IS NOT NULL AND inventory_sample_rule_version = 'DAILY_SCHEDULED_FIRST_V1' AND execution_purpose = 'BUSINESS' AND run_role = 'WAREHOUSE_SYNC' AND status = 'PUBLISHED' AND coverage_status = 'COMPLETE')), + CONSTRAINT chk_selfop_sync_run CHECK (attempt_no > 0 AND page_count >= 0 AND unique_object_count >= 0 AND execution_purpose IN ('BUSINESS','VALIDATION') AND (execution_purpose <> 'VALIDATION' OR status NOT IN ('PUBLISHED','REUSED')) AND (source_total_count IS NULL OR source_total_count >= 0) AND ((run_role = 'ACCOUNT_DISCOVERY' AND scope_id IS NULL AND warehouse_code IS NULL) OR (run_role = 'WAREHOUSE_SYNC' AND scope_id IS NOT NULL AND warehouse_code IS NOT NULL))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='执行尝试、覆盖证明和版本头'; + +CREATE TABLE ll_selfop_sync_stage ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + run_id BIGINT NOT NULL COMMENT '首次写入该暂存对象的执行尝试', + stage_batch_id BIGINT NOT NULL COMMENT '出库取共享发现 task.id,便于断点续取;库存取本次 run.id,严禁跨尝试混页', + last_fetch_run_id BIGINT NOT NULL COMMENT '最后一次补齐该对象的执行尝试,保留重试来源', + observation_sequence BIGINT NULL COMMENT '该完整出库详情实际请求的采集代次;有效出库对象必填,目录待补对象和库存为空;不能填 task 调度序号', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + previous_warehouse_code VARCHAR(64) NULL COMMENT '路由时从已有单据识别的原仓库;归并前再校验,跨仓时通知两仓', + object_type VARCHAR(16) NOT NULL COMMENT 'OUTBOUND_ORDER 或 PRODUCT_STOCK', + object_key_hash CHAR(64) NOT NULL COMMENT '规范化业务键摘要;原业务键保留在载荷中并核对', + first_page_no INT NOT NULL COMMENT '首次发现页码', + observed_at_utc DATETIME(3) NOT NULL COMMENT '实际接收该对象的时刻', + content_hash CHAR(64) NOT NULL COMMENT '完整规范化对象摘要', + payload_json JSON NOT NULL COMMENT '目录阶段可先存订单头,详情齐后补完整 SKU;VALID 后不再修改;库存保存产品库存字段', + before_payload_json JSON NULL COMMENT '订单归并事务内记录归并前的完整规范化事实;新增单为空', + validation_status VARCHAR(16) NOT NULL COMMENT '暂存校验状态:PENDING待核验、VALID有效、INVALID异常', + validation_code VARCHAR(64) NULL COMMENT '字段缺失、冲突、未知时区等原因', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_stage_object (stage_batch_id,object_type,object_key_hash), + KEY idx_selfop_stage_history (object_type,object_key_hash,stage_batch_id), + KEY idx_selfop_stage_observation (object_type,object_key_hash,validation_status,observation_sequence), + KEY idx_selfop_stage_fact_observation (observation_sequence,warehouse_code,object_type,validation_status,run_id,observed_at_utc), + KEY idx_selfop_stage_route (stage_batch_id,warehouse_code,validation_status), + CONSTRAINT chk_selfop_sync_stage CHECK (first_page_no > 0 AND (object_type <> 'OUTBOUND_ORDER' OR validation_status <> 'VALID' OR observation_sequence IS NOT NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='待验证事实与修订证据'; + +CREATE TABLE ll_selfop_outbound_order ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + delivery_no VARCHAR(128) NOT NULL COMMENT 'OMP 发货单号,采用列表及详情同一身份', + source_no VARCHAR(128) NULL COMMENT 'WMS 来源出库单号,缺失不以内部 ID 回退展示', + source_status INT NOT NULL COMMENT '原始状态,100 已出库、99 已取消;不改造成内部状态码', + source_created_time VARCHAR(64) NULL COMMENT '来源创建时间原始字符串,不使用采集时间替代', + source_outbound_time VARCHAR(64) NULL COMMENT '来源实际出库时间原始字符串,不使用采集时间替代', + source_timezone VARCHAR(64) NULL COMMENT '解析采用的源时区', + outbound_at_utc DATETIME(3) NULL COMMENT '已核准的实际出库时间点;非出库状态可空', + business_date DATE NULL COMMENT '已核准业务时区下的出库日', + business_timezone VARCHAR(64) NOT NULL COMMENT '该事实当前归属仓所用业务时区,迁仓后重新确定业务日', + sales_eligible TINYINT NOT NULL DEFAULT 0 COMMENT '仅已出库、时间与全部数量有效时为 1', + revision_no BIGINT NOT NULL DEFAULT 1 COMMENT '该单内容变更次数,不等于拉取次数', + content_hash CHAR(64) NOT NULL COMMENT '订单头及完整 SKU 明细规范化摘要', + last_run_id BIGINT NOT NULL COMMENT '最后一次确认或修订的执行尝试', + last_observation_sequence BIGINT NOT NULL COMMENT '最后接受的完整详情采集代次;仅防旧观测重放,不按 task 调度序号拒绝较晚实际读取的事实,不冒充领星源版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_order (source_account,customer_code,delivery_no), + KEY idx_selfop_order_daily (source_account,warehouse_code,sales_eligible,business_date,id), + KEY idx_selfop_order_run (last_run_id), + CONSTRAINT chk_selfop_outbound_order CHECK (revision_no > 0 AND last_observation_sequence > 0 AND sales_eligible IN (0,1) AND (sales_eligible = 0 OR (source_status = 100 AND source_timezone IS NOT NULL AND outbound_at_utc IS NOT NULL AND business_date IS NOT NULL))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='最后确认的单据事实'; + +CREATE TABLE ll_selfop_outbound_item ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + order_id BIGINT NOT NULL COMMENT '对应外部出库事实', + sku_code VARCHAR(128) NOT NULL COMMENT '原始 productSku', + product_name VARCHAR(255) NULL COMMENT '来源商品名称,仅作展示', + actual_quantity DECIMAL(20,6) NOT NULL COMMENT '详情 actualDeliveryQty,必须为非负有效数值', + quantity_unit VARCHAR(32) NOT NULL COMMENT '已核准业务单位;未确认单位不发布正式数量统计', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_order_sku (order_id,sku_code), + KEY idx_selfop_item_sku (sku_code,order_id), + CONSTRAINT chk_selfop_outbound_item CHECK (actual_quantity >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='整单替换的实际出库明细'; + +CREATE TABLE ll_selfop_inventory_snapshot ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + snapshot_run_id BIGINT NOT NULL COMMENT 'INVENTORY 执行尝试;发布后该批明细不可修改', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU', + product_quality SMALLINT NOT NULL COMMENT '来源品质,当前 0 正品、1 次品;全量均保存,不套用整箱库存', + product_name VARCHAR(255) NULL COMMENT '来源名称', + physical_quantity DECIMAL(20,6) NULL COMMENT '来源stkQty实物库存,未知为空', + available_quantity DECIMAL(20,6) NULL COMMENT '来源useStkQty可用库存,未知为空,不再次扣冻结', + frozen_quantity DECIMAL(20,6) NULL COMMENT '来源freezeQty冻结库存,未知为空', + transit_quantity DECIMAL(20,6) NULL COMMENT '来源transitQty在途数量,未知为空,不计可售库存', + source_unique_key VARCHAR(512) NULL COMMENT '原 uniqueKey4tenant,只作来源证据', + observed_at_utc DATETIME(3) NOT NULL COMMENT '所在页实际采集时间;缺行占位取本批采集完成时间', + quality_status VARCHAR(16) NOT NULL COMMENT 'VALID、UNKNOWN、INVALID;STALE 按批次年龄实时派生', + quality_reason VARCHAR(64) NULL COMMENT 'SOURCE_ROW_MISSING、FIELD_MISSING、NEGATIVE_QUANTITY 等', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_stock_snapshot (snapshot_run_id,source_account,customer_code,warehouse_code,sku_code,product_quality), + KEY idx_selfop_stock_lookup (source_account,customer_code,warehouse_code,sku_code,product_quality,snapshot_run_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='产品库存全量批次明细'; + +CREATE TABLE ll_selfop_sku_daily_sales ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + sales_run_id BIGINT NOT NULL COMMENT 'OUTBOUND 执行尝试,同时是计算版本', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU', + business_date DATE NOT NULL COMMENT '已核准业务日', + outbound_quantity DECIMAL(20,6) NOT NULL COMMENT '有效出库实际件数之和,非负', + outbound_order_count BIGINT NOT NULL COMMENT '该维度去重发货单数,非负', + quantity_unit VARCHAR(32) NOT NULL COMMENT '同一统计维度内一致的业务单位', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_daily_version (sales_run_id,source_account,customer_code,warehouse_code,sku_code,business_date), + KEY idx_selfop_daily_window_cover (sales_run_id,source_account,warehouse_code,business_date,customer_code,sku_code,outbound_quantity,outbound_order_count,quantity_unit), + KEY idx_selfop_daily_sku_cover (sales_run_id,source_account,warehouse_code,customer_code,sku_code,business_date,outbound_quantity,outbound_order_count,quantity_unit), + CONSTRAINT chk_selfop_sku_daily_sales CHECK (outbound_quantity >= 0 AND outbound_order_count >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本的稀疏日事实'; + +CREATE TABLE ll_selfop_warehouse_daily_sales ( + sales_run_id BIGINT NOT NULL COMMENT '完整销量计算版本标识', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码', + business_date DATE NOT NULL COMMENT '已核准业务日', + observed_quantity DECIMAL(38,6) NULL COMMENT '该日PCS非负有效出库量之和,无有效行时为空', + invalid_row_count BIGINT NOT NULL COMMENT '该日数量为空或负数或单位非PCS的日事实行数', + source_row_count BIGINT NOT NULL COMMENT '该日参与仓库汇总的SKU日事实总行数', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + PRIMARY KEY (sales_run_id,source_account,warehouse_code,business_date), + CONSTRAINT chk_selfop_warehouse_daily_sales CHECK (source_row_count > 0 AND invalid_row_count >= 0 AND invalid_row_count <= source_row_count AND (observed_quantity IS NULL OR observed_quantity >= 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本按仓按日汇总,随SKU日事实批次原子构建'; + +CREATE TABLE ll_selfop_sku_profile ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + profile_run_id BIGINT NOT NULL COMMENT '本仓销量或库存候选版本;与对应 daily/snapshot 使用同一 run,发布后该版本全部维度行不可修改', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU', + product_name VARCHAR(255) NULL COMMENT '本版本确认的名称,不实时连接其他版本名称', + quantity_unit VARCHAR(32) NULL COMMENT '本版本已核准单位;未知或两版本单位冲突时不计算混合指标', + first_seen_at_utc DATETIME(3) NULL COMMENT '本证据族真实观测到本仓商品的最早接收时刻;缺行占位不能生成观察证据', + earliest_evidence_at_utc DATETIME(3) NULL COMMENT '库存版取真实库存首次观察时刻;销量版从该版有效事实重新求最早已核准出库时间,可随改期、取消或迁仓撤回/后移', + evidence_type VARCHAR(24) NOT NULL COMMENT 'INVENTORY_OBSERVATION、VALID_OUTBOUND、NONE;不得将销量证据复制成库存真实观察证据', + evidence_run_id BIGINT NULL COMMENT '本证据族真实依据的采集批次,无证据占位为空', + last_seen_run_id BIGINT NULL COMMENT '本证据族最近真实观测批次,无证据占位为空', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_profile_version (profile_run_id,source_account,customer_code,warehouse_code,sku_code), + KEY idx_selfop_profile_evidence (profile_run_id,earliest_evidence_at_utc,customer_code,sku_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='随发布版本固定的 SKU 维度与观察依据'; -CREATE TABLE IF NOT EXISTS ll_appointment_impact ( + +CREATE TABLE ll_appointment_impact ( container_booking_id VARCHAR(64) NOT NULL COMMENT '稳定柜次关联键,不作业务编号展示', impact_no VARCHAR(40) NOT NULL COMMENT '船期核对事项业务号', baseline_json LONGTEXT NOT NULL COMMENT '已核对基准或首次变化前观察,原来源时间口径', @@ -3928,7 +4349,7 @@ CREATE TABLE IF NOT EXISTS ll_appointment_impact ( CONSTRAINT chk_appointment_impact_status CHECK (status IN ('MONITORING','OPEN','RESOLVED')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='一柜一个稳定船期变化核对事项,不改写预约或运输'; -CREATE TABLE IF NOT EXISTS ll_appointment_impact_history ( +CREATE TABLE ll_appointment_impact_history ( id BIGINT NOT NULL COMMENT '历史记录标识', container_booking_id VARCHAR(64) NOT NULL COMMENT '关联柜次标识', version INT NOT NULL COMMENT '记录版本', @@ -3948,3 +4369,256 @@ CREATE TABLE IF NOT EXISTS ll_appointment_impact_history ( CONSTRAINT fk_appointment_impact_history FOREIGN KEY (container_booking_id) REFERENCES ll_appointment_impact(container_booking_id), CONSTRAINT chk_appointment_impact_action CHECK (action IN ('OBSERVED','CLAIM','RECORD','RESOLVE')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='船期观察及人工处理追加历史'; + +-- SKU 换标实物转换:先执行本增量,再部署服务。不会自动改写存量业务事实。 +CREATE TABLE ll_return_sku_conversion ( + id BIGINT NOT NULL COMMENT 'SKU库存转换记录ID', + customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID', + application_id BIGINT NOT NULL COMMENT '换标申请ID', + relabel_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标单号', + item_id BIGINT NOT NULL COMMENT '换标箱级明细ID', + round_key VARCHAR(64) NOT NULL COMMENT '作业轮次,退回重做使用新轮次', + carton_id BIGINT NOT NULL COMMENT '实物来源包裹或箱ID', + carton_mark VARCHAR(128) NOT NULL COMMENT '完工时仓库识别码', + old_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标前SKU', + new_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标后SKU', + product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名快照', + quantity BIGINT NOT NULL COMMENT '实际完成转换件数', + status VARCHAR(32) NOT NULL COMMENT 'APPLIED待客户确认/CONFIRMED已确认/REVERSED退回冲回', + ownership_json LONGTEXT NOT NULL COMMENT '本次转换件数的来源退货单快照,供精确冲回', + completed_at DATETIME(3) NOT NULL COMMENT '实物SKU转换时间', + confirmed_at DATETIME(3) NULL COMMENT '客户确认时间', + reversed_at DATETIME(3) NULL COMMENT '退回冲回时间', + PRIMARY KEY(id), + UNIQUE KEY uk_return_sku_conversion_round(item_id,round_key,old_sku_code,new_sku_code), + KEY idx_return_sku_conversion_app(customer_id,application_id,status), + KEY idx_return_sku_conversion_carton(customer_id,carton_id), + CONSTRAINT chk_return_sku_conversion_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物SKU换标库存转换和冲回记录'; + +-- PDA cargo discard photo evidence +CREATE TABLE ll_cargo_discard_photo ( + id BIGINT NOT NULL COMMENT '照片记录标识', + customer_id VARCHAR(64) NOT NULL COMMENT '所属客户标识', + application_id BIGINT NOT NULL COMMENT '废弃申请标识', + pallet_no VARCHAR(128) NOT NULL COMMENT '托盘业务码', + file_id BIGINT NOT NULL COMMENT '照片文件标识', + file_name VARCHAR(255) NOT NULL COMMENT '原始文件名', + content_type VARCHAR(128) NOT NULL COMMENT '文件媒体类型', + size_bytes BIGINT NOT NULL COMMENT '文件字节数', + operator_id VARCHAR(64) NOT NULL COMMENT '上传操作人标识', + created_at DATETIME(6) NOT NULL COMMENT '照片创建时间', + submitted_at DATETIME(6) NULL COMMENT '照片提交时间', + PRIMARY KEY (id), + UNIQUE KEY uk_cargo_discard_photo_file (file_id), + KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA货物废弃照片凭证'; + +-- 货物废弃支持所有入库来源及散箱全量作业;不回填或修改历史单据。 + + + + + + +CREATE TABLE ll_selfop_owner ( + id BIGINT NOT NULL COMMENT '主键', + run_id BIGINT NOT NULL COMMENT '完整资料采集版本', + source_account VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源账号', + customer_code VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主编码', + customer_name VARCHAR(255) NULL COMMENT '领星货主名称', + source_status VARCHAR(32) NULL COMMENT '来源启停状态', + observed_at_utc DATETIME(6) NOT NULL COMMENT '资料观测时间', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_owner_version (run_id,source_account,customer_code), + KEY idx_selfop_owner_lookup (source_account,customer_code,run_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星货主资料版本'; + +CREATE TABLE ll_selfop_product ( + id BIGINT NOT NULL COMMENT '主键', + run_id BIGINT NOT NULL COMMENT '完整资料采集版本', + source_account VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源账号', + customer_code VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主编码', + sku_code VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '货主商品编码', + product_name VARCHAR(255) NULL COMMENT '商品名称', + declared_length DECIMAL(24,6) NULL COMMENT '商品资料长度', + declared_width DECIMAL(24,6) NULL COMMENT '商品资料宽度', + declared_height DECIMAL(24,6) NULL COMMENT '商品资料高度', + declared_size_unit VARCHAR(32) NULL COMMENT '商品资料尺寸单位', + measured_length DECIMAL(24,6) NULL COMMENT '仓库实测长度', + measured_width DECIMAL(24,6) NULL COMMENT '仓库实测宽度', + measured_height DECIMAL(24,6) NULL COMMENT '仓库实测高度', + measured_size_unit VARCHAR(32) NULL COMMENT '仓库实测尺寸单位', + observed_at_utc DATETIME(6) NOT NULL COMMENT '资料观测时间', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_product_version (run_id,source_account,customer_code,sku_code), + KEY idx_selfop_product_lookup (source_account,customer_code,sku_code,run_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星商品资料版本'; + + +-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。 +USE baiyue_lastleg; + + + +-- SKU 申报件数独立于预计箱数;历史未申报件数保持 NULL,实收以 PDA 清点为准。 + +USE baiyue_lastleg; + +-- 历史库存基线;执行前必须取得指定环境的建表授权。 +CREATE TABLE ll_selfop_inventory_flow ( + id BIGINT NOT NULL COMMENT '应用生成的流水记录标识', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码', + customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码', + sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码', + source_flow_id VARCHAR(64) NOT NULL COMMENT '来源流水字符串标识', + product_quality INT NOT NULL COMMENT '来源品质,零正品一次品,其他未知', + cell_no VARCHAR(128) NULL COMMENT '来源库位编码', + inner_batch_no VARCHAR(128) NULL COMMENT '来源内部批次号', + bill_no VARCHAR(128) NULL COMMENT '来源业务单号', + source_no VARCHAR(128) NULL COMMENT '来源关联单号', + bill_type VARCHAR(32) NULL COMMENT '来源业务类型,未验证类型不得参与正式还原', + book_qty DECIMAL(20,6) NULL COMMENT '来源记账增减量,未知为空', + book_change_qty_raw VARCHAR(64) NULL COMMENT '来源带符号变动量原文', + delta_quantity DECIMAL(20,6) NULL COMMENT '解析后的有符号实物变动量', + close_quantity DECIMAL(20,6) NULL COMMENT '对应库位批次品质链的结存', + source_create_time VARCHAR(64) NULL COMMENT '来源北京时间原文', + event_at_utc DATETIME(3) NULL COMMENT '流水发生UTC时刻,无法解析时为空', + business_date DATE NULL COMMENT '北京时间业务日期', + quantity_unit VARCHAR(16) NULL COMMENT '已确认数量单位,未知为空', + content_hash CHAR(64) NOT NULL COMMENT '当前事实内容摘要', + revision_no BIGINT NOT NULL COMMENT '同来源流水单调增加的修订号', + stage_id BIGINT NOT NULL COMMENT '当前事实对应不可变暂存证据', + first_run_id BIGINT NOT NULL COMMENT '首次观察执行尝试', + last_run_id BIGINT NOT NULL COMMENT '最近确认执行尝试', + last_observed_at DATETIME(3) NOT NULL COMMENT '最近观察UTC时刻', + validation_status VARCHAR(16) NOT NULL COMMENT '字段资格,有效或未知', + validation_reason VARCHAR(64) NULL COMMENT '数量或身份待核验原因', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_flow_source (source_account,warehouse_code,source_flow_id), + KEY idx_selfop_flow_sku (source_account,warehouse_code,customer_code,sku_code,event_at_utc), + KEY idx_selfop_flow_day (source_account,warehouse_code,business_date), + CONSTRAINT chk_selfop_flow_revision CHECK (revision_no > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='自营历史库存流水最新事实,旧版本保留于暂存证据'; + +CREATE TABLE ll_selfop_inventory_day_version ( + id BIGINT NOT NULL COMMENT '不可变历史日版本标识', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码', + business_date DATE NOT NULL COMMENT '北京时间日末所属日期', + scope_hash CHAR(64) NOT NULL COMMENT '业务范围摘要', + rule_version VARCHAR(32) NOT NULL COMMENT '日末库存还原规则版本', + build_run_id BIGINT NOT NULL COMMENT '历史重建执行尝试', + publication_group_id BIGINT NOT NULL COMMENT '必须一起切换的发布组标识', + sales_run_id BIGINT NULL COMMENT '构建时固定的销量证据版本', + anchor_run_id BIGINT NOT NULL COMMENT '固定的独立库存锚点版本', + input_hash CHAR(64) NOT NULL COMMENT '冻结流水修订与覆盖及锚点输入摘要', + evidence_json JSON NOT NULL COMMENT '固定输入清单及不可变证据引用', + status VARCHAR(16) NOT NULL COMMENT '构建中、就绪、已发布、已替代或失败', + head_slot TINYINT NULL COMMENT '当前日头为一,非当前版本为空', + valid_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '合格商品数', + unknown_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '待核验商品数', + created_at DATETIME(3) NOT NULL COMMENT '版本创建UTC时刻', + published_at DATETIME(3) NULL COMMENT '发布组提交UTC时刻', + superseded_at DATETIME(3) NULL COMMENT '被新组替代的UTC时刻', + invalidated_at DATETIME(3) NULL COMMENT '已确认输入失效的UTC时刻', + invalidation_reason VARCHAR(64) NULL COMMENT '旧版本失效原因', + rebuild_run_id BIGINT NULL COMMENT '处理失效版本的重建尝试', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_history_head (source_account,warehouse_code,business_date,head_slot), + UNIQUE KEY uk_selfop_history_build_day (build_run_id,business_date), + KEY idx_selfop_history_group (publication_group_id,status), + CONSTRAINT chk_selfop_history_head CHECK (head_slot IS NULL OR (head_slot=1 AND status='PUBLISHED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日库存不可变版本及原子发布日头'; + +CREATE TABLE ll_selfop_inventory_daily ( + day_version_id BIGINT NOT NULL COMMENT '所属历史日版本', + customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码', + sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码', + product_quality INT NOT NULL COMMENT '来源库存品质', + physical_quantity DECIMAL(20,6) NULL COMMENT '日末实物库存,未知为空,已确认零为零', + quality_status VARCHAR(16) NOT NULL COMMENT '日末资格,有效或未知', + quality_reason VARCHAR(64) NULL COMMENT '缺覆盖或锚点等未知原因', + anchor_reference BIGINT NULL COMMENT '独立库存锚点版本', + evidence_hash CHAR(64) NOT NULL COMMENT '该日计算证据摘要', + source VARCHAR(32) NOT NULL COMMENT '库存来源标记,流水还原', + PRIMARY KEY (day_version_id,customer_code,sku_code,product_quality), + KEY idx_selfop_daily_identity (customer_code,sku_code,day_version_id), + CONSTRAINT chk_selfop_daily_quantity CHECK ((quality_status='VALID' AND physical_quantity IS NOT NULL AND physical_quantity>=0) OR (quality_status='UNKNOWN' AND physical_quantity IS NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日末分货主商品品质实物库存'; + +-- 仓储转运一箱多SKU清点;仅增加结构,不回填历史业务数据。 +USE baiyue_lastleg; + + +-- Appointment work scope, audit and physical batch allocation. +USE baiyue_lastleg; +CREATE TABLE ll_dispatch_appointment_allocation ( + dispatch_id BIGINT NOT NULL COMMENT '排车单标识', + dispatch_item_id BIGINT NOT NULL COMMENT '排车明细标识', + plan_pallet_id BIGINT NOT NULL COMMENT '入仓计划托盘标识', + batch_key VARCHAR(96) NOT NULL COMMENT '预约稳定批次键', + customer_id VARCHAR(64) NOT NULL COMMENT '所属客户标识', + source_key VARCHAR(160) NOT NULL COMMENT '货物来源稳定键', + destination_key VARCHAR(160) NOT NULL COMMENT '目的地稳定键', + package_count INT NOT NULL COMMENT '本票分配实物箱数', + PRIMARY KEY(dispatch_item_id,batch_key,source_key,destination_key), + KEY idx_dispatch_appointment_batch(batch_key,customer_id), + KEY idx_dispatch_appointment_dispatch(dispatch_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车实物与预约批次逐票分配,原执行域事实'; +CREATE TABLE ll_appointment_work_commitment ( + entity_key VARCHAR(160) NOT NULL COMMENT '原业务实体稳定键', + state_json LONGTEXT NOT NULL COMMENT '原业务命令产生的只读语义影响基线,不是第二份可编辑计划', + impact_version BIGINT NOT NULL DEFAULT 0 COMMENT '承诺影响单调递增版本', + PRIMARY KEY (entity_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工作事项承诺影响审计基线'; +CREATE TABLE ll_appointment_work_guard ( + id INT NOT NULL COMMENT '工作事项协调锁,固定1', + scan_cursor VARCHAR(128) NULL COMMENT '已开船来源同步游标', + initialized BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否完成首次完整来源扫描', + scope_version VARCHAR(128) NULL COMMENT '完整同步已确认的权威来源集合版本', + scan_scope_version VARCHAR(128) NULL COMMENT '当前扫描来源集合版本,防跨页漂移', + completed_at DATETIME(3) NULL COMMENT '最近完整同步UTC时间', + requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '原域命令在同一事务推进的需求版本', + synchronized_requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '完整扫描已消费需求版本', + scan_requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '当前扫描需求版本,防跨页漂移', + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项同步与命令串行守卫'; +INSERT INTO ll_appointment_work_guard (id,initialized) VALUES (1,FALSE); + +CREATE TABLE ll_appointment_work ( + work_item_key VARCHAR(96) NOT NULL COMMENT '不可变事项键,业务身份确定性生成', + container_booking_id VARCHAR(64) NOT NULL COMMENT '来源柜次内部关联标识,不作展示编号', + version INT NOT NULL DEFAULT 0 COMMENT '工作命令乐观锁版本', + retired BOOLEAN NOT NULL DEFAULT FALSE COMMENT '已替代或已完成路线需求,不计活动工作', + state_json LONGTEXT NOT NULL COMMENT '工作关系责任跟进及影响基线,不保存可编辑业务执行事实', + PRIMARY KEY (work_item_key), + KEY idx_appointment_work_container (container_booking_id,retired) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项需求与关系'; + +CREATE TABLE ll_appointment_work_history ( + id VARCHAR(36) NOT NULL COMMENT '历史记录标识', + work_item_key VARCHAR(96) NOT NULL COMMENT '事项稳定键', + operator_id VARCHAR(64) NOT NULL COMMENT '真实操作人员标识', + operator_name VARCHAR(128) NULL COMMENT '操作人显示名称', + action VARCHAR(48) NOT NULL COMMENT '工作命令或观察动作', + summary VARCHAR(2000) NOT NULL COMMENT '处理或责任变更依据', + command_key VARCHAR(64) NULL COMMENT '请求幂等键,观察记录为空', + request_hash VARCHAR(64) NULL COMMENT '包含操作者的规范请求摘要', + response_json LONGTEXT NULL COMMENT '原命令响应快照,重放不随当前状态改变', + occurred_at DATETIME(3) NOT NULL COMMENT '历史发生UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_appointment_work_command (work_item_key,command_key), + KEY idx_appointment_work_history (work_item_key,occurred_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令'; + +-- 海外业务短单号分配时钟。 +CREATE TABLE ll_compact_number_clock ( + number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写', + last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒', + PRIMARY KEY (number_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟'; + +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 214 AS initialized_tables; diff --git a/docs/backend-business-index.md b/docs/backend-business-index.md index c28c57c98..fcd5cec42 100644 --- a/docs/backend-business-index.md +++ b/docs/backend-business-index.md @@ -56,3 +56,5 @@ | PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` | 散货托盘码与跨区传递:`firstleg/` 的 `LoadingPalletLabelService.java` 生成冻结标签并调用 Print 输出 PDF,`LoadingCompletedOutboxService.java` 传递装柜快照;`lastleg-service/` 通过 `LastlegSourcePalletMapper.java` 保存来源托盘,`InboundPlanService.java` 和 `ContainerInboundPlanConversionCoordinator.java` 消费。标签与资源预留的细节继续读对应服务指引。 + +平台自营销量、产品库存与采集:正式入口为 ../docs/自营业务/README.md;适配从 integration-service/AGENTS.md 进入,持久化、指标和平台运行查询从 lastleg-service/AGENTS.md 进入 selfoperated/analytics/,权限源为 iam/src/main/resources/db/selfoperated-analytics-permissions.sql。Admin 从 ../apps/admin-web/src/modules/self-operated/AGENTS.md 进入;与客户物流订单和仓储作业库存分开。 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/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index d88862760..52f3dc3ba 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -31,6 +31,7 @@ enum BusinessFileCategory { CONTAINER_DELIVERY_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_UNLOAD_PHOTO(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_CARGO_DISCARD_PHOTO(true, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_TRANSFER_MEASUREMENT_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), diff --git a/file/src/main/java/com/baiyue/file/service/ExportTaskService.java b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java index 2bf1f64fb..dca2e30c0 100644 --- a/file/src/main/java/com/baiyue/file/service/ExportTaskService.java +++ b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java @@ -21,9 +21,11 @@ import java.time.LocalDateTime; import java.util.Locale; import java.util.Map; import java.util.TreeMap; +import java.util.Set; @Service public class ExportTaskService { + private static final String TURNOVER = "SELFOP_INVENTORY_TURNOVER"; @Resource private ExportTaskRepository repository; @Resource private ExportStorage storage; @Resource private ObjectMapper objectMapper; @@ -34,6 +36,7 @@ public class ExportTaskService { public ExportTaskResponse create(AuthenticatedUser user, CreateExportTaskRequest request) { requirePlatform(user); String type = request.exportType().trim().toUpperCase(Locale.ROOT); + if (TURNOVER.equals(type) && user.impersonated()) throw new BizException(ResultCode.FORBIDDEN, "平台自营导出不支持代登录"); String format = request.format().trim().toUpperCase(Locale.ROOT); var definition = registry.require(type); if (!"XLSX".equals(format)) throw new BizException(ResultCode.VALIDATION_FAILED, "当前仅支持 XLSX 格式"); @@ -135,6 +138,9 @@ public class ExportTaskService { } private void requireExportCostAccess(AuthenticatedUser user, FileExportTask task) { + if (TURNOVER.equals(task.getExportType()) && (user.impersonated() || !user.permissions().contains("platform:self-operated-analytics:export"))) { + throw new BizException(ResultCode.FORBIDDEN, "缺少平台自营导出权限"); + } if (!"TRIP_OPERATIONS_LIST".equals(task.getExportType())) return; try { if (objectMapper.readTree(task.getParametersJson()).path("costVisible").asBoolean(false) @@ -172,11 +178,14 @@ public class ExportTaskService { } values.forEach((key, value) -> { if (key == null || key.isBlank() || key.length() > 64) throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选键无效"); - validateParameterValue(value, 1); + if (TURNOVER.equals(type) && "snapshotContext".equals(key)) { + if (!(value instanceof String text) || text.isBlank() || text.length() > 16000) + throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转查询版本无效,请刷新后重新导出"); + } else validateParameterValue(value, 1); }); try { int maxBytes = ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST.equals(type) - ? 512 * 1024 : 4096; + ? 512 * 1024 : TURNOVER.equals(type) ? 64 * 1024 : 4096; if (objectMapper.writeValueAsBytes(values).length > maxBytes) { throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选条件不能超过 " + (maxBytes / 1024) + "KB"); @@ -210,6 +219,22 @@ public class ExportTaskService { } private void validateTypeParameters(String type, Map parameters) { + if (TURNOVER.equals(type)) { + Map values=parameters==null?Map.of():parameters; + if (!values.keySet().containsAll(Set.of("sourceAccount","warehouseCode","endDate","baseWindowDays","snapshotContext","columns","expectedTotal")) + || !Set.of("sourceAccount","warehouseCode","endDate","baseWindowDays","groupBy","customerCodes","sku","focus","coverageDaysThreshold","turnoverStatus","sortBy","hideZeroInventory","snapshotContext","columns","expectedTotal").containsAll(values.keySet())) + throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转导出筛选无效"); + if (!(values.get("columns") instanceof java.util.List columns) || columns.isEmpty() || columns.size()>30 + || columns.stream().anyMatch(v -> !(v instanceof String)) || new java.util.HashSet<>(columns).size()!=columns.size()) + throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转导出列无效"); + if (values.containsKey("customerCodes") && (!(values.get("customerCodes") instanceof java.util.List owners) || owners.size()>100 || owners.stream().anyMatch(v -> !(v instanceof String)))) + throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转货主范围无效"); + try { + long count=new java.math.BigDecimal(String.valueOf(values.get("expectedTotal"))).longValueExact(); + if(count<0 || count>200000) throw new ArithmeticException(); + } catch(RuntimeException e) { throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转导出条数无效"); } + return; + } if (!ExportSchemaContracts.BUY_DECLARATION_ITEMS.equals(type)) return; Map values = parameters == null ? Map.of() : parameters; if (!values.keySet().equals(java.util.Set.of("loadingPlanId"))) { diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml index 1dc24570c..001eb5dc2 100644 --- a/file/src/main/resources/application.yml +++ b/file/src/main/resources/application.yml @@ -224,6 +224,15 @@ baiyue: max-rows: 200000 max-attempts: 3 batch-size: 200 + SELFOP_INVENTORY_TURNOVER: + provider-service: lastleg-service + provider-path: /internal/exports/v1/SELFOP_INVENTORY_TURNOVER/batches + required-permission: platform:self-operated-analytics:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 100 OVERSEAS_INVENTORY_LEDGER: provider-service: lastleg-service provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_LEDGER/batches diff --git a/file/src/main/resources/openapi/cargo-discard-photo-v1.yaml b/file/src/main/resources/openapi/cargo-discard-photo-v1.yaml new file mode 100644 index 000000000..f9c2f6779 --- /dev/null +++ b/file/src/main/resources/openapi/cargo-discard-photo-v1.yaml @@ -0,0 +1,12 @@ +openapi: 3.0.3 +info: + title: Cargo discard photo storage ownership contract + version: 1.0.0 + description: >- + Uses the existing internal business-file upload, metadata and content endpoints, protected + by X-Internal-Service-Token. businessType is OVERSEAS_CARGO_DISCARD_PHOTO; tenantId is a + positive customer ID and businessId is the positive cargo discard application ID. + File enforces composite ownership and non-empty JPG/PNG bytes with image signature and + 10 MiB limit. Lastleg owns the pallet binding, submitted visibility and immutable confirmation. + No anonymous or public file endpoint is introduced. +paths: {} diff --git a/file/src/main/resources/openapi/selfop-turnover-export-v1.yaml b/file/src/main/resources/openapi/selfop-turnover-export-v1.yaml new file mode 100644 index 000000000..b43137b17 --- /dev/null +++ b/file/src/main/resources/openapi/selfop-turnover-export-v1.yaml @@ -0,0 +1,50 @@ +openapi: 3.0.3 +info: + title: 库存周转后台导出 + version: 1.0.0 + description: File 下载中心生成 XLSX,Lastleg 内部 Provider 按固定库存周转版本供数。浏览器不分页取数、不生成文件。 +paths: + /api/platform/v1/files/export-tasks: + post: + summary: 提交库存周转 Excel 导出 + description: | + 仅平台、非代登录身份及 platform:self-operated-analytics:export 权限。 + 保留列表的 sourceAccount、warehouseCode、endDate、baseWindowDays、groupBy、customerCodes、sku、focus、coverageDaysThreshold、turnoverStatus、sortBy、hideZeroInventory、snapshotContext。 + columns 为当前显示列的有序白名单键,服务端决定列名、格式并追加周转率证据列;expectedTotal 为已查询总条数,范围0至200000。 + 不接受 current/size/includeOverview。snapshotContext 最长16000字符,整组参数最大64KiB。空结果生成只有表头的文件。 + 后台每批最多100条,沿用固定上下文30分钟有效期;过期、服务重启或历史版本失效须刷新列表重新提交,不自动改用新版本。 + 版本或条数变化、分页缺失、超行数、生成失败时不提供部分文件。下载中心复用进度、临时失败退避重试、归属校验及过期规则。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [exportType, format, taskName, parameters] + properties: + exportType: {type: string, enum: [SELFOP_INVENTORY_TURNOVER]} + format: {type: string, enum: [XLSX]} + taskName: {type: string, maxLength: 128} + parameters: + type: object + required: [sourceAccount, warehouseCode, endDate, baseWindowDays, snapshotContext, columns, expectedTotal] + properties: + snapshotContext: {type: string, minLength: 1, maxLength: 16000} + columns: {type: array, minItems: 1, maxItems: 30, uniqueItems: true, items: {type: string}} + expectedTotal: {type: integer, minimum: 0, maximum: 200000} + additionalProperties: true + responses: + '200': {description: ApiResponse 包装下载中心任务;业务错误遵循通用 code 契约} + '403': {description: 无平台自营导出权限或非平台身份} + /api/platform/v1/files/export-tasks/{taskId}/download: + get: + summary: 下载后台生成的库存周转 Excel + description: 校验任务归属、完成状态、有效期,以及当前非代登录身份和平台自营导出权限。 + parameters: + - {name: taskId, in: path, required: true, schema: {type: string}} + responses: + '200': + description: XLSX 文件 + content: + application/vnd.openxmlformats-officedocument.spreadsheetml.sheet: + schema: {type: string, format: binary} diff --git a/file/src/test/java/com/baiyue/file/config/LastlegExportDefinitionContractTest.java b/file/src/test/java/com/baiyue/file/config/LastlegExportDefinitionContractTest.java index 15c4872e9..8485da15c 100644 --- a/file/src/test/java/com/baiyue/file/config/LastlegExportDefinitionContractTest.java +++ b/file/src/test/java/com/baiyue/file/config/LastlegExportDefinitionContractTest.java @@ -13,6 +13,7 @@ class LastlegExportDefinitionContractTest { Map nacos = definitions(Path.of("../config/nacos/test/file-service.yml")); Map expected = Map.ofEntries( Map.entry("LAST_MILE_ORDER_LIST", "platform:overseas-warehouse:last-mile-order:export"), + Map.entry("SELFOP_INVENTORY_TURNOVER", "platform:self-operated-analytics:export"), Map.entry("OVERSEAS_INVENTORY_BALANCE", "platform:overseas-warehouse:inventory:export"), Map.entry("OVERSEAS_INVENTORY_LEDGER", "platform:overseas-warehouse:inventory:export"), Map.entry("OVERSEAS_QUOTE_LIST", "platform:overseas-master-data:quote:export"), @@ -28,7 +29,7 @@ class LastlegExportDefinitionContractTest { assertEquals("/internal/exports/v1/" + entry.getKey() + "/batches", definition.get("provider-path")); assertEquals(entry.getValue(), definition.get("required-permission")); assertEquals("PLATFORM", definition.get("app-scope")); - assertEquals(200, definition.get("batch-size")); + assertEquals(entry.getKey().equals("SELFOP_INVENTORY_TURNOVER") ? 100 : 200, definition.get("batch-size")); var controlled = new ExportDefinitionRegistry.Definition(); controlled.setProviderService("lastleg-service"); controlled.setProviderPath(String.valueOf(definition.get("provider-path"))); diff --git a/file/src/test/java/com/baiyue/file/service/CargoDiscardPhotoStorageTest.java b/file/src/test/java/com/baiyue/file/service/CargoDiscardPhotoStorageTest.java new file mode 100644 index 000000000..91c0ebf0f --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/CargoDiscardPhotoStorageTest.java @@ -0,0 +1,25 @@ +package com.baiyue.file.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import java.nio.file.Path; +import static org.assertj.core.api.Assertions.*; + +class CargoDiscardPhotoStorageTest { + @TempDir Path root; + @Test void discardEvidenceIsCustomerOwnedAndRequiresRealImageSignature() throws Exception { + var category = BusinessFileCategory.require("OVERSEAS_CARGO_DISCARD_PHOTO"); + category.validateOwner(7); + assertThatThrownBy(() -> category.validateOwner(0)).isInstanceOf(com.baiyue.common.core.exception.BizException.class); + assertThatThrownBy(() -> category.storageBusinessId("0")).isInstanceOf(com.baiyue.common.core.exception.BizException.class); + var storage = new BusinessFileStorage(); ReflectionTestUtils.setField(storage, "configuredRoot", root); storage.initializeRoot(); + byte[] bytes = {(byte)0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10}; + var result = storage.store(7, "11", new MockMultipartFile("file", "discard.png", "image/png", bytes), category); + assertThat(storage.read(result.objectKey())).isEqualTo(bytes); + assertThatThrownBy(() -> storage.store(7, "11", new MockMultipartFile("file", "fake.png", "image/png", "fake".getBytes()), category)).hasMessageContaining("不是有效图片"); + assertThatThrownBy(() -> storage.store(7, "11", new MockMultipartFile("file", "doc.pdf", "application/pdf", "%PDF-test".getBytes()), category)).hasMessageContaining("仅支持JPG"); + assertThatThrownBy(() -> storage.store(7, "11", new MockMultipartFile("file", "large.png", "image/png", new byte[10*1024*1024+1]), category)).isInstanceOf(com.baiyue.common.core.exception.BizException.class); + } +} diff --git a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java index de2d60427..c35fb8dc5 100644 --- a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java @@ -39,6 +39,23 @@ class ExportTaskServiceTest { @MockBean private ExportStateService states; @MockBean private ExportMetrics metrics; @Resource private ExportTaskService service; + @Test void turnoverKeepsBoundedSignedContextAndRequiresIndependentExportPermission() { + var definition=definition();definition.setRequiredPermission("platform:self-operated-analytics:export"); + when(registry.require("SELFOP_INVENTORY_TURNOVER")).thenReturn(definition); + when(states.createConstrained(any(FileExportTask.class))).thenAnswer(c->new ExportStateService.CreationResult(c.getArgument(0),true)); + Map parameters=new LinkedHashMap<>(); + parameters.put("sourceAccount","demo");parameters.put("warehouseCode","W");parameters.put("endDate","2026-10-14");parameters.put("baseWindowDays",30); + parameters.put("snapshotContext","x".repeat(15000));parameters.put("columns",List.of("sku","quantityTurnoverRate"));parameters.put("expectedTotal",205); + var request=new CreateExportTaskRequest("SELFOP_INVENTORY_TURNOVER","XLSX","库存周转",parameters); + assertThrows(BizException.class,()->service.create(user(1L,Set.of("platform:self-operated-analytics:list")),request)); + assertEquals("PENDING",service.create(user(1L,Set.of("platform:self-operated-analytics:export")),request).status()); + parameters.put("snapshotContext","x".repeat(16001)); + assertThrows(BizException.class,()->service.create(user(1L,Set.of("platform:self-operated-analytics:export")),request)); + parameters.put("snapshotContext","fixed");parameters.put("current",2); + assertThrows(BizException.class,()->service.create(user(1L,Set.of("platform:self-operated-analytics:export")),request)); + parameters.remove("current");parameters.remove("columns"); + assertThrows(BizException.class,()->service.create(user(1L,Set.of("platform:self-operated-analytics:export")),request)); + } @Test void createsPersistedTaskOnlyWhenSourceExportPermissionIsPresent() { when(registry.require("CUSTOMER_LIST")).thenReturn(definition()); @@ -94,6 +111,15 @@ class ExportTaskServiceTest { assertEquals("9", service.get(user(1L, Set.of("platform:download:all")), 9L).id()); } + @Test void turnoverDownloadAndRetryRecheckExportPermissionEvenWithDownloadAll() { + var task=new FileExportTask();task.setId(9L);task.setTenantId(0L);task.setAppCode("PLATFORM");task.setRequestedBy(2L); + task.setExportType("SELFOP_INVENTORY_TURNOVER");task.setStatus("SUCCEEDED");when(repository.find(9L)).thenReturn(task); + var reader=user(1L,Set.of("platform:download:all","platform:self-operated-analytics:list")); + assertEquals(ResultCode.FORBIDDEN,assertThrows(BizException.class,()->service.download(reader,9L)).getResultCode()); + assertEquals(ResultCode.FORBIDDEN,assertThrows(BizException.class,()->service.retry(reader,9L)).getResultCode()); + verifyNoInteractions(storage); + } + @Test void rejectsParametersDeeperThanTwoLevels() { when(registry.require("CUSTOMER_LIST")).thenReturn(definition()); diff --git a/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java b/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java index 50efcf033..815b76abe 100644 --- a/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java +++ b/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java @@ -66,6 +66,24 @@ class GenericXlsxExportRunnerTest { assertNull(requests.getAllValues().get(0).snapshotToken());assertEquals("snapshot-9",requests.getAllValues().get(1).snapshotToken()); } + @Test void turnoverWorkbookPreservesColumnOrderPercentUnknownAndLiteralSku() throws Exception { + var columns=List.of(new ExportColumn("quantityTurnoverRate","期间周转率",ExportColumnType.STRING,24), + new ExportColumn("sku","SKU",ExportColumnType.STRING,24),new ExportColumn("historyDays","合格历史库存天数",ExportColumnType.STRING,24)); + var batch=new ExportBatchResponse(1,"sha256:turnover","fixed",columns,List.of( + Map.of("quantityTurnoverRate","125%","sku","=SUM(1,2)","historyDays","30/30"), + Map.of("quantityTurnoverRate","—","sku","未知商品","historyDays","29/30"), + Map.of("quantityTurnoverRate","0%","sku","零销量","historyDays","30/30")),"3",false,3L); + when(provider.batch(anyString(),eq("SELFOP_INVENTORY_TURNOVER"),any(),any())).thenReturn(ApiResponse.success(batch)); + var task=new FileExportTask();task.setId(18L);task.setTenantId(0L);task.setAppCode("PLATFORM");task.setRequestedBy(7L);task.setDispatchVersion(1); + task.setExportType("SELFOP_INVENTORY_TURNOVER");task.setTaskName("库存周转");task.setParametersJson("{\"snapshotContext\":\"fixed\"}"); + var definition=new ExportDefinitionRegistry.Definition();definition.setMaxRows(200000);definition.setBatchSize(100); + var result=runner.run(task,definition,new AtomicBoolean(),ignored->{}); + try(var workbook=new XSSFWorkbook(storage.load(result.objectKey()).getInputStream())) { + var sheet=workbook.getSheetAt(0);assertEquals("期间周转率",sheet.getRow(0).getCell(0).getStringCellValue()); + assertEquals("125%",sheet.getRow(1).getCell(0).getStringCellValue());assertEquals("'=SUM(1,2)",sheet.getRow(1).getCell(1).getStringCellValue()); + assertEquals("—",sheet.getRow(2).getCell(0).getStringCellValue());assertEquals("0%",sheet.getRow(3).getCell(0).getStringCellValue());assertEquals(3,result.rows()); + } + } @Test void deletesTheCompletedFileWhenReadingItsFinalSizeFails() throws Exception { var batch=new ExportBatchResponse(1,"sha256:test","snapshot-9", List.of(new ExportColumn("text","文本",ExportColumnType.STRING,18)), 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 2d60e5d5e..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`。 @@ -54,7 +59,9 @@ | 海外派送完成回写 | 同一内部事件入口、消费者 `service/OverseasDeliveryCompletedConsumer.java`;按来源入仓单、订单、入仓码和 FBA 仓校验后推进当前入仓单 `LOADED→DELIVERED`,同一 Order 全部有效入仓单完成后才经 `OrderFulfillmentCompletionClient` 推进 Order `COMPLETED` | | 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | | 海外仓业务国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/海外仓业务/船节点标准化契约.md` | -| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/OVERSEAS` 海外车队供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 | +| 已开船预约事项完整来源 | `portschedule/controller/InternalAppointmentWorkSourceController.java`、`service/AppointmentWorkSourceService.java`、`mapper/AppointmentWorkSourceMapper.java`;按完整来源版本游标提供所有实际开船或后续节点的有效货柜,保留无仓绑定与完成货柜;原始当地时间无时区时不得转UTC;契约 `src/main/resources/openapi/appointment-work-source-v1.yaml` | +| 直送目的仓实际卸货 | `terminaldrayage/controller/PlatformDirectDeliveryController.java`、`service/DirectDeliveryService.java`、`UnloadingEvidenceGuard.java`;按实装入仓来源逐票记录签收人与凭据,必须经 Lastleg 确认 DIRECT 路线且已提柜;不创建海外库存,全柜卸毕才可人工还空;契约 `src/main/resources/openapi/direct-delivery-v1.yaml`,基线 `src/main/resources/db/phase3-direct-delivery-schema.sql` | +| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/OVERSEAS` 海外车队供应商,依次选择车队、预约提柜、确认实际提柜,海外仓卸柜或全部直送目的仓实际卸货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 | | 船节点交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 直投 Lastleg `/internal/lastleg/events`;消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java`,ACK/重试按 `../../docs/统一认证与服务通信契约.md` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 | diff --git a/firstleg/docs/firstleg-internal-modularization-baseline.md b/firstleg/docs/firstleg-internal-modularization-baseline.md index df1b0fc9e..c0bd8fc5a 100644 --- a/firstleg/docs/firstleg-internal-modularization-baseline.md +++ b/firstleg/docs/firstleg-internal-modularization-baseline.md @@ -248,3 +248,10 @@ PriceBookQueryMapper -> PriceBookQueryReader + PriceBookQueryRows - 回滚仅恢复原三文件及消费者类型引用;不涉及 URI、权限、JSON、SQL、表结构、业务状态或环境配置。 - 只读模型仍保留原 Lombok 可变 Bean 形态以维持 MyBatis 映射行为;本轮不顺带改造成不可变领域对象。 - 验证结果:七段 `@Select` SQL 文本逐块完全一致;Node 合同 5/5;`test-compile` 成功;价格簿、ArchUnit、根扫描及三个消费者切片定向测试 78/78;Firstleg 聚合全量 878 个测试,0 失败、0 错误、3 跳过,构建成功。 + + +## 预约工作集成时的精确边界核对 + +`PlatformContainerShippingNodeController` 归船节点切片,`sailingcalendar` 归订舱船期切片;不再误归共享技术层。既有海外事件适配、实装费用重算和渠道目录读取在 `reviewedClassDependencies` 按具体调用者、目标类型和理由登记,不放开整个切片。 + +现存全局 service 包同时承载暂扣、装柜费用和订舱文档编排,形成 loading.bridge.service、loading.service、oceanbooking.service、service 四包 SCC。它是尚未拆分的既有包级债务,本轮未宣称消除;`reviewedPackageCycles` 只冻结这一精确集合,新增节点或第二个环仍失败,价格簿查询与客户统计仍必须处于所有环之外。合同有新增消费者与新增环的负向测试。预约新增提供端通过原域适配接口消费事实,不增加该包环。 diff --git a/firstleg/scripts/analyze-firstleg-dependencies.mjs b/firstleg/scripts/analyze-firstleg-dependencies.mjs index cdcc88bd8..263522193 100644 --- a/firstleg/scripts/analyze-firstleg-dependencies.mjs +++ b/firstleg/scripts/analyze-firstleg-dependencies.mjs @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; +import { pathToFileURL } from 'node:url'; const moduleRoot = path.resolve(import.meta.dirname, '..'); const sourceRoots = { @@ -15,8 +16,8 @@ const sliceRules = [ ['customer-statistics', /(?:^|\.)(?:customerstatistics)(?:\.|$)/], ['pricebook-query', /(?:^|\.)(?:pricebookquery)(?:\.|$)/], ['finance-integration', /(?:InternalFinance|FinanceFeeSource|FinanceCostSnapshot|FeePaymentVoucher|FeePaymentSnapshot|ContainerFeeSourceSnapshot)/], - ['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException)/], - ['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs|terminaldrayage)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/], + ['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException|ContainerShippingNode)/], + ['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs|terminaldrayage|sailingcalendar)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/], ['outbound', /(?:^|\.)outbound(?:\.|$)|(?:OutboundDispatch|ContainerTransport|OverseasContainerEvent)/], ['split-loading', /(?:^|\.)(?:split|loading)(?:\.|$)|(?:Loading|SmartLoading|Container(?:Booking|Overview|Service|Dtos)|PdaPick)/], ['measurement-pricing', /(?:^|\.)pricing(?:\.|$)|(?:Measurement|MeasureReview|Mojia|Pricing|PriceBook|PriceChannel|PriceOrigin|PriceWarehouse|Fba|Dimension|FeeItem|MerchantInboundBilling|InboundFee)/], @@ -261,14 +262,28 @@ function renderDot(report) { return lines.join('\n'); } -function checkContract(report, contractPath) { - const absolute = path.resolve(process.cwd(), contractPath); - const contract = JSON.parse(fs.readFileSync(absolute, 'utf8')); +export function contractViolations(report, contract) { const allowed = new Set(contract.allowedSliceEdges ?? []); - const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`); - const additions = actual.filter(edge => !allowed.has(edge)); - const violations = additions.map(edge => `new slice dependency: ${edge}`); + const violations = []; + const cycles=new Set((contract.reviewedPackageCycles ?? []).filter(c=>c.reason?.trim() + && c.packages?.length>1 && c.packages.every(p=>!p.includes('*'))).map(c=>[...c.packages].sort().join('|'))); + for (const cycle of report.packageSccs ?? []) { + if (!cycles.has([...cycle].sort().join('|'))) violations.push(`unreviewed package cycle: ${cycle.join(' -> ')}`); + } + const reviewed = new Set(); + for (const edge of contract.reviewedClassDependencies ?? []) { + if (!edge.source || !edge.target || !edge.reason?.trim() || /[*]/.test(edge.source + edge.target)) { + violations.push('reviewed dependency requires exact classes and a rationale'); + continue; + } + reviewed.add(`${edge.source}->${edge.target}`); + } const classesByName = new Map(report.classes.map(entry => [entry.fqcn, entry])); + for (const edge of report.classDependencies) { + const from=classesByName.get(edge.source)?.slice, to=classesByName.get(edge.target)?.slice; + if (from && to && from!==to && !allowed.has(`${from}->${to}`) && !reviewed.has(`${edge.source}->${edge.target}`)) + violations.push(`unreviewed slice dependency: ${from}->${to}: ${edge.source}->${edge.target}`); + } for (const [fqcn, expectedFile] of Object.entries(contract.requiredClassLocations ?? {})) { const actualClass = classesByName.get(fqcn); if (!actualClass) violations.push(`required class missing: ${fqcn}`); @@ -312,12 +327,19 @@ function checkContract(report, contractPath) { violations.push(`${slice} incoming dependencies changed: ${incoming.join(', ')}`); } } + return violations; +} + +function checkContract(report, contractPath) { + const contract=JSON.parse(fs.readFileSync(path.resolve(process.cwd(),contractPath),'utf8')); + const violations=contractViolations(report,contract); if (violations.length > 0) { process.stderr.write(`Firstleg dependency contract violations in ${contractPath}:\n${violations.map(item => `- ${item}`).join('\n')}\n`); process.exitCode = 1; } } +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { const options = parseArgs(process.argv.slice(2)); const report = buildReport(options.sourceSet); if (options.check) checkContract(report, options.check); @@ -328,3 +350,5 @@ const rendered = options.format === 'json' : renderDot(report); if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8'); else process.stdout.write(rendered); + +} diff --git a/firstleg/scripts/analyze-firstleg-dependencies.test.mjs b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs index 101c2f807..d0bc18efd 100644 --- a/firstleg/scripts/analyze-firstleg-dependencies.test.mjs +++ b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs @@ -1,5 +1,7 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { contractViolations } from './analyze-firstleg-dependencies.mjs'; import path from 'node:path'; import test from 'node:test'; @@ -7,6 +9,17 @@ const moduleRoot = path.resolve(import.meta.dirname, '..'); const analyzer = path.join(moduleRoot, 'scripts', 'analyze-firstleg-dependencies.mjs'); const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'firstleg-slice-dependencies.json'); +test('reviewed original-domain calls do not open an entire slice or allow wildcard grants', () => { + const report=analyze(); const baseline=JSON.parse(readFileSync(contract,'utf8')); + assert.deepEqual(contractViolations(report,baseline),[]); + report.classDependencies.push({source:'com.baiyue.firstleg.service.SmartLoadingService',target:'com.baiyue.firstleg.domain.PriceBookChannelCatalog'}); + assert.ok(contractViolations(report,baseline).some(message=>message.includes('SmartLoadingService->com.baiyue.firstleg.domain.PriceBookChannelCatalog'))); + baseline.reviewedClassDependencies.push({source:'com.baiyue.firstleg.*',target:'com.baiyue.firstleg.service.FbaPricingService',reason:'invalid wildcard'}); + assert.ok(contractViolations(report,baseline).some(message=>message.includes('exact classes'))); + report.packageSccs.push(['com.baiyue.firstleg.service','com.baiyue.firstleg.pricebookquery.application']); + assert.ok(contractViolations(report,baseline).some(message=>message.includes('unreviewed package cycle'))); +}); + function analyze() { return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'json'], { cwd: moduleRoot, @@ -53,7 +66,7 @@ test('customer statistics application layer only depends inward', () => { assert.deepEqual(outwardDependencies, []); }); -test('price book query is a leaf slice and removes the package cycle', () => { +test('price book query remains a leaf slice outside reviewed pre-existing package cycles', () => { const report = analyze(); const classes = report.classes.filter(entry => entry.slice === 'pricebook-query'); assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [ @@ -67,7 +80,9 @@ test('price book query is a leaf slice and removes the package cycle', () => { assert.deepEqual(report.sliceEdges.filter(edge => edge.target === 'pricebook-query') .map(edge => edge.source).sort(), ['booking-customs-trucking', 'measurement-pricing', 'split-loading']); assert.equal(report.sliceSccs.some(component => component.includes('pricebook-query')), false); - assert.deepEqual(report.packageSccs, []); + assert.equal(report.packageSccs.some(cycle=>cycle.some(pkg=>pkg.startsWith('com.baiyue.firstleg.pricebookquery'))),false); + const reviewed=JSON.parse(readFileSync(contract,'utf8')).reviewedPackageCycles.map(c=>c.packages); + assert.deepEqual(report.packageSccs,reviewed); }); test('price book query application and consumers respect ports', () => { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/AppointmentWorkRouteClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/AppointmentWorkRouteClient.java new file mode 100644 index 000000000..03879f376 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/AppointmentWorkRouteClient.java @@ -0,0 +1,12 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; + +@FeignClient(name="lastleg-service",contextId="appointmentWorkRouteClient",url="${LASTLEG_SERVICE_URL:${baiyue.clients.lastleg.url:}}") +public interface AppointmentWorkRouteClient { + record Route(String flow) { } + @GetMapping("/internal/lastleg/appointment-work/containers/{id}/route") + ApiResponse route(@RequestHeader("X-Internal-Service-Token") String token,@PathVariable("id") long id); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java index 0a742df39..5a892a742 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java @@ -13,7 +13,7 @@ public final class ContainerOverviewDtos { public record Overview(ContainerBookingEntityView container, Long loadingPlanId, PdaSummary pda, List documents, ExceptionState exception, List exceptionHistory, List timeline) {} - public record ContainerBookingEntityView(Long id, String bookingNo, String cabinetNo, String containerNo, + public record ContainerBookingEntityView(Long id, String cabinetNo, String containerNo, String status, Long warehouseId, String warehouseName, String voyageNo, String vesselName, String billOfLadingNo, Long shippingCompanyId, String shippingCompanyName, String departurePortCode, String departurePortName, String destinationPortCode, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantWorkbenchFilters.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantWorkbenchFilters.java index cdde0b81e..589488bf6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantWorkbenchFilters.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantWorkbenchFilters.java @@ -10,7 +10,8 @@ public final class MerchantWorkbenchFilters { private MerchantWorkbenchFilters() {} public record Request(@NotNull @Positive Long tenantId, - @Pattern(regexp = "WAIT_MEASURE|WAIT_PALLETIZE|WAIT_SPLIT|LOADING|LOADED|DELIVERED") String mainStatus) {} + @Pattern(regexp = "WAIT_MEASURE|WAIT_PALLETIZE|WAIT_SPLIT|LOADING|LOADED|DELIVERED") String mainStatus, + @jakarta.validation.constraints.Size(min=1,max=200) List<@NotNull @Positive Long> orderIds) {} public record Response(List matchingStatusOrderIds, List feeOrderIds, List customsOrderIds, List progress) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java index 7374c753a..7b9e6f6f4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -48,7 +48,7 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){} if (size == 0) size = 20; } } - public record WorkbenchContainer(Long bookingId, String bookingNo, String cabinetNo, String containerNo, + public record WorkbenchContainer(Long bookingId, String cabinetNo, String containerNo, Long warehouseId, String warehouseName, String channelGroupCode, String channelCode, String channelName, String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg, Long planId, String planNo, String planStatus, Integer version) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java index fd19f6105..9c3829cbd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java @@ -19,7 +19,6 @@ import java.time.LocalDateTime; @TableName("fl_container_booking") public class ContainerBookingEntity { @TableId(type = IdType.ASSIGN_ID) private Long id; - private String bookingNo; private String containerNo; private String cabinetNo; private String sourceType; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java index 8e81bcdd3..12b1223ea 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java @@ -65,6 +65,7 @@ public interface MerchantWorkbenchFilterMapper { List pendingCustoms(@Param("tenantId") long tenantId); /** Only physically loaded fragments can expose container milestones to the customer. */ @Select(""" + """) - List progressFragments(@Param("tenantId") long tenantId); + List progressFragments(@Param("tenantId") long tenantId, @Param("orderIds") List orderIds); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index fbf5a11c9..22a75c851 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -335,7 +335,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba @Select(""" ") + List sourcesByIds(@Param("ids") List ids); + + @Select(""" + + """) + List cargo(@Param("ids") List ids); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java index 630b2bdfe..f256e9c9a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java @@ -15,6 +15,15 @@ import java.util.List; @Mapper public interface CarrierScheduleQueryMapper extends BaseMapper { + @Select(""" + SELECT container_booking_id FROM fl_carrier_schedule_node_fact + WHERE standard_node='PICKED_UP' AND actual_at IS NOT NULL + GROUP BY container_booking_id + HAVING MAX(actual_at)>=#{from} AND MAX(actual_at)<#{to} + ORDER BY MAX(actual_at),container_booking_id LIMIT 1001 + """) + List selectPickupCalendarContainers(@Param("from") LocalDateTime from,@Param("to") LocalDateTime to); + @Select(""" + """) + List flowEvidence(@Param("ids") List ids); + @Select(""" + + """) + List containers(@Param("ids") List ids); + @Select(""" + + """) + List deliveriesBatch(@Param("ids") List ids); + @Select("SELECT id,record_status,unloading_completed_at warehouse_unloaded_at FROM fl_container_booking WHERE id=#{id} FOR UPDATE") + @Options(flushCache=Options.FlushCachePolicy.TRUE,useCache=false) + Container lockContainer(@Param("id") long id); + @Select("SELECT id,record_status,unloading_completed_at warehouse_unloaded_at FROM fl_container_booking WHERE id=#{id}") + Container container(@Param("id") long id); + @Select(""" + SELECT i.inbound_id,MAX(i.inbound_code) inbound_code,MAX(i.order_no) order_no, + MAX(i.destination_warehouse_name) destination_name,SUM(i.package_count) package_count, + COALESCE(d.version,0) version,d.actual_at,d.timezone,d.received_by,d.evidence_no,d.remark,d.idempotency_key,d.request_hash + FROM fl_loading_plan p JOIN fl_loading_plan_item i ON i.loading_plan_id=p.id AND i.deleted=0 + LEFT JOIN fl_direct_delivery_unloading d ON d.container_booking_id=p.container_booking_id AND d.inbound_id=i.inbound_id + WHERE p.container_booking_id=#{id} AND p.plan_status='COMPLETED' + GROUP BY i.inbound_id,d.version,d.actual_at,d.timezone,d.received_by,d.evidence_no,d.remark,d.idempotency_key,d.request_hash + ORDER BY i.inbound_id + """) + List deliveries(@Param("id") long id); + @Select("SELECT COUNT(*) FROM fl_direct_delivery_unloading WHERE container_booking_id=#{id}") + int evidenceCount(@Param("id") long id); + @Select("SELECT COUNT(*) FROM fl_direct_delivery_unloading WHERE container_booking_id=#{id} AND idempotency_key=#{key}") + int keyCount(@Param("id") long id,@Param("key") String key); + @Insert(""" + INSERT INTO fl_direct_delivery_unloading(container_booking_id,inbound_id,actual_at,timezone,received_by, + evidence_no,remark,version,idempotency_key,request_hash,created_by,created_by_name) + VALUES(#{id},#{inboundId},#{actualAt},#{timezone},#{receivedBy},#{evidenceNo},#{remark},1,#{key},#{hash},#{operatorId},#{operatorName}) + """) + int insert(@Param("id") long id,@Param("inboundId") long inboundId,@Param("actualAt") LocalDateTime actualAt, + @Param("timezone") String timezone,@Param("receivedBy") String receivedBy,@Param("evidenceNo") String evidenceNo, + @Param("remark") String remark,@Param("key") String key,@Param("hash") String hash, + @Param("operatorId") long operatorId,@Param("operatorName") String operatorName); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java index d835d49dc..b80cee070 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java @@ -15,7 +15,7 @@ import java.util.List; @Mapper public interface TerminalDrayageMapper extends BaseMapper { @Select(""" - SELECT c.id containerBookingId,c.booking_no bookingNo,c.container_no containerNo, + SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo, c.destination_port_name destinationPortName,c.destination_warehouse_code destinationWarehouseCode, t.fleet_supplier_name fleetSupplierName,t.pickup_appointment_at pickupAppointmentAt,NULL actualPickupAt FROM fl_terminal_drayage t JOIN fl_container_booking c ON c.id=t.container_booking_id @@ -27,6 +27,21 @@ public interface TerminalDrayageMapper extends BaseMapper List calendar( @Param("from") LocalDateTime from,@Param("to") LocalDateTime to,@Param("limit") int limit); + @Select(""" + + """) + List calendarByContainers(@Param("ids") List ids); + @Select(""" SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId, b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt @@ -99,6 +114,16 @@ public interface TerminalDrayageMapper extends BaseMapper ORDER BY created_at DESC,id DESC """) List histories(@Param("containerBookingId") long containerBookingId); + record PickupChanges(long containerBookingId,long schedules,long fleets) { } + @Select(""" + + """) + List pickupChanges(@Param("ids") List ids); @Insert(""" INSERT INTO fl_terminal_drayage_history(id,drayage_id,container_booking_id,action,from_status,to_status, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/AppointmentWorkTerminalFacts.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/AppointmentWorkTerminalFacts.java new file mode 100644 index 000000000..efcc9bda9 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/AppointmentWorkTerminalFacts.java @@ -0,0 +1,127 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.firstleg.portschedule.dto.AppointmentWorkSourceDtos.*; +import com.baiyue.firstleg.portschedule.service.AppointmentWorkSourceService.OriginalFacts; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; +import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity; +import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.time.*; +import java.security.MessageDigest; +import java.util.*; + +/** The terminal domain adapts its facts to a wire port; the work service never imports its persistence. */ +@Component +public class AppointmentWorkTerminalFacts implements OriginalFacts { + private static final String READ="platform:firstleg:terminal-drayage:list"; + private final TerminalDrayageMapper terminal; + private final DirectDeliveryMapper direct; + private final CarrierScheduleQueryService nodes; + private final UnloadingEvidenceGuard unloading; + private final ObjectMapper json; + public AppointmentWorkTerminalFacts(TerminalDrayageMapper terminal,DirectDeliveryMapper direct, + CarrierScheduleQueryService nodes,UnloadingEvidenceGuard unloading,ObjectMapper json) { + this.terminal=terminal;this.direct=direct;this.nodes=nodes;this.unloading=unloading;this.json=json; + } + public Map establishedFlows(List ids) { + var result=new HashMap(); + if(ids.isEmpty()) return result; + for(var row:direct.flowEvidence(ids)) { + boolean transfer=row.warehouseUnloadedAt()!=null; + boolean delivery=row.evidenceCount()>0; + if(transfer && delivery) throw new ResponseStatusException(HttpStatus.CONFLICT,"货柜存在冲突的中转和直送执行事实"); + result.put(row.id(),transfer?"TRANSFER":delivery?"DIRECT":"UNKNOWN"); + } + return result; + } + public List read(List requests,Map sources) { + var ids=requests.stream().map(r->Long.parseLong(r.containerBookingId())).distinct().toList(); + if(ids.isEmpty()) return List.of(); + var actuals=new HashMap(); + for(var row:nodes.containerActualFacts(ids)) actuals.put(row.containerBookingId(),row); + var plans=new HashMap(); + for(var row:terminal.findAll(ids)) plans.put(row.getContainerBookingId(),row); + var pickupIds=requests.stream().filter(r->"PICKUP".equals(r.type())).map(r->Long.parseLong(r.containerBookingId())).distinct().toList(); + var changes=new HashMap(); + if(!pickupIds.isEmpty()) for(var row:terminal.pickupChanges(pickupIds)) changes.put(row.containerBookingId(),row); + var deliveryIds=requests.stream().filter(r->"DELIVERY".equals(r.type())).map(r->Long.parseLong(r.containerBookingId())).distinct().toList(); + var deliveries=new HashMap>(); + if(!deliveryIds.isEmpty()) for(var row:direct.deliveriesBatch(deliveryIds)) + deliveries.computeIfAbsent(row.containerBookingId(),ignored->new ArrayList<>()).add(row.delivery()); + var returnIds=requests.stream().filter(r->"EMPTY_RETURN".equals(r.type())).map(r->Long.parseLong(r.containerBookingId())).distinct() + .filter(id->(actuals.get(id)==null || actuals.get(id).emptyReturnedAt()==null) + && (plans.get(id)==null || plans.get(id).getActualEmptyReturnAt()==null)).toList(); + var completed=returnIds.isEmpty()?Map.of():unloading.completedUnloading(returnIds); + return requests.stream().map(request->{ + long id=Long.parseLong(request.containerBookingId()); + return read(request,sources.get(id),actuals.get(id),plans.get(id),changes.get(id), + deliveries.getOrDefault(id,List.of()),completed.get(id)); + }).toList(); + } + private Fact read(FactRequest request,ContainerSource source,ContainerActualFacts node,TerminalDrayageEntity plan, + TerminalDrayageMapper.PickupChanges changes,List deliveries,LocalDateTime completedUnloading) { + var references=new ArrayList(); + source.requirements().forEach(r->r.references().forEach(ref->{if(!references.contains(ref))references.add(ref);})); + var blocks=new ArrayList(); var missing=new ArrayList(); + LocalDateTime at=null; String zone=null; Slot confirmed=null; boolean exists=false; + String code=null,label=null,permission=null; Responsibility responsibility=null; + String businessNo=references.stream().filter(r->"CONTAINER".equals(r.kind())).map(Reference::businessNo).filter(Objects::nonNull).findFirst().orElse(null); + String impact="BASELINE"; + switch(request.type()) { + case "PICKUP" -> { + at=node==null?null:node.pickedUpAt(); + if(plan!=null && plan.getPickupAppointmentAt()!=null) { + var time=plan.getPickupAppointmentAt(); confirmed=new Slot(time.toLocalDate(),time.toLocalTime(),null,null,null); exists=true; + } + if(plan!=null && plan.getFleetSupplierId()!=null) + responsibility=new Responsibility("SUPPLIER",plan.getFleetSupplierId().toString(),plan.getFleetSupplierName()); + // The initial arrangement/assignment establishes a baseline. Only later changes invalidate dependants. + long reschedules=Math.max(0,(changes==null?0:changes.schedules())-1); + long reassignments=Math.max(0,(changes==null?0:changes.fleets())-1); + impact=reschedules+":"+reassignments; + if(at==null) { + if(node==null || node.arrivedDestinationPortAt()==null) blocks.add("实际到港后才能安排提柜"); + else if(plan==null || plan.getFleetSupplierId()==null) {code="SAVE_PLAN";label="选择码头车队";permission="platform:firstleg:terminal-drayage:fleet:edit";} + else if(confirmed==null) {code="REQUEST_BOOKING";label="预约提柜";permission="platform:firstleg:terminal-drayage:pickup-appointment:edit";} + else {code="RECORD_EXECUTION";label="记录实际提柜";permission="platform:firstleg:port-schedule-query:manual";} + } + } + case "EMPTY_RETURN" -> { + at=node==null?null:node.emptyReturnedAt(); + if(at==null && plan!=null) at=plan.getActualEmptyReturnAt(); + if(at==null && completedUnloading==null) blocks.add("海外仓卸柜或全部直送目的仓实际卸货后才能还空"); + else if(at==null) {code="RECORD_EXECUTION";label="确认实际还空";permission="platform:firstleg:terminal-drayage:empty-return:confirm";} + // Return responsibility belongs to the work handoff, not the historical pickup carrier. + } + case "DELIVERY" -> { + var requirement=source.requirements().stream().filter(r->Objects.equals(r.requirementKey(),request.requirementKey())).findFirst() + .orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"直送需求与原柜来源不一致")); + references.clear(); references.addAll(requirement.references()); + var row=deliveries.stream().filter(d->("INBOUND:"+d.inboundId()).equals(request.requirementKey())).findFirst() + .orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"直送需求与实际装柜来源不一致")); + at=row.actualAt();zone=row.timezone();businessNo=row.inboundCode(); + if(at==null && (node==null || node.pickedUpAt()==null)) blocks.add("实际提柜后才能记录直送卸货"); + else if(at==null) {code="RECORD_EXECUTION";label="登记目的仓实际卸货";permission="platform:firstleg:terminal-drayage:direct-unload:confirm";} + } + default -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"不支持的Firstleg事实类型"); + } + if((at!=null || confirmed!=null) && zone==null) missing.add("SOURCE_TIMEZONE"); + Actual actual=at==null?null:new Actual(zone==null?null:at.atZone(ZoneId.of(zone)).toInstant(),at,zone); + Action action=code==null?null:new Action(code,label,null,new Target("FIRSTLEG_CONTAINER",source.containerBookingId())); + var fact=new Fact(request.workItemKey(),"pending",impact,exists,confirmed==null?"UNPLANNED":"CONFIRMED", + actual!=null?"COMPLETED":"PENDING",null,confirmed,actual,zone,businessNo,null,List.of(READ), + permission==null?List.of():List.of(permission),action,List.copyOf(blocks),List.copyOf(missing),List.copyOf(references),responsibility); + return new Fact(fact.workItemKey(),hash(fact),fact.impactToken(),fact.planExists(),fact.planStatus(),fact.executionStatus(), + fact.target(),fact.confirmed(),fact.actual(),fact.timezone(),fact.businessNo(),fact.tripKey(),fact.readPermissions(), + fact.actionPermissions(),fact.action(),fact.blockReasons(),fact.missingFields(),fact.references(),fact.responsibility()); + } + private String hash(Object value) { + try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value)));} + catch(Exception error){throw new IllegalStateException(error);} + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryRouteGuard.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryRouteGuard.java new file mode 100644 index 000000000..85cc6b379 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryRouteGuard.java @@ -0,0 +1,22 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.firstleg.client.AppointmentWorkRouteClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +@Service +public class DirectDeliveryRouteGuard { + private final AppointmentWorkRouteClient client; + private final String token; + public DirectDeliveryRouteGuard(AppointmentWorkRouteClient client,@Value("${baiyue.security.internal-service-token:}") String token){this.client=client;this.token=token;} + public void requireDirect(long id) { + if(token==null || token.isBlank()) throw unavailable(); + com.baiyue.common.core.api.ApiResponse response; + try {response=client.route(token,id);} catch(RuntimeException error){throw unavailable();} + if(response==null || !response.successful() || response.data()==null) throw unavailable(); + if(!"DIRECT".equals(response.data().flow())) throw new ResponseStatusException(HttpStatus.CONFLICT,"请先在预约工作中确认整柜直送路线"); + } + private ResponseStatusException unavailable(){return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"无法核实已确认路线,请稍后重试");} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryService.java new file mode 100644 index 000000000..da5e2cb91 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryService.java @@ -0,0 +1,77 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.constraints.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.security.MessageDigest; +import java.time.*; +import java.util.*; + +/** Actual direct delivery evidence. It does not call warehouse receipt or create stock. */ +@Service +public class DirectDeliveryService { + public record Request(@Min(0) int expectedVersion,@NotNull LocalDateTime actualAt,@NotBlank String timezone, + @NotBlank @Size(max=128) String receivedBy,@NotBlank @Size(max=128) String evidenceNo,@Size(max=1000) String remark) { } + public record View(String requirementKey,String inboundCode,String orderNo,String destinationName,int packageCount, + int version,LocalDateTime actualAt,String timezone,String receivedBy,String evidenceNo,String remark) { } + private final DirectDeliveryMapper mapper; + private final CarrierScheduleQueryService nodes; + private final ObjectMapper json; + private final DirectDeliveryRouteGuard routes; + public DirectDeliveryService(DirectDeliveryMapper mapper,CarrierScheduleQueryService nodes,ObjectMapper json,DirectDeliveryRouteGuard routes) { + this.mapper=mapper;this.nodes=nodes;this.json=json;this.routes=routes; + } + public List list(long id) { + var container=mapper.container(id); + if(container==null || !"ACTIVE".equals(container.recordStatus())) throw new BizException(ResultCode.NOT_FOUND,"货柜不存在"); + return mapper.deliveries(id).stream().map(this::view).toList(); + } + @Transactional + public View record(long id,long inboundId,long operatorId,String operatorName,String key,Request request) { + if(key==null || key.isBlank() || key.length()>128 || request==null) throw bad("请提供有效幂等键及卸货记录"); + var container=mapper.lockContainer(id); + if(container==null || !"ACTIVE".equals(container.recordStatus())) throw new BizException(ResultCode.NOT_FOUND,"货柜不存在"); + var current=mapper.deliveries(id).stream().filter(row->row.inboundId()==inboundId).findFirst() + .orElseThrow(()->bad("该入仓来源不在本柜实际装柜清单内")); + String hash=hash(request); + if(key.equals(current.idempotencyKey())) { + if(!hash.equals(current.requestHash())) throw conflict("同一幂等键对应的卸货内容已变化"); + return view(current); + } + if(mapper.keyCount(id,key)>0) throw conflict("幂等键已被其他卸货记录使用"); + if(current.version()!=request.expectedVersion() || current.actualAt()!=null) throw conflict("实际卸货已确认,请刷新核对"); + if(container.warehouseUnloadedAt()!=null) throw conflict("已有海外仓卸柜事实,不能改记为直送"); + routes.requireDirect(id); + ZoneId zone; + try {if(!ZoneId.getAvailableZoneIds().contains(request.timezone())) throw new IllegalArgumentException(); zone=ZoneId.of(request.timezone());} catch(RuntimeException e){throw bad("请选择有效目的地时区");} + if(request.actualAt()==null || zone.getRules().getValidOffsets(request.actualAt()).size()!=1) + throw bad("实际卸货时间缺失或处于夏令时歧义区间,请核对"); + if(request.actualAt().atZone(zone).toInstant().isAfter(Instant.now())) throw bad("实际卸货时间不能在未来"); + if(request.receivedBy()==null || request.receivedBy().isBlank() || request.evidenceNo()==null || request.evidenceNo().isBlank()) + throw bad("请填写真实签收人及签收凭据号"); + var facts=nodes.containerActualFacts(List.of(id)); + var fact=facts.stream().filter(f->f.containerBookingId()==id).findFirst().orElse(null); + if(fact==null || fact.pickedUpAt()==null) throw conflict("实际提柜后才能记录直送卸货"); + if(fact.emptyReturnedAt()!=null) throw conflict("货柜已实际还空,不能新增卸货事实"); + // Pickup has no source zone. Its local clock cannot order a destination-zone actual; + // require the real pickup fact, without inventing an offset for it. + mapper.insert(id,inboundId,request.actualAt(),zone.getId(),request.receivedBy().trim(),request.evidenceNo().trim(), + request.remark(),key,hash,operatorId,operatorName); + return mapper.deliveries(id).stream().filter(row->row.inboundId()==inboundId).findFirst().map(this::view).orElseThrow(); + } + private View view(DirectDeliveryMapper.Delivery row) { + return new View("INBOUND:"+row.inboundId(),row.inboundCode(),row.orderNo(),row.destinationName(),row.packageCount(), + row.version(),row.actualAt(),row.timezone(),row.receivedBy(),row.evidenceNo(),row.remark()); + } + private String hash(Object value) { + try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value)));} + catch(Exception e){throw new IllegalStateException(e);} + } + private static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);} + private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageCalendarService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageCalendarService.java index 46873bc55..346e51f23 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageCalendarService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageCalendarService.java @@ -23,11 +23,18 @@ public class TerminalDrayageCalendarService { @Value("${baiyue.security.internal-service-token}") String token) {this.mapper=mapper;this.warehouses=warehouses;this.shipping=shipping;this.token=token;} public record Calendar(List records,boolean truncated,int unclassifiedCount, String scopeBasis,String timeBasis) { } + @org.springframework.transaction.annotation.Transactional(readOnly=true) public Calendar calendar(LocalDate from,LocalDate to) { if(from==null || to==null || !to.isAfter(from) || ChronoUnit.DAYS.between(from,to)>42) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"日历查询范围必须为1至42天"); - var rows=mapper.calendar(from.atStartOfDay(),to.atStartOfDay(),1001); - if(rows.isEmpty()) return result(List.of(),false,0); + var planned=mapper.calendar(from.atStartOfDay(),to.atStartOfDay(),1001); + var actualIds=shipping.pickupCalendarContainers(from.atStartOfDay(),to.atStartOfDay()); + var candidates=new LinkedHashMap(); + planned.stream().limit(1000).forEach(row->candidates.put(row.containerBookingId(),row)); + if(!actualIds.isEmpty()) mapper.calendarByContainers(actualIds.stream().limit(1000).toList()) + .forEach(row->candidates.put(row.containerBookingId(),row)); + boolean truncated=planned.size()>1000 || actualIds.size()>1000; + if(candidates.isEmpty()) return result(List.of(),truncated,0); final Map countries=new HashMap<>(); try { var response=warehouses.catalog(token); @@ -36,7 +43,7 @@ public class TerminalDrayageCalendarService { countries.put(warehouse.warehouseCode(),warehouse.countryCode()); } catch(RuntimeException error) {throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"提柜目的仓范围暂不可核实",error);} var records=new ArrayList();int unknown=0; - for(var row:rows.stream().limit(1000).toList()) { + for(var row:candidates.values()) { var country=countries.get(row.destinationWarehouseCode()); if(country==null || country.isBlank()) unknown++; else if("US".equals(country)) records.add(row); @@ -47,9 +54,14 @@ public class TerminalDrayageCalendarService { for(var fact:shipping.containerActualFacts(ids.subList(start,Math.min(start+200,ids.size())))) if(fact.pickedUpAt()!=null) actual.put(fact.containerBookingId(),fact.pickedUpAt()); } - var enriched=records.stream().map(row->new PickupCalendarEntry(row.containerBookingId(),row.bookingNo(),row.containerNo(), - row.destinationPortName(),row.destinationWarehouseCode(),row.fleetSupplierName(),row.pickupAppointmentAt(),actual.get(row.containerBookingId()))).toList(); - return result(enriched,rows.size()>1000,unknown); + var enriched=records.stream().map(row->new PickupCalendarEntry(row.containerBookingId(),row.cabinetNo(),row.containerNo(), + row.destinationPortName(),row.destinationWarehouseCode(),row.fleetSupplierName(),row.pickupAppointmentAt(),actual.get(row.containerBookingId()))) + .filter(row->{var at=occurrence(row);return at!=null && !at.isBefore(from.atStartOfDay()) && at.isBefore(to.atStartOfDay());}) + .sorted(Comparator.comparing(TerminalDrayageCalendarService::occurrence).thenComparing(PickupCalendarEntry::containerBookingId)).toList(); + return result(enriched.stream().limit(1000).toList(),truncated || enriched.size()>1000,unknown); + } + private static java.time.LocalDateTime occurrence(PickupCalendarEntry row) { + return row.actualPickupAt()!=null?row.actualPickupAt():row.pickupAppointmentAt(); } private Calendar result(List records,boolean truncated,int unknown) { return new Calendar(List.copyOf(records),truncated,unknown,"US_DESTINATION_WAREHOUSE","SOURCE_LOCAL_UNSPECIFIED"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java index 2035ae70f..639405693 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java @@ -48,6 +48,7 @@ public class TerminalDrayageService { private final CarrierScheduleQueryService carrierSchedules; private final ObjectMapper objectMapper; private final TerminalDrayageMailService mails; + private final UnloadingEvidenceGuard unloadingEvidence; private Clock clock = Clock.systemDefaultZone(); public View detail(long containerBookingId) { @@ -145,10 +146,10 @@ public class TerminalDrayageService { TerminalDrayageEntity value = mapper.lock(containerBookingId); requireVersion(value, request.expectedVersion()); LocalDateTime pickupAt = actualPickupAt(context, value); - if (context.base().unloadingCompletedAt() == null) throw conflict("海外卸柜收货完成后才能确认还柜"); - LocalDateTime earliest = pickupAt != null && pickupAt.isAfter(context.base().unloadingCompletedAt()) - ? pickupAt : context.base().unloadingCompletedAt(); - if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间"); + if (completedUnloading(context) == null) throw conflict("海外仓卸柜或全部直送目的仓实际卸货完成后才能确认还柜"); + LocalDateTime earliest = context.base().unloadingCompletedAt(); + if (pickupAt != null && (earliest == null || pickupAt.isAfter(earliest))) earliest = pickupAt; + if (earliest != null && request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间"); if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认"); advanceNode(context, CarrierScheduleNode.EMPTY_RETURNED, request.actualAt(), action, key, request.remark(), operatorId, operatorName); @@ -229,23 +230,25 @@ public class TerminalDrayageService { } private void applyAuthoritativeDeadline(WorkContext context, TerminalDrayageEntity value) { - if (context.base().unloadingCompletedAt() != null) { - value.setEstimatedEmptyReturnAt(context.base().unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS)); + if (completedUnloading(context) != null) { + value.setEstimatedEmptyReturnAt(returnDeadline(context)); } } private View view(WorkContext context, TerminalDrayageEntity value) { String status = status(context, value); - LocalDateTime deadline = context.base().unloadingCompletedAt() == null ? null - : context.base().unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS); + LocalDateTime deadline = returnDeadline(context); + Duration remaining = deadline == null ? null : context.directCompletion() == null + ? Duration.between(LocalDateTime.now(clock), deadline) + : Duration.between(clock.instant(), context.directCompletion().instant().plus(Duration.ofHours(EMPTY_RETURN_WARNING_HOURS))); LocalDateTime returnedAt = actualEmptyReturnAt(context, value); String warningStatus; long overdueHours = 0; if (returnedAt != null) warningStatus = "RESOLVED"; else if (deadline == null) warningStatus = "NOT_APPLICABLE"; - else if (LocalDateTime.now(clock).isAfter(deadline)) { + else if (remaining.isNegative()) { warningStatus = "OVERDUE"; - overdueHours = Math.max(0, Duration.between(deadline, LocalDateTime.now(clock)).toHours()); + overdueHours = Math.max(0, remaining.negated().toHours()); } else warningStatus = "PENDING"; Warning warning = new Warning(warningStatus, deadline, overdueHours, overdueHours / 24); return new View(context.base().containerBookingId(), context.base().containerNo(), context.base().oceanBookingId(), status, @@ -253,14 +256,14 @@ public class TerminalDrayageService { value == null ? null : value.getFleetSupplierName(), value == null ? null : value.getPickupAppointmentAt(), actualPickupAt(context, value), deadline, returnedAt, - context.base().unloadingCompletedAt(), warning, allowedActions(context, value), context.facts(), + completedUnloading(context), warning, allowedActions(context, value), context.facts(), value == null ? null : value.getUpdatedAt()); } private List allowedActions(WorkContext context, TerminalDrayageEntity value) { List result = new ArrayList<>(); if (value != null && value.getFleetSupplierId() != null && actualPickupAt(context, value) == null - && actualEmptyReturnAt(context, value) == null && context.base().unloadingCompletedAt() == null) result.add("SEND_PICKUP_EMAIL"); + && actualEmptyReturnAt(context, value) == null && completedUnloading(context) == null) result.add("SEND_PICKUP_EMAIL"); CarrierScheduleNode current = currentNode(context); if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence() && current.sequence() < CarrierScheduleNode.PICKED_UP.sequence() @@ -268,14 +271,14 @@ public class TerminalDrayageService { result.add("SELECT_FLEET"); if (value != null && value.getFleetSupplierId() != null) result.add("SCHEDULE_PICKUP"); } - if (context.base().unloadingCompletedAt() != null + if (completedUnloading(context) != null && actualEmptyReturnAt(context, value) == null) result.add("RECORD_EMPTY_RETURN"); return List.copyOf(result); } private String status(WorkContext context, TerminalDrayageEntity value) { if (actualEmptyReturnAt(context, value) != null) return "EMPTY_RETURNED"; - if (context.base().unloadingCompletedAt() != null) return "UNLOADED"; + if (completedUnloading(context) != null) return "UNLOADED"; if (actualPickupAt(context, value) != null) return "PICKED_UP"; if (value != null && value.getPickupAppointmentAt() != null) return "PICKUP_SCHEDULED"; CarrierScheduleNode current = currentNode(context); @@ -300,6 +303,14 @@ public class TerminalDrayageService { return context.facts() == null ? null : node(context.facts().latestActualNode()); } + private LocalDateTime completedUnloading(WorkContext context) { + return context.completedUnloadingAt(); + } + private LocalDateTime returnDeadline(WorkContext context) { + if(context.directCompletion()!=null) return context.directCompletion().deadline(EMPTY_RETURN_WARNING_HOURS); + return completedUnloading(context)==null?null:completedUnloading(context).plusHours(EMPTY_RETURN_WARNING_HOURS); + } + private LocalDateTime actualPickupAt(WorkContext context, TerminalDrayageEntity value) { if (context.facts() != null && context.facts().pickedUpAt() != null) return context.facts().pickedUpAt(); return context.base().unloadingCompletedAt(); @@ -334,10 +345,12 @@ public class TerminalDrayageService { private WorkContext context(Context base, ContainerActualFacts facts) { if (base == null || !"ACTIVE".equals(base.recordStatus())) return null; - return new WorkContext(base, facts); + var direct=base.unloadingCompletedAt()==null?unloadingEvidence.directCompletion(base.containerBookingId()):null; + return new WorkContext(base, facts,base.unloadingCompletedAt()!=null?base.unloadingCompletedAt():direct==null?null:direct.localAt(),direct); } - private record WorkContext(Context base, ContainerActualFacts facts) {} + private record WorkContext(Context base, ContainerActualFacts facts,LocalDateTime completedUnloadingAt, + UnloadingEvidenceGuard.DirectCompletion directCompletion) {} private TerminalDrayageEntity requiredAggregate(long id) { TerminalDrayageEntity value = mapper.lock(id); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/UnloadingEvidenceGuard.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/UnloadingEvidenceGuard.java new file mode 100644 index 000000000..27c3d6173 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/UnloadingEvidenceGuard.java @@ -0,0 +1,63 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.firstleg.portschedule.service.EmptyReturnPrerequisite; +import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.springframework.stereotype.Service; +import java.time.*; +import java.util.*; + +@Service +public class UnloadingEvidenceGuard implements EmptyReturnPrerequisite { + private final DirectDeliveryMapper mapper; + public UnloadingEvidenceGuard(DirectDeliveryMapper mapper){this.mapper=mapper;} + public record DirectCompletion(LocalDateTime localAt,String timezone) { + public Instant instant(){return localAt.atZone(ZoneId.of(timezone)).toInstant();} + public LocalDateTime deadline(int hours){return instant().plus(Duration.ofHours(hours)).atZone(ZoneId.of(timezone)).toLocalDateTime();} + } + public DirectCompletion directCompletion(long id) { + return directCompletion(mapper.deliveries(id)); + } + private DirectCompletion directCompletion(List rows) { + if(rows.isEmpty() || rows.stream().anyMatch(row->row.actualAt()==null)) return null; + return rows.stream().map(row->new DirectCompletion(row.actualAt(),row.timezone())) + .max(java.util.Comparator.comparing(DirectCompletion::instant)).orElse(null); + } + public Map completedUnloading(List ids) { + var result=new HashMap(); + if(ids.isEmpty()) return result; + var pending=new ArrayList(); + for(var container:mapper.containers(ids)) { + if(!"ACTIVE".equals(container.recordStatus())) continue; + if(container.warehouseUnloadedAt()!=null) result.put(container.id(),container.warehouseUnloadedAt()); + else pending.add(container.id()); + } + if(!pending.isEmpty()) { + var deliveries=new HashMap>(); + for(var row:mapper.deliveriesBatch(pending)) deliveries.computeIfAbsent(row.containerBookingId(),ignored->new ArrayList<>()).add(row.delivery()); + for(long id:pending) { + var completed=directCompletion(deliveries.getOrDefault(id,List.of())); + if(completed!=null) result.put(id,completed.localAt()); + } + } + return result; + } + public LocalDateTime completedUnloading(long id) { + var container=mapper.container(id); + if(container==null || !"ACTIVE".equals(container.recordStatus())) return null; + if(container.warehouseUnloadedAt()!=null) return container.warehouseUnloadedAt(); + var direct=directCompletion(id); + return direct==null?null:direct.localAt(); + } + public void requireUnloaded(long id,LocalDateTime returnedAt) { + var container=mapper.lockContainer(id); + if(container==null) throw new BizException(ResultCode.NOT_FOUND,"货柜不存在"); + LocalDateTime completed=completedUnloading(id); + if(completed==null) throw new BizException(ResultCode.CONFLICT,"海外仓卸柜或全部直送目的仓实际卸货完成后才能确认还柜"); + // Original return nodes have no zone. Preserve the existing warehouse-local check, + // but do not compare that unzoned value with another destination's wall clock. + if(container.warehouseUnloadedAt()!=null && returnedAt.isBefore(completed)) + throw new BizException(ResultCode.BAD_REQUEST,"实际还柜时间不能早于卸货完成时间"); + } +} diff --git a/firstleg/src/main/resources/db/phase3-channel-container-migration.sql b/firstleg/src/main/resources/db/phase3-channel-container-migration.sql index c7ca8bad3..5cde7af51 100644 --- a/firstleg/src/main/resources/db/phase3-channel-container-migration.sql +++ b/firstleg/src/main/resources/db/phase3-channel-container-migration.sql @@ -94,7 +94,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.id; IF current_plan_status IN ('LOADING', 'COMPLETED') AND ( - NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) + NOT (OLD.id <=> NEW.id) OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.channel_group_code <=> NEW.channel_group_code) OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name) diff --git a/firstleg/src/main/resources/db/phase3-direct-delivery-schema.sql b/firstleg/src/main/resources/db/phase3-direct-delivery-schema.sql new file mode 100644 index 000000000..537561dbf --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-direct-delivery-schema.sql @@ -0,0 +1,19 @@ +-- Current schema for actual destination unloading; never an overseas warehouse receipt. +CREATE TABLE IF NOT EXISTS fl_direct_delivery_unloading ( + container_booking_id BIGINT UNSIGNED NOT NULL, + inbound_id BIGINT UNSIGNED NOT NULL COMMENT 'Immutable physically loaded inbound source', + actual_at DATETIME(3) NOT NULL COMMENT 'Actual destination local time', + timezone VARCHAR(64) NOT NULL COMMENT 'Explicit destination IANA timezone', + received_by VARCHAR(128) NOT NULL COMMENT 'Actual recipient', + evidence_no VARCHAR(128) NOT NULL COMMENT 'Actual delivery receipt reference', + remark VARCHAR(1000) NULL, + version INT NOT NULL DEFAULT 1, + idempotency_key VARCHAR(128) NOT NULL, + request_hash CHAR(64) NOT NULL, + created_by BIGINT UNSIGNED NOT NULL, + created_by_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (container_booking_id,inbound_id), + UNIQUE KEY uk_direct_unloading_command (container_booking_id,idempotency_key), + CONSTRAINT fk_direct_unloading_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='直送目的仓真实卸货凭据'; diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index cbb43637f..8d264ff4a 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -35,7 +35,6 @@ CREATE TABLE IF NOT EXISTS fl_document_sequence ( CREATE TABLE IF NOT EXISTS fl_container_booking ( id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)', - booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号', container_no VARCHAR(64) NULL COMMENT '国内柜次真实集装箱号权威快照;ISO 6346标准格式;同一订舱内唯一,不同提单允许复用;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改', cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号', source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜', @@ -90,7 +89,6 @@ CREATE TABLE IF NOT EXISTS fl_container_booking ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜资料最后更新时间', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), - UNIQUE KEY uk_container_booking_no (booking_no), UNIQUE KEY uk_container_booking_cabinet (cabinet_no), UNIQUE KEY uk_container_booking_ocean_container (ocean_booking_id,container_no), KEY idx_container_booking_container_no (container_no,updated_at,id), @@ -895,7 +893,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.id; IF current_plan_status IN ('LOADING', 'COMPLETED') AND ( - NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) + NOT (OLD.id <=> NEW.id) OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name) @@ -1542,9 +1540,9 @@ BEGIN END IF; IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_container_booking' - AND column_name IN ('booking_no', 'cabinet_no', 'warehouse_id', 'bill_of_lading_no', + AND column_name IN ('cabinet_no', 'warehouse_id', 'bill_of_lading_no', 'vessel_name', 'voyage_no', 'container_type', 'capacity_cbm', - 'max_weight_kg', 'terminal_trucking_company_id', 'terminal_trucking_company')) <> 11 THEN + 'max_weight_kg', 'terminal_trucking_company_id', 'terminal_trucking_company')) <> 10 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:货柜原型字段缺失'; END IF; IF (SELECT COUNT(*) FROM information_schema.columns diff --git a/firstleg/src/main/resources/openapi/appointment-work-source-v1.yaml b/firstleg/src/main/resources/openapi/appointment-work-source-v1.yaml new file mode 100644 index 000000000..ea5898aaf --- /dev/null +++ b/firstleg/src/main/resources/openapi/appointment-work-source-v1.yaml @@ -0,0 +1,178 @@ +openapi: 3.0.3 +info: {title: Firstleg appointment work authoritative sources, version: 1.0.0} +security: [{InternalServiceToken: []}] +paths: + /internal/firstleg/appointment-work/containers: + get: + summary: Complete actual-sailed container scope + description: Includes missing warehouses, private destinations and stopped tracking tasks. An actual departure or later maritime milestone is required. GET never creates work. Scope-versioned cursors reject changes between pages with 409; consumers restart a full scan. Original local times without a source timezone are never converted to UTC. + parameters: + - {name: after, in: query, schema: {type: string, maxLength: 128}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 200}} + responses: + '200': + description: ApiResponse + content: + application/json: + schema: + type: object + properties: + data: {$ref: '#/components/schemas/SourcePage'} + '401': {description: Invalid internal service identity} + '409': {description: Scope changed; restart from the first page} + /internal/firstleg/appointment-work/containers/{id}: + get: + summary: Read one authoritative sailed container and physical cargo lineage + parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}] + responses: + '200': + description: ApiResponse + content: + application/json: + schema: + type: object + properties: {data: {$ref: '#/components/schemas/ContainerSource'}} + '401': {description: Invalid internal service identity} + '404': {description: No active actual-sailed source} + /internal/firstleg/appointment-work/facts: + post: + summary: Read pickup, direct unloading and empty-return facts; no writes + requestBody: + required: true + content: + application/json: + schema: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/FactRequest'}} + responses: + '200': + description: Exactly one result per requested work key, never a partial success + content: + application/json: + schema: + type: object + properties: {data: {type: array, items: {$ref: '#/components/schemas/Fact'}}} + '400': {description: Invalid type or duplicate identity} + '401': {description: Invalid internal service identity} + '409': {description: Direct requirement does not match physical cargo} +components: + securitySchemes: + InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token} + schemas: + Reference: + type: object + required: [kind, key, label] + properties: + kind: {type: string} + key: {type: string, description: Internal association only} + businessNo: {type: string, nullable: true} + label: {type: string} + SourceReference: + type: object + properties: + customerId: {type: string} + sourceKey: {type: string} + destinationKey: {type: string, nullable: true} + Requirement: + type: object + required: [requirementKey, references, replacesRequirementKeys, sources] + properties: + requirementKey: {type: string, description: 'Immutable INBOUND:'} + batchKey: {type: string, nullable: true} + batchNo: {type: string, nullable: true} + destinationKind: {type: string, nullable: true} + originName: {type: string, nullable: true} + destinationName: {type: string, nullable: true} + timezone: {type: string, nullable: true} + references: {type: array, items: {$ref: '#/components/schemas/Reference'}} + replacesRequirementKeys: {type: array, items: {type: string}} + sources: {type: array, items: {$ref: '#/components/schemas/SourceReference'}, description: Empty until Lastleg resolves its canonical source; domestic ORDER references are not tail-leg ORDER keys} + ContainerSource: + type: object + required: [containerBookingId, sourceVersion, establishedFlow, requirements] + properties: + containerBookingId: {type: string} + containerNo: {type: string, nullable: true} + departedAt: {type: string, format: date-time, nullable: true} + departedLocalAt: {type: string, nullable: true, description: Exact actual departure only; source local timestamp without offset} + sailedEvidenceLocalAt: {type: string, nullable: true, description: Actual departure-or-later node proving sailed eligibility; never presented as an actual departure time} + sourceVersion: {type: string} + establishedFlow: {type: string, enum: [UNKNOWN, DIRECT, TRANSFER]} + timezone: {type: string, nullable: true} + requirements: {type: array, items: {$ref: '#/components/schemas/Requirement'}} + SourcePage: + type: object + required: [containers, complete, scopeVersion] + properties: + containers: {type: array, items: {$ref: '#/components/schemas/ContainerSource'}} + nextCursor: {type: string, nullable: true} + complete: {type: boolean} + scopeVersion: {type: string} + ContainerReference: + type: object + properties: + containerBookingId: {type: string} + bookingNo: {type: string, nullable: true} + containerNo: {type: string, nullable: true} + FactRequest: + type: object + required: [workItemKey, type, flow, containerBookingId] + properties: + workItemKey: {type: string} + type: {type: string, enum: [PICKUP, EMPTY_RETURN, DELIVERY]} + flow: {type: string, enum: [TRANSFER, DIRECT, UNKNOWN]} + containerBookingId: {type: string} + requirementKey: {type: string, nullable: true} + batchKey: {type: string, nullable: true} + tripKey: {type: string, nullable: true} + batchKeys: {type: array, items: {type: string}} + containers: {type: array, items: {$ref: '#/components/schemas/ContainerReference'}} + sources: {type: array, items: {$ref: '#/components/schemas/SourceReference'}} + Slot: + type: object + properties: + date: {type: string, format: date} + startTime: {type: string, nullable: true} + endDate: {type: string, format: date, nullable: true} + endTime: {type: string, nullable: true} + timezone: {type: string, nullable: true, description: Null for unzoned original plans; source-local date is retained} + Actual: + type: object + description: At least one time is required. Unzoned source facts contain localOccurredAt only. + properties: + occurredAt: {type: string, format: date-time, nullable: true} + localOccurredAt: {type: string, nullable: true} + timezone: {type: string, nullable: true} + Fact: + type: object + required: [workItemKey, fingerprint, impactToken, planExists, planStatus, executionStatus, readPermissions, actionPermissions, blockReasons, missingFields, references] + properties: + workItemKey: {type: string} + fingerprint: {type: string} + impactToken: {type: string, description: First scheduling and first carrier assignment do not increase semantic impact} + planExists: {type: boolean} + planStatus: {type: string, enum: [UNPLANNED, TARGET_SET, AWAITING_REPLY, CONFIRMED, NOT_REQUIRED]} + executionStatus: {type: string, enum: [PENDING, IN_PROGRESS, COMPLETED]} + target: {$ref: '#/components/schemas/Slot'} + confirmed: {$ref: '#/components/schemas/Slot'} + actual: {$ref: '#/components/schemas/Actual'} + timezone: {type: string, nullable: true} + businessNo: {type: string, nullable: true} + tripKey: {type: string, nullable: true} + readPermissions: {type: array, items: {type: string}} + actionPermissions: {type: array, items: {type: string}} + blockReasons: {type: array, items: {type: string}} + missingFields: {type: array, items: {type: string}} + references: {type: array, items: {$ref: '#/components/schemas/Reference'}} + responsibility: + type: object + nullable: true + properties: {kind: {type: string}, id: {type: string, nullable: true}, name: {type: string}} + action: + type: object + nullable: true + properties: + code: {type: string} + label: {type: string} + targetWorkItemKey: {type: string, nullable: true} + target: + type: object + properties: {kind: {type: string, enum: [FIRSTLEG_CONTAINER]}, containerBookingId: {type: string}} diff --git a/firstleg/src/main/resources/openapi/arrival-calendar-v1.yaml b/firstleg/src/main/resources/openapi/arrival-calendar-v1.yaml index 6868e6850..4d2151eb1 100644 --- a/firstleg/src/main/resources/openapi/arrival-calendar-v1.yaml +++ b/firstleg/src/main/resources/openapi/arrival-calendar-v1.yaml @@ -28,7 +28,7 @@ paths: type: object properties: containerBookingId: {type: string} - bookingNo: {type: string} + cabinetNo: {type: string} containerNo: {type: string, nullable: true} vesselName: {type: string, nullable: true} voyageNo: {type: string, nullable: true} diff --git a/firstleg/src/main/resources/openapi/direct-delivery-v1.yaml b/firstleg/src/main/resources/openapi/direct-delivery-v1.yaml new file mode 100644 index 000000000..30b814db6 --- /dev/null +++ b/firstleg/src/main/resources/openapi/direct-delivery-v1.yaml @@ -0,0 +1,70 @@ +openapi: 3.0.3 +info: {title: Direct destination unloading evidence, version: 1.0.0} +paths: + /api/platform/v1/terminal-drayages/{containerBookingId}/direct-deliveries: + get: + summary: Physical loaded inbound sources and immutable direct unloading evidence + x-permission: platform:firstleg:terminal-drayage:list + parameters: [{$ref: '#/components/parameters/Container'}] + responses: + '200': + description: ApiResponse + content: + application/json: + schema: + type: object + properties: {data: {type: array, items: {$ref: '#/components/schemas/View'}}} + '403': {description: Requires platform identity and list permission} + /api/platform/v1/terminal-drayages/{containerBookingId}/direct-deliveries/{inboundId}: + post: + summary: Record actual unloading for one complete physically loaded source + description: Locks the physical container. Requires Lastleg's persisted confirmed DIRECT route, actual pickup, a real recipient and receipt reference, an unambiguous IANA local time, and no warehouse unloading or empty-return fact. Route source failure returns 503. Exact retries replay; changed retries conflict. Does not create warehouse stock. Every loaded source must complete before manual empty return. Unzoned pickup/return timestamps are not compared to destination wall-clock times; cross-location chronology cannot be proven without the source zone. Direct completion and its 72-hour reminder use the latest evidence Instant. + x-permission: platform:firstleg:terminal-drayage:direct-unload:confirm + parameters: + - {$ref: '#/components/parameters/Container'} + - {name: inboundId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + - {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [expectedVersion, actualAt, timezone, receivedBy, evidenceNo] + properties: + expectedVersion: {type: integer, minimum: 0} + actualAt: {type: string, description: Local YYYY-MM-DDTHH:mm:ss without offset} + timezone: {type: string, description: Explicit destination IANA zone} + receivedBy: {type: string, minLength: 1, maxLength: 128} + evidenceNo: {type: string, minLength: 1, maxLength: 128} + remark: {type: string, maxLength: 1000, nullable: true} + responses: + '200': + description: ApiResponse + content: + application/json: + schema: + type: object + properties: {data: {$ref: '#/components/schemas/View'}} + '400': {description: Invalid source, time or evidence} + '403': {description: Requires platform identity and independent unloading permission} + '409': {description: Invalid business prerequisites, version or replay content} + '503': {description: Cannot verify authoritative route} +components: + parameters: + Container: {name: containerBookingId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + schemas: + View: + type: object + properties: + requirementKey: {type: string, description: 'INBOUND:, association only, not a visible business number'} + inboundCode: {type: string, nullable: true} + orderNo: {type: string, nullable: true} + destinationName: {type: string, nullable: true} + packageCount: {type: integer} + version: {type: integer, description: Zero until first actual unloading} + actualAt: {type: string, nullable: true} + timezone: {type: string, nullable: true} + receivedBy: {type: string, nullable: true} + evidenceNo: {type: string, nullable: true} + remark: {type: string, nullable: true} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 415b08cb6..f22a25424 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -355,7 +355,7 @@ paths: /internal/firstleg/v1/inbounds/unified-orders/workbench-filters: post: summary: 查询单一客户工作台履约筛选标识(Order内部) - description: 只读返回命中状态、最新待确认费用及待上传资料的订单标识。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。 + description: 只读返回命中状态、最新待确认费用及待上传资料的订单标识。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。 security: [{internalServiceToken: []}] requestBody: required: true @@ -367,6 +367,13 @@ paths: properties: tenantId: {type: integer, format: int64, minimum: 1} mainStatus: {type: string, nullable: true, enum: [WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED]} + orderIds: + type: array + nullable: true + minItems: 1 + maxItems: 200 + items: {type: integer, format: int64, minimum: 1} + description: 省略或null查询当前客户全部进度;传入时仅在SQL内查询这些来源Order订单,空数组拒绝,不允许扩大到全客户。 responses: '200': description: 按响应code判断,标识只供Order在自身客户范围内过滤、计数和分页 @@ -2323,13 +2330,12 @@ components: allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}} ContainerBooking: type: object - required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, channelGroupCode, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version] + required: [id, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, channelGroupCode, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version] properties: id: {type: string, pattern: '^[1-9][0-9]*$'} currentPortScheduleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, readOnly: true, description: 当前运输读模型由 latestForDispatch 选中的船期记录 ID;无匹配时为空,仅用于定位关联船期,不作为业务编号展示} planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空} planNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号;未创建计划时为空} - bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001, description: 平台内部货柜登记号} containerNo: {type: string, maxLength: 64, nullable: true} cabinetNo: {type: string, pattern: '^D[0-9]{4,}$', example: D0001, description: 平台全局连续且不按日期重置的稳定柜次号} sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜} @@ -2606,11 +2612,10 @@ components: pages: {type: string, pattern: '^[0-9]+$'} SmartLoadingWorkbenchContainer: type: object - required: [bookingId, bookingNo, cabinetNo, warehouseId, warehouseName, channelGroupCode, + required: [bookingId, cabinetNo, warehouseId, warehouseName, channelGroupCode, containerType, capacityCbm, maxWeightKg, planStatus, version] properties: bookingId: {type: string, pattern: '^[1-9][0-9]*$', description: containerBookingId 技术关联} - bookingNo: {type: string, description: 货柜登记业务号} cabinetNo: {type: string, description: 稳定柜次业务号} containerNo: {type: string, nullable: true, description: 真实集装箱号,允许在装柜前后补} warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} diff --git a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml index 3b7d0f913..65cf09583 100644 --- a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml @@ -74,7 +74,7 @@ components: exception: {$ref: '#/components/schemas/ExceptionState'} exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}} timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}} - Container: {type: object, properties: {id: {type: integer, format: int64}, bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001}, cabinetNo: {type: string, pattern: '^D[0-9]{4,}$', example: D0001}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, pickupCode: {type: string, pattern: '^[0-9]{1,64}$', nullable: true}, pickupCodeUpdatedBy: {type: integer, format: int64, nullable: true}, pickupCodeUpdatedByName: {type: string, nullable: true}, pickupCodeUpdatedAt: {type: string, format: date-time, nullable: true}, version: {type: integer, minimum: 0}}} + Container: {type: object, properties: {id: {type: integer, format: int64}, cabinetNo: {type: string, pattern: '^D[0-9]{4,}$', example: D0001}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, pickupCode: {type: string, pattern: '^[0-9]{1,64}$', nullable: true}, pickupCodeUpdatedBy: {type: integer, format: int64, nullable: true}, pickupCodeUpdatedByName: {type: string, nullable: true}, pickupCodeUpdatedAt: {type: string, format: date-time, nullable: true}, version: {type: integer, minimum: 0}}} PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}} PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}} Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}} diff --git a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml index a6b2dc43e..b6ac31d49 100644 --- a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml +++ b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml @@ -4,7 +4,7 @@ paths: /api/platform/v1/terminal-drayages/calendar: get: summary: 美国目的仓相关的提柜预约日历 - description: 平台身份,同时要求预约列表和码头拖车列表权限。按原业务预约提柜日期查询,不将实际提柜或预计到仓替代预约时间。美国范围按启用目的仓国家确认,不代表已确认港口所在国。原字段未提供港口时区,timeBasis明确待核实,禁止当作UTC或套用收货仓时区。最多42天、读取1001条以检测截断;返回最多1000条中的美国范围记录,未知目的仓计入unclassifiedCount,不能将缺失数据当零。 + description: 平台身份,同时要求预约列表和码头拖车列表权限。已提柜按船节点权威实际提柜日期、尚未提柜按原预约日期查询,改期不移动已执行事实;原预约与实际时间分别返回,不以预计到仓替代。美国范围按启用目的仓国家确认,不代表已确认港口所在国。原字段未提供港口时区,timeBasis明确待核实,禁止当作UTC或套用收货仓时区。最多42天,原预约和船节点实际日期候选分别读取1001条检测截断,经各自所属服务读取并按柜去重;返回最多1000条美国范围记录,未知目的仓计入unclassifiedCount,不能将缺失数据当零。 x-permissions: [platform:appointment:list, 'platform:firstleg:terminal-drayage:list'] parameters: - {name: from, in: query, required: true, schema: {type: string, format: date}, description: 包含的原业务当地日期} @@ -33,12 +33,12 @@ paths: type: object properties: containerBookingId: {type: string, description: 仅用于来源详情定位} - bookingNo: {type: string} + cabinetNo: {type: string} containerNo: {type: string, nullable: true} destinationPortName: {type: string, nullable: true} destinationWarehouseCode: {type: string, nullable: true} fleetSupplierName: {type: string, nullable: true} - pickupAppointmentAt: {type: string, description: 原业务当地时间,无时区偏移,不是UTC时间点} + pickupAppointmentAt: {type: string, nullable: true, description: 原业务当地预约时间,无时区偏移;仅有实际提柜事实时可为空} actualPickupAt: {type: string, nullable: true} '400': {description: 日期范围非法} '403': {description: 非平台身份或权限不足} @@ -82,8 +82,8 @@ paths: responses: {'200': {$ref: '#/components/responses/ViewResponse'}} /api/platform/v1/terminal-drayages/{containerBookingId}/empty-return: post: - summary: 海外卸柜收货完成后确认实际还柜并推进EMPTY_RETURNED节点 - description: 以海外卸柜完成事实开放还柜;无需已有车队、预约或实际提柜记录。无拖车记录时提交 expectedVersion=0,由命令创建记录,不补造提柜时间。实际还柜时间不得早于卸柜完成时间及已有实际提柜时间。 + summary: 海外仓卸柜或全部直送目的仓实际卸货完成后确认实际还柜并推进EMPTY_RETURNED节点 + description: 海外仓卸柜或全部直送目的仓实际卸货完成后开放还柜。无需已有车队或预约;海外仓卸柜可无实际提柜记录。无拖车记录时提交 expectedVersion=0,不补造提柜时间。保留原海外仓卸柜及已有船节点实际提柜的当地时间先后校验;直送目的仓时区不能套用于缺少时区的还柜时间,不比较不同地点的当地钟面值。 x-permission: platform:firstleg:terminal-drayage:empty-return:confirm parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}} @@ -173,7 +173,7 @@ components: required: [status, overdueHours, overdueDays] properties: status: {type: string, enum: [NOT_APPLICABLE, PENDING, OVERDUE, RESOLVED]} - deadlineAt: {type: string, format: date-time, nullable: true, description: 卸柜完成时间加72小时} + deadlineAt: {type: string, format: date-time, nullable: true, description: 原海外仓卸柜时间加72小时;直送按最晚真实卸货Instant加72小时后保留该目的地当地时间} overdueHours: {type: integer, format: int64, minimum: 0} overdueDays: {type: integer, format: int64, minimum: 0} TerminalDrayageView: @@ -189,7 +189,7 @@ components: fleetSupplierName: {type: string, nullable: true} pickupAppointmentAt: {type: string, format: date-time, nullable: true} actualPickupAt: {type: string, format: date-time, nullable: true, description: 优先使用实际船节点提柜时间;缺失时在海外PDA确认整柜拆柜完成后使用卸柜完成时间} - estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: 服务端权威值,卸柜完成时间加72小时} + estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: 服务端权威值;直送72小时按绝对时长计算,时区不明的原海外仓事实保留原规则} actualEmptyReturnAt: {type: string, format: date-time, nullable: true} unloadingCompletedAt: {type: string, format: date-time, nullable: true} emptyReturnWarning: {$ref: '#/components/schemas/Warning'} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java index f294caa1b..7da4e3849 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java @@ -128,7 +128,7 @@ class ContainerControllerContractTest { .contains("required: [oceanBookingId, requirementId, expectedVersion]") .contains("containerNo: {type: string, nullable: true") .doesNotContain("required: [oceanBookingId, requirementId, containerNo, expectedVersion]") - .contains("required: [id, bookingNo, cabinetNo, sourceType, recordStatus") + .contains("required: [id, cabinetNo, sourceType, recordStatus") .contains("sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]") .contains("recordStatus: {type: string, enum: [ACTIVE, MERGED]") .contains("mergedIntoContainerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java index a7f41e878..c72e432b4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java @@ -45,7 +45,7 @@ class InternalInboundOrderReferenceControllerTest { private InternalInboundOrderController controller; @Test void workbenchFiltersRejectMissingAndWrongInternalIdentity() { - var request = new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L, "LOADED"); + var request = new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L, "LOADED", null); assertThatThrownBy(() -> controller.workbenchFilters(null, request)).isInstanceOf(BizException.class); assertThatThrownBy(() -> controller.workbenchFilters("wrong", request)).isInstanceOf(BizException.class); org.mockito.Mockito.verifyNoInteractions(workbenchFilters); @@ -143,4 +143,18 @@ class InternalInboundOrderReferenceControllerTest { carrierId == null ? null : "承运商", null, null, null, null, null, null, null, null, null, null); } + + @Test void optionalExactScopeRejectsEmptyOversizeNullOrNonPositiveItems() { + try(var factory=jakarta.validation.Validation.buildDefaultValidatorFactory()) { + var validator=factory.getValidator(); + for(java.util.List ids: java.util.List.of(java.util.List.of(),java.util.List.of(0L), + java.util.Arrays.asList(1L,null),java.util.stream.LongStream.rangeClosed(1,201).boxed().toList())) { + assertThat(validator.validate(new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L,null,ids))).isNotEmpty(); + } + for(java.util.List ids: java.util.Arrays.asList(null,java.util.List.of(1L),java.util.stream.LongStream.rangeClosed(1,200).boxed().toList())) { + assertThat(validator.validate(new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L,null,ids))).isEmpty(); + } + } + } + } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java index 7ae30b21b..258f214fb 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java @@ -74,4 +74,16 @@ class UnifiedInboundOpenApiContractTest { .containsOnlyKeys("standardNode","nodeSequence","providerCode","estimatedAt","actualAt","locationCode","locationName"); } + + @Test @SuppressWarnings("unchecked") void documentsBoundedOptionalExactOrderScope() throws Exception { + var document=(java.util.Map)new org.yaml.snakeyaml.Yaml().load( + new org.springframework.core.io.ClassPathResource("openapi/firstleg-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8)); + var paths=(java.util.Map)document.get("paths"); + var operation=(java.util.Map)((java.util.Map)paths.get("/internal/firstleg/v1/inbounds/unified-orders/workbench-filters")).get("post"); + var content=(java.util.Map)((java.util.Map)operation.get("requestBody")).get("content"); + var schema=(java.util.Map)((java.util.Map)content.get("application/json")).get("schema"); + var ids=(java.util.Map)((java.util.Map)schema.get("properties")).get("orderIds"); + assertThat(ids).containsEntry("minItems",1).containsEntry("maxItems",200).containsEntry("nullable",true); + assertThat((java.util.List)schema.get("required")).doesNotContain("orderIds"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/mapper/AppointmentWorkSourceMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/mapper/AppointmentWorkSourceMapperTest.java new file mode 100644 index 000000000..972a4d8b4 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/mapper/AppointmentWorkSourceMapperTest.java @@ -0,0 +1,42 @@ +package com.baiyue.firstleg.portschedule.mapper; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; + +class AppointmentWorkSourceMapperTest { + @Test void actualSailedScopeHasNoWarehouseDestinationOrOpenQueryFilter() throws Exception { + var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + try(var c=ds.getConnection();var s=c.createStatement()) { + s.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR,departure_port_name VARCHAR,destination_warehouse_name VARCHAR,version INT,record_status VARCHAR)"); + s.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP)"); + s.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT)"); + s.execute("INSERT INTO fl_container_booking VALUES(1,'D1',NULL,'上海',NULL,1,'ACTIVE'),(2,'D2','C2','上海','私人仓',1,'ACTIVE'),(3,'D3','C3','上海',NULL,1,'ACTIVE'),(4,'D4','C4','上海',NULL,1,'MERGED')"); + s.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(10,1,'DEPARTED_ORIGIN_PORT','2026-10-01'),(20,2,'EMPTY_RETURNED','2026-10-03'),(30,3,'LOADED_ON_VESSEL','2026-10-04'),(40,4,'DEPARTED_ORIGIN_PORT','2026-10-01'),(50,3,'DEPARTED_ORIGIN_PORT',NULL)"); + s.execute("INSERT INTO fl_direct_delivery_unloading VALUES(2),(2)"); + s.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(60,1,'IN_TRANSIT','2026-10-02'),(70,1,'ARRIVED_DESTINATION_PORT','2026-10-03'),(80,2,'EMPTY_RETURNED',NULL)"); + } + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true); + config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(AppointmentWorkSourceMapper.class); + try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) { + var mapper=session.getMapper(AppointmentWorkSourceMapper.class); + var rows=mapper.sources(); + assertEquals(2,rows.size());assertEquals(1,rows.get(0).id());assertEquals(2,rows.get(1).id()); + assertNull(rows.get(0).destinationWarehouseName());assertEquals(2,rows.get(1).deliveryVersion()); + assertNull(rows.get(1).departedLocalAt());assertNotNull(rows.get(1).sailedEvidenceLocalAt()); + assertEquals(70,rows.get(0).factVersion());assertEquals(20,rows.get(1).factVersion()); + // The optimized path must equal the former full-manifest + Java filter, including + // dates, versions, null names, unloading counts, duplicate IDs and ineligible sources. + for(var ids:java.util.List.of(java.util.List.of(2L,1L),java.util.List.of(1L,1L), + java.util.List.of(2L,3L,4L,999L),java.util.List.of(3L,4L),java.util.List.of())) { + assertEquals(rows.stream().filter(row->ids.contains(row.id())).toList(),mapper.sourcesByIds(ids)); + } + assertTrue(mapper.sourcesByIds(null).isEmpty()); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/mapper/ArrivalCalendarMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/mapper/ArrivalCalendarMapperTest.java new file mode 100644 index 000000000..08bc5f32e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/mapper/ArrivalCalendarMapperTest.java @@ -0,0 +1,37 @@ +package com.baiyue.firstleg.portschedule.mapper; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ArrivalCalendarMapperTest { + @Test + void readsTheExistingCabinetNumberWithoutASecondContainerNumberColumn() throws Exception { + var ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:arrival-cabinet-number;MODE=MySQL;DB_CLOSE_DELAY=-1"); + try (var connection = ds.getConnection(); var sql = connection.createStatement()) { + sql.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR," + + "vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," + + "actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,record_status VARCHAR)"); + sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT," + + "standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)"); + sql.execute("INSERT INTO fl_container_booking VALUES(1,'D0007','TEST-CONT','Vessel','Voyage','Port'," + + "'US-W',NULL,'2026-11-25 08:00:00','ACTIVE')"); + } + var config = new MybatisConfiguration(); + config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds)); + config.addMapper(PortScheduleMapper.class); + try (var session = new MybatisSqlSessionFactoryBuilder().build(config).openSession()) { + var rows = session.getMapper(PortScheduleMapper.class).arrivalCalendar( + LocalDateTime.parse("2026-11-01T00:00:00"), LocalDateTime.parse("2026-12-01T00:00:00")); + assertEquals(1, rows.size()); + assertEquals("D0007", rows.get(0).cabinetNo()); + assertEquals(1L, rows.get(0).containerBookingId()); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/mapper/PickupActualCalendarMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/mapper/PickupActualCalendarMapperTest.java new file mode 100644 index 000000000..6b8ac26f4 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/mapper/PickupActualCalendarMapperTest.java @@ -0,0 +1,34 @@ +package com.baiyue.firstleg.portschedule.mapper; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; + +class PickupActualCalendarMapperTest { + @Test void selectsCanonicalActualPickupOnceInAHalfOpenRangeAndDetectsTruncation() throws Exception { + var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + try(var connection=ds.getConnection();var sql=connection.createStatement()) { + sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP)"); + sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(1,'PICKED_UP','2026-09-30 23:59:59'),(2,'PICKED_UP','2026-10-01 00:00:00'),(3,'PICKED_UP','2026-10-31 23:59:59'),(4,'PICKED_UP','2026-11-01 00:00:00'),(5,'ARRIVED_DESTINATION_PORT','2026-10-10'),(6,'PICKED_UP',NULL)"); + sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(2,'PICKED_UP','2026-10-01 00:00:00'),(4,'PICKED_UP','2026-10-10')"); + } + var config=new MybatisConfiguration();config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(CarrierScheduleQueryMapper.class); + try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) { + var mapper=session.getMapper(CarrierScheduleQueryMapper.class); + var from=LocalDateTime.parse("2026-10-01T00:00:00");var to=LocalDateTime.parse("2026-11-01T00:00:00"); + assertEquals(List.of(2L,3L),mapper.selectPickupCalendarContainers(from,to)); + try(var connection=ds.getConnection();var sql=connection.prepareStatement("INSERT INTO fl_carrier_schedule_node_fact VALUES(?,'PICKED_UP','2026-10-15')")) { + for(long id=10;id<1012;id++){sql.setLong(1,id);sql.addBatch();}sql.executeBatch(); + } + session.clearCache(); + assertEquals(1001,mapper.selectPickupCalendarContainers(from,to).size()); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/AppointmentWorkSourceServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/AppointmentWorkSourceServiceTest.java new file mode 100644 index 000000000..a1ce5e101 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/AppointmentWorkSourceServiceTest.java @@ -0,0 +1,92 @@ +package com.baiyue.firstleg.portschedule.service; + +import com.baiyue.firstleg.portschedule.mapper.AppointmentWorkSourceMapper; +import com.baiyue.firstleg.portschedule.controller.InternalAppointmentWorkSourceController; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import com.baiyue.firstleg.portschedule.dto.AppointmentWorkSourceDtos.*; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +class AppointmentWorkSourceServiceTest { + private final AppointmentWorkSourceMapper mapper=mock(AppointmentWorkSourceMapper.class); + private final AppointmentWorkSourceService.OriginalFacts facts=mock(AppointmentWorkSourceService.OriginalFacts.class); + private final AppointmentWorkSourceService service=new AppointmentWorkSourceService(mapper,facts,new ObjectMapper().findAndRegisterModules()); + private AppointmentWorkSourceMapper.Source source(long id,int version) { + return new AppointmentWorkSourceMapper.Source(id,"D000"+id,null,"上海",null,LocalDateTime.parse("2026-10-01T12:00:00"),LocalDateTime.parse("2026-10-01T12:00:00"),version,10L,0L); + } + @Test void completeCursorManifestIncludesUnboundSourcesAndRejectsScopeDrift() { + when(mapper.sources()).thenReturn(List.of(source(1,1),source(2,1))); + when(mapper.cargo(anyList())).thenReturn(List.of()); stubFlows("UNKNOWN"); + var first=service.discover(null,1); + assertFalse(first.complete()); assertNotNull(first.nextCursor()); + assertNull(first.containers().get(0).departedAt()); + assertEquals(LocalDateTime.parse("2026-10-01T12:00:00"),first.containers().get(0).departedLocalAt()); + var next=service.discover(first.nextCursor(),1); + assertTrue(next.complete());assertEquals("2",next.containers().get(0).containerBookingId()); + when(mapper.sources()).thenReturn(List.of(source(1,2),source(2,1))); + assertEquals(409,assertThrows(ResponseStatusException.class,()->service.discover(first.nextCursor(),1)).getStatusCode().value()); + } + @Test void sourceKeepsCustomerAndPhysicalInboundLineageWithoutDisplayingIdsAsNumbers() { + when(mapper.sources()).thenReturn(List.of(source(1,1))); + when(mapper.cargo(List.of(1L))).thenReturn(List.of(new AppointmentWorkSourceMapper.ContainerCargo(1,8,9,10,"ORDER-10","IN-8","PRIVATE","P","客户仓",12))); + stubFlows("DIRECT"); + var source=service.container(1);var requirement=source.requirements().get(0); + assertEquals(service.hash(List.of(source(1,1),List.of(new AppointmentWorkSourceMapper.Cargo(8,9,10,"ORDER-10","IN-8","PRIVATE","P","客户仓",12)))),source.sourceVersion()); + assertEquals(source,service.discover(null,200).containers().get(0)); + assertEquals("INBOUND:8",requirement.requirementKey());assertEquals("PRIVATE",requirement.destinationKind()); + assertTrue(requirement.references().stream().anyMatch(r->r.kind().equals("CUSTOMER") && r.key().equals("9") && r.businessNo()==null)); + } + @Test void internalControllerFailsClosedBeforeReadingSources() { + var guarded=new InternalAppointmentWorkSourceController(service,"secret"); + assertEquals(401,assertThrows(ResponseStatusException.class,()->guarded.discover(null,null,20)).getStatusCode().value()); + verifyNoInteractions(mapper); + } + @Test void missingFlowEvidenceIsNotSilentlyPresentedAsUnknown() { + when(mapper.sources()).thenReturn(List.of(source(1,1))); + assertEquals(503,assertThrows(ResponseStatusException.class,()->service.container(1)).getStatusCode().value()); + } + @Test void factBatchReadsOnlyRequestedSourcesAndPreservesEachContainerIdentity() { + when(mapper.sourcesByIds(anyList())).thenReturn(IntStream.rangeClosed(1,100).mapToObj(id->source(id,1)).toList()); + when(mapper.cargo(anyList())).thenReturn(List.of()); + stubFlows("UNKNOWN"); + var fact=mock(Fact.class); + when(facts.read(any(),any())).thenAnswer(invocation->{ + List requests=invocation.getArgument(0); Map sources=invocation.getArgument(1); + assertEquals(100,sources.size()); + requests.forEach(request->assertEquals(request.containerBookingId(),sources.get(Long.parseLong(request.containerBookingId())).containerBookingId())); + return requests.stream().map(request->fact).toList(); + }); + var requests=IntStream.rangeClosed(1,100).boxed().flatMap(id->List.of( + request("PICKUP-"+id,"PICKUP",id),request("RETURN-"+id,"EMPTY_RETURN",id)).stream()).toList(); + assertEquals(200,service.facts(requests).size()); + verify(mapper,times(1)).sourcesByIds(IntStream.rangeClosed(1,100).mapToObj(Long::valueOf).toList()); + verify(mapper,never()).sources(); + verify(mapper,times(1)).cargo(IntStream.rangeClosed(1,100).mapToObj(Long::valueOf).toList()); + verify(facts,times(1)).establishedFlows(anyList()); + verify(facts,times(1)).read(any(),any()); + } + @Test void nextFactBatchReadsFreshSourcesAndRejectsRemovedContainer() { + when(mapper.sourcesByIds(List.of(1L))).thenReturn(List.of(source(1,1)),List.of()); + when(mapper.cargo(List.of(1L))).thenReturn(List.of()); + stubFlows("UNKNOWN"); + var request=request("PICKUP-1","PICKUP",1); + service.facts(List.of(request)); + assertEquals(404,assertThrows(ResponseStatusException.class,()->service.facts(List.of(request))).getStatusCode().value()); + verify(mapper,times(2)).sourcesByIds(List.of(1L)); + verify(mapper,never()).sources(); + verify(facts,times(1)).read(any(),any()); + } + private void stubFlows(String flow) { + when(facts.establishedFlows(anyList())).thenAnswer(invocation->((List)invocation.getArgument(0)).stream().collect(Collectors.toMap(id->id,id->flow))); + } + private FactRequest request(String key,String type,long id) { + return new FactRequest(key,type,"TRANSIT",Long.toString(id),null,null,null,List.of(),List.of(),List.of()); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index 1351048a4..52e4846b0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -53,6 +53,7 @@ class CarrierScheduleQueryServiceTest { @Mock private CarrierScheduleQueryRepository repository; @Mock private PortScheduleRepository portSchedules; @Mock private CarrierScheduleNodeOutboxService nodeOutbox; + @Mock private EmptyReturnPrerequisite emptyReturnPrerequisite; @InjectMocks private CarrierScheduleQueryService service; private CarrierScheduleAdapterRegistry adapters; @@ -84,6 +85,19 @@ class CarrierScheduleQueryServiceTest { verify(repository).containerActualFacts(List.of(20L, 21L)); } + @Test + void containerManualEmptyReturnCannotBypassRealUnloadingEvidence() { + org.mockito.Mockito.doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,"未卸货")) + .when(emptyReturnPrerequisite).requireUnloaded(20L,NOW); + assertThatThrownBy(()->service.addContainerManualNode(20L,7L,"admin","empty-no-unload", + new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest( + "EMPTY_RETURNED",NOW,null,NOW,null,null,"还柜"))) + .isInstanceOf(BizException.class).hasMessage("未卸货"); + verifyNoNodePublication(); + } + + private void verifyNoNodePublication() { org.mockito.Mockito.verifyNoInteractions(nodeOutbox); } + @Test void rejectsInvalidActualFactBatchBeforeRepositoryAccess() { assertThatThrownBy(() -> service.containerActualFacts(null)) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java index 1ebb617d3..00b029a6d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java @@ -83,19 +83,28 @@ class MerchantWorkbenchFilterMapperTest { } @Test void onlyActualLoadedOwnedFragmentsAndActualClearanceAdvanceProgress() { - assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==0); + assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==0); jdbc.update("INSERT INTO fl_loading_pick_task VALUES(900,100,'COMPLETED')"); jdbc.update("INSERT INTO fl_loading_pick_item SELECT 900,id,'PICKED',2 FROM fl_loading_plan_item"); - assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==1).noneMatch(f->f.orderId()==103||f.orderId()==104); + assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==1).noneMatch(f->f.orderId()==103||f.orderId()==104); jdbc.update("INSERT INTO fl_customs_container VALUES(200,100,'DECLARING')"); - assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==1); + assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==1); jdbc.update("UPDATE fl_customs_container SET status='COMPLETED'"); - assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==2); + assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==2); jdbc.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(100,'PICKUP_READY',NULL)"); - assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==2); + assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==2); jdbc.update("UPDATE fl_carrier_schedule_node_fact SET actual_at=CURRENT_TIMESTAMP"); - assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==3); + assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==3); + assertThat(mapper.progressFragments(11,List.of(101L))).singleElement().satisfies(f->{assertThat(f.orderId()).isEqualTo(101);assertThat(f.stage()).isEqualTo(3);}); jdbc.update("UPDATE fl_container_booking SET record_status='CANCELLED'"); - assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==0); + assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==0); } + + @Test void exactOrderScopeKeepsAllItsFragmentsAndCannotReadForeignOrDeletedOrders() { + jdbc.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,deleted,tax_mode,process_status) VALUES(50,11,101,0,'TAX_REFUND','LOADED')"); + assertThat(mapper.progressFragments(11,List.of(101L,103L,104L))).extracting(f->f.orderId()).containsExactly(101L,101L); + assertThat(mapper.progressFragments(11,List.of(999L))).isEmpty(); + assertThat(mapper.progressFragments(22,List.of(101L))).isEmpty(); + } + } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index 0b9bc9808..fc8bb1ade 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -80,7 +80,7 @@ class Phase3FinalUnifiedSqlContractTest { @Test void initializationPassIsUniqueAndFinal() throws Exception { String sql = sql(); - int expectedTables = 226; + int expectedTables = 227; long actualTables = java.util.regex.Pattern.compile("(?m)^CREATE TABLE\\s+") .matcher(sql).results().count(); assertThat(actualTables).isEqualTo(expectedTables); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarServiceTest.java index 1c7a8635a..8381c951c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarServiceTest.java @@ -13,6 +13,7 @@ import org.mockito.MockitoAnnotations; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -25,6 +26,7 @@ import static org.mockito.Mockito.when; class SailingCalendarServiceTest { @Mock private SailingCalendarRepository repository; @InjectMocks private SailingCalendarService service; + private final LocalDateTime copiedDeparture = LocalDateTime.now(ZoneId.of("Asia/Shanghai")).plusDays(14); @BeforeEach void setUp() { @@ -33,8 +35,8 @@ class SailingCalendarServiceTest { @Test void replaysAnExistingCopyInsteadOfReturningAnInternalError() { - SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", LocalDateTime.of(2026, 10, 5, 10, 0)); - SailingEntity existing = sailing(2L, "SL-COPY", "COPY", "QA-20261005", LocalDateTime.of(2026, 10, 12, 10, 0)); + SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", copiedDeparture.minusDays(7)); + SailingEntity existing = sailing(2L, "SL-COPY", "COPY", "QA-20261005", copiedDeparture); SailingHistoryEntity history = new SailingHistoryEntity(); history.setAction("COPY"); history.setReason("复制自SL-SOURCE"); history.setSailingId(2L); when(repository.sailing(1L)).thenReturn(source); @@ -45,7 +47,7 @@ class SailingCalendarServiceTest { when(repository.bookingSummary(any(SailingEntity.class))).thenReturn(null); var result = service.copy(1L, 7L, "admin", - new CopyRequest(existing.getEtdAt(), LocalDateTime.of(2026, 10, 9, 18, 0))); + new CopyRequest(existing.getEtdAt(), copiedDeparture.minusDays(3))); assertThat(result.sailing().id()).isEqualTo(2L); verify(repository, never()).insert(any(SailingEntity.class)); @@ -53,13 +55,13 @@ class SailingCalendarServiceTest { @Test void reportsBusinessConflictForAnUnrelatedExistingSailing() { - SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", LocalDateTime.of(2026, 10, 5, 10, 0)); - SailingEntity existing = sailing(2L, "SL-OTHER", "TEMPLATE", "QA-20261005", LocalDateTime.of(2026, 10, 12, 10, 0)); + SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", copiedDeparture.minusDays(7)); + SailingEntity existing = sailing(2L, "SL-OTHER", "TEMPLATE", "QA-20261005", copiedDeparture); when(repository.sailing(1L)).thenReturn(source); when(repository.sailingByBusinessKey(8L, "MATSON", "CNNGB", "USLGB", existing.getEtdAt(), "QA-20261005")).thenReturn(existing); assertThatThrownBy(() -> service.copy(1L, 7L, "admin", - new CopyRequest(existing.getEtdAt(), LocalDateTime.of(2026, 10, 9, 18, 0)))) + new CopyRequest(existing.getEtdAt(), copiedDeparture.minusDays(3)))) .isInstanceOf(BizException.class) .hasMessageContaining("已存在航次 QA-20261005"); verify(repository, never()).insert(any(SailingEntity.class)); @@ -67,11 +69,11 @@ class SailingCalendarServiceTest { @Test void rejectsCutoffAtOrAfterCopiedDeparture() { - SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", LocalDateTime.of(2026, 10, 5, 10, 0)); + SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", copiedDeparture.minusDays(7)); when(repository.sailing(1L)).thenReturn(source); assertThatThrownBy(() -> service.copy(1L, 7L, "admin", - new CopyRequest(LocalDateTime.of(2026, 10, 12, 10, 0), LocalDateTime.of(2026, 10, 12, 10, 0)))) + new CopyRequest(copiedDeparture, copiedDeparture))) .isInstanceOf(BizException.class) .hasMessageContaining("收货截止必须早于开船"); verify(repository, never()).insert(any(SailingEntity.class)); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index e241e630f..4d4834e08 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -52,7 +52,6 @@ class ContainerServiceTest { @BeforeEach void setUpWarehouse() { - when(documentNumbers.next("CB")).thenReturn("CB-260818-0001"); when(documentNumbers.nextCabinetNo()).thenReturn("D0001"); when(smartLoadingService.createForContainer(anyLong(), anyLong(), anyString())).thenAnswer(invocation -> new com.baiyue.firstleg.dto.SmartLoadingDtos.Plan(501L, "LP-260818-0001", "EDITING", @@ -76,8 +75,9 @@ class ContainerServiceTest { verify(repository).insert(captor.capture()); ContainerBookingEntity saved = captor.getValue(); assertThat(created).isSameAs(saved); - assertThat(saved.getBookingNo()).isEqualTo("CB-260818-0001"); assertThat(saved.getCabinetNo()).isEqualTo("D0001"); + verify(documentNumbers).nextCabinetNo(); + verify(documentNumbers, never()).next("CB"); assertThat(saved.getContainerNo()).isNull(); assertThat(saved.getOceanBookingId()).isNull(); assertThat(saved.getChannelGroupCode()).isEqualTo("MATSON_CLX"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGeneratorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGeneratorTest.java index 783adf9fe..e0cd6f665 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGeneratorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGeneratorTest.java @@ -41,7 +41,7 @@ class FirstlegDocumentNumberGeneratorTest { assertThat(generator.next("OB")).isEqualTo("OB-260818-0001"); assertThat(generator.next("TA")).isEqualTo("TA-260818-0001"); - assertThat(generator.next("CB")).isEqualTo("CB-260818-0001"); + assertThatThrownBy(() -> generator.next("CB")).isInstanceOf(IllegalArgumentException.class); assertThat(generator.next("LP")).isEqualTo("LP-260818-0001"); assertThat(generator.nextCabinetNo()).isEqualTo("D0001"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java new file mode 100644 index 000000000..b2c74a883 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java @@ -0,0 +1,26 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.dto.MerchantWorkbenchFilters; +import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class MerchantWorkbenchFilterServiceTest { + @Test void exactDetailProgressSkipsUnrelatedCustomerQueues() { + var mapper=mock(MerchantWorkbenchFilterMapper.class); + var unified=mock(UnifiedInboundQueryService.class); + var service=new MerchantWorkbenchFilterService(); + ReflectionTestUtils.setField(service,"mapper",mapper); + ReflectionTestUtils.setField(service,"unified",unified); + when(mapper.progressFragments(11,List.of(101L))).thenReturn(List.of( + new MerchantWorkbenchFilters.Fragment(101,3),new MerchantWorkbenchFilters.Fragment(101,1))); + var response=service.read(new MerchantWorkbenchFilters.Request(11L,null,List.of(101L))); + assertThat(response.progress()).containsExactly(new MerchantWorkbenchFilters.Progress(101,"cleared",true)); + assertThat(response.feeOrderIds()).isEmpty();assertThat(response.customsOrderIds()).isEmpty(); + assertThat(response.matchingStatusOrderIds()).isNull(); + verify(mapper).progressFragments(11,List.of(101L));verifyNoMoreInteractions(mapper);verifyNoInteractions(unified); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index e1065048f..33232b39c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -627,7 +627,7 @@ class SmartLoadingServiceTest { @Test void pagesWorkbenchContainersWithTheSameGlobalFilters() { - WorkbenchContainer container = new WorkbenchContainer(99L, "CB-1", "CAB-1", null, + WorkbenchContainer container = new WorkbenchContainer(99L, "D0001", null, 100L, "义乌仓", "MATSON_CLX", null, null, "40HQ", new BigDecimal("68"), new BigDecimal("19500"), 1L, "LP-1", "EDITING", 2); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java index 3a43c1dce..5e31d5059 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java @@ -71,10 +71,10 @@ class TaillegRealProducerAcceptanceTest { } private void seedCompletedLoadingPlan() { - jdbc.update("INSERT IGNORE INTO fl_container_booking(id,booking_no,container_no,cabinet_no,warehouse_id," + + jdbc.update("INSERT IGNORE INTO fl_container_booking(id,container_no,cabinet_no,warehouse_id," + "warehouse_name,customer_id,customer_name,estimated_port_arrival_at,container_type,created_by,updated_by) " + - "VALUES(?,?,?,?,?,?,?,?,?,?,1,1)", - BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓", + "VALUES(?,?,?,?,?,?,?,?,?,1,1)", + BOOKING, MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓", CUSTOMER, "真实验收客户", java.time.LocalDateTime.of(2026, 9, 30, 12, 0), "40HQ"); jdbc.update("INSERT IGNORE INTO fl_loading_plan(id,plan_no,container_booking_id,plan_status,created_by," + "updated_by,version) VALUES(?,?,?,'LOADING',1,1,0)", PLAN, MARKER + "-PLAN", BOOKING); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java index fdc975777..c7baa1099 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java @@ -51,7 +51,8 @@ class TerminalDrayageOpenApiContractTest { "emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays", "船节点跟踪任务尚未建立也返回WAITING_FOR_ARRIVAL空详情", "允许读取空邮件列表,不以船节点跟踪任务是否已经建立作为货柜存在性条件"); - assertThat(yaml).contains("description: 卸柜完成时间加72小时"); + assertThat(yaml).contains("原海外仓卸柜时间加72小时", "直送按最晚真实卸货Instant加72小时", + "直送72小时按绝对时长计算", "不比较不同地点的当地钟面值"); assertThat(yaml).doesNotContain("/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:", "platform:firstleg:terminal-drayage:pickup:confirm", "RECORD_PICKUP"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/DirectDeliveryMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/DirectDeliveryMapperTest.java new file mode 100644 index 000000000..bde05b582 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/DirectDeliveryMapperTest.java @@ -0,0 +1,61 @@ +package com.baiyue.firstleg.terminaldrayage.mapper; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import java.util.UUID; +import java.util.List; +import com.baiyue.firstleg.portschedule.mapper.AppointmentWorkSourceMapper; +import static org.junit.jupiter.api.Assertions.*; + +class DirectDeliveryMapperTest { + @Test void physicalSourceAggregationAndImmutableEvidenceSurviveActualPersistence() throws Exception { + var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + try(var c=ds.getConnection();var s=c.createStatement()) { + s.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,record_status VARCHAR,unloading_completed_at TIMESTAMP)"); + s.execute("CREATE TABLE fl_loading_plan(id BIGINT,container_booking_id BIGINT,plan_status VARCHAR)"); + s.execute("CREATE TABLE fl_loading_plan_item(loading_plan_id BIGINT,inbound_id BIGINT,inbound_code VARCHAR,order_no VARCHAR,destination_warehouse_name VARCHAR,package_count INT,deleted INT)"); + s.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT,inbound_id BIGINT,actual_at TIMESTAMP,timezone VARCHAR,received_by VARCHAR,evidence_no VARCHAR,remark VARCHAR,version INT,idempotency_key VARCHAR,request_hash VARCHAR,created_by BIGINT,created_by_name VARCHAR,PRIMARY KEY(container_booking_id,inbound_id),UNIQUE(container_booking_id,idempotency_key))"); + s.execute("INSERT INTO fl_container_booking VALUES(1,'ACTIVE',NULL)");s.execute("INSERT INTO fl_loading_plan VALUES(10,1,'COMPLETED')"); + s.execute("INSERT INTO fl_loading_plan_item VALUES(10,20,'I20','O20','D',3,0),(10,20,'I20','O20','D',4,0),(10,21,'I21','O21','D',5,0),(10,22,'I22','O22','D',100,1)"); + s.execute("INSERT INTO fl_container_booking VALUES(2,'ACTIVE','2026-01-02 12:00:00'),(3,'ACTIVE',NULL)"); + s.execute("INSERT INTO fl_loading_plan VALUES(11,2,'COMPLETED'),(12,3,'DRAFT')"); + s.execute("INSERT INTO fl_loading_plan_item VALUES(11,20,'I20','O20','D',2,0),(12,20,'I20','O20','D',100,0)"); + s.execute("ALTER TABLE fl_loading_plan_item ADD account_id BIGINT DEFAULT 7"); + s.execute("ALTER TABLE fl_loading_plan_item ADD order_id BIGINT DEFAULT 8"); + s.execute("ALTER TABLE fl_loading_plan_item ADD destination_warehouse_code VARCHAR DEFAULT 'D'"); + s.execute("CREATE TABLE fl_inbound_order(id BIGINT,destination_type VARCHAR)"); + s.execute("INSERT INTO fl_inbound_order VALUES(20,'PRIVATE'),(21,'AMAZON'),(22,'PRIVATE')"); + s.execute("CREATE TABLE fl_terminal_drayage_history(container_booking_id BIGINT,action VARCHAR)"); + s.execute("INSERT INTO fl_terminal_drayage_history VALUES(1,'SELECT_FLEET'),(1,'SCHEDULE_PICKUP'),(1,'SCHEDULE_PICKUP'),(1,'OTHER'),(2,'SELECT_FLEET')"); + } + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(DirectDeliveryMapper.class); + config.addMapper(AppointmentWorkSourceMapper.class);config.addMapper(TerminalDrayageMapper.class); + try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) { + var mapper=session.getMapper(DirectDeliveryMapper.class);assertEquals(1,mapper.lockContainer(1).id()); + assertEquals(2,mapper.deliveries(1).size());assertEquals(7,mapper.deliveries(1).get(0).packageCount()); + var at=LocalDateTime.parse("2026-01-01T10:30:00"); + mapper.insert(1,20,at,"America/Los_Angeles","签收人","POD20",null,"key","hash",7,"admin"); + assertEquals(1,mapper.evidenceCount(1));assertEquals(1,mapper.keyCount(1,"key")); + assertEquals(at,mapper.deliveries(1).get(0).actualAt());assertNull(mapper.deliveries(1).get(1).actualAt()); + var batch=mapper.deliveriesBatch(List.of(1L,2L,3L)); + assertEquals(3,batch.size());assertEquals(mapper.deliveries(1),batch.stream().filter(r->r.containerBookingId()==1).map(DirectDeliveryMapper.ContainerDelivery::delivery).toList()); + assertEquals(2,batch.get(2).packageCount());assertNull(batch.get(2).actualAt()); + assertEquals(3,mapper.containers(List.of(1L,2L,3L)).size()); + var flows=mapper.flowEvidence(List.of(1L,2L,3L)); + assertEquals(1,flows.stream().filter(r->r.id()==1).findFirst().orElseThrow().evidenceCount()); + assertNotNull(flows.stream().filter(r->r.id()==2).findFirst().orElseThrow().warehouseUnloadedAt()); + var cargo=session.getMapper(AppointmentWorkSourceMapper.class).cargo(List.of(1L,2L,3L)); + assertEquals(3,cargo.size());assertEquals(7,cargo.get(0).packageCount());assertEquals(1,cargo.get(0).containerBookingId()); + assertEquals(2,cargo.get(2).containerBookingId());assertEquals(2,cargo.get(2).packageCount()); + var changes=session.getMapper(TerminalDrayageMapper.class).pickupChanges(List.of(1L,2L,3L)); + assertEquals(new TerminalDrayageMapper.PickupChanges(1,2,1),changes.stream().filter(r->r.containerBookingId()==1).findFirst().orElseThrow()); + assertThrows(RuntimeException.class,()->mapper.insert(1,20,at,"America/Los_Angeles","签收人","POD20",null,"another","hash",7,"admin")); + session.rollback(); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/DirectDeliverySchemaContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/DirectDeliverySchemaContractTest.java new file mode 100644 index 000000000..1b95bf344 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/DirectDeliverySchemaContractTest.java @@ -0,0 +1,30 @@ +package com.baiyue.firstleg.terminaldrayage.mapper; + +import org.junit.jupiter.api.Test; +import java.nio.file.*; +import java.util.regex.Pattern; +import static org.junit.jupiter.api.Assertions.*; + +class DirectDeliverySchemaContractTest { + @Test void immutableDirectEvidenceAndIndependentWritePermissionsReachDeploymentBaseline() throws Exception { + String source=Files.readString(Path.of("src/main/resources/db/phase3-direct-delivery-schema.sql")); + String release=Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + assertEquals(definition(source),definition(release)); + assertTrue(source.contains("PRIMARY KEY (container_booking_id,inbound_id)")); + assertTrue(source.contains("UNIQUE KEY uk_direct_unloading_command")); + String permissions=Files.readString(Path.of("../iam/src/main/resources/db/appointment-work-permissions.sql")); + for(String code:java.util.List.of("platform:appointment:manage-work","platform:firstleg:terminal-drayage:direct-unload:confirm")) { + assertTrue(release.contains("'"+code+"'"));assertTrue(permissions.contains("'"+code+"'")); + } + assertTrue(release.contains("VALUES (4000001,5107283,0,CURRENT_TIMESTAMP(3))")); + assertTrue(release.contains("VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3))")); + assertTrue(permissions.indexOf("CALL check_appointment_work_permission_identity")r.containerBookingId()).toList());assertNull(rows.get(0).actualPickupAt()); + assertEquals("D0001", rows.get(0).cabinetNo()); + try(var c=ds.getConnection();var s=c.createStatement()) { + s.execute("UPDATE fl_terminal_drayage SET pickup_appointment_at='2026-12-01' WHERE container_booking_id=3"); + s.execute("DELETE FROM fl_terminal_drayage WHERE container_booking_id=2"); + } + session.clearCache(); + rows=session.getMapper(TerminalDrayageMapper.class).calendarByContainers(List.of(1L,2L,3L,4L,5L)); + assertEquals(List.of(1L,2L,3L),rows.stream().map(r->r.containerBookingId()).toList()); + assertNull(rows.get(1).pickupAppointmentAt()); + assertEquals(LocalDateTime.parse("2026-12-01T00:00:00"),rows.get(2).pickupAppointmentAt()); } } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/AppointmentWorkTerminalFactsTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/AppointmentWorkTerminalFactsTest.java new file mode 100644 index 000000000..bcf2a3324 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/AppointmentWorkTerminalFactsTest.java @@ -0,0 +1,77 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.firstleg.portschedule.dto.AppointmentWorkSourceDtos.*; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import com.baiyue.firstleg.terminaldrayage.mapper.*; +import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +class AppointmentWorkTerminalFactsTest { + private final TerminalDrayageMapper terminal=mock(TerminalDrayageMapper.class); + private final DirectDeliveryMapper direct=mock(DirectDeliveryMapper.class); + private final CarrierScheduleQueryService nodes=mock(CarrierScheduleQueryService.class); + private final UnloadingEvidenceGuard guard=mock(UnloadingEvidenceGuard.class); + private final AppointmentWorkTerminalFacts adapter=new AppointmentWorkTerminalFacts(terminal,direct,nodes,guard,new ObjectMapper().findAndRegisterModules()); + private final LocalDateTime at=LocalDateTime.parse("2026-01-01T12:30:00"); + private ContainerSource source(){return new ContainerSource("1","C1",null,at,at,"v","UNKNOWN",null,List.of());} + private FactRequest request(String type){return new FactRequest("W1",type,"DIRECT","1",null,null,null,List.of(),List.of(),List.of());} + private Fact read(FactRequest request,ContainerSource source) {return adapter.read(List.of(request),Map.of(1L,source)).get(0);} + @Test void twoHundredRequestsReadEachFactSetOnceAndKeepMissingFactsVisible() { + var ids=java.util.stream.LongStream.rangeClosed(1,100).boxed().toList(); + var sources=new java.util.HashMap(); + var requests=new java.util.ArrayList(); + for(long id:ids) { + sources.put(id,new ContainerSource(Long.toString(id),"C"+id,null,at,at,"v","UNKNOWN",null,List.of())); + requests.add(new FactRequest("P"+id,"PICKUP","TRANSIT",Long.toString(id),null,null,null,List.of(),List.of(),List.of())); + requests.add(new FactRequest("R"+id,"EMPTY_RETURN","TRANSIT",Long.toString(id),null,null,null,List.of(),List.of(),List.of())); + } + var results=adapter.read(requests,sources); + assertEquals(200,results.size()); + for(int i=0;iadapter.establishedFlows(List.of(1L,2L))).getStatusCode().value()); + } + @Test void initialPickupPlanRetainsLocalDateAndDoesNotInventZoneOrReview() { + var plan=new TerminalDrayageEntity();plan.setContainerBookingId(1L);plan.setPickupAppointmentAt(at);plan.setFleetSupplierId(4L);plan.setFleetSupplierName("车队"); + when(terminal.findAll(List.of(1L))).thenReturn(List.of(plan)); + when(terminal.pickupChanges(List.of(1L))).thenReturn(List.of(new TerminalDrayageMapper.PickupChanges(1,1,1))); + var fact=read(request("PICKUP"),source()); + assertEquals("0:0",fact.impactToken());assertEquals(at.toLocalDate(),fact.confirmed().date()); + assertNull(fact.timezone());assertTrue(fact.missingFields().contains("SOURCE_TIMEZONE")); + var returnRequest=new FactRequest("W2","EMPTY_RETURN","DIRECT","1",null,null,null,List.of(),List.of(),List.of()); + assertEquals(fact,adapter.read(List.of(request("PICKUP"),returnRequest),Map.of(1L,source())).get(0)); + when(terminal.pickupChanges(List.of(1L))).thenReturn(List.of(new TerminalDrayageMapper.PickupChanges(1,2,1))); + assertEquals("1:0",read(request("PICKUP"),source()).impactToken()); + } + @Test void emptyReturnActualIsCompleteEvenWhenOriginalTimezoneIsUnknownAndDoesNotCopyPickupResponsibility() { + var plan=new TerminalDrayageEntity();plan.setContainerBookingId(1L);plan.setFleetSupplierId(4L);plan.setFleetSupplierName("提柜承运人"); + when(terminal.findAll(List.of(1L))).thenReturn(List.of(plan)); + when(nodes.containerActualFacts(List.of(1L))).thenReturn(List.of(new ContainerActualFacts(1L,2L,3,"STOPPED","EMPTY_RETURNED",at,null,null,null,null,at.minusDays(1),at))); + var fact=read(request("EMPTY_RETURN"),source()); + assertEquals("COMPLETED",fact.executionStatus());assertNull(fact.actual().occurredAt());assertEquals(at,fact.actual().localOccurredAt()); + assertNull(fact.responsibility());assertNull(fact.action());assertNull(fact.businessNo()); + } + @Test void directDeliveryReferencesOnlyItsOwnPhysicalSourceAndCustomer() { + var own=List.of(new Reference("CUSTOMER","7",null,"客户A"),new Reference("INBOUND","2","IN2","入仓单")); + var other=List.of(new Reference("CUSTOMER","8",null,"客户B"),new Reference("INBOUND","3","IN3","入仓单")); + var source=new ContainerSource("1","C1",null,at,at,"v","DIRECT",null,List.of( + new Requirement("INBOUND:2",null,null,"PRIVATE","港","A",null,own,List.of(),List.of()), + new Requirement("INBOUND:3",null,null,"PRIVATE","港","B",null,other,List.of(),List.of()))); + when(direct.deliveriesBatch(List.of(1L))).thenReturn(List.of(new DirectDeliveryMapper.ContainerDelivery(1,2,"IN2","O2","A",4,1,at,"America/Los_Angeles","a","P2",null,"k","h"))); + var request=new FactRequest("W1","DELIVERY","DIRECT","1","INBOUND:2",null,null,List.of(),List.of(),List.of()); + var fact=read(request,source); + assertEquals(own,fact.references());assertEquals("IN2",fact.businessNo());assertEquals("COMPLETED",fact.executionStatus()); + assertEquals(fact,adapter.read(List.of(request,new FactRequest("W2","PICKUP","DIRECT","1",null,null,null,List.of(),List.of(),List.of())),Map.of(1L,source)).get(0)); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java index 74175e9c0..73a53dcf7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java @@ -132,7 +132,7 @@ class ContainerWorkbenchServiceTest { var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class); var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(); var realDrayages = new TerminalDrayageService(mapper, - mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, json, mock(TerminalDrayageMailService.class)); + mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, json, mock(TerminalDrayageMailService.class), mock(UnloadingEvidenceGuard.class)); ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages); ContainerBookingEntity container = container(); container.setUnloadingCompletedAt(LocalDateTime.of(2026, 9, 5, 8, 0)); @@ -176,7 +176,7 @@ class ContainerWorkbenchServiceTest { var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class); var realDrayages = new TerminalDrayageService(mapper, mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, - new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(), mock(TerminalDrayageMailService.class)); + new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(), mock(TerminalDrayageMailService.class), mock(UnloadingEvidenceGuard.class)); ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages); ContainerBookingEntity container = container(); LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 5, 8, 0); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryRouteGuardTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryRouteGuardTest.java new file mode 100644 index 000000000..ecdd95a33 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryRouteGuardTest.java @@ -0,0 +1,22 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.firstleg.client.AppointmentWorkRouteClient; +import com.baiyue.common.core.api.ApiResponse; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +class DirectDeliveryRouteGuardTest { + @Test void onlyConfirmedDirectRouteCanExecuteAndRemoteFailureIsNotUnknownSuccess() { + var client=mock(AppointmentWorkRouteClient.class);var guard=new DirectDeliveryRouteGuard(client,"token"); + for(String flow:new String[]{"UNKNOWN","TRANSFER"}) { + when(client.route("token",1)).thenReturn(ApiResponse.success(new AppointmentWorkRouteClient.Route(flow))); + assertEquals(409,assertThrows(ResponseStatusException.class,()->guard.requireDirect(1)).getStatusCode().value()); + } + when(client.route("token",1)).thenReturn(ApiResponse.success(new AppointmentWorkRouteClient.Route("DIRECT"))); + assertDoesNotThrow(()->guard.requireDirect(1)); + when(client.route("token",1)).thenThrow(new IllegalStateException("offline")); + assertEquals(503,assertThrows(ResponseStatusException.class,()->guard.requireDirect(1)).getStatusCode().value()); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryServiceTest.java new file mode 100644 index 000000000..c7f3b3701 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryServiceTest.java @@ -0,0 +1,50 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import java.util.List; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static org.junit.jupiter.api.Assertions.*; + +class DirectDeliveryServiceTest { + private final DirectDeliveryMapper mapper=mock(DirectDeliveryMapper.class); + private final CarrierScheduleQueryService nodes=mock(CarrierScheduleQueryService.class); + private final DirectDeliveryService service=new DirectDeliveryService(mapper,nodes,new ObjectMapper().findAndRegisterModules(),mock(DirectDeliveryRouteGuard.class)); + private final LocalDateTime at=LocalDateTime.parse("2026-01-03T12:00:00"); + private DirectDeliveryMapper.Delivery pending(){return new DirectDeliveryMapper.Delivery(2,"IN2","O2","客户仓",4,0,null,null,null,null,null,null,null);} + private DirectDeliveryService.Request request(){return new DirectDeliveryService.Request(0,at,"America/Los_Angeles","收货人","POD-2","直送签收");} + @Test void recordsOnlyActualLoadedSourceAfterPickupAndReplaysWithoutDuplicateWrite() { + when(mapper.lockContainer(1)).thenReturn(new DirectDeliveryMapper.Container(1,"ACTIVE",null)); + when(mapper.deliveries(1)).thenReturn(List.of(pending())); + // Unknown pickup zone: a numerically later source-local clock is not evidence + // that the explicitly Los Angeles destination actual occurred first. + when(nodes.containerActualFacts(List.of(1L))).thenReturn(List.of(new ContainerActualFacts(1L,2L,3,"READY","PICKED_UP",at.plusHours(1),null,null,null,null,at.plusHours(1),null))); + doAnswer(inv->{ + when(mapper.deliveries(1)).thenReturn(List.of(new DirectDeliveryMapper.Delivery(2,"IN2","O2","客户仓",4,1,at,"America/Los_Angeles","收货人","POD-2","直送签收","k",inv.getArgument(8)))); + return 1; + }).when(mapper).insert(anyLong(),anyLong(),any(),anyString(),anyString(),anyString(),any(),anyString(),anyString(),anyLong(),anyString()); + assertEquals(1,service.record(1,2,7,"admin","k",request()).version()); + assertEquals(1,service.record(1,2,7,"admin","k",request()).version()); + verify(mapper,times(1)).insert(eq(1L),eq(2L),eq(at),eq("America/Los_Angeles"),eq("收货人"),eq("POD-2"),any(),eq("k"),anyString(),eq(7L),eq("admin")); + } + @Test void refusesForeignSourceAndWarehouseReceiptWithoutWriting() { + when(mapper.lockContainer(1)).thenReturn(new DirectDeliveryMapper.Container(1,"ACTIVE",at.minusDays(1))); + when(mapper.deliveries(1)).thenReturn(List.of(pending())); + assertThrows(BizException.class,()->service.record(1,99,7,"a","k",request())); + assertThrows(BizException.class,()->service.record(1,2,7,"a","k",request())); + verifyNoInteractions(nodes); + verify(mapper,never()).insert(anyLong(),anyLong(),any(),anyString(),anyString(),anyString(),any(),anyString(),anyString(),anyLong(),anyString()); + } + @Test void refusesAmbiguousLocalTimeInsteadOfGuessingAnOffset() { + when(mapper.lockContainer(1)).thenReturn(new DirectDeliveryMapper.Container(1,"ACTIVE",null)); + when(mapper.deliveries(1)).thenReturn(List.of(pending())); + var ambiguous=new DirectDeliveryService.Request(0,LocalDateTime.parse("2025-11-02T01:30:00"),"America/Los_Angeles","收货人","POD",null); + assertThrows(BizException.class,()->service.record(1,2,7,"a","k",ambiguous));verifyNoInteractions(nodes); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageCalendarServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageCalendarServiceTest.java index 261f645f1..14deae53a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageCalendarServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageCalendarServiceTest.java @@ -18,7 +18,7 @@ class TerminalDrayageCalendarServiceTest { final com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService shipping=mock(com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService.class); final TerminalDrayageCalendarService service=new TerminalDrayageCalendarService(mapper,warehouses,shipping,"test"); final LocalDate from=LocalDate.of(2026,10,1),to=from.plusDays(31); - PickupCalendarEntry row(long id,String code) {return new PickupCalendarEntry(id,"CB-"+id,"TEST-CONT","Port",code,"Fleet",from.atTime(9,0),null);} + PickupCalendarEntry row(long id,String code) {return new PickupCalendarEntry(id,"D000"+id,"TEST-CONT","Port",code,"Fleet",from.atTime(9,0),null);} @Test void batchesTheCatalogAndKeepsUnknownScopeVisibleWithoutGuessingPortTimeZone() { when(mapper.calendar(any(),any(),eq(1001))).thenReturn(List.of(row(1,"US-W"),row(2,"CA-W"),row(3,"UNKNOWN"))); when(warehouses.catalog("test")).thenReturn(ApiResponse.success(List.of( @@ -46,8 +46,35 @@ class TerminalDrayageCalendarServiceTest { when(mapper.calendar(any(),any(),anyInt())).thenReturn(List.of());assertTrue(service.calendar(from,to).records().isEmpty());verifyNoInteractions(warehouses); } @Test void reportsTruncationInsteadOfClaimingCompleteResults() { - when(mapper.calendar(any(),any(),anyInt())).thenReturn(Collections.nCopies(1001,row(1,"US-W"))); + when(mapper.calendar(any(),any(),anyInt())).thenReturn(java.util.stream.LongStream.rangeClosed(1,1001).mapToObj(id->row(id,"US-W")).toList()); when(warehouses.catalog("test")).thenReturn(ApiResponse.success(List.of(new WarehouseMasterDataClient.CatalogWarehouse(1L,"US-W","US","OVERSEAS","US")))); var result=service.calendar(from,to);assertTrue(result.truncated());assertEquals(1000,result.records().size()); } + @Test void mergesActualMonthWithoutMovingExecutedWorkWhenPlansChangeOrDuplicatingTheContainer() { + when(mapper.calendar(any(),any(),anyInt())).thenReturn(List.of(row(1,"US-W"),row(2,"US-W"))); + when(shipping.pickupCalendarContainers(any(),any())).thenReturn(List.of(2L,3L)); + var movedPlan=new PickupCalendarEntry(3L,"D0003","CONT3","Port","US-W","Fleet",to.plusDays(20).atStartOfDay(),null); + when(mapper.calendarByContainers(List.of(2L,3L))).thenReturn(List.of(row(2,"US-W"),movedPlan)); + when(warehouses.catalog("test")).thenReturn(ApiResponse.success(List.of(new WarehouseMasterDataClient.CatalogWarehouse(1L,"US-W","US","OVERSEAS","US")))); + when(shipping.containerActualFacts(List.of(1L,2L,3L))).thenReturn(List.of( + fact(1L,to.atStartOfDay()),fact(2L,from.atStartOfDay()),fact(3L,from.plusDays(1).atStartOfDay()))); + var result=service.calendar(from,to); + assertEquals(List.of(2L,3L),result.records().stream().map(PickupCalendarEntry::containerBookingId).toList()); + assertEquals(to.plusDays(20).atStartOfDay(),result.records().get(1).pickupAppointmentAt()); + assertEquals(from.plusDays(1).atStartOfDay(),result.records().get(1).actualPickupAt()); + assertFalse(result.truncated()); + } + private com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts fact(long id,java.time.LocalDateTime at) { + return new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts(id,null,null,null,null,null,null,null,null,null,at,null); + } + @Test void includesAnActualPickupWithoutInventingAnAppointment() { + when(shipping.pickupCalendarContainers(any(),any())).thenReturn(List.of(3L)); + when(mapper.calendarByContainers(List.of(3L))).thenReturn(List.of(new PickupCalendarEntry(3L,"D0003","CONT3","Port","US-W",null,null,null))); + when(warehouses.catalog("test")).thenReturn(ApiResponse.success(List.of(new WarehouseMasterDataClient.CatalogWarehouse(1L,"US-W","US","OVERSEAS","US")))); + when(shipping.containerActualFacts(List.of(3L))).thenReturn(List.of(fact(3L,from.atStartOfDay()))); + var result=service.calendar(from,to); + assertEquals(1,result.records().size()); + assertNull(result.records().get(0).pickupAppointmentAt()); + assertEquals(from.atStartOfDay(),result.records().get(0).actualPickupAt()); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java index c25510484..c5e6ef656 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java @@ -47,6 +47,7 @@ class TerminalDrayageServiceTest { private CarrierScheduleQueryService carrierSchedules; private TerminalDrayageService service; private TerminalDrayageMailService mails; + private UnloadingEvidenceGuard unloadingEvidence; private Deque facts; @BeforeEach @@ -60,7 +61,8 @@ class TerminalDrayageServiceTest { return List.of(facts.size() > 1 ? facts.removeFirst() : facts.peekFirst()); }); mails = mock(TerminalDrayageMailService.class); - service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules(), mails); + unloadingEvidence = mock(UnloadingEvidenceGuard.class); + service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules(), mails, unloadingEvidence); service.setClock(Clock.fixed(Instant.parse("2026-09-05T02:00:00Z"), ZoneOffset.UTC)); } @@ -115,7 +117,7 @@ class TerminalDrayageServiceTest { assertThatThrownBy(() -> service.recordEmptyReturn(11L, 7L, "admin", "return-1", new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 10, 0), null))) .isInstanceOf(BizException.class) - .hasMessage("海外卸柜收货完成后才能确认还柜"); + .hasMessage("海外仓卸柜或全部直送目的仓实际卸货完成后才能确认还柜"); verify(carrierSchedules, never()).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any()); } @@ -138,6 +140,19 @@ class TerminalDrayageServiceTest { assertThat(service.detail(11L).emptyReturnWarning().overdueHours()).isZero(); } + @Test + void directReturnWarningUsesDestinationInstantInsteadOfServerWallClock() { + when(mapper.context(11L)).thenReturn(context("PICKED_UP", null)); + when(mapper.find(11L)).thenReturn(row(3)); + when(unloadingEvidence.directCompletion(11L)).thenReturn(new UnloadingEvidenceGuard.DirectCompletion( + LocalDateTime.parse("2026-09-01T22:00:00"), "America/Los_Angeles")); + var result=service.detail(11L); + assertThat(result.estimatedEmptyReturnAt()).isEqualTo(LocalDateTime.parse("2026-09-04T22:00:00")); + assertThat(result.emptyReturnWarning().status()).isEqualTo("PENDING"); + service.setClock(Clock.fixed(Instant.parse("2026-09-05T06:00:00Z"), ZoneOffset.ofHours(9))); + assertThat(service.detail(11L).emptyReturnWarning().overdueHours()).isEqualTo(1); + } + @Test void offersEmptyReturnAfterUnloadWithoutDrayageAggregateOrPickupFact() { LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 3, 0, 0); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/UnloadingEvidenceGuardTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/UnloadingEvidenceGuardTest.java new file mode 100644 index 000000000..2a667b66c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/UnloadingEvidenceGuardTest.java @@ -0,0 +1,56 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import java.util.List; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +class UnloadingEvidenceGuardTest { + @Test void batchRetainsInactiveMissingPartialAndZonedCompletionRules() { + var mapper=mock(DirectDeliveryMapper.class);var guard=new UnloadingEvidenceGuard(mapper); + var at=LocalDateTime.parse("2026-10-10T12:00:00"); + var ids=List.of(1L,2L,3L,4L,5L,6L); + when(mapper.containers(ids)).thenReturn(List.of(new DirectDeliveryMapper.Container(1,"ACTIVE",at), + new DirectDeliveryMapper.Container(2,"ACTIVE",null),new DirectDeliveryMapper.Container(3,"ACTIVE",null), + new DirectDeliveryMapper.Container(4,"CANCELLED",at),new DirectDeliveryMapper.Container(5,"ACTIVE",null))); + when(mapper.deliveriesBatch(List.of(2L,3L,5L))).thenReturn(List.of( + new DirectDeliveryMapper.ContainerDelivery(2,20,"I20","O20","D",1,1,at,"America/New_York",null,null,null,null,null), + new DirectDeliveryMapper.ContainerDelivery(2,21,"I21","O21","D",1,1,at.minusHours(2),"America/Los_Angeles",null,null,null,null,null), + new DirectDeliveryMapper.ContainerDelivery(3,30,"I30","O30","D",1,1,at,"America/New_York",null,null,null,null,null), + new DirectDeliveryMapper.ContainerDelivery(3,31,"I31","O31","D",1,0,null,null,null,null,null,null,null))); + assertEquals(java.util.Map.of(1L,at,2L,at.minusHours(2)),guard.completedUnloading(ids)); + verify(mapper).containers(ids);verify(mapper).deliveriesBatch(List.of(2L,3L,5L));verifyNoMoreInteractions(mapper); + } + @Test void requiresEveryPhysicalInboundSourceAndAcceptsLatestActualEvidence() { + var mapper=mock(DirectDeliveryMapper.class);var guard=new UnloadingEvidenceGuard(mapper); + var container=new DirectDeliveryMapper.Container(1,"ACTIVE",null); + when(mapper.container(1)).thenReturn(container);when(mapper.lockContainer(1)).thenReturn(container); + var at=LocalDateTime.parse("2026-10-10T12:00:00"); + var first=new DirectDeliveryMapper.Delivery(2,"IN2","O2","D",4,1,at,"America/Los_Angeles","张三","POD2",null,"k","h"); + var pending=new DirectDeliveryMapper.Delivery(3,"IN3","O3","D",6,0,null,null,null,null,null,null,null); + when(mapper.deliveries(1)).thenReturn(List.of(first,pending)); + assertNull(guard.completedUnloading(1));assertThrows(BizException.class,()->guard.requireUnloaded(1,at.plusDays(1))); + var second=new DirectDeliveryMapper.Delivery(3,"IN3","O3","D",6,1,at.plusHours(1),"America/Los_Angeles","李四","POD3",null,"k3","h3"); + when(mapper.deliveries(1)).thenReturn(List.of(first,second)); + assertEquals(at.plusHours(1),guard.completedUnloading(1)); + assertDoesNotThrow(()->guard.requireUnloaded(1,at)); // Return has no source zone. + assertDoesNotThrow(()->guard.requireUnloaded(1,at.plusDays(1))); + var warehouse=new DirectDeliveryMapper.Container(1,"ACTIVE",at.plusHours(1)); + when(mapper.container(1)).thenReturn(warehouse);when(mapper.lockContainer(1)).thenReturn(warehouse); + assertThrows(BizException.class,()->guard.requireUnloaded(1,at)); + } + @Test void comparesDestinationEvidenceByInstantAndMeasuresSeventyTwoHoursAcrossDst() { + var mapper=mock(DirectDeliveryMapper.class);var guard=new UnloadingEvidenceGuard(mapper); + var ny=LocalDateTime.parse("2026-10-10T12:00:00"); + var la=LocalDateTime.parse("2026-10-10T10:00:00"); + when(mapper.deliveries(1)).thenReturn(List.of( + new DirectDeliveryMapper.Delivery(2,"IN2","O2","D",4,1,ny,"America/New_York","a","P2",null,"k2","h2"), + new DirectDeliveryMapper.Delivery(3,"IN3","O3","D",6,1,la,"America/Los_Angeles","a","P3",null,"k3","h3"))); + assertEquals(la,guard.directCompletion(1).localAt()); + var beforeFallback=new UnloadingEvidenceGuard.DirectCompletion(LocalDateTime.parse("2026-10-31T12:00:00"),"America/Los_Angeles"); + assertEquals(LocalDateTime.parse("2026-11-03T11:00:00"),beforeFallback.deadline(72)); + } +} diff --git a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json index 89298259e..885f6cd5f 100644 --- a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json +++ b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json @@ -85,12 +85,56 @@ ], "sliceSpecificRules": { "customer-statistics": { - "allowedOutgoing": ["shared-technical"], + "allowedOutgoing": [ + "shared-technical" + ], "allowedIncoming": [] }, "pricebook-query": { "allowedOutgoing": [], - "allowedIncoming": ["booking-customs-trucking", "measurement-pricing", "split-loading"] + "allowedIncoming": [ + "booking-customs-trucking", + "measurement-pricing", + "split-loading" + ] } - } + }, + "reviewedClassDependencies": [ + { + "source": "com.baiyue.firstleg.controller.InternalOverseasContainerEventController", + "target": "com.baiyue.firstleg.service.OverseasInboundOrderDetainedConsumer", + "reason": "海外事件HTTP适配器按事件类型派发到暂扣消费者;入仓数据仍由消费者维护。" + }, + { + "source": "com.baiyue.firstleg.loading.service.LoadingDeliveryService", + "target": "com.baiyue.firstleg.service.FbaPricingService", + "reason": "装柜实物完成时按真实分配刷新费用,调用既有计价应用服务,不读取计价持久化。" + }, + { + "source": "com.baiyue.firstleg.service.LoadingListService", + "target": "com.baiyue.firstleg.domain.PriceBookChannelCatalog", + "reason": "分柜按当前有效渠道目录校验,复用纯领域目录,不访问价格簿实现。" + }, + { + "source": "com.baiyue.firstleg.service.LoadingListService", + "target": "com.baiyue.firstleg.service.FbaPricingService", + "reason": "装柜分配变动触发既有计价服务按客户重算,费用冻结门槛保留在原服务。" + }, + { + "source": "com.baiyue.firstleg.service.SmartLoadingService", + "target": "com.baiyue.firstleg.service.FbaPricingService", + "reason": "智能排柜分配后调用原计价服务守恒重算,不跨域修改费用表。" + } + ], + "reviewedPackageCycles": [ + { + "packages": [ + "com.baiyue.firstleg.loading.bridge.service", + "com.baiyue.firstleg.loading.service", + "com.baiyue.firstleg.oceanbooking.service", + "com.baiyue.firstleg.service" + ], + "reason": "既有全局service包混合了入仓暂扣、装柜费用与订舱文档的应用编排。本轮不拆分这些无关既有服务;仅冻结当前四包SCC,新增节点或第二环继续失败。价格簿查询切片仍必须独立无环。" + } + ] } 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 d7663b79e..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`,不得增加子域扫描配置。 @@ -17,6 +22,8 @@ | 商户成员、登录手机号与个人资料 | `controller/MerchantTenantController.java`、`service/MemberService.java`、`service/PersonalProfileService.java`、`client/AuthAccountClient.java`;成员手机号是可选登录标识,变更时同步 Auth、撤销该账号全部会话并审计,不与公司联系电话联动 | | 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 必须且只能绑定一个 `overseasWarehouseId` | | 角色/权限/RBAC | `controller/RbacController.java`、`service/RbacService.java` | +| 平台自营领星统计权限 | `src/main/resources/db/selfoperated-analytics-permissions.sql`;自营业务下经营总览、销量分析、库存分析、数据采集菜单资源同步国内统一空库基线;四个页面和所有只读接口统一使用 `platform:self-operated-analytics:list`,采集写入独立使用 `platform:self-operated-analytics:sync`。菜单资源不替代服务端读写授权。 | +| 预约负责人权威目录 | `controller/InternalAuthorizationController.java` 的 `/internal/iam/platform-owners/{authUserId}`、`service/PlatformOwnerDirectoryService.java`;仅有效平台成员与 PLATFORM 角色,返回 Auth 主体 ID 和权威姓名;新工作安排及直送卸货写权限种子 `src/main/resources/db/appointment-work-permissions.sql` | | 授权快照/内部资格 | `controller/AuthorizationController.java`、`InternalAuthorizationController.java`、`service/AuthorizationSnapshotService.java` | | 持久化安全版本 | `service/SecurityVersionService.java`、`src/main/resources/db/security-version.sql`;安全变更与 subject/customer 版本同事务提交,Auth 在线核对单 SQL 授权快照,无专用安全事件投影 | | 消息中心/站内通知 | `notification/controller/NotificationInboxController.java`、`notification/controller/PlatformNotificationController.java`、`notification/service/NotificationService.java`;模板、任务、收件箱及事件消费均在 `notification/` | diff --git a/iam/src/main/java/com/baiyue/iam/controller/InternalAuthorizationController.java b/iam/src/main/java/com/baiyue/iam/controller/InternalAuthorizationController.java index 46a0f711c..2854f8200 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/InternalAuthorizationController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/InternalAuthorizationController.java @@ -15,6 +15,10 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import com.baiyue.iam.service.PlatformOwnerDirectoryService; +import com.baiyue.iam.dto.PlatformOwner; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -27,6 +31,7 @@ import java.security.MessageDigest; public class InternalAuthorizationController { @Resource private AuthorizationSnapshotService service; + @Resource private PlatformOwnerDirectoryService owners; @Value("${baiyue.security.internal-service-token}") private String internalTokenValue; private byte[] internalToken; @@ -49,4 +54,13 @@ public class InternalAuthorizationController { } return ApiResponse.success(service.load(request)); } + + @GetMapping("/platform-owners/{authUserId}") + public ApiResponse owner(@RequestHeader(value="X-Internal-Service-Token",required=false) String token, + @PathVariable long authUserId) { + byte[] supplied=token==null?new byte[0]:token.getBytes(StandardCharsets.UTF_8); + if(internalToken==null || !MessageDigest.isEqual(internalToken,supplied)) + throw new BizException(ResultCode.FORBIDDEN,"无效的服务间凭据"); + return ApiResponse.success(owners.require(authUserId)); + } } diff --git a/iam/src/main/java/com/baiyue/iam/dto/PlatformOwner.java b/iam/src/main/java/com/baiyue/iam/dto/PlatformOwner.java new file mode 100644 index 000000000..1ad582e7b --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/dto/PlatformOwner.java @@ -0,0 +1,4 @@ +package com.baiyue.iam.dto; + +/** Auth subject identity, not the IAM profile or membership primary key. */ +public record PlatformOwner(String id,String name) { } diff --git a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java index 42a525c93..d98f433e4 100644 --- a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java +++ b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java @@ -11,6 +11,18 @@ import java.util.List; */ @Mapper public interface AuthorizationQueryMapper { + @Select(""" + SELECT CONCAT('',u.auth_user_id) id,u.display_name name + FROM iam_user u + WHERE u.auth_user_id=#{authUserId} AND u.status='ACTIVE' AND u.deleted=0 + AND u.display_name IS NOT NULL AND TRIM(u.display_name)<>'' + AND EXISTS ( + SELECT 1 FROM iam_membership m JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=0 + JOIN iam_role r ON r.id=mr.role_id AND r.tenant_id=0 AND r.app_code='PLATFORM' AND r.status='ACTIVE' AND r.deleted=0 + WHERE m.user_id=u.id AND m.tenant_id=0 AND m.status='ACTIVE' AND m.deleted=0) + """) + com.baiyue.iam.dto.PlatformOwner findActivePlatformOwner(@Param("authUserId") long authUserId); + @Select(""" SELECT r.role_code AS roleCode, p.permission_code AS permissionCode, COALESCE(sv.permission_version,0) AS permissionVersion, diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformOwnerDirectoryService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformOwnerDirectoryService.java new file mode 100644 index 000000000..a7082a489 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformOwnerDirectoryService.java @@ -0,0 +1,19 @@ +package com.baiyue.iam.service; + +import com.baiyue.iam.dto.PlatformOwner; +import com.baiyue.iam.mapper.AuthorizationQueryMapper; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; +import org.springframework.stereotype.Service; + +@Service +public class PlatformOwnerDirectoryService { + private final AuthorizationQueryMapper mapper; + public PlatformOwnerDirectoryService(AuthorizationQueryMapper mapper){this.mapper=mapper;} + public PlatformOwner require(long authUserId) { + if(authUserId<=0) throw new BizException(ResultCode.BAD_REQUEST,"平台操作人员标识无效"); + var owner=mapper.findActivePlatformOwner(authUserId); + if(owner==null) throw new BizException(ResultCode.NOT_FOUND,"未找到有效平台操作人员"); + return owner; + } +} diff --git a/iam/src/main/resources/db/appointment-work-permissions.sql b/iam/src/main/resources/db/appointment-work-permissions.sql new file mode 100644 index 000000000..0f5279dcf --- /dev/null +++ b/iam/src/main/resources/db/appointment-work-permissions.sql @@ -0,0 +1,45 @@ +USE baiyue_iam; + +-- Reject identity collisions before any permission or grant is changed. +DROP PROCEDURE IF EXISTS check_appointment_work_permission_identity; +DELIMITER $$ +CREATE PROCEDURE check_appointment_work_permission_identity() +BEGIN + IF EXISTS(SELECT 1 FROM iam_permission WHERE + (id=5107283 AND permission_code<>'platform:appointment:manage-work') + OR (id=5102213 AND permission_code<>'platform:firstleg:terminal-drayage:direct-unload:confirm') + OR (permission_code='platform:appointment:manage-work' AND id<>5107283) + OR (permission_code='platform:firstleg:terminal-drayage:direct-unload:confirm' AND id<>5102213)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Appointment work permission identity conflict'; + END IF; +END$$ +DELIMITER ; +CALL check_appointment_work_permission_identity(); +DROP PROCEDURE check_appointment_work_permission_identity; + +-- Two independent write capabilities; appointment/list remains read-only. +INSERT INTO iam_permission + (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) +VALUES + (5107283,0,'PLATFORM','BUTTON',5107280,'platform:appointment:manage-work','维护预约工作安排',NULL,'POST',743,'ACTIVE',1,0), + (5102213,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:direct-unload:confirm','登记直送目的仓实际卸货',NULL,'POST',60,'ACTIVE',1,0) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),status='ACTIVE',deleted=0; + +INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at) +SELECT 0,r.id,p.id,CURRENT_TIMESTAMP(3) +FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.id IN (5107283,5102213) +WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND r.status='ACTIVE' AND r.deleted=0; + +DROP PROCEDURE IF EXISTS assert_appointment_work_permissions; +DELIMITER $$ +CREATE PROCEDURE assert_appointment_work_permissions() +BEGIN + IF (SELECT COUNT(*) FROM iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND status='ACTIVE' AND deleted=0 + AND ((id=5107283 AND permission_code='platform:appointment:manage-work' AND parent_id=5107280) + OR (id=5102213 AND permission_code='platform:firstleg:terminal-drayage:direct-unload:confirm' AND parent_id=5102200))) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Appointment work permission identity conflict'; + END IF; +END$$ +DELIMITER ; +CALL assert_appointment_work_permissions(); +DROP PROCEDURE assert_appointment_work_permissions; diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index 6b4c39d13..cb9388409 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -142,6 +142,7 @@ VALUES (5306180,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:discard:list','货物废弃','/overseas-warehouse/cargo-disposal','GET',130,'ACTIVE',1,0), (5306181,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:create','创建货物废弃申请',NULL,'POST',10,'ACTIVE',1,0), (5306182,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:cancel','取消货物废弃申请',NULL,'POST',20,'ACTIVE',1,0), + (5306183,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:confirm','确认废弃完成',NULL,'POST',30,'ACTIVE',1,0), (5306190,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-order:list','退货换标','/overseas-warehouse/return-relabel','GET',140,'ACTIVE',1,0), (5306191,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:create','创建退货预报',NULL,'POST',10,'ACTIVE',1,0), (5306192,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:relabel','退货转换标申请',NULL,'POST',20,'ACTIVE',1,0) @@ -336,6 +337,7 @@ JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code 'merchant:overseas-warehouse:discard:list', 'merchant:overseas-warehouse:discard:create', 'merchant:overseas-warehouse:discard:cancel', + 'merchant:overseas-warehouse:discard:confirm', 'merchant:overseas-warehouse:return-order:list', 'merchant:overseas-warehouse:return-order:create', 'merchant:overseas-warehouse:return-order:relabel' @@ -371,9 +373,10 @@ BEGIN 'merchant:overseas-warehouse:discard:list', 'merchant:overseas-warehouse:discard:create', 'merchant:overseas-warehouse:discard:cancel', + 'merchant:overseas-warehouse:discard:confirm', 'merchant:overseas-warehouse:return-order:list', 'merchant:overseas-warehouse:return-order:create', - 'merchant:overseas-warehouse:return-order:relabel')) <> 24 THEN + 'merchant:overseas-warehouse:return-order:relabel')) <> 25 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:权限资源不完整'; END IF; IF (SELECT COUNT(*) FROM iam_role_permission grant_row @@ -405,9 +408,10 @@ BEGIN 'merchant:overseas-warehouse:discard:list', 'merchant:overseas-warehouse:discard:create', 'merchant:overseas-warehouse:discard:cancel', + 'merchant:overseas-warehouse:discard:confirm', 'merchant:overseas-warehouse:return-order:list', 'merchant:overseas-warehouse:return-order:create', - 'merchant:overseas-warehouse:return-order:relabel')) <> 24 THEN + 'merchant:overseas-warehouse:return-order:relabel')) <> 25 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:客户默认管理员授权不完整'; END IF; END$$ diff --git a/iam/src/main/resources/db/selfoperated-analytics-permissions.sql b/iam/src/main/resources/db/selfoperated-analytics-permissions.sql new file mode 100644 index 000000000..ab2a0d0fd --- /dev/null +++ b/iam/src/main/resources/db/selfoperated-analytics-permissions.sql @@ -0,0 +1,21 @@ +-- 自营业务菜单资源用于菜单管理;四个页面和所有只读接口统一由 list 授权,采集写权限及后台导出权限独立。 +USE baiyue_iam; +SET NAMES utf8mb4; +INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES + (5118100,0,'PLATFORM','MENU',0,'platform:self-operated:menu','自营业务',NULL,NULL,1100,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118140,0,'PLATFORM','MENU',5118100,'platform:self-operated:overview:menu','经营总览','/self-operated/overview','GET',5,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118110,0,'PLATFORM','MENU',5118100,'platform:self-operated:sales:menu','销量分析','/self-operated/sales','GET',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118120,0,'PLATFORM','MENU',5118100,'platform:self-operated:inventory:menu','库存分析','/self-operated/inventory','GET',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118130,0,'PLATFORM','MENU',5118100,'platform:self-operated:collection:menu','数据采集','/self-operated/collection','GET',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118000,0,'PLATFORM','API',5118100,'platform:self-operated-analytics:list','读取平台自营出库销量与库存分析','/api/platform/v1/last-leg/self-operated-analytics','GET',40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118002,0,'PLATFORM','API',5118120,'platform:self-operated-analytics:export','导出平台自营库存周转','/api/platform/v1/files/export-tasks','POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118001,0,'PLATFORM','API',5118130,'platform:self-operated-analytics:sync','平台自营采集重试与重采集','/api/platform/v1/last-leg/self-operated-analytics/sync-tasks','POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES + (4000001,5118000,0,CURRENT_TIMESTAMP(3)), + (4000001,5118001,0,CURRENT_TIMESTAMP(3)), + (4000001,5118002,0,CURRENT_TIMESTAMP(3)), + (4000001,5118100,0,CURRENT_TIMESTAMP(3)), + (4000001,5118140,0,CURRENT_TIMESTAMP(3)), + (4000001,5118110,0,CURRENT_TIMESTAMP(3)), + (4000001,5118120,0,CURRENT_TIMESTAMP(3)), + (4000001,5118130,0,CURRENT_TIMESTAMP(3)); diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 28e223b91..cd0957681 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -17,6 +17,28 @@ tags: - name: RBAC description: Platform and merchant role-permission management / 平台端与商户端角色权限管理 paths: + /internal/iam/platform-owners/{authUserId}: + get: + summary: Read active platform operator identity and authoritative display name + description: Internal service identity only. Auth subject ID is distinct from IAM profile and membership IDs. Requires active undeleted user, platform membership and PLATFORM role; no caller-supplied name fallback. + security: [] + parameters: + - {$ref: '#/components/parameters/InternalServiceToken'} + - {name: authUserId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + responses: + '200': + description: ApiResponse + content: + application/json: + schema: + type: object + properties: + data: + type: object + required: [id, name] + properties: {id: {type: string}, name: {type: string}} + '403': {description: Invalid internal service credential} + '404': {description: No eligible active platform operator} /api/merchant/v1/iam/profile: get: tags: [Merchant Tenant / 商户企业] diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformOwnerDirectoryControllerTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformOwnerDirectoryControllerTest.java new file mode 100644 index 000000000..f1c7573a9 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformOwnerDirectoryControllerTest.java @@ -0,0 +1,21 @@ +package com.baiyue.iam.controller; + +import com.baiyue.iam.service.PlatformOwnerDirectoryService; +import com.baiyue.iam.dto.PlatformOwner; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +class PlatformOwnerDirectoryControllerTest { + @Test void internalIdentityIsRequiredBeforeReadingDirectory() { + var service=mock(PlatformOwnerDirectoryService.class);var controller=new InternalAuthorizationController(); + String token="test-internal-token-at-least-32-characters"; + ReflectionTestUtils.setField(controller,"owners",service);ReflectionTestUtils.setField(controller,"internalTokenValue",token); + controller.initializeInternalToken(); + assertThrows(BizException.class,()->controller.owner(null,101));verifyNoInteractions(service); + when(service.require(101)).thenReturn(new PlatformOwner("101","运营甲")); + assertEquals("运营甲",controller.owner(token,101).data().name()); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/mapper/PlatformOwnerDirectoryMapperTest.java b/iam/src/test/java/com/baiyue/iam/mapper/PlatformOwnerDirectoryMapperTest.java new file mode 100644 index 000000000..e769cc5e1 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/mapper/PlatformOwnerDirectoryMapperTest.java @@ -0,0 +1,33 @@ +package com.baiyue.iam.mapper; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; + +class PlatformOwnerDirectoryMapperTest { + @Test void returnsAuthSubjectAndNameOnlyForActivePlatformMembershipAndRole() throws Exception { + var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + try(var c=ds.getConnection();var s=c.createStatement()) { + s.execute("CREATE TABLE iam_user(id BIGINT,auth_user_id BIGINT,display_name VARCHAR,status VARCHAR,deleted INT)"); + s.execute("CREATE TABLE iam_membership(id BIGINT,user_id BIGINT,tenant_id BIGINT,status VARCHAR,deleted INT)"); + s.execute("CREATE TABLE iam_membership_role(membership_id BIGINT,tenant_id BIGINT,role_id BIGINT)"); + s.execute("CREATE TABLE iam_role(id BIGINT,tenant_id BIGINT,app_code VARCHAR,status VARCHAR,deleted INT)"); + s.execute("INSERT INTO iam_user VALUES(1,101,'运营甲','ACTIVE',0),(2,102,'客户乙','ACTIVE',0),(3,103,'停用丙','DISABLED',0),(4,104,'失效成员','ACTIVE',0)"); + s.execute("INSERT INTO iam_membership VALUES(11,1,0,'ACTIVE',0),(12,2,88,'ACTIVE',0),(13,3,0,'ACTIVE',0),(14,4,0,'DISABLED',0)"); + s.execute("INSERT INTO iam_membership_role VALUES(11,0,21),(12,88,21),(13,0,21),(14,0,21)"); + s.execute("INSERT INTO iam_role VALUES(21,0,'PLATFORM','ACTIVE',0)"); + } + var config=new MybatisConfiguration();config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(AuthorizationQueryMapper.class); + try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) { + var mapper=session.getMapper(AuthorizationQueryMapper.class); + assertEquals("101",mapper.findActivePlatformOwner(101).id());assertEquals("运营甲",mapper.findActivePlatformOwner(101).name()); + assertNull(mapper.findActivePlatformOwner(1));assertNull(mapper.findActivePlatformOwner(102)); + assertNull(mapper.findActivePlatformOwner(103));assertNull(mapper.findActivePlatformOwner(104)); + } + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/MerchantReturnInventoryPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/MerchantReturnInventoryPermissionSqlContractTest.java index d7deb63ea..6768e66bd 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/MerchantReturnInventoryPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/MerchantReturnInventoryPermissionSqlContractTest.java @@ -20,7 +20,7 @@ class MerchantReturnInventoryPermissionSqlContractTest { assertThat(sql).contains( "(5306140,0,'MERCHANT','MENU',0,'" + PERMISSION + "','退货库存'", "role.role_code='MERCHANT_OWNER'", - ")) <> 24 THEN", + ")) <> 25 THEN", "客户默认管理员授权不完整"); int grantStart = sql.indexOf("-- 客户默认管理员获得当前客户海外业务入口"); diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasCustomerPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasCustomerPermissionSqlContractTest.java index ba27c34e8..3bf4205bb 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasCustomerPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasCustomerPermissionSqlContractTest.java @@ -36,6 +36,7 @@ class OverseasCustomerPermissionSqlContractTest { "merchant:overseas-warehouse:discard:list", "merchant:overseas-warehouse:discard:create", "merchant:overseas-warehouse:discard:cancel", + "merchant:overseas-warehouse:discard:confirm", "merchant:overseas-warehouse:return-order:list", "merchant:overseas-warehouse:return-order:create", "merchant:overseas-warehouse:return-order:relabel"); @@ -85,7 +86,7 @@ class OverseasCustomerPermissionSqlContractTest { "海外客户权限初始化失败:客户默认管理员授权不完整", "permission.app_code='MERCHANT'", "role.role_code='MERCHANT_OWNER'", - ")) <> 24 THEN"); + ")) <> 25 THEN"); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index c7cdc23e9..4314ad8fe 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -150,6 +150,8 @@ class OverseasPhase4PermissionSqlContractTest { .getContentAsString(StandardCharsets.UTF_8)).stream() .filter(code -> code.startsWith("platform:")) .forEach(moduleCatalog::add); + codes(new ClassPathResource("db/procurement-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8)).forEach(moduleCatalog::add); Set serviceCatalog = new LinkedHashSet<>(); serviceCatalog.addAll(codes(repositoryFile( "lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java"))); diff --git a/integration-service/.env.example b/integration-service/.env.example new file mode 100644 index 000000000..a246fea07 --- /dev/null +++ b/integration-service/.env.example @@ -0,0 +1,3 @@ +# Copy to .env.local for local WMS connectivity verification. +LINGXING_OMS_APP_KEY= +LINGXING_OMS_APP_SECRET= diff --git a/integration-service/AGENTS.md b/integration-service/AGENTS.md index 8347661ef..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、海外仓业务船司/船舶轨迹查询等第三方适配和能力开关;不保存业务主数据。 @@ -9,6 +14,7 @@ - 应用/配置:`IntegrationServiceApplication.java`、`config/IntegrationProperties.java` - 内部接口:`controller/IntegrationProviderController.java`、`WeightOcrController.java` +- 领星 WMS 自研系统出库只读查询:`controller/LingxingInternalController.java`、`provider/lingxing/`、`dto/lingxing/`、`config/LingxingWmsProperties.java`;内部契约 `openapi/lingxing-wms-v1.yaml` 位于 `src/main/resources/`。内部提供出库单页摘要、独立按单定位、出库详情、仓库目录及单仓产品库存单页查询;统一账号节流、有限退避和严格分页校验,保留未知数量与源时间原值。不编排定时采集、多页库存同步或业务持久化;本地隔离启动与环境变量入口见 `README.md`,真实调用默认关闭。 - OCR 编排:`service/ocr/WeightOcrBatchService.java`、`ImageCropper.java`、`WeightCandidateSelector.java` - 供应商适配:`provider/IntegrationProviderAdapter.java`、`BaiduOcrAdapter.java`、`AmazonLogisticsAdapter.java` - 海外仓业务船节点外部适配:内部契约 `controller/IntegrationProviderController.java` 的 `/internal/integrations/carrier-schedules/query`,统一十节点与四类异常 DTO `dto/CarrierScheduleDtos.java`,provider SPI `provider/CarrierScheduleProviderAdapter.java`,标准枚举 `domain/CarrierScheduleStandardNode.java`、`CarrierScheduleExceptionCode.java`;美森网页会话、动态 `ttr` 与公开查询代理适配见 `provider/MatsonTrackingClient.java`、`MatsonCarrierScheduleProviderAdapter.java`,由 `MATSON_TRACKING_ENABLED` 显式启用;SML 的 122→125→124→126 JSON 调用链与 A/E 时间解析见 `provider/SmlTrackingClient.java`、`SmlCarrierScheduleProviderAdapter.java`,由 `SML_TRACKING_ENABLED` 显式启用;ZIM 正式 Tracing API 的 OAuth2 token 缓存、401 单次刷新及柜号活动解析见 `provider/ZimTrackingClient.java`、`ZimCarrierScheduleProviderAdapter.java`,凭据只从外部 secret 注入并由 `ZIM_TRACKING_ENABLED` 显式启用;长荣柜号→关联提单两段 UTF-8 HTML 查询、表头定位与日粒度事实解析见 `provider/EvergreenTrackingClient.java`、`EvergreenCarrierScheduleProviderAdapter.java`,由 `EMC_TRACKING_ENABLED` 显式启用且不输出完整提单号;合德 UTF-8 HTML 主表解析见 `provider/HedeTrackingClient.java`、`HedeCarrierScheduleProviderAdapter.java`,由 `HEDE_TRACKING_ENABLED` 显式启用且当前只自动解析柜号;轮询任务、租约、订舱/柜次关联、最新实际节点、节点/异常事实和历史归 `firstleg/portschedule/`,标准见 `../../docs/海外仓业务/船节点标准化契约.md` diff --git a/integration-service/README.md b/integration-service/README.md new file mode 100644 index 000000000..14974e443 --- /dev/null +++ b/integration-service/README.md @@ -0,0 +1,102 @@ +# Integration 本地领星 WMS 查询 + +本入口用于在现有 Java 服务内验证 WMS 只读调用。仅返回单页出库摘要,不启动月度采集、库存全量同步、定时任务或数据库写入。业务方案见[领星一件代发数据统计一期方案](../../docs/自营业务/领星一件代发数据统计一期方案.md)。 + +后续开发交接见[接口联调手册](../../docs/自营业务/领星WMS接口联调手册.md):已另行用只读 HTTP 验证仓库发现、一件代发详情和按仓产品库存全分页。此处现有 Java 接口仍只有出库摘要;库存内部接口、分仓任务和持久化尚待开发,不能以外部直连成功推定 Java 功能已经具备。 + +## 凭据与开关 + +从 WMS 管理后台的“个人中心 → API 信息 → 自研系统”获取 `appKey` 和 `appSecret`,填写本目录 Git 忽略的 `.env.local`,格式参考 [.env.example](.env.example)。这两个值分别加载为 `LINGXING_OMS_APP_KEY` 和 `LINGXING_OMS_APP_SECRET` 进程环境变量,不提交真实值。Spring 本身不会自动读取 `.env.local`,应在启动 Java 的同一终端加载。该凭据属于 OMP 服务商内部系统对接,不使用 OMS 货主或 WMS 智能设备协议。 + +正式应用配置默认 `LINGXING_WMS_ENABLED=false`。下面的本地命令显式启用适配器,为该本地实例生成内部调用令牌。不要复用共享环境令牌。 + +测试部署通过 `deploy/docker-compose.yml` 将 `LINGXING_OMS_APP_KEY`、`LINGXING_OMS_APP_SECRET` 传入 Integration 容器。现有 Jenkins 下游部署任务读取 `firstleg-test-env` Secret File;可将这两项加入该文件,或在实际执行部署的下游服务任务中以凭据绑定注入同名环境变量。只在上游总控任务设置变量不会自动传给下游任务。变量命名采用用户指定的 `OMS`,接口协议仍为当前 WMS 自研系统(OMP);真实凭据是否有该接口权限仍待联调核验。不要将密钥作为普通构建参数或打印完整 Compose 配置。 + +2026-10-12 用户确认唯一账号、所有源时间按北京时间、数量按 SKU 件数。Lastleg 应用配置使用 `source-account=lingxing-primary`(内部稳定标识,不是 appKey)、`source-query-timezone=Asia/Shanghai`、`quantity-unit=PCS`,分仓配置的源时区默认 `Asia/Shanghai`;数量仍保留十进制精度。仓库编码须在获得真实仓库目录后配置到 `baiyue.self-operated-analytics.warehouses`,不预填虚构仓库。`SELFOP_EXECUTION_ENABLED` 和 `SELFOP_SALES_PUBLICATION_ENABLED` 均默认关闭。 + +## 本地启动 + +先在后端仓库根目录执行构建,包含相关模块的编译与测试: + +```powershell +mvn -pl integration-service -am package +``` + +然后在同一目录的 PowerShell 中执行。只加载明确允许的两个名称,不执行环境文件内的代码: + +```powershell +$credentialNames = @('LINGXING_OMS_APP_KEY', 'LINGXING_OMS_APP_SECRET') +foreach ($line in [IO.File]::ReadAllLines((Join-Path (Get-Location) 'integration-service/.env.local'))) { + if ($line -match '^\s*(LINGXING_OMS_APP_KEY|LINGXING_OMS_APP_SECRET)\s*=(.*)$') { + $credentialName = $matches[1] + $credentialValue = $matches[2].Trim() + if ($credentialValue.Length -ge 2 -and + (($credentialValue.StartsWith('"') -and $credentialValue.EndsWith('"')) -or + ($credentialValue.StartsWith("'") -and $credentialValue.EndsWith("'")))) { + $credentialValue = $credentialValue.Substring(1, $credentialValue.Length - 2) + } + [Environment]::SetEnvironmentVariable($credentialName, $credentialValue, 'Process') + } +} +foreach ($credentialName in $credentialNames) { + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($credentialName, 'Process'))) { + throw "Missing credential: $credentialName" + } +} +$env:INTERNAL_SERVICE_TOKEN = [guid]::NewGuid().ToString() +$localInternalToken = $env:INTERNAL_SERVICE_TOKEN +$env:INTEGRATION_LOCAL_PORT = '19210' +$buildJar = (Resolve-Path 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar').Path +$localJava = Start-Process -FilePath (Get-Command java).Source -WindowStyle Hidden -PassThru ` + -ArgumentList @('-jar', ('"' + $buildJar + '"'), + '--spring.config.location=classpath:/application-lingxing-local.yml', + '--baiyue.integration.lingxing-wms.enabled=true') ` + -RedirectStandardOutput 'integration-service/target/lingxing-local.stdout.log' ` + -RedirectStandardError 'integration-service/target/lingxing-local.stderr.log' +``` + +该配置仅监听 `127.0.0.1`,关闭 Nacos Config、Discovery、服务注册及 Spring 定时任务,不导入共享环境配置。若端口占用,选择其他本地端口并同步替换后续 URL,不停止未知进程。确认 `http://127.0.0.1:19210/actuator/health` 返回 `UP` 后再查询。 + +## 读取一条出库单 + +在保留上述变量的同一 PowerShell 中执行一次: + +```powershell +$businessZone = [TimeZoneInfo]::FindSystemTimeZoneById('China Standard Time') +$queryEnd = [TimeZoneInfo]::ConvertTimeFromUtc([DateTime]::UtcNow, $businessZone) +$query = @{ + startTime = $queryEnd.AddMonths(-1).ToString('yyyy-MM-ddTHH:mm:ss') + endTime = $queryEnd.ToString('yyyy-MM-ddTHH:mm:ss') + current = 1 + size = 1 +} | ConvertTo-Json -Compress +$result = Invoke-RestMethod -Method Post ` + -Uri 'http://127.0.0.1:19210/internal/integrations/lingxing/wms/outbound-orders/query' ` + -Headers @{'X-Internal-Service-Token' = $localInternalToken} ` + -ContentType 'application/json' -Body $query +$result | ConvertTo-Json -Depth 6 +``` + +返回摘要只包含出库单号、来源单号、货主编码、仓库编码、状态、创建时间和出库时间,不返回收件人或地址。业务 `code="200"` 且 `records` 包含一条才表示已读到样本;空页表示调用成功但未取得样本;`B0001`/`S0503` 表示明确失败,保留 `errorCode`、来源数值错误码及请求标识排查,不能当作零销量。 + +当前调用 OMP `POST /openapi/v2/order/delivery/page`,固定 `timeType=outboundTime`、`status=100` 已出库,窗口最多向前一个自然月。该本地保护不代表供应商历史上限;单页样本也不代表整月采集完成。订单详情、全量库存、增量任务和统计落库仍按业务方案后续实现。 + +测试完毕,在同一终端停止本次启动的进程,并清理当前终端注入的变量: + +```powershell +if (-not $localJava.HasExited) { Stop-Process -Id $localJava.Id } +foreach ($credentialName in @('LINGXING_OMS_APP_KEY', 'LINGXING_OMS_APP_SECRET', 'INTERNAL_SERVICE_TOKEN')) { + [Environment]::SetEnvironmentVariable($credentialName, $null, 'Process') +} +$localInternalToken = $null +$credentialValue = $null +``` + +## 接口与验证 + +- 2026-10-12 已通过本地 Java 实例调用 OMP WMS 出库分页,按最近 1 个月、`current=1`、`size=1` 成功返回 1 条已出库摘要;无内部令牌请求被拒绝。验证后本地进程已停止。该结果仅证明当前只读链路可用,不代表全量采集或库存同步完成。 +- 实测成功响应为数值 `code=200`,但缺少官方示例及 Schema 中的 `success`。客户端以成功码和有效分页结构共同判定;`success` 出现时仍须为布尔 `true`。此差异已加入回归测试,失败响应不转换为空页。 +- 内部前缀为 `POST /internal/integrations/lingxing/wms`,提供 `/outbound-orders/query`、`/outbound-orders/locate`、`/outbound-orders/detail`、`/warehouses/query`、`/product-inventory/query`;仅接受 `X-Internal-Service-Token`,不经公网 Gateway。各请求只返回一页或一单,多页与业务版本归 Lastleg 编排。定位不传时间/状态;产品库存必须指定仓库、保留全部品质和零库存。源时间原值与 BigDecimal 数量未知值保留,不推断时区或单位。 +- [OpenAPI](src/main/resources/openapi/lingxing-wms-v1.yaml)定义输入、分页摘要和失败结构。 +- 从后端根执行 `mvn -pl integration-service -am test` 与 `node --test scripts/gateway-route-contract.test.mjs`。 +- 本地模拟 HTTP 单测验证签名、请求体、敏感信息隔离和错误处理;不能代替真实领星样本验证。官方入口为[服务商内部系统对接](https://apidoc-omp.xlwms.com/)与[一件代发出库单 WMS 分页查询](https://apidoc-omp.xlwms.com/reference/post_openapi-v2-order-delivery-page-1)。 diff --git a/integration-service/src/main/java/com/baiyue/integration/config/LingxingEgressProperties.java b/integration-service/src/main/java/com/baiyue/integration/config/LingxingEgressProperties.java new file mode 100644 index 000000000..5a2ec5ca4 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/config/LingxingEgressProperties.java @@ -0,0 +1,37 @@ +package com.baiyue.integration.config; + +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; + +/** Exclusive grant identity; externalExclusiveUntil bounds VALIDATION only, not BUSINESS. */ +@ConfigurationProperties(prefix = "baiyue.integration.lingxing-wms.egress") +public record LingxingEgressProperties(@DefaultValue("OFF") CollectionMode collectionMode, + @DefaultValue("test") String environmentLabel, String sourceAccount, String externalExclusiveGrantId, + Instant externalExclusiveUntil, URI budgetAuthorityBaseUri, @DefaultValue("5s") Duration minStartInterval, + @DefaultValue("5m") Duration cooldownFallback, + @DefaultValue("false") boolean continuousBusinessCollectionEnabled) { + public LingxingEgressProperties { + collectionMode = collectionMode == null ? CollectionMode.OFF : collectionMode; + environmentLabel = environmentLabel == null ? "test" : environmentLabel; + minStartInterval = minStartInterval == null ? Duration.ofSeconds(5) : minStartInterval; + cooldownFallback = cooldownFallback == null ? Duration.ofMinutes(5) : cooldownFallback; + Duration minimumInterval = collectionMode == CollectionMode.VALIDATION ? Duration.ofSeconds(5) : Duration.ofMillis(1); + if (minStartInterval.compareTo(minimumInterval) < 0 || minStartInterval.compareTo(Duration.ofMinutes(5)) > 0 + || cooldownFallback.compareTo(Duration.ofSeconds(5)) < 0 || cooldownFallback.compareTo(Duration.ofDays(1)) > 0) { + throw new IllegalArgumentException("领星 VALIDATION 出口间隔至少5秒,其他模式至少1毫秒;间隔和冷却配置须为有界正值"); + } + if (budgetAuthorityBaseUri != null && (budgetAuthorityBaseUri.getHost() == null + || !("http".equals(budgetAuthorityBaseUri.getScheme()) || "https".equals(budgetAuthorityBaseUri.getScheme())) + || budgetAuthorityBaseUri.getUserInfo() != null || budgetAuthorityBaseUri.getQuery() != null + || budgetAuthorityBaseUri.getFragment() != null || !budgetAuthorityBaseUri.getPath().isEmpty())) { + throw new IllegalArgumentException("领星预算授权地址必须为受控内部服务源地址"); + } + } + @Override public String toString() { + return "LingxingEgressProperties[collectionMode=" + collectionMode + ", environmentLabel=" + environmentLabel + "]"; + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/config/LingxingQuantityCaptureProperties.java b/integration-service/src/main/java/com/baiyue/integration/config/LingxingQuantityCaptureProperties.java new file mode 100644 index 000000000..95d4099e1 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/config/LingxingQuantityCaptureProperties.java @@ -0,0 +1,6 @@ +package com.baiyue.integration.config; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; +/** Feature gate only. Explicit, durable grants and deadlines are owned by Lastleg. */ +@ConfigurationProperties("baiyue.integration.lingxing-wms.quantity-capture") +public record LingxingQuantityCaptureProperties(@DefaultValue("false") boolean enabled,String sourceAccount) {} diff --git a/integration-service/src/main/java/com/baiyue/integration/config/LingxingWmsProperties.java b/integration-service/src/main/java/com/baiyue/integration/config/LingxingWmsProperties.java new file mode 100644 index 000000000..07ba44975 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/config/LingxingWmsProperties.java @@ -0,0 +1,46 @@ +package com.baiyue.integration.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +import java.net.URI; +import java.time.Duration; + +@ConfigurationProperties(prefix = "baiyue.integration.lingxing-wms") +public record LingxingWmsProperties( + @DefaultValue("false") boolean enabled, + @DefaultValue("https://api.xlwms.com") URI baseUri, + String appKey, + String appSecret, + @DefaultValue("5s") Duration connectTimeout, + @DefaultValue("15s") Duration requestTimeout +) { + public LingxingWmsProperties { + baseUri = baseUri == null ? URI.create("https://api.xlwms.com") : baseUri; + connectTimeout = connectTimeout == null ? Duration.ofSeconds(5) : connectTimeout; + requestTimeout = requestTimeout == null ? Duration.ofSeconds(15) : requestTimeout; + if (connectTimeout.isZero() || connectTimeout.isNegative() + || requestTimeout.isZero() || requestTimeout.isNegative()) { + throw new IllegalArgumentException("领星 WMS 超时必须为正数"); + } + String host = baseUri.getHost(); + boolean loopback = "localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host) + || "[::1]".equals(host); + boolean secureScheme = "https".equalsIgnoreCase(baseUri.getScheme()) + || ("http".equalsIgnoreCase(baseUri.getScheme()) && loopback); + if (host == null || !secureScheme || baseUri.getUserInfo() != null + || baseUri.getQuery() != null || baseUri.getFragment() != null + || (!baseUri.getPath().isEmpty() && !"/".equals(baseUri.getPath()))) { + throw new IllegalArgumentException("领星 WMS base-uri 必须是 HTTPS 源地址;仅本机测试允许 HTTP"); + } + } + + public boolean hasCredentials() { + return appKey != null && !appKey.isBlank() && appSecret != null && !appSecret.isBlank(); + } + + @Override + public String toString() { + return "LingxingWmsProperties[enabled=" + enabled + ", credentialsConfigured=" + hasCredentials() + "]"; + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/controller/LingxingEgressRuntimeController.java b/integration-service/src/main/java/com/baiyue/integration/controller/LingxingEgressRuntimeController.java new file mode 100644 index 000000000..13a329a75 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/controller/LingxingEgressRuntimeController.java @@ -0,0 +1,32 @@ +package com.baiyue.integration.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.integration.config.LingxingWmsProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressRuntimeSnapshot; +import com.baiyue.integration.provider.lingxing.LingxingEgressGate; +import com.baiyue.integration.security.InternalServiceTokenGuard; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +/** Internal token is checked before the active gate is inspected. Never calls the provider. */ +@RestController +public class LingxingEgressRuntimeController { + private final InternalServiceTokenGuard guard; + private final LingxingEgressGate gate; + private final LingxingWmsProperties provider; + + public LingxingEgressRuntimeController(InternalServiceTokenGuard guard, LingxingEgressGate gate, + LingxingWmsProperties provider) { + this.guard = guard; this.gate = gate; this.provider = provider; + } + + @Operation(summary = "读取实际领星出口绑定配置的脱敏快照,无供应商或许可副作用") + @GetMapping("/internal/integrations/lingxing/wms/egress/runtime") + public ApiResponse runtime( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token) { + guard.verify(token); + return ApiResponse.success(gate.runtimeSnapshot(provider.enabled())); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java b/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java new file mode 100644 index 000000000..25ca5f81d --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java @@ -0,0 +1,154 @@ +package com.baiyue.integration.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.web.CorrelationContext; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.*; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.core.JsonParser; +import com.baiyue.integration.provider.lingxing.LingxingWmsClient; +import com.baiyue.integration.provider.lingxing.LingxingWmsException; +import com.baiyue.integration.security.InternalServiceTokenGuard; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "领星 WMS 内部查询", description = "仅内部服务令牌认证,不提供公网路由") +@RestController +@RequestMapping("/internal/integrations/lingxing/wms") +public class LingxingInternalController { + private final InternalServiceTokenGuard tokenGuard; + private final LingxingWmsClient client; + private final ObjectMapper mapper; + + public LingxingInternalController(InternalServiceTokenGuard tokenGuard, LingxingWmsClient client, ObjectMapper mapper) { + this.tokenGuard = tokenGuard; + this.client = client; + this.mapper = mapper; + } + + @Operation(summary = "按出库时间分页只读查询 WMS 已出库摘要") + @PostMapping("/outbound-orders/query") + public ApiResponse query( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context, + @Valid @RequestBody QueryRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(client.queryPage(request, context(context))); + } + + @Operation(summary = "读取正常仓库目录单页") + @PostMapping("/warehouses/query") + public ApiResponse> warehouses( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context, + @Valid @RequestBody WarehouseQueryRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(client.queryWarehouses(request, context(context))); + } + + @Operation(summary = "按单号定位单页,不附加时间或状态筛选") + @PostMapping("/outbound-orders/locate") + public ApiResponse locate( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context, + @Valid @RequestBody LocateRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(client.locate(request, context(context))); + } + + @Operation(summary = "按仓库、货主、发货单号读取完整 SKU 详情") + @PostMapping("/outbound-orders/detail") + public ApiResponse detail( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context, + @Valid @RequestBody DetailRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(client.queryDetail(request, context(context))); + } + + @Operation(summary = "单SKU最多31日库存流水诊断,仅第一页最多1000条") + @PostMapping("/inventory-flow/query") + public ApiResponse> inventoryFlow( + @RequestHeader(value="X-Internal-Service-Token",required=false) String token, + @RequestHeader(value="X-Lingxing-Egress-Context",required=false) String context, + @Valid @RequestBody InventoryFlowRequest request) { + tokenGuard.verify(token);return ApiResponse.success(client.queryInventoryFlow(request,context(context))); + } + + @Operation(summary = "读取固定单仓最多一天的全部货主品质库存流水单页") + @PostMapping("/inventory-flow-page/query") + public ApiResponse> inventoryFlowPage( + @RequestHeader(value="X-Internal-Service-Token",required=false) String token, + @RequestHeader(value="X-Lingxing-Egress-Context",required=false) String context, + @Valid @RequestBody InventoryFlowPageRequest request) { + tokenGuard.verify(token);return ApiResponse.success(client.queryInventoryFlowPage(request,context(context))); + } + + @Operation(summary = "读取单仓全部货主、全部品质且保留零库存的产品库存单页") + @PostMapping("/product-inventory/query") + public ApiResponse> inventory( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context, + @Valid @RequestBody InventoryQueryRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(client.queryInventory(request, context(context))); + } + + @PostMapping("/customers/query") + public ApiResponse> customers( + @RequestHeader(value="X-Internal-Service-Token",required=false) String token, + @RequestHeader(value="X-Lingxing-Egress-Context",required=false) String context, + @Valid @RequestBody WarehouseQueryRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(client.queryCustomers(request,context(context))); + } + + @PostMapping("/products/query") + public ApiResponse> products( + @RequestHeader(value="X-Internal-Service-Token",required=false) String token, + @RequestHeader(value="X-Lingxing-Egress-Context",required=false) String context, + @Valid @RequestBody ProductQueryRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(client.queryProducts(request,context(context))); + } + + private EgressContext context(String header) { + if (header == null || header.isBlank() || header.length() > 2048 || !header.chars().allMatch(c -> c >= 32 && c <= 126)) + throw invalidContext(); + try { + EgressContext context = mapper.readerFor(EgressContext.class).with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, + DeserializationFeature.FAIL_ON_TRAILING_TOKENS).with(JsonParser.Feature.STRICT_DUPLICATE_DETECTION).readValue(header); + if (context == null || !context.wellFormed()) throw invalidContext(); + return context; + } catch (Exception ignored) { throw invalidContext(); } + } + private static LingxingWmsException invalidContext() { + return new LingxingWmsException("LINGXING_WMS_EGRESS_CONTEXT_INVALID", "领星安全出口控制上下文缺失或无效", false, null, null); + } + + @ExceptionHandler(LingxingWmsException.class) + public ApiResponse handleProviderFailure(LingxingWmsException exception, + HttpServletRequest request) { + ResultCode code = exception.retryable() ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.BUSINESS_ERROR; + return ApiResponse.of(code, exception.getMessage(), + new ProviderFailure(exception.code(), exception.retryable(), + exception.upstreamStatus(), exception.sourceCode(), exception.egress() == null + ? new AttemptMetadata(null, 0, false, null) : exception.egress(), exception.denial()), + CorrelationContext.requestId(request), CorrelationContext.traceId(request)); + } + + public record ProviderFailure(String errorCode, boolean retryable, + Integer upstreamStatus, Integer sourceCode, AttemptMetadata egress, AuthorizationDenial denial) { } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressDtos.java b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressDtos.java new file mode 100644 index 000000000..70b95d999 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressDtos.java @@ -0,0 +1,31 @@ +package com.baiyue.integration.dto.lingxing; + +import java.time.Instant; + +/** Control identities are supplied by the collector and revalidated by its durable authority. */ +public final class LingxingEgressDtos { + private LingxingEgressDtos() { } + public enum CollectionMode { OFF, VALIDATION, BUSINESS } + public enum Operation { WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY, CUSTOMERS, PRODUCTS, INVENTORY_FLOW } + public record EgressContext(String sourceAccount, CollectionMode executionPurpose, String rootTaskId, + String sessionId, String attemptId, String exclusiveGrantId) { + public boolean wellFormed() { + return (executionPurpose == CollectionMode.VALIDATION || executionPurpose == CollectionMode.BUSINESS) + && identifier(sourceAccount, 64) && identifier(rootTaskId, 128) && identifier(sessionId, 128) + && identifier(attemptId, 128) && identifier(exclusiveGrantId, 128); + } + private static boolean identifier(String value, int maximum) { + return value != null && value.length() <= maximum && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*"); + } + } + public record AuthorizationRequest(EgressContext context, Operation operation, String requestHash) { } + public record AttemptPermit(EgressContext context, Operation operation, String requestHash, + int allowedAttempts, Instant expiresAt, boolean exclusiveConfirmed, + boolean budgetConsumed) { } + public record CompletionRequest(EgressContext context, Operation operation, String requestHash, + int actualHttpAttempts, String errorCode, Integer upstreamStatus, + Integer sourceCode, boolean accountPaused, Instant cooldownUntil) { } + public record AttemptMetadata(String attemptId, int actualHttpAttempts, boolean accountPaused, + Instant cooldownUntil) { } + public record AuthorizationDenial(String errorCode, boolean sampleDone, Instant waitUntil) { } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressRuntimeSnapshot.java b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressRuntimeSnapshot.java new file mode 100644 index 000000000..521478ed9 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressRuntimeSnapshot.java @@ -0,0 +1,9 @@ +package com.baiyue.integration.dto.lingxing; + +import java.time.Instant; + +/** Bound runtime configuration only. No credentials, addresses or execution attestation. */ +public record LingxingEgressRuntimeSnapshot(int schemaVersion, String service, Instant observedAt, + String mode, String environment, boolean continuousEnabled, String sourceAccountFingerprint, + boolean grantPresent, String grantFingerprint, long intervalMs, int permitMaxSeconds, + boolean providerEnabled, String budgetAuthorityFingerprint) { } diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java new file mode 100644 index 000000000..19cc34506 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java @@ -0,0 +1,70 @@ +package com.baiyue.integration.dto.lingxing; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.math.BigDecimal; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AttemptMetadata; + +public final class LingxingWmsDtos { + private LingxingWmsDtos() { } + + public record QueryRequest(@NotNull LocalDateTime startTime, @NotNull LocalDateTime endTime, + @Min(1) int current, @Min(1) @Max(100) int size) { } + + public record WarehouseQueryRequest(@Min(1) int current, @Min(1) @Max(1000) int size) { } + public record ProductQueryRequest(@Min(1) int current, @Min(1) @Max(100) int size) { } + public record InventoryFlowRequest(@NotBlank @Size(max=64) String warehouseCode, + @NotBlank @Size(max=64) String customerCode, @NotBlank @Size(max=128) String sku, + @NotNull LocalDateTime startTime, @NotNull LocalDateTime endTime) { } + public record InventoryFlowPageRequest(@NotBlank @Size(max=64) String warehouseCode, + @NotNull LocalDateTime startTime,@NotNull LocalDateTime endTime, + @Min(1) int current,@Min(1) @Max(1000) int size) { } + public record InventoryFlow(String id, String customerCode, String whCode, String productSku, + String cellNo, String innerBatchNo, String billNo, String billType, Integer productQuality, + BigDecimal bookQty, String bookChangQty, BigDecimal closeQty, String createTime, String sourceNo) { } + public record Customer(String customerCode, String customerName, String status) { } + public record Product(String customerCode, String sku, String productName, + BigDecimal length, BigDecimal width, BigDecimal height, String sizeUnit, + BigDecimal wmsLength, BigDecimal wmsWidth, BigDecimal wmsHeight, String wmsSizeUnit) { } + public record LocateRequest(@NotBlank @Size(max=128) String deliveryNo, + @Min(1) int current, @Min(1) @Max(1000) int size) { } + public record DetailRequest(@NotBlank @Size(max=64) String warehouseCode, + @NotBlank @Size(max=64) String customerCode, + @NotBlank @Size(max=128) String deliveryNo) { } + public record InventoryQueryRequest(@NotBlank @Size(max=64) String warehouseCode, + @Min(1) int current, @Min(1) @Max(1000) int size) { } + public record Warehouse(String whCode, String whNameCn, Integer warehouseType, + Integer status, String timeZone) { } + public record SkuLine(String productSku, String productName, BigDecimal actualDeliveryQty, + BigDecimal deliveryQty) { } + public record OrderDetail(String deliveryNo, String sourceNo, String customerCode, String whCode, + Integer status, String orderCreateTime, String outboundTime, + List skuVOList, int sourceCode, Instant observedAt, AttemptMetadata egress) { + public OrderDetail { skuVOList = List.copyOf(skuVOList); } + } + public record ProductStock(String customerCode, String whCode, String productSku, String productName, + Integer productQuality, BigDecimal stkQty, BigDecimal freezeQty, + BigDecimal useStkQty, BigDecimal transitQty, String innerBatchNo, + String outBatchNo, String uniqueKey4tenant) { } + public record QueryPage(List records, Long total, int current, int size, int pages, + int sourceCurrent, int sourceSize, int sourceCode, Instant observedAt, AttemptMetadata egress) { + public QueryPage { records = List.copyOf(records); } + } + + // Preserve source strings; the collector interprets confirmed Asia/Shanghai source time. + public record ListSku(String productSku, String productName, BigDecimal qty) { } + public record OrderSummary(String deliveryNo, String sourceNo, String customerCode, String whCode, + Integer status, String createTime, String outboundTime, List productList) { } + + public record PageResult(List records, Long total, int current, int size, + int pages, int sourceCurrent, int sourceSize, int sourceCode, Instant observedAt, AttemptMetadata egress) { + public PageResult { records = List.copyOf(records); } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingBudgetAuthority.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingBudgetAuthority.java new file mode 100644 index 000000000..bd51b96fd --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingBudgetAuthority.java @@ -0,0 +1,9 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*; + +/** authorize must consume a persisted single-use attempt before returning; never refund on lost response. */ +public interface LingxingBudgetAuthority { + AttemptPermit authorize(AuthorizationRequest request); + void complete(CompletionRequest request); +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingBudgetDeniedException.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingBudgetDeniedException.java new file mode 100644 index 000000000..b86578610 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingBudgetDeniedException.java @@ -0,0 +1,11 @@ +package com.baiyue.integration.provider.lingxing; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AuthorizationDenial; + +/** A known, authenticated authority decision; distinct from a lost/ambiguous response. */ +public final class LingxingBudgetDeniedException extends RuntimeException { + private final AuthorizationDenial denial; + public LingxingBudgetDeniedException(AuthorizationDenial denial) { + super("领星持久预算授权拒绝"); this.denial = denial; + } + public AuthorizationDenial denial() { return denial; } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingEgressGate.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingEgressGate.java new file mode 100644 index 000000000..05a4e7393 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingEgressGate.java @@ -0,0 +1,170 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingEgressProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.time.Clock; +import java.time.Instant; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import com.baiyue.integration.dto.lingxing.LingxingEgressRuntimeSnapshot; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +/** Local serialization supplements the exclusive grant and durable account protection. */ +@Component +public final class LingxingEgressGate { + private static final int PERMIT_MAX_SECONDS = 30; + private static final Logger log = LoggerFactory.getLogger(LingxingEgressGate.class); + private final LingxingEgressProperties properties; + private final LingxingBudgetAuthority authority; + private final Clock clock; + private final Waiter waiter; + private final Semaphore inFlight = new Semaphore(1, true); + private volatile boolean paused; + private volatile boolean recoverableRatePause; + private Instant lastStart; + private Instant cooldownUntil; + @Autowired public LingxingEgressGate(LingxingEgressProperties properties, LingxingBudgetAuthority authority) { + this(properties, authority, Clock.systemUTC(), Thread::sleep); + } + LingxingEgressGate(LingxingEgressProperties properties, LingxingBudgetAuthority authority, Clock clock, Waiter waiter) { + this.properties = properties; this.authority = authority; this.clock = clock; this.waiter = waiter; + } + /** The very properties and clock used by execute; no semaphore, authority, budget or HTTP work. */ + public LingxingEgressRuntimeSnapshot runtimeSnapshot(boolean providerEnabled) { + String grant = properties.externalExclusiveGrantId(); + return new LingxingEgressRuntimeSnapshot(1, "integration", clock.instant(), + properties.collectionMode().name(), properties.environmentLabel(), + properties.continuousBusinessCollectionEnabled(), fingerprint("source-account", properties.sourceAccount()), + grant != null && !grant.isBlank(), fingerprint("grant", grant), properties.minStartInterval().toMillis(), + PERMIT_MAX_SECONDS, providerEnabled, fingerprint("budget-authority", properties.budgetAuthorityBaseUri() == null ? null + : properties.budgetAuthorityBaseUri().toString())); + } + private static String fingerprint(String domain, String value) { + if (value == null || value.isBlank()) return null; + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(("selfop-runtime:v1:" + domain + "\n" + value).getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException("SHA256_UNAVAILABLE", impossible); } + } + public T execute(EgressContext context, Operation operation, String hash, AuthorizedCall call) { + try { return guarded(context, operation, hash, call); } + catch (LingxingWmsException error) { + if (error.egress() == null) error.withEgress(new AttemptMetadata( + context != null && identifier(context.attemptId(), 128) ? context.attemptId() : null, 0, paused, cooldownUntil)); + throw error; + } + } + private T guarded(EgressContext context, Operation operation, String hash, AuthorizedCall call) { + check(context); + if (operation == null || hash == null || !hash.matches("[a-f0-9]{64}")) throw failure("EGRESS_CONTEXT_INVALID"); + boolean acquired = false; + try { + while (!(acquired = inFlight.tryAcquire(100, TimeUnit.MILLISECONDS))) check(context); + check(context); + if (lastStart != null) { + Instant next = lastStart.plus(properties.minStartInterval()); + long wait = java.time.Duration.between(clock.instant(), next).toMillis(); + if (wait > 0) waiter.sleep(wait + 1); + } + check(context); + AuthorizationRequest request = new AuthorizationRequest(context, operation, hash); + AttemptPermit permit; + try { permit = authority.authorize(request); } + catch (LingxingBudgetDeniedException knownDecision) { + throw failure("BUDGET_AUTHORIZATION_DENIED").withDenial(knownDecision.denial()); + } + catch (RuntimeException error) { recoverableRatePause = false; paused = true; throw failure("BUDGET_AUTHORITY_UNAVAILABLE"); } + if (permit == null || !context.equals(permit.context()) || operation != permit.operation() + || !hash.equals(permit.requestHash()) || permit.allowedAttempts() != 1 || !permit.exclusiveConfirmed() + || !permit.budgetConsumed() || permit.expiresAt() == null || !clock.instant().isBefore(permit.expiresAt()) + || permit.expiresAt().isAfter(clock.instant().plusSeconds(PERMIT_MAX_SECONDS)) + || (!business() && permit.expiresAt().isAfter(properties.externalExclusiveUntil()))) { + recoverableRatePause = false; paused = true; throw failure("BUDGET_PERMIT_INVALID"); + } + // A new persisted permit proves the coordinator accepted the account's cooldown/budget/root state. + // Time alone never clears the pause. VALIDATION and uncertain callbacks cannot reach this branch. + if (paused && recoverableRatePause && properties.collectionMode() == CollectionMode.BUSINESS) { + paused = false; recoverableRatePause = false; cooldownUntil = null; + } + AttemptHandle handle = new AttemptHandle(context, permit.expiresAt()); + T result = null; + LingxingWmsException failure = null; + try { result = call.run(handle); } + catch (LingxingWmsException error) { failure = error; } + catch (RuntimeException error) { failure = failure("LOCAL_PROCESSING_FAILED"); } + Instant callFinishedAt = clock.instant(); + CompletionRequest completion = new CompletionRequest(context, operation, hash, handle.actualHttpAttempts, + failure == null ? null : failure.code(), failure == null ? Integer.valueOf(200) : failure.upstreamStatus(), + failure == null ? Integer.valueOf(200) : failure.sourceCode(), paused, cooldownUntil); + boolean completionConfirmed = false; + try { authority.complete(completion); completionConfirmed = true; } + catch (RuntimeException error) { + recoverableRatePause = false; paused = true; + // Unconfirmed completion must remain distinguishable even when the provider already failed. + failure = failure("BUDGET_COMPLETION_UNCONFIRMED"); + } + log.debug("lingxingAttempt operation={} start={} end={} upstreamStatus={} sourceCode={} errorCode={} completionConfirmed={}", + operation, handle.startedAt, callFinishedAt, completion.upstreamStatus(), completion.sourceCode(), + completion.errorCode(), completionConfirmed); + if (failure != null) throw failure.withEgress(handle.metadata()); + return result; + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); throw failure("INTERRUPTED"); + } finally { if (acquired) inFlight.release(); } + } + private void check(EgressContext context) { + if (properties.collectionMode() == CollectionMode.OFF) throw failure("EGRESS_OFF"); + if (properties.collectionMode() == CollectionMode.BUSINESS + && !("production".equals(properties.environmentLabel()) || "test".equals(properties.environmentLabel()))) + throw failure("BUSINESS_ENVIRONMENT_REJECTED"); + if (paused && (!recoverableRatePause || properties.collectionMode() != CollectionMode.BUSINESS + || cooldownUntil == null || clock.instant().isBefore(cooldownUntil))) throw failure("ACCOUNT_PAUSED"); + if (context == null || context.executionPurpose() != properties.collectionMode() + || !context.wellFormed() + || !context.sourceAccount().equals(properties.sourceAccount())) throw failure("EGRESS_CONTEXT_INVALID"); + if (properties.externalExclusiveGrantId() == null || properties.externalExclusiveGrantId().isBlank() + || !properties.externalExclusiveGrantId().equals(context.exclusiveGrantId()) + || (!business() && (properties.externalExclusiveUntil() == null || !clock.instant().isBefore(properties.externalExclusiveUntil()))) + || properties.budgetAuthorityBaseUri() == null) throw failure("EXCLUSIVE_WINDOW_UNCONFIRMED"); + } + private boolean business() { + // The account authority validates the original task identity, including manual roots. + // BUSINESS has no operator session cutoff; each individual permit remains finite. + return properties.collectionMode() == CollectionMode.BUSINESS; + } + private static boolean identifier(String value, int maximum) { + return value != null && value.length() <= maximum && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*"); + } + private static LingxingWmsException failure(String code) { + return new LingxingWmsException("LINGXING_WMS_" + code, "领星安全出口拒绝请求(" + code + ")", false, null, null); + } + public final class AttemptHandle { + private final EgressContext context; + private final Instant expiresAt; + private int actualHttpAttempts; + private Instant startedAt; + private AttemptHandle(EgressContext context, Instant expiresAt) { this.context = context; this.expiresAt = expiresAt; } + /** Immediately before send, after signing. One permit can never send twice. */ + public void startHttp() { + check(context); + if (actualHttpAttempts != 0 || !clock.instant().isBefore(expiresAt)) throw failure("ATTEMPT_NOT_SENDABLE"); + actualHttpAttempts = 1; lastStart = clock.instant(); startedAt = lastStart; + } + public void rateLimited(Instant retryAfter) { + Instant fallback = clock.instant().plus(properties.cooldownFallback()); + cooldownUntil = retryAfter != null && retryAfter.isAfter(fallback) ? retryAfter : fallback; + recoverableRatePause = properties.collectionMode() == CollectionMode.BUSINESS; + paused = true; + } + public AttemptMetadata metadata() { return new AttemptMetadata(context.attemptId(), actualHttpAttempts, paused, cooldownUntil); } + } + @FunctionalInterface interface Waiter { void sleep(long millis) throws InterruptedException; } + @FunctionalInterface public interface AuthorizedCall { T run(AttemptHandle handle); } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingHttpBudgetAuthority.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingHttpBudgetAuthority.java new file mode 100644 index 000000000..37e85301d --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingHttpBudgetAuthority.java @@ -0,0 +1,73 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingEgressProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import java.net.http.*; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +/** Fixed server-configured authority only. No retries, redirects or user-selected callback URLs. */ +@Component +public final class LingxingHttpBudgetAuthority implements LingxingBudgetAuthority { + private static final String PREFIX = "/internal/lastleg/self-operated-analytics/egress/"; + private final LingxingEgressProperties properties; + private final ObjectMapper mapper; + private final String internalToken; + private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)) + .followRedirects(HttpClient.Redirect.NEVER).build(); + public LingxingHttpBudgetAuthority(LingxingEgressProperties properties, ObjectMapper mapper, + @Value("${baiyue.security.internal-service-token:}") String internalToken) { + this.properties = properties; this.mapper = mapper; this.internalToken = internalToken; + } + @Override public AttemptPermit authorize(AuthorizationRequest request) { + JsonNode data = post("authorize", request); + if (!data.isObject() || !data.path("allowedAttempts").isIntegralNumber() + || data.path("allowedAttempts").intValue() != 1 || !data.path("exclusiveConfirmed").isBoolean() + || !data.path("exclusiveConfirmed").booleanValue() || !data.path("budgetConsumed").isBoolean() + || !data.path("budgetConsumed").booleanValue() || !data.path("context").isObject() + || !data.path("expiresAt").isTextual()) throw unavailable(); + try { return mapper.readerFor(AttemptPermit.class).with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(data.traverse(mapper)); } + catch (Exception ignored) { throw unavailable(); } + } + @Override public void complete(CompletionRequest request) { + JsonNode data = post("complete", request); + if (!data.isBoolean() || !data.booleanValue()) throw unavailable(); + } + private JsonNode post(String suffix, Object payload) { + if (properties.budgetAuthorityBaseUri() == null || internalToken == null || internalToken.isBlank() + || internalToken.getBytes(StandardCharsets.UTF_8).length < 32) throw unavailable(); + try { + HttpRequest request = HttpRequest.newBuilder(properties.budgetAuthorityBaseUri().resolve(PREFIX + suffix)) + .timeout(Duration.ofSeconds(5)).header("X-Internal-Service-Token", internalToken) + .header("Content-Type", "application/json; charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(payload), StandardCharsets.UTF_8)).build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() != 200) throw unavailable(); + JsonNode root = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(response.body()); + if (root == null || !root.isObject() || !root.path("code").isTextual() + || !root.hasNonNull("data")) throw unavailable(); + if ("authorize".equals(suffix) && "B0001".equals(root.path("code").textValue())) { + JsonNode data = root.get("data"); + if (!data.isObject() || !data.path("errorCode").isTextual() + || !data.path("errorCode").textValue().matches("[A-Z][A-Z0-9_]{0,63}") + || !data.path("sampleDone").isBoolean() || !data.has("waitUntil")) throw unavailable(); + AuthorizationDenial denial = mapper.readerFor(AuthorizationDenial.class) + .with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(data.traverse(mapper)); + throw new LingxingBudgetDeniedException(denial); + } + if (!"200".equals(root.path("code").textValue())) throw unavailable(); + return root.get("data"); + } catch (LingxingBudgetDeniedException knownDecision) { throw knownDecision; + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); throw unavailable(); + } catch (Exception ignored) { throw unavailable(); } + } + private static LingxingWmsException unavailable() { + return new LingxingWmsException("LINGXING_WMS_BUDGET_AUTHORITY_UNAVAILABLE", "领星持久预算授权不可用,出口已拒绝", false, null, null); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityCapture.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityCapture.java new file mode 100644 index 000000000..976cc06df --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityCapture.java @@ -0,0 +1,122 @@ +package com.baiyue.integration.provider.lingxing; + +import static com.baiyue.integration.provider.lingxing.LingxingQuantityEvidence.*; +import com.baiyue.integration.config.LingxingQuantityCaptureProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.EgressContext; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.Operation; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PreDestroy; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantLock; + +/** Passive whitelist snapshots only. No JSON serialization, HTTP or waiting locks on egress threads. */ +@Component +public final class LingxingQuantityCapture { + private static final int MAX_BYTES=2097152,MAX_ROWS=2000,MAX_QUEUE=100; + private final LingxingQuantityCaptureProperties config;private final Sink sink;private final Clock clock; + private final String producer=UUID.randomUUID().toString(); + private final ScheduledExecutorService worker; + private final ReentrantLock intake=new ReentrantLock(); + private final ArrayDeque queue=new ArrayDeque<>(); + private final Set selected=new HashSet<>(),details=new HashSet<>(); + private final AtomicReference failure=new AtomicReference<>(); + private String observedRoot; + private volatile View grant;private volatile boolean closed;private int bytes,rows;private boolean attached; + @Autowired public LingxingQuantityCapture(LingxingQuantityCaptureProperties config,ObjectMapper mapper,ObjectProvider sink){this(config,mapper,sink.getIfAvailable(),Clock.systemUTC());} + LingxingQuantityCapture(LingxingQuantityCaptureProperties config,ObjectMapper mapper,Sink sink,Clock clock){this(config,sink,clock,true);} + LingxingQuantityCapture(LingxingQuantityCaptureProperties config,Sink sink,Clock clock,boolean start){ + this.config=config;this.sink=sink;this.clock=clock; + worker=Executors.newSingleThreadScheduledExecutor(r->{Thread t=new Thread(r,"lingxing-quantity-capture");t.setDaemon(true);return t;}); + if(start&&config.enabled()&&sink!=null)worker.scheduleWithFixedDelay(this::pump,0,2,TimeUnit.SECONDS); + } + public void observe(JsonNode data,Operation operation,EgressContext context,Instant observedAt){ + View current=grant; + if(closed||!config.enabled()||failure.get()!=null||current==null||"TERMINAL".equals(current.state())||"DRAINING".equals(current.state())||!clock.instant().isBefore(current.expiresAt()))return; + if(context==null||!context.wellFormed()||!"BUSINESS".equals(String.valueOf(context.executionPurpose()))||!Objects.equals(config.sourceAccount(),context.sourceAccount()))return; + if(operation!=Operation.OUTBOUND_QUERY&&operation!=Operation.OUTBOUND_DETAIL)return; + if(current.rootTaskId()!=null&&!current.rootTaskId().equals(context.rootTaskId())||Objects.equals(current.excludedRootTaskId(),context.rootTaskId()))return; + if(!intake.tryLock()){failure.compareAndSet(null,"OBSERVER_FAILED");return;} + try{ + if(current!=grant){failure.compareAndSet(null,"OBSERVER_FAILED");return;} + if(operation==Operation.OUTBOUND_DETAIL)snapshot(data,true,context,observedAt,current); + else {JsonNode records=data.path("records");if(!records.isArray()||records.size()>100){failure.compareAndSet(null,"OBSERVER_FAILED");return;} + for(JsonNode row:records){if(failure.get()!=null)break;snapshot(row,false,context,observedAt,current);}} + }catch(RuntimeException bad){failure.compareAndSet(null,"OBSERVER_FAILED");}finally{intake.unlock();} + } + private void snapshot(JsonNode data,boolean detail,EgressContext c,Instant time,View g){ + String warehouse=text(data,"whCode",64),owner=text(data,"customerCode",128),order=text(data,"deliveryNo",128); + if(!g.warehouseCodes().contains(warehouse)||!("*".equals(g.ownerCode())||g.ownerCode().equals(owner)))return; + if(observedRoot!=null&&!observedRoot.equals(c.rootTaskId()))return; + String key=key(c.rootTaskId(),warehouse,owner,order); + if(detail&&(!selected.contains(key)||details.contains(key)))return; + if(!detail&&(selected.contains(key)||selected.size()>=100))return; + JsonNode items=data.path(detail?"skuVOList":"productList");boolean complete=items.isArray()&&!items.isEmpty(); + if(items.size()>200){failure.compareAndSet(null,"OBSERVER_FAILED");return;} + // Conservative UTF-8 bound: identifiers <=128 chars, quantity <=38 digits, bounded metadata. + int retained=4096+items.size()*1024; + if(queue.size()>=MAX_QUEUE||rows+items.size()>MAX_ROWS||bytes+retained>MAX_BYTES){failure.compareAndSet(null,"QUEUE_FULL");return;} + var quantities=new TreeMap(); + if(items.isArray())for(JsonNode item:items){String sku=text(item,"productSku",128); + Sku value=new Sku(sku,detail?null:quantity(item.get("qty")),detail?quantity(item.get("deliveryQty")):null,detail?quantity(item.get("actualDeliveryQty")):null); + quantities.merge(sku,value,(a,b)->new Sku(sku,sum(a.listQty(),b.listQty()),sum(a.forecastQty(),b.forecastQty()),sum(a.actualQty(),b.actualQty()))); + } + String status=data.path("status").isIntegralNumber()?data.path("status").asText():null; + if(status!=null&&status.length()>32)throw new IllegalArgumentException(); + Event event=new Event(g.diagnosticSessionId(),c.sourceAccount(),producer,c.rootTaskId(),c.sessionId(),detail?"OUTBOUND_DETAIL":"OUTBOUND_QUERY",warehouse,owner,order,detail?"DETAIL":"LIST",new Observation(status,optional(data,"outboundTime",64),time,c.attemptId(),complete),List.copyOf(quantities.values())); + queue.addLast(new Queued(event,retained,items.size()));bytes+=retained;rows+=items.size(); + if(detail)details.add(key);else {selected.add(key);observedRoot=c.rootTaskId();} + } + /** Single worker owns HTTP; status(drained) occurs only after previously queued observations finish. */ + void pump(){ + if(closed||!config.enabled()||sink==null)return; + try{ + if(grant==null||"TERMINAL".equals(grant.state())){ + View next=sink.status(new Status(config.sourceAccount(),producer,!attached,false));attached=true; + if(next==null)return; + if(grant==null||!grant.diagnosticSessionId().equals(next.diagnosticSessionId()))reset(next);else grant=next; + } + if(grant==null||"TERMINAL".equals(grant.state()))return; + if(failure.get()!=null){grant=sink.stop(new Stop(grant.diagnosticSessionId(),config.sourceAccount(),producer,failure.get()));clearQueue();return;} + while(true){ + if(failure.get()!=null)break; + Queued next=poll();if(next==null)break; + try{ + if(!clock.instant().isBefore(grant.expiresAt())){failure.compareAndSet(null,"INCOMPLETE");break;} + Receipt receipt=sink.observe(next.event());if(receipt==null||receipt.session()==null)throw new IllegalStateException(); + grant=receipt.session(); + if("TERMINAL".equals(grant.state())){clearQueue();break;} + }finally{release(next);} + } + if(failure.get()!=null&&!"TERMINAL".equals(grant.state())){grant=sink.stop(new Stop(grant.diagnosticSessionId(),config.sourceAccount(),producer,failure.get()));clearQueue();return;} + if(!"TERMINAL".equals(grant.state())){ + View next=sink.status(new Status(config.sourceAccount(),producer,false,false));if(next==null)throw new IllegalStateException();grant=next; + if("TERMINAL".equals(next.state()))clearQueue(); + // After DRAINING is visible to intake, a later pump drains its tail before acknowledging. + if("DRAINING".equals(next.state())&&empty())grant=sink.status(new Status(config.sourceAccount(),producer,false,true)); + } + }catch(Exception failed){failure.compareAndSet(null,"INCOMPLETE");} + } + private Queued poll(){intake.lock();try{return queue.pollFirst();}finally{intake.unlock();}} + private void release(Queued q){intake.lock();try{bytes-=q.bytes();rows-=q.rows();}finally{intake.unlock();}} + private boolean empty(){intake.lock();try{return queue.isEmpty();}finally{intake.unlock();}} + private void clearQueue(){intake.lock();try{for(Queued q:queue){bytes-=q.bytes();rows-=q.rows();}queue.clear();}finally{intake.unlock();}} + private void reset(View v){intake.lock();try{queue.clear();bytes=0;rows=0;selected.clear();details.clear();failure.set(null);observedRoot=v.rootTaskId(); + for(Key k:v.selected()){String key=key(v.rootTaskId(),k.warehouseCode(),k.ownerCode(),k.orderNo());selected.add(key);if(k.paired())details.add(key);}grant=v; + }finally{intake.unlock();}} + private static String key(String root,String warehouse,String owner,String order){return root+"\u001f"+warehouse+"\u001f"+owner+"\u001f"+order;} + public String stopReason(){return failure.get()!=null?failure.get():grant==null?null:grant.stopReason();} + @PreDestroy public void close(){closed=true;worker.shutdownNow();clearQueue();} + private static String text(JsonNode n,String field,int max){String s=optional(n,field,max);if(s==null||s.isBlank())throw new IllegalArgumentException();return s;} + private static String optional(JsonNode n,String field,int max){JsonNode v=n.get(field);if(v==null||v.isNull())return null;if(!v.isTextual()||v.textValue().length()>max||v.textValue().codePoints().anyMatch(Character::isISOControl))throw new IllegalArgumentException();return v.textValue();} + private static BigDecimal quantity(JsonNode n){if(n==null||!n.isNumber())return null;BigDecimal value=n.decimalValue();return value.signum()<0||value.scale()>6||value.precision()-value.scale()>32?null:value.stripTrailingZeros();} + private static BigDecimal sum(BigDecimal a,BigDecimal b){return a==null||b==null?null:a.add(b);} + private record Queued(Event event,int bytes,int rows){} +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityEvidence.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityEvidence.java new file mode 100644 index 000000000..4b3601e0d --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityEvidence.java @@ -0,0 +1,17 @@ +package com.baiyue.integration.provider.lingxing; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +/** Internal diagnostic whitelist; excludes raw payloads, addresses and supplier credentials. */ +public final class LingxingQuantityEvidence { + private LingxingQuantityEvidence(){} + public record Status(String sourceAccount,String producerId,boolean attach,boolean drained){} + public record Stop(String diagnosticSessionId,String sourceAccount,String producerId,String reason){} + public record Observation(String status,String outboundTime,Instant observedAt,String attemptId,Boolean skuSetComplete){} + public record Sku(String sku,BigDecimal listQty,BigDecimal forecastQty,BigDecimal actualQty){} + public record Event(String diagnosticSessionId,String sourceAccount,String producerId,String rootTaskId,String collectionSessionId,String operation,String warehouseCode,String ownerCode,String orderNo,String side,Observation observation,List skus){} + public record Key(String warehouseCode,String ownerCode,String orderNo,boolean paired){} + public record View(String diagnosticSessionId,String sourceAccount,String rootTaskId,String collectionSessionId,String state,String stopReason,Instant startsAt,Instant expiresAt,Instant bindBefore,List warehouseCodes,String ownerCode,String excludedRootTaskId,int candidates,int pairedOrders,int retainedRows,long retainedBytes,boolean deliveryUncertain,List selected){} + public record Receipt(String outcome,String comparison,View session){} + public interface Sink {View status(Status request);Receipt observe(Event event);View stop(Stop request);} +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSink.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSink.java new file mode 100644 index 000000000..897baff0e --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSink.java @@ -0,0 +1,50 @@ +package com.baiyue.integration.provider.lingxing; +import static com.baiyue.integration.provider.lingxing.LingxingQuantityEvidence.*; +import com.baiyue.integration.config.LingxingEgressProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import java.net.URI; +import java.net.http.*; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +/** Invoked only by the bounded observer worker. Never retries or contacts the supplier. */ +@Component +public final class LingxingQuantityHttpSink implements Sink { + private static final String PATH="/internal/lastleg/self-operated-analytics/quantity-diagnostics/"; + private final URI baseUri;private final ObjectMapper json;private final String token; + private final HttpClient client=HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).followRedirects(HttpClient.Redirect.NEVER).build(); + public LingxingQuantityHttpSink(LingxingEgressProperties config,ObjectMapper json,@Value("${baiyue.security.internal-service-token:}") String token){this.baseUri=config.budgetAuthorityBaseUri();this.json=json.copy().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);this.token=token;} + public View status(Status request){return call("status",request,View.class);} + public Receipt observe(Event event){return call("observe",event,Receipt.class);} + public View stop(Stop request){return call("stop",request,View.class);} + private T call(String path,Object payload,Class type){ + try{ + if(baseUri==null||token==null||token.getBytes(StandardCharsets.UTF_8).length<32)throw unavailable(); + byte[] body=json.writeValueAsBytes(payload);if(body.length>262144)throw unavailable(); + HttpRequest request=HttpRequest.newBuilder(baseUri.resolve(PATH+path)).timeout(Duration.ofSeconds(3)).header("X-Internal-Service-Token",token).header("Content-Type","application/json; charset=UTF-8").POST(HttpRequest.BodyPublishers.ofByteArray(body)).build(); + // Limit while receiving, not after allocating an arbitrary response String. + HttpResponse response=client.send(request,info->new LimitedBody(262144)); + if(response.statusCode()!=200)throw unavailable(); + var root=json.readTree(response.body());if(root==null||!"200".equals(root.path("code").asText()))throw unavailable(); + if(root.path("data").isNull())return null; + return json.treeToValue(root.path("data"),type); + }catch(InterruptedException e){Thread.currentThread().interrupt();throw unavailable();} + catch(Exception e){throw unavailable();} + } + private static IllegalStateException unavailable(){return new IllegalStateException("DIAGNOSTIC_RECEIVER_UNAVAILABLE");} + static final class LimitedBody implements HttpResponse.BodySubscriber { + private final int limit;private final java.io.ByteArrayOutputStream out=new java.io.ByteArrayOutputStream(); + private final java.util.concurrent.CompletableFuture result=new java.util.concurrent.CompletableFuture<>(); + private java.util.concurrent.Flow.Subscription subscription; + LimitedBody(int limit){this.limit=limit;} + public java.util.concurrent.CompletionStage getBody(){return result;} + public void onSubscribe(java.util.concurrent.Flow.Subscription s){subscription=s;s.request(1);} + public void onNext(java.util.List buffers){ + for(var buffer:buffers){if(buffer.remaining()>limit-out.size()){subscription.cancel();result.completeExceptionally(unavailable());return;}byte[] bytes=new byte[buffer.remaining()];buffer.get(bytes);out.writeBytes(bytes);}subscription.request(1); + } + public void onError(Throwable error){result.completeExceptionally(error);} + public void onComplete(){result.complete(out.toByteArray());} + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java new file mode 100644 index 000000000..e04c31ed7 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java @@ -0,0 +1,507 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingWmsProperties; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.OrderSummary; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.*; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*; +import java.util.function.BiFunction; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.math.BigDecimal; +import java.util.HashSet; +import java.util.function.Function; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Component +public final class LingxingWmsClient { + private static final String PAGE_PATH = "/openapi/v2/order/delivery/page"; + private static final Set AUTH_ERROR_CODES = Set.of(11002, 11003, 11004, 11005, 11006, 11007, 11008, 11010, 31001); + private static final DateTimeFormatter SOURCE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private final LingxingWmsProperties properties; + private final ObjectMapper mapper; + private final LingxingWmsSigner signer; + private final HttpClient httpClient; + private final Clock clock; + private final LingxingEgressGate egressGate; + private LingxingQuantityCapture quantityCapture; + + @Autowired + void setQuantityCapture(LingxingQuantityCapture quantityCapture) { + this.quantityCapture = quantityCapture; + } + + @Autowired + public LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, LingxingEgressGate egressGate) { + this(properties, mapper, Clock.systemUTC(), egressGate); + } + + LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, Clock clock, LingxingEgressGate egressGate) { + this.properties = properties; + this.mapper = mapper.copy().disable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS); + this.clock = clock; + this.egressGate = egressGate; + signer = new LingxingWmsSigner(this.mapper); + httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()) + .followRedirects(HttpClient.Redirect.NEVER).build(); + } + + public PageResult queryPage(QueryRequest request, EgressContext context) { + validate(request); + Map data = pagination(request.current(), request.size()); + data.put("timeType", "outboundTime"); + data.put("startTime", SOURCE_TIME.format(request.startTime())); + data.put("endTime", SOURCE_TIME.format(request.endTime())); + data.put("status", 100); + return execute(PAGE_PATH, data, context, Operation.OUTBOUND_QUERY, + (json, metadata) -> orders(json, request.current(), request.size(), null, metadata)); + } + + public PageResult locate(LocateRequest request, EgressContext context) { + if (request == null) throw badRequest(); + requireInput(request.deliveryNo(), 128); + validatePagination(request.current(), request.size(), 1000); + Map data = pagination(request.current(), request.size()); + data.put("deliveryNo", request.deliveryNo()); + return execute(PAGE_PATH, data, context, Operation.OUTBOUND_LOCATE, + (json, metadata) -> orders(json, request.current(), request.size(), request.deliveryNo(), metadata)); + } + + public QueryPage queryWarehouses(WarehouseQueryRequest request, EgressContext context) { + if (request == null) throw badRequest(); + validatePagination(request.current(), request.size(), 1000); + return execute("/openapi/v2/warehouse/page", pagination(request.current(), request.size()), context, Operation.WAREHOUSES, + (data, metadata) -> mappedPage(data, request.current(), request.size(), "page", "pageSize", false, metadata, row -> + new Warehouse(requiredText(row, "whCode", 64), optionalText(row, "whNameCn"), + requiredInt(row.get("warehouseType"), "WAREHOUSE_TYPE"), requiredInt(row.get("status"), "STATUS"), + optionalText(row, "timeZone")))); + } + + public OrderDetail queryDetail(DetailRequest request, EgressContext context) { + if (request == null) throw badRequest(); + requireInput(request.warehouseCode(), 64); + requireInput(request.customerCode(), 64); + requireInput(request.deliveryNo(), 128); + Map body = new LinkedHashMap<>(); + body.put("whCode", request.warehouseCode()); + body.put("customerCode", request.customerCode()); + body.put("deliveryNo", request.deliveryNo()); + return execute("/openapi/v2/order/delivery/detail", body, context, Operation.OUTBOUND_DETAIL, + (data, metadata) -> detail(data, request, metadata)); + } + + private OrderDetail detail(JsonNode data, DetailRequest request, AttemptMetadata metadata) { + String deliveryNo = requiredText(data, "deliveryNo", 128); + String customer = requiredText(data, "customerCode", 64); + String warehouse = requiredText(data, "whCode", 64); + if (!deliveryNo.equals(request.deliveryNo()) || !customer.equals(request.customerCode()) + || !warehouse.equals(request.warehouseCode())) throw invalidResponse("DETAIL_IDENTITY_MISMATCH", 200); + JsonNode rows = data.get("skuVOList"); + if (rows == null || !rows.isArray() || rows.isEmpty()) throw invalidResponse("SKU_LIST_INVALID", 200); + List lines = new ArrayList<>(); + for (JsonNode row : rows) { + if (!row.isObject()) throw invalidResponse("SKU_ROW_INVALID", 200); + lines.add(new SkuLine(requiredText(row, "productSku", 128), optionalText(row, "productName"), + quantity(row.get("actualDeliveryQty")), quantity(row.get("deliveryQty")))); + } + return new OrderDetail(deliveryNo, optionalText(data, "sourceNo"), customer, warehouse, + requiredInt(data.get("status"), "STATUS"), optionalText(data, "orderCreateTime"), + optionalText(data, "outboundTime"), lines, 200, clock.instant(), metadata); + } + + public QueryPage queryCustomers(WarehouseQueryRequest request, EgressContext context) { + if (request == null) throw badRequest(); + validatePagination(request.current(), request.size(), 1000); + return execute("/openapi/v2/customer/list", pagination(request.current(), request.size()), context, Operation.CUSTOMERS, + (data, metadata) -> mappedPage(data, request.current(), request.size(), "page", "pageSize", false, metadata, + row -> new Customer(requiredText(row,"customerCode",64), optionalText(row,"customerName"), optionalText(row,"status")))); + } + + public QueryPage queryProducts(ProductQueryRequest request, EgressContext context) { + if (request == null) throw badRequest(); + validatePagination(request.current(), request.size(), 100); + return execute("/openapi/v2/product/page", pagination(request.current(), request.size()), context, Operation.PRODUCTS, + (data, metadata) -> mappedPage(data, request.current(), request.size(), "page", "pageSize", false, metadata, + row -> new Product(requiredText(row,"customerCode",64), requiredText(row,"sku",128), optionalText(row,"productName"), + quantity(row.get("length")),quantity(row.get("width")),quantity(row.get("height")),optionalText(row,"sizeUnit"), + quantity(row.get("wmsLength")),quantity(row.get("wmsWidth")),quantity(row.get("wmsHeight")),optionalText(row,"wmsSizeUnit")))); + } + + public QueryPage queryInventory(InventoryQueryRequest request, EgressContext context) { + if (request == null) throw badRequest(); + requireInput(request.warehouseCode(), 64); + validatePagination(request.current(), request.size(), 1000); + Map body = pagination(request.current(), request.size()); + body.put("whCode", request.warehouseCode()); + body.put("isHideInventory", 0); + return execute("/openapi/v2/stock/stockSku/page", body, context, Operation.PRODUCT_INVENTORY, + (data, metadata) -> inventory(data, request, metadata)); + } + + /** Single bounded diagnostic page; never changes the current-inventory collection protocol. */ + public QueryPage queryInventoryFlow(InventoryFlowRequest request, EgressContext context) { + if(request==null)throw badRequest(); + requireInput(request.warehouseCode(),64);requireInput(request.customerCode(),64);requireInput(request.sku(),128); + if(request.startTime()==null||request.endTime()==null||!request.startTime().isBefore(request.endTime()) + ||request.endTime().isAfter(request.startTime().plusDays(31)))throw badRequest(); + Map body=pagination(1,1000); + body.put("whCode",request.warehouseCode());body.put("customerCode",request.customerCode()); + body.put("barcodeAccurate",request.sku());body.put("startTime",SOURCE_TIME.format(request.startTime())); + body.put("endTime",SOURCE_TIME.format(request.endTime()));body.put("sortName","createTime");body.put("sortOrder","asc"); + return execute("/openapi/v2/stock/stockCellBook/page",body,context,Operation.INVENTORY_FLOW, + (data,metadata)->mappedPage(data,1,1000,"current","size",true,metadata,row->{ + if(!request.warehouseCode().equals(requiredText(row,"whCode",64)) + ||!request.customerCode().equals(requiredText(row,"customerCode",64)) + ||!request.sku().equals(requiredText(row,"productSku",128)))throw invalidResponse("FLOW_IDENTITY_MISMATCH",200); + String id=requiredText(row,"id",64); + return new InventoryFlow(id,request.customerCode(),request.warehouseCode(),request.sku(), + optionalText(row,"cellNo"),optionalText(row,"innerBatchNo"),optionalText(row,"billNo"),optionalText(row,"billType"), + requiredInt(row.get("productQuality"),"PRODUCT_QUALITY"),quantity(row.get("bookQty")), + optionalText(row,"bookChangQty"),quantity(row.get("closeQty")),optionalText(row,"createTime"),optionalText(row,"sourceNo")); + })); + } + + /** One fixed warehouse interval page. Scheduling and coverage belong to Lastleg. */ + public QueryPage queryInventoryFlowPage(InventoryFlowPageRequest request,EgressContext context) { + if(request==null) throw badRequest(); + requireInput(request.warehouseCode(),64);validatePagination(request.current(),request.size(),1000); + if(request.startTime()==null || request.endTime()==null || !request.startTime().isBefore(request.endTime()) + || request.endTime().isAfter(request.startTime().plusDays(1))) throw badRequest(); + Map body=pagination(request.current(),request.size()); + body.put("whCode",request.warehouseCode());body.put("startTime",SOURCE_TIME.format(request.startTime())); + body.put("endTime",SOURCE_TIME.format(request.endTime()));body.put("sortName","createTime");body.put("sortOrder","asc"); + return execute("/openapi/v2/stock/stockCellBook/page",body,context,Operation.INVENTORY_FLOW, + (data,metadata)->mappedPage(data,request.current(),request.size(),"current","size",true,metadata,row->{ + String warehouse=requiredText(row,"whCode",64); + if(!request.warehouseCode().equals(warehouse)) throw invalidResponse("FLOW_IDENTITY_MISMATCH",200); + return new InventoryFlow(requiredText(row,"id",64),requiredText(row,"customerCode",64),warehouse, + requiredText(row,"productSku",128),optionalText(row,"cellNo"),optionalText(row,"innerBatchNo"), + optionalText(row,"billNo"),optionalText(row,"billType"),requiredInt(row.get("productQuality"),"PRODUCT_QUALITY"), + quantity(row.get("bookQty")),optionalText(row,"bookChangQty"),quantity(row.get("closeQty")),optionalText(row,"createTime"),optionalText(row,"sourceNo")); + })); + } + + private QueryPage inventory(JsonNode data, InventoryQueryRequest request, AttemptMetadata metadata) { + Set> keys = new HashSet<>(); + return mappedPage(data, request.current(), request.size(), "current", "size", false, metadata, row -> { + String customer = requiredText(row, "customerCode", 64); + String warehouse = requiredText(row, "whCode", 64); + String sku = requiredText(row, "productSku", 128); + int quality = requiredInt(row.get("productQuality"), "PRODUCT_QUALITY"); + if (!warehouse.equals(request.warehouseCode())) throw invalidResponse("INVENTORY_WAREHOUSE_MISMATCH", 200); + if (!keys.add(List.of(customer, warehouse, sku, quality))) throw invalidResponse("DUPLICATE_INVENTORY_KEY", 200); + return new ProductStock(customer, warehouse, sku, optionalText(row, "productName"), quality, + quantity(row.get("stkQty")), quantity(row.get("freezeQty")), quantity(row.get("useStkQty")), + quantity(row.get("transitQty")), optionalText(row, "innerBatchNo"), optionalText(row, "outBatchNo"), + optionalText(row, "uniqueKey4tenant")); + }); + } + + private T execute(String path, Map data, EgressContext context, Operation operation, + BiFunction projection) { + if (!properties.enabled()) throw failure("DISABLED", "领星 WMS 接入未启用", false); + if (!properties.hasCredentials()) throw failure("MISSING_CREDENTIALS", "领星 WMS 授权配置不完整", false); + if (context != null && context.executionPurpose() == CollectionMode.VALIDATION + && data.containsKey("size") && (int)data.get("size") > 10) throw badRequest(); + String hash; + try { + hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest( + (path + "\n" + mapper.writer().without(SerializationFeature.INDENT_OUTPUT, + SerializationFeature.WRAP_ROOT_VALUE).writeValueAsString(data)).getBytes(StandardCharsets.UTF_8))); + } catch (Exception ignored) { throw failure("REQUEST_HASH_FAILED", "领星请求授权摘要生成失败", false); } + return egressGate.execute(context, operation, hash, handle -> { + var signed = signer.sign(properties.appKey(), properties.appSecret(), path, data, clock.instant()); + HttpRequest httpRequest = HttpRequest.newBuilder(properties.baseUri().resolve(path)) + .timeout(properties.requestTimeout()).header("Accept", "application/json") + .header("Content-Type", "application/json; charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(signed.body(), StandardCharsets.UTF_8)).build(); + handle.startHttp(); + HttpResponse response = send(httpRequest); + if (response.statusCode() == 429) handle.rateLimited(retryAfter(response)); + validateStatus(response.statusCode()); + JsonNode json; + try { json = envelope(response.body()); } + catch (LingxingWmsException error) { + // This optional endpoint may lack permission while ordinary collection remains authorized. + if(operation==Operation.INVENTORY_FLOW&&Integer.valueOf(11008).equals(error.sourceCode())) + throw upstreamFailure("FLOW_PERMISSION_DENIED","当前账号没有库存流水接口权限",false,200,11008); + if (Integer.valueOf(31001).equals(error.sourceCode()) || Integer.valueOf(429).equals(error.sourceCode())) { + handle.rateLimited(retryAfter(response)); + } + throw error; + } + T result = projection.apply(json, handle.metadata()); + if (quantityCapture != null && (operation == Operation.OUTBOUND_QUERY + || operation == Operation.OUTBOUND_DETAIL)) { + // Diagnostics must never alter the normal projection or the egress completion result. + try { quantityCapture.observe(json, operation, context, clock.instant()); } + catch (RuntimeException ignored) { /* Passive diagnostic failure is isolated. */ } + } + return result; + }); + } + + private Instant retryAfter(HttpResponse response) { + String value = response.headers().firstValue("Retry-After").orElse(null); + if (value == null || value.length() > 128) return null; + try { + if (value.matches("[0-9]{1,9}")) return clock.instant().plusSeconds(Long.parseLong(value)); + Instant time = ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant(); + return time.isAfter(clock.instant()) ? time : null; + } catch (RuntimeException ignored) { return null; } + } + + private static Map pagination(int current, int size) { + Map data = new LinkedHashMap<>(); + data.put("current", current); + data.put("size", size); + return data; + } + + private static void validatePagination(int current, int size, int maximum) { + if (current < 1 || size < 1 || size > maximum) throw badRequest(); + } + + private static void requireInput(String value, int maximum) { + if (value == null || value.isBlank() || value.length() > maximum) throw badRequest(); + } + + private static LingxingWmsException badRequest() { + return failure("INVALID_REQUEST", "领星 WMS 查询参数不符合内部契约", false); + } + + private void validate(QueryRequest request) { + if (request == null || request.startTime() == null || request.endTime() == null + || !request.startTime().truncatedTo(ChronoUnit.SECONDS).isBefore(request.endTime().truncatedTo(ChronoUnit.SECONDS)) + || request.startTime().isBefore(request.endTime().minusMonths(1)) + || request.current() < 1 || request.size() < 1 || request.size() > 100) { + // These are local request bounds, not a claim about the supplier's pagination limits. + throw failure("INVALID_REQUEST", "领星 WMS 查询范围最多一个自然月,每页条数须为 1 至 100", false); + } + } + + + private HttpResponse send(HttpRequest request) { + try { + return httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } catch (HttpTimeoutException exception) { + throw failure("TIMEOUT", "领星 WMS 请求超时", true); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw failure("INTERRUPTED", "领星 WMS 请求被中断", true); + } catch (IOException exception) { + throw failure("IO_FAILED", "领星 WMS 网络请求失败", true); + } + } + + private void validateStatus(int status) { + if (status == 429) throw upstreamFailure("HTTP_RATE_LIMITED", "领星 WMS 请求受到限流,账号已暂停", false, status, null); + if (status == 401 || status == 403) { + throw upstreamFailure("AUTHORIZATION_REJECTED", "领星 WMS 拒绝授权,请核对凭据与账号权限", false, status, null); + } + if (status != 200) { + throw upstreamFailure("HTTP_FAILED", "领星 WMS 返回异常 HTTP 状态", status >= 500, status, null); + } + } + + private JsonNode envelope(String body) { + JsonNode root; + try { + root = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS, + DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS).readTree(body); + } catch (JsonProcessingException exception) { + throw invalidResponse("JSON", null); + } + if (root == null || !root.isObject()) throw invalidResponse("ROOT_TYPE_" + nodeType(root), null); + JsonNode sourceCode = root.get("code"); + // Official OMP ErrorResponse is distinct from its integer-code business response. + if (sourceCode != null && sourceCode.isTextual()) { + Integer errorCode = diagnosticCode(sourceCode); + if (errorCode != null && AUTH_ERROR_CODES.contains(errorCode)) throw sourceRejected(errorCode); + throw invalidResponse("CODE_TYPE_STRING", errorCode); + } + if (sourceCode == null || !sourceCode.isIntegralNumber() || !sourceCode.canConvertToInt()) { + throw invalidResponse("CODE_TYPE_" + nodeType(sourceCode), diagnosticCode(sourceCode)); + } + if (sourceCode.intValue() != 200) { + throw sourceRejected(sourceCode.intValue()); + } + JsonNode success = root.get("success"); + // OMP marks success required, but the verified WMS response omits it; validate it when present. + if (success != null) { + if (!success.isBoolean()) throw invalidResponse("SUCCESS_TYPE_" + nodeType(success), 200); + if (!success.booleanValue()) throw sourceRejected(200); + } + JsonNode data = root.get("data"); + if (data == null || !data.isObject()) throw invalidResponse("DATA_TYPE_" + nodeType(data), 200); + return data; + } + + private PageResult orders(JsonNode data, int current, int size, String exactDeliveryNo, AttemptMetadata metadata) { + QueryPage page = mappedPage(data, current, size, "current", "size", size == 100, metadata, record -> { + String deliveryNo = requiredText(record, "deliveryNo", 128); + if (exactDeliveryNo != null && !exactDeliveryNo.equals(deliveryNo)) throw invalidResponse("LOCATE_DELIVERY_NO_MISMATCH", 200); + int status = requiredInt(record.get("status"), "STATUS"); + if (exactDeliveryNo == null && status != 100) throw invalidResponse("DISCOVERY_STATUS_MISMATCH", 200); + return new OrderSummary(deliveryNo, optionalText(record, "sourceNo"), requiredText(record, "customerCode", 64), + requiredText(record, "whCode", 64), status, optionalText(record, "createTime"), optionalText(record, "outboundTime"), listSkus(record.get("productList"))); + }); + return new PageResult(page.records(), page.total(), current, size, page.pages(), page.sourceCurrent(), + page.sourceSize(), 200, page.observedAt(), metadata); + } + + private List listSkus(JsonNode products) { + if (products == null || products.isNull()) return null; + if (!products.isArray()) throw invalidResponse("PRODUCT_LIST_TYPE_INVALID", 200); + List items = new ArrayList<>(); + for (JsonNode item : products) { + if (!item.isObject()) throw invalidResponse("PRODUCT_LIST_ROW_INVALID", 200); + items.add(new ListSku(optionalText(item, "productSku"), optionalText(item, "productName"), quantity(item.get("qty")))); + } + return List.copyOf(items); + } + + private QueryPage mappedPage(JsonNode data, int current, int size, String currentField, + String sizeField, boolean allowEmptyDefaultSize, AttemptMetadata metadata, Function projection) { + JsonNode records = data.get("records"); + if (records == null || !records.isArray()) throw invalidResponse("RECORDS_TYPE_" + nodeType(records), 200); + if (records.size() > size) throw invalidResponse("RECORDS_OVER_PAGE_SIZE", 200); + Long total = optionalTotal(data.get("total")); + if (total == null) throw invalidResponse("TOTAL_MISSING", 200); + int sourceCurrent = requiredInt(data.get(currentField), "CURRENT"); + int sourceSize = requiredInt(data.get(sizeField), "SIZE"); + int pages = requiredInt(data.get("pages"), "PAGES"); + if (sourceCurrent != current) throw invalidResponse("CURRENT_MISMATCH", 200); + // Verified for outbound size 100 and the bounded inventory-flow query only. + boolean emptyException = allowEmptyDefaultSize && current == 1 && sourceCurrent == 1 + && sourceSize == 10 && total == 0 && pages == 0 && records.isEmpty(); + if (sourceSize != size && !emptyException) throw invalidResponse("SIZE_MISMATCH", 200); + long expectedPages = total / size + (total % size == 0 ? 0 : 1); + if (pages < 0 || pages != expectedPages || (total == 0 && current != 1) + || (total > 0 && current > pages)) throw invalidResponse("PAGES_MISMATCH", 200); + long expectedRows = total == 0 ? 0 : Math.min(size, total - (long)(current - 1) * size); + if (records.size() != expectedRows) throw invalidResponse("PAGE_ROW_COUNT_MISMATCH", 200); + List summaries = new ArrayList<>(); + for (JsonNode record : records) { + if (!record.isObject()) throw invalidResponse("ROW_TYPE_" + nodeType(record), 200); + summaries.add(projection.apply(record)); + } + return new QueryPage<>(summaries, total, current, size, pages, sourceCurrent, sourceSize, 200, clock.instant(), metadata); + } + + private static String requiredText(JsonNode row, String field, int maximum) { + String text = optionalText(row, field); + if (text == null || text.isBlank() || text.length() > maximum) throw invalidResponse("IDENTITY_INVALID", 200); + return text; + } + + private static int requiredInt(JsonNode value, String stage) { + if (value == null || !value.isIntegralNumber() || !value.canConvertToInt()) throw invalidResponse(stage + "_TYPE_" + nodeType(value), 200); + return value.intValue(); + } + + private static BigDecimal quantity(JsonNode value) { + if (missingNumber(value)) return null; + if (value.isNumber()) return value.decimalValue(); + // Nonempty malformed quantities are a contract failure, never invented zero. + throw invalidResponse("QUANTITY_TYPE_" + nodeType(value), 200); + } + + private static String optionalText(JsonNode record, String field) { + JsonNode value = record.get(field); + if (value == null || value.isNull()) return null; + if (!value.isTextual()) { + String stage = switch (field) { + case "deliveryNo" -> "DELIVERY_NO"; + case "sourceNo" -> "SOURCE_NO"; + case "customerCode" -> "CUSTOMER_CODE"; + case "whCode" -> "WAREHOUSE_CODE"; + case "createTime" -> "CREATE_TIME"; + case "outboundTime" -> "OUTBOUND_TIME"; + default -> "TEXT_FIELD"; + }; + throw invalidResponse(stage + "_TYPE_" + nodeType(value), 200); + } + return value.textValue(); + } + + private static Long optionalTotal(JsonNode value) { + if (missingNumber(value)) return null; + if (!value.isIntegralNumber() || !value.canConvertToLong()) { + throw invalidResponse("TOTAL_TYPE_" + nodeType(value), 200); + } + if (value.longValue() < 0) throw invalidResponse("TOTAL_NEGATIVE", 200); + return value.longValue(); + } + + private static Integer diagnosticCode(JsonNode value) { + if (value == null) return null; + if (value.isIntegralNumber() && value.canConvertToInt()) return value.intValue(); + // Only the documented authentication error envelope accepts string error codes. + if (!value.isTextual() || !value.textValue().matches("[0-9]{1,10}")) return null; + try { + return Integer.valueOf(value.textValue()); + } catch (NumberFormatException exception) { + return null; + } + } + + private static String nodeType(JsonNode value) { + return value == null ? "MISSING" : value.getNodeType().name(); + } + + private static boolean missingNumber(JsonNode value) { + // OMP explicitly serializes null numeric fields as an empty string or null. + return value == null || value.isNull() || (value.isTextual() && value.textValue().isEmpty()); + } + + private static LingxingWmsException sourceRejected(int code) { + if (code == 31001 || code == 429) return upstreamFailure("SOURCE_RATE_LIMITED", "领星 WMS 来源限流,账号已暂停", false, 200, code); + if (code == 11008) { + return upstreamFailure("AUTHORIZATION_REJECTED", + "领星 WMS 未授予查询接口权限,请核对管理后台自研系统授权", false, 200, code); + } + if (code == 11006) { + return upstreamFailure("SIGNATURE_REJECTED", "领星 WMS 请求验签未通过", false, 200, code); + } + return upstreamFailure("SOURCE_REJECTED", "领星 WMS 业务请求失败,请核对来源错误码", false, 200, code); + } + + private static LingxingWmsException invalidResponse(String stage, Integer sourceCode) { + return upstreamFailure("INVALID_RESPONSE", "领星 WMS 返回内容不符合接口契约(" + stage + ")", + false, 200, sourceCode); + } + + private static LingxingWmsException failure(String code, String message, boolean retryable) { + return upstreamFailure(code, message, retryable, null, null); + } + + private static LingxingWmsException upstreamFailure(String code, String message, boolean retryable, + Integer status, Integer sourceCode) { + return new LingxingWmsException("LINGXING_WMS_" + code, message, retryable, status, sourceCode); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsException.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsException.java new file mode 100644 index 000000000..0131730b1 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsException.java @@ -0,0 +1,32 @@ +package com.baiyue.integration.provider.lingxing; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AttemptMetadata; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AuthorizationDenial; + +/** Only fixed messages and numeric upstream metadata may leave the adapter. */ +public final class LingxingWmsException extends RuntimeException { + private final String code; + private final boolean retryable; + private final Integer upstreamStatus; + private final Integer sourceCode; + private AttemptMetadata egress; + private AuthorizationDenial denial; + + public LingxingWmsException(String code, String message, boolean retryable, + Integer upstreamStatus, Integer sourceCode) { + // Upstream exceptions can contain signed URLs or response fragments; never attach them. + super(message); + this.code = code; + this.retryable = retryable; + this.upstreamStatus = upstreamStatus; + this.sourceCode = sourceCode; + } + + public String code() { return code; } + public boolean retryable() { return retryable; } + public Integer upstreamStatus() { return upstreamStatus; } + public Integer sourceCode() { return sourceCode; } + public AttemptMetadata egress() { return egress; } + public LingxingWmsException withEgress(AttemptMetadata metadata) { this.egress = metadata; return this; } + public AuthorizationDenial denial() { return denial; } + public LingxingWmsException withDenial(AuthorizationDenial metadata) { this.denial = metadata; return this; } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsSigner.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsSigner.java new file mode 100644 index 000000000..7293fdf1e --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsSigner.java @@ -0,0 +1,74 @@ +package com.baiyue.integration.provider.lingxing; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.time.Instant; +import java.util.HexFormat; +import java.util.Map; +import java.util.StringJoiner; + +/** OMP service-provider signature; the data object keeps its transmitted insertion order. */ +public final class LingxingWmsSigner { + private final ObjectMapper mapper; + private final ObjectWriter writer; + + public LingxingWmsSigner(ObjectMapper mapper) { + this.mapper = mapper; + writer = mapper.writer().without(SerializationFeature.INDENT_OUTPUT, SerializationFeature.WRAP_ROOT_VALUE); + } + + public SignedRequest sign(String appKey, String appSecret, String path, Map data, Instant requestedAt) { + if (appKey == null || appKey.isBlank() || appSecret == null || appSecret.isBlank() + || path == null || !path.startsWith("/openapi/v2/") || path.contains("?") + || data == null || requestedAt == null) throw failure(); + try { + JsonNode dataNode = mapper.valueToTree(data); + String timestamp = Long.toString(requestedAt.getEpochSecond()); + // Official OMP sample uses Map/List string form, not JSON, and includes parameter names. + String input = appSecret + path + "appKey" + appKey + "data" + parameterValue(dataNode) + + "timestamp" + timestamp + appSecret; + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(appSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + String signature = HexFormat.of().formatHex(mac.doFinal(input.getBytes(StandardCharsets.UTF_8))); + ObjectNode body = mapper.createObjectNode(); + body.put("appKey", appKey); + body.set("data", dataNode); + body.put("timestamp", timestamp); + body.put("sign", signature); + return new SignedRequest(writer.writeValueAsString(body)); + } catch (JsonProcessingException | GeneralSecurityException | IllegalArgumentException exception) { + throw failure(); + } + } + + private static String parameterValue(JsonNode value) { + if (value.isObject()) { + StringJoiner rendered = new StringJoiner(", ", "{", "}"); + value.fields().forEachRemaining(field -> rendered.add(field.getKey() + "=" + parameterValue(field.getValue()))); + return rendered.toString(); + } + if (value.isArray()) { + StringJoiner rendered = new StringJoiner(", ", "[", "]"); + value.forEach(child -> rendered.add(parameterValue(child))); + return rendered.toString(); + } + return value.isNull() ? "null" : value.asText(); + } + + private static LingxingWmsException failure() { + return new LingxingWmsException("LINGXING_WMS_SIGNATURE_ERROR", "领星 WMS 请求签名失败", false, null, null); + } + + public record SignedRequest(String body) { + @Override public String toString() { return "SignedRequest[REDACTED]"; } + } +} diff --git a/integration-service/src/main/resources/application-lingxing-local.yml b/integration-service/src/main/resources/application-lingxing-local.yml new file mode 100644 index 000000000..d216886d9 --- /dev/null +++ b/integration-service/src/main/resources/application-lingxing-local.yml @@ -0,0 +1,77 @@ +# Select explicitly with --spring.config.location=classpath:/application-lingxing-local.yml. +# No remote imports, database, registry or scheduled jobs are used by this local instance. +server: + address: 127.0.0.1 + port: ${INTEGRATION_LOCAL_PORT:19210} + +spring: + application: + name: integration-service + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration + - com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration + cloud: + bootstrap: + enabled: false + discovery: + enabled: false + service-registry: + auto-registration: + enabled: false + nacos: + config: + enabled: false + import-check: + enabled: false + discovery: + enabled: false + register-enabled: false + +baiyue: + runtime: + scheduling-enabled: false + security: + internal-service-token: ${INTERNAL_SERVICE_TOKEN:} + integration: + lingxing-wms: + enabled: ${LINGXING_WMS_ENABLED:false} + base-uri: https://api.xlwms.com + app-key: ${LINGXING_OMS_APP_KEY:} + app-secret: ${LINGXING_OMS_APP_SECRET:} + connect-timeout: 5s + request-timeout: 15s + egress: + collection-mode: ${LINGXING_COLLECTION_MODE:OFF} + environment-label: ${LINGXING_ENVIRONMENT_LABEL:test} + source-account: ${LINGXING_SOURCE_ACCOUNT:lingxing-primary} + external-exclusive-grant-id: ${LINGXING_EXTERNAL_EXCLUSIVE_GRANT_ID:} + budget-authority-base-uri: ${LINGXING_BUDGET_AUTHORITY_BASE_URI:http://lastleg-service:9303} + min-start-interval: 5s + cooldown-fallback: 5m + # Explicitly inject external-exclusive-until (ISO Instant) and budget-authority-base-uri. + # Missing proof/authority fails closed even when the old enabled switch is true. + baidu-ocr: + enabled: false + amazon-sp-api: + enabled: false + carrier-schedule: + matson: + enabled: false + hede: + enabled: false + sml: + enabled: false + zim: + enabled: false + evergreen: + enabled: false + +management: + endpoints: + web: + exposure: + include: health + endpoint: + health: + show-details: never diff --git a/integration-service/src/main/resources/application.yml b/integration-service/src/main/resources/application.yml index 7916af7b8..51fa8a5d3 100644 --- a/integration-service/src/main/resources/application.yml +++ b/integration-service/src/main/resources/application.yml @@ -35,6 +35,24 @@ baiyue: security: internal-service-token: ${INTERNAL_SERVICE_TOKEN:} integration: + lingxing-wms: + enabled: ${LINGXING_WMS_ENABLED:false} + base-uri: https://api.xlwms.com + app-key: ${LINGXING_OMS_APP_KEY:} + app-secret: ${LINGXING_OMS_APP_SECRET:} + connect-timeout: 5s + request-timeout: 15s + egress: + collection-mode: ${LINGXING_COLLECTION_MODE:OFF} + continuous-business-collection-enabled: ${LINGXING_CONTINUOUS_BUSINESS_COLLECTION_ENABLED:false} + environment-label: ${LINGXING_ENVIRONMENT_LABEL:test} + source-account: ${LINGXING_SOURCE_ACCOUNT:lingxing-primary} + external-exclusive-grant-id: ${LINGXING_EXTERNAL_EXCLUSIVE_GRANT_ID:} + budget-authority-base-uri: ${LINGXING_BUDGET_AUTHORITY_BASE_URI:http://lastleg-service:9303} + min-start-interval: 5s + cooldown-fallback: 5m + # Finite sessions require external-exclusive-until; continuous BUSINESS still requires the exclusive grant ID and authority. + # Missing proof/authority fails closed even when the old enabled switch is true. baidu-ocr: enabled: ${BAIDU_OCR_ENABLED:false} api-key: ${BAIDU_OCR_API_KEY:} diff --git a/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml b/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml new file mode 100644 index 000000000..a54b25396 --- /dev/null +++ b/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml @@ -0,0 +1,646 @@ +openapi: 3.0.3 +info: + title: Lingxing WMS Internal Read API + version: 1.0.0 + description: >- + Single-page or single-order adapter. No scheduling, business persistence or public Gateway route. + Source times remain uninterpreted strings; warehouse timeZone does not certify source timezone. + Server-side collection mode defaults to OFF. VALIDATION requires an externally confirmed exclusive + account window; BUSINESS requires environmentLabel exactly production or test. TEST business task creation is restricted by Lastleg to controlled commands and their legitimate derived work; the adapter does not schedule work. Provider-query endpoints apply the same gate. + Missing exclusive proof, persistent account budget authorization or control context fails closed before egress. + At most one account request is in flight; request starts obey the bound minStartInterval (VALIDATION at least 5 seconds). + A JVM lock is only local protection and is not proof of cross-environment account exclusivity. + Each invocation performs at most one supplier HTTP attempt. No mode uses implicit HTTP retries; + 429 is never retried, immediately pauses the account and returns persistent cooldown metadata. + VALIDATION page size must not exceed 10. Lastleg owns root/session budgets and cooldown persistence. + External server configuration supplies externalExclusiveGrantId and a VALIDATION externalExclusiveUntil; + the nonempty grant must match context.exclusiveGrantId. BUSINESS has no grant, session or root time cutoff; + explicit stops, persistent account protections and each short single-attempt permit remain enforced. + A BUSINESS rate-limit pause is recoverable after both the persistently returned + cooldown and the local minimum fallback cooldown have elapsed, and after the authority issues a new + strictly valid single-attempt permit confirming account budget, cooldown, stop limits and root state. + Time alone never permits egress. VALIDATION 429 does not automatically recover. Unavailable, lost + or malformed authorization and uncertain completion hold an unrecoverable local pause until + operations verify persistent account state. Completion failure never automatically recovers. + A well-formed explicit authorization denial is deterministic and does not cause a permanent pause. + sourceAccount defaults to lingxing-primary and budgetAuthorityBaseUri defaults internally to + http://lastleg-service:9303; neither default enables egress. +x-lastleg-egress-coordination: + description: >- + Integration calls these Lastleg endpoints with X-Internal-Service-Token. These are dependencies, + not Integration Controller paths. Lastleg prepares attemptId server-side and binds context and operation + before authorization. authorize only consumes an already prepared attempt, first binds the opaque + requestHash and atomically consumes persistent account/root/session budget. Duplicate authorization + is refused; arbitrary client-created attempts are refused. complete verifies the same bindings. + A permit is bound to its full context, operation and requestHash, requires exclusiveConfirmed=true + and budgetConsumed=true, expires after now but no later than now plus 30 seconds (also within externalExclusiveUntil for VALIDATION), + and cannot be reused. Explicit denial uses HTTP 200 with ApiResponse code string B0001 and + AuthorizationDenial data. A valid denial is not an uncertain authorization and does not impose + a permanent local pause. Unavailable, lost or malformed authorization or an unconfirmed callback blocks further egress; + pending or ambiguous attempts remain conservatively consumed, including after restart. + authorize: + method: POST + path: /internal/lastleg/self-operated-analytics/egress/authorize + requestSchema: '#/components/schemas/AuthorizationRequest' + responseSchema: '#/components/schemas/AttemptPermitResponse' + complete: + method: POST + path: /internal/lastleg/self-operated-analytics/egress/complete + requestSchema: '#/components/schemas/CompletionRequest' + responseSchema: '#/components/schemas/CompletionResponse' +security: + - InternalServiceToken: [] +paths: + /internal/integrations/lingxing/wms/inventory-flow-page/query: + post: + summary: One frozen warehouse inventory-flow page + description: >- + Shared INVENTORY_FLOW permit, one upstream request, no automatic retry. Fixed warehouse and + inclusive Beijing start/end bounds at most one day, current>=1, size 1..1000, sorted by createTime asc. + Customer/SKU filters are absent. Lastleg validates persisted task/page binding, page counts, + duplicate IDs and ordering before admitting coverage. Only quantity/identity whitelist fields return. + Empty first-page current=1,size=10,total=0,pages=0,records=[] is normalized to the requested size. + parameters: + - {$ref: '#/components/parameters/LingxingEgressContext'} + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [warehouseCode, startTime, endTime, current, size] + properties: + warehouseCode: {type: string, minLength: 1, maxLength: 64} + startTime: {type: string, format: date-time, description: Beijing local datetime without offset} + endTime: {type: string, format: date-time, description: Inclusive bound, at most one day after start} + current: {type: integer, minimum: 1} + size: {type: integer, minimum: 1, maximum: 1000} + responses: + '200': + description: Envelope of QueryPage with current/size/total/pages/records and source pagination evidence, or sanitized ProviderFailure. Rows retain string id, customerCode, whCode, productSku, cellNo, innerBatchNo, billNo, sourceNo, billType, productQuality, numeric bookQty/closeQty and string bookChangQty/createTime. + /internal/integrations/lingxing/wms/inventory-flow/query: + post: + summary: One SKU inventory-flow diagnostic page + description: >- + Calls stock/stockCellBook/page through the existing INVENTORY_FLOW permit. Exact warehouse/customer/SKU, + at most 31 days, current=1 and size=1000 fixed. No automatic next page or retry. Source quality and signed + quantities are preserved, not translated to inventory-snapshot quality. Source 11008 only rejects this optional + operation; authentication, rate-limit and uncertain-result account protection remain effective. + Source movement IDs must be strings. An empty first response with current=1, size=10, total=0, + pages=0 and records=[] is normalized to requested size=1000 while retaining sourceSize=10. + Other pagination mismatches remain errors. + parameters: + - {$ref: '#/components/parameters/LingxingEgressContext'} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [warehouseCode, customerCode, sku, startTime, endTime] + properties: + warehouseCode: {type: string, maxLength: 64} + customerCode: {type: string, maxLength: 64} + sku: {type: string, maxLength: 128} + startTime: {type: string, format: date-time, description: Beijing local datetime without offset} + endTime: {type: string, format: date-time, description: Beijing local datetime without offset} + responses: + '200': + description: Standard envelope with QueryPage or sanitized ProviderFailure. Flow rows retain id as string, customerCode, whCode, productSku, cellNo, innerBatchNo, billNo, sourceNo, billType, productQuality, numeric bookQty/closeQty, string bookChangQty/createTime. Unknown source fields remain null; no calculated inventory is published. + /internal/integrations/lingxing/wms/customers/query: + post: + summary: Query one page of customers using the existing account egress permit + description: Calls /openapi/v2/customer/list. BUSINESS MASTER_DATA tasks only; no retries or separate egress path. Dimensions and units remain separate; blanks are null. + parameters: + - {$ref: '#/components/parameters/LingxingEgressContext'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/WarehouseQueryRequest'} + responses: + '200': + description: Standard envelope with one page or sanitized ProviderFailure. + content: + application/json: + schema: {$ref: '#/components/schemas/CustomerResponse'} + /internal/integrations/lingxing/wms/products/query: + post: + summary: Query one page of products using the existing account egress permit + description: Calls /openapi/v2/product/page. BUSINESS MASTER_DATA tasks only; no retries or separate egress path. Dimensions and units remain separate; blanks are null. + parameters: + - {$ref: '#/components/parameters/LingxingEgressContext'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ProductQueryRequest'} + responses: + '200': + description: Standard envelope with one page or sanitized ProviderFailure. + content: + application/json: + schema: {$ref: '#/components/schemas/ProductResponse'} + /internal/integrations/lingxing/wms/egress/runtime: + get: + operationId: lingxingEgressRuntime + summary: Read sanitized configuration from the active egress gate + description: >- + Internal token authentication only. No provider, database, budget-authority, permit or persistence side effect. + Returns actual bound configuration, not a verified claim or a commit identity. Fingerprints use lowercase + SHA-256 of UTF-8 "selfop-runtime:v1:\n" plus the exact original nonblank value, without trimming. + Domains are source-account, grant and budget-authority. Missing/blank values yield null, never an empty-value hash. + security: [{InternalServiceToken: []}] + responses: + '200': + description: ApiResponse code 200 with runtime snapshot, or A0401 for missing/invalid internal token + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: {$ref: '#/components/schemas/EgressRuntimeSnapshot'} + /internal/integrations/lingxing/wms/warehouses/query: + post: + summary: Query one page of normal warehouses + parameters: + - {$ref: '#/components/parameters/LingxingEgressContext'} + description: Calls /openapi/v2/warehouse/page without filters. Maps page/pageSize into current/size; preserves original metadata. + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/WarehouseQueryRequest'} + responses: + '200': + description: Standard envelope with WarehousePage or sanitized ProviderFailure. + content: + application/json: + schema: {$ref: '#/components/schemas/WarehouseResponse'} + /internal/integrations/lingxing/wms/outbound-orders/query: + post: + summary: Query one page of completed outbound orders by source outbound time + parameters: + - {$ref: '#/components/parameters/LingxingEgressContext'} + description: Calls /openapi/v2/order/delivery/page with timeType=outboundTime and status=100. No external warehouse filter. At current=1,size=100 only, records=[],total=0,pages=0,sourceCurrent=1,sourceSize=10 is accepted and internal size remains 100. All other pagination metadata is strictly required. No completeness claim across pages. + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/QueryRequest'} + responses: + '200': + description: Standard envelope with OrderPage or sanitized ProviderFailure. + content: + application/json: + schema: {$ref: '#/components/schemas/OrderResponse'} + /internal/integrations/lingxing/wms/outbound-orders/locate: + post: + summary: Locate one page by delivery number without time or status filters + parameters: + - {$ref: '#/components/parameters/LingxingEgressContext'} + description: Sends only current,size,deliveryNo to delivery/page. Every returned deliveryNo must match exactly. Caller reads all required pages and verifies original customer identity and uniqueness before accepting the current warehouse. Missing results do not prove cancellation. + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/LocateRequest'} + responses: + '200': + description: Standard envelope with OrderPage or sanitized ProviderFailure. + content: + application/json: + schema: {$ref: '#/components/schemas/OrderResponse'} + /internal/integrations/lingxing/wms/outbound-orders/detail: + post: + summary: Read one order using warehouse, customer and delivery identifiers + parameters: + - {$ref: '#/components/parameters/LingxingEgressContext'} + description: Maps warehouseCode to whCode for delivery/detail. All three returned identifiers must match. Returns only skuVOList; packageList is never added to quantities. Duplicate SKU lines remain separate for caller normalization. Missing quantities remain null; no quantity unit is inferred. + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/DetailRequest'} + responses: + '200': + description: Standard envelope with OrderDetail or sanitized ProviderFailure. + content: + application/json: + schema: {$ref: '#/components/schemas/DetailResponse'} + /internal/integrations/lingxing/wms/product-inventory/query: + post: + summary: Query one page of all qualities and customers in a required warehouse + parameters: + - {$ref: '#/components/parameters/LingxingEgressContext'} + description: Calls stock/stockSku/page with whCode and isHideInventory=0. No SKU, quality, customer or quantity filters. Warehouse mismatch and duplicate business keys within a page fail closed. Caller validates cross-page keys and counts. Unknown quantities stay null, negative quantities remain evidence, stockCompare is not accumulated. + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/InventoryQueryRequest'} + responses: + '200': + description: Standard envelope with InventoryPage or sanitized ProviderFailure. + content: + application/json: + schema: {$ref: '#/components/schemas/InventoryResponse'} +components: + parameters: + LingxingEgressContext: + name: X-Lingxing-Egress-Context + in: header + required: true + description: >- + JSON control context; no credentials. Maximum 2048 characters, all printable ASCII (32 through 126). + Unknown fields, duplicate fields and trailing JSON are rejected. Identifiers are strings supplied + by the server-side coordinator and do not confer authorization by themselves. + content: + application/json: + schema: {$ref: '#/components/schemas/EgressContext'} + securitySchemes: + InternalServiceToken: + type: apiKey + in: header + name: X-Internal-Service-Token + schemas: + CustomerPage: + allOf: + - {$ref: '#/components/schemas/Pagination'} + - type: object + required: [records] + properties: + records: {type: array, items: {$ref: '#/components/schemas/Customer'}} + CustomerResponse: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - type: object + properties: + data: + nullable: true + oneOf: + - {$ref: '#/components/schemas/CustomerPage'} + - {$ref: '#/components/schemas/ProviderFailure'} + ProductPage: + allOf: + - {$ref: '#/components/schemas/Pagination'} + - type: object + required: [records] + properties: + records: {type: array, items: {$ref: '#/components/schemas/Product'}} + ProductResponse: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - type: object + properties: + data: + nullable: true + oneOf: + - {$ref: '#/components/schemas/ProductPage'} + - {$ref: '#/components/schemas/ProviderFailure'} + ProductQueryRequest: + type: object + required: [current, size] + properties: + current: {type: integer, minimum: 1} + size: {type: integer, minimum: 1, maximum: 100} + Customer: + type: object + required: [customerCode] + properties: + customerCode: {type: string} + customerName: {type: string, nullable: true} + status: {type: string, nullable: true} + Product: + type: object + required: [customerCode, sku] + properties: + customerCode: {type: string} + sku: {type: string} + productName: {type: string, nullable: true} + length: {type: number, nullable: true} + width: {type: number, nullable: true} + height: {type: number, nullable: true} + wmsLength: {type: number, nullable: true} + wmsWidth: {type: number, nullable: true} + wmsHeight: {type: number, nullable: true} + sizeUnit: {type: string, nullable: true} + wmsSizeUnit: {type: string, nullable: true} + EgressRuntimeSnapshot: + type: object + additionalProperties: false + required: [schemaVersion, service, observedAt, mode, environment, continuousEnabled, grantPresent, intervalMs, permitMaxSeconds, providerEnabled] + properties: + schemaVersion: {type: integer, enum: [1]} + service: {type: string, enum: [integration]} + observedAt: {type: string, format: date-time} + mode: {type: string, enum: [OFF, VALIDATION, BUSINESS]} + environment: {type: string} + continuousEnabled: {type: boolean} + sourceAccountFingerprint: {type: string, nullable: true, pattern: '^[a-f0-9]{64}$'} + grantPresent: {type: boolean} + grantFingerprint: {type: string, nullable: true, pattern: '^[a-f0-9]{64}$'} + intervalMs: + oneOf: [{type: integer, format: int64}, {type: string, pattern: '^[0-9]+$'}] + description: Actual bound interval; shared long-value JSON serialization may encode it as a decimal string. + permitMaxSeconds: {type: integer, enum: [30]} + providerEnabled: {type: boolean} + budgetAuthorityFingerprint: {type: string, nullable: true, pattern: '^[a-f0-9]{64}$'} + EgressContext: + type: object + additionalProperties: false + required: [sourceAccount, executionPurpose, rootTaskId, sessionId, attemptId, exclusiveGrantId] + properties: + sourceAccount: {type: string, minLength: 1, maxLength: 64, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: 'Server-configured account alias, never App Key or Secret.'} + executionPurpose: {type: string, enum: [VALIDATION, BUSINESS]} + rootTaskId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Stable root control identifier; child tasks share root budget.} + sessionId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Stable session control identifier; reopening or restart does not reset it.} + attemptId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Prepared unique single-attempt control identifier; duplicate authorization is refused.} + exclusiveGrantId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Must equal the valid externalExclusiveGrantId configured server-side.} + EgressRequestHash: + type: string + pattern: '^[0-9a-f]{64}$' + description: >- + Lowercase hexadecimal SHA256 of UTF-8 bytes of OMP path, one LF character and compact + insertion-ordered external business JSON. Lastleg treats this digest as opaque and binds + it on the first authorization of an already prepared attempt. + example: fa60a92c78b4803878930e5914e6db03591acc31b7c09abad53c91912fa3d627 + x-preimage-example: |- + /openapi/v2/stock/stockSku/page + {"current":1,"size":2,"whCode":"WH-DEMO","isHideInventory":0} + EgressOperation: + type: string + enum: [WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY, CUSTOMERS, PRODUCTS, INVENTORY_FLOW] + EgressMetadata: + type: object + required: [attemptId, actualHttpAttempts, accountPaused] + properties: + attemptId: {type: string, nullable: true, description: Null only if no valid control context was accepted.} + actualHttpAttempts: {type: integer, minimum: 0, maximum: 1, description: 'Supplier HTTP attempts actually started, including failed calls; authorization callbacks do not count.'} + accountPaused: {type: boolean} + cooldownUntil: {type: string, format: date-time, nullable: true, description: Account cooldown deadline to persist; valid supplier retry time must not be shortened.} + AuthorizationRequest: + type: object + required: [context, operation, requestHash] + properties: + context: {$ref: '#/components/schemas/EgressContext'} + operation: {$ref: '#/components/schemas/EgressOperation'} + requestHash: {$ref: '#/components/schemas/EgressRequestHash'} + AuthorizationDenial: + type: object + required: [errorCode, sampleDone] + properties: + errorCode: {type: string, pattern: '^[A-Z][A-Z0-9_]{0,63}$'} + sampleDone: {type: boolean, description: Strict JSON boolean; validation budget normal completion is distinct from a failure.} + waitUntil: {type: string, format: date-time, nullable: true, description: Nullable ISO-8601 Instant from the persistent authority.} + AuthorizationDenialResponse: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - type: object + properties: + code: {type: string, enum: [B0001]} + data: {$ref: '#/components/schemas/AuthorizationDenial'} + AttemptPermit: + type: object + required: [context, operation, requestHash, allowedAttempts, expiresAt, exclusiveConfirmed, budgetConsumed] + properties: + context: {$ref: '#/components/schemas/EgressContext'} + operation: {$ref: '#/components/schemas/EgressOperation'} + requestHash: {$ref: '#/components/schemas/EgressRequestHash'} + allowedAttempts: {type: integer, enum: [1]} + expiresAt: {type: string, format: date-time, description: Must be after now and no later than now plus 30 seconds and configured externalExclusiveUntil.} + exclusiveConfirmed: {type: boolean, enum: [true]} + budgetConsumed: {type: boolean, enum: [true], description: Lastleg has atomically consumed persistent account and root/session budget before returning this permit.} + CompletionRequest: + type: object + required: [context, operation, requestHash, actualHttpAttempts, accountPaused] + properties: + context: {$ref: '#/components/schemas/EgressContext'} + operation: {$ref: '#/components/schemas/EgressOperation'} + requestHash: {$ref: '#/components/schemas/EgressRequestHash'} + actualHttpAttempts: {type: integer, minimum: 0, maximum: 1} + errorCode: {type: string, nullable: true, description: Sanitized local error code; null on success.} + upstreamStatus: {type: integer, nullable: true} + sourceCode: {type: integer, nullable: true} + accountPaused: {type: boolean, description: True on 429 or explicit provider rate limiting; all account request families must pause.} + cooldownUntil: {type: string, format: date-time, nullable: true} + AttemptPermitResponse: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - type: object + properties: + code: {type: string, enum: ['200', B0001]} + data: + oneOf: + - {$ref: '#/components/schemas/AttemptPermit'} + - {$ref: '#/components/schemas/AuthorizationDenial'} + description: Code 200 requires AttemptPermit; code B0001 requires AuthorizationDenial. Lost or malformed responses are uncertain and fail closed with a local pause. + CompletionResponse: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - type: object + properties: + data: {type: boolean, enum: [true], description: Successful acknowledgement confirms durable completion and cooldown persistence; any other result fails closed.} + WarehouseQueryRequest: + type: object + required: [current, size] + properties: + current: {type: integer, minimum: 1} + size: {type: integer, minimum: 1, maximum: 1000} + QueryRequest: + type: object + required: [startTime, endTime, current, size] + properties: + startTime: {type: string, example: '2026-09-12T08:00:00', description: Source local datetime. Must precede endTime and be no earlier than endTime minus one calendar month. No source timezone inference.} + endTime: {type: string, example: '2026-10-12T08:00:00'} + current: {type: integer, minimum: 1} + size: {type: integer, minimum: 1, maximum: 100, description: 'Local request cap, not supplier quota.'} + LocateRequest: + type: object + required: [deliveryNo, current, size] + properties: + deliveryNo: {type: string, minLength: 1, maxLength: 128} + current: {type: integer, minimum: 1} + size: {type: integer, minimum: 1, maximum: 1000} + DetailRequest: + type: object + required: [warehouseCode, customerCode, deliveryNo] + properties: + warehouseCode: {type: string, minLength: 1, maxLength: 64} + customerCode: {type: string, minLength: 1, maxLength: 64} + deliveryNo: {type: string, minLength: 1, maxLength: 128} + InventoryQueryRequest: + type: object + required: [warehouseCode, current, size] + properties: + warehouseCode: {type: string, minLength: 1, maxLength: 64} + current: {type: integer, minimum: 1} + size: {type: integer, minimum: 1, maximum: 1000} + Pagination: + type: object + required: [total, current, size, pages, sourceCurrent, sourceSize, sourceCode, observedAt, egress] + properties: + egress: {$ref: '#/components/schemas/EgressMetadata'} + total: {type: string, pattern: '^[0-9]+$', description: Required provider total. Long follows platform string serialization.} + current: {type: integer} + size: {type: integer} + pages: {type: integer, minimum: 0} + sourceCurrent: {type: integer} + sourceSize: {type: integer} + sourceCode: {type: integer, enum: [200]} + observedAt: {type: string, format: date-time} + OrderSummary: + type: object + required: [deliveryNo, customerCode, whCode, status] + properties: + deliveryNo: {type: string} + sourceNo: {type: string, nullable: true} + customerCode: {type: string} + whCode: {type: string} + status: {type: integer} + createTime: {type: string, nullable: true} + outboundTime: {type: string, nullable: true} + productList: + type: array + nullable: true + description: Per-SKU list quantities; missing items, SKU or qty require one detail supplement for this order. Zero is a known quantity. + items: + type: object + properties: + productSku: {type: string, nullable: true} + productName: {type: string, nullable: true} + qty: {type: number, nullable: true} + Warehouse: + type: object + required: [whCode, warehouseType, status] + properties: + whCode: {type: string} + whNameCn: {type: string, nullable: true} + warehouseType: {type: integer} + status: {type: integer} + timeZone: {type: string, nullable: true, description: Warehouse metadata only.} + SkuLine: + type: object + required: [productSku] + properties: + productSku: {type: string} + productName: {type: string, nullable: true} + actualDeliveryQty: {type: number, nullable: true, description: 'BigDecimal actual quantity, unknown is null.'} + deliveryQty: {type: number, nullable: true, description: Forecast only; never a replacement for actual quantity.} + OrderDetail: + type: object + required: [deliveryNo, customerCode, whCode, status, skuVOList, sourceCode, observedAt, egress] + properties: + egress: {$ref: '#/components/schemas/EgressMetadata'} + deliveryNo: {type: string} + sourceNo: {type: string, nullable: true} + customerCode: {type: string} + whCode: {type: string} + status: {type: integer} + orderCreateTime: {type: string, nullable: true} + outboundTime: {type: string, nullable: true} + skuVOList: {type: array, minItems: 1, items: {$ref: '#/components/schemas/SkuLine'}} + sourceCode: {type: integer, enum: [200]} + observedAt: {type: string, format: date-time} + ProductStock: + type: object + required: [customerCode, whCode, productSku, productQuality] + properties: + customerCode: {type: string} + whCode: {type: string} + productSku: {type: string} + productName: {type: string, nullable: true} + productQuality: {type: integer} + stkQty: {type: number, nullable: true} + freezeQty: {type: number, nullable: true} + useStkQty: {type: number, nullable: true} + transitQty: {type: number, nullable: true} + innerBatchNo: {type: string, nullable: true} + outBatchNo: {type: string, nullable: true} + uniqueKey4tenant: {type: string, nullable: true} + OrderPage: + allOf: + - {$ref: '#/components/schemas/Pagination'} + - type: object + required: [records] + properties: + records: {type: array, items: {$ref: '#/components/schemas/OrderSummary'}} + WarehousePage: + allOf: + - {$ref: '#/components/schemas/Pagination'} + - type: object + required: [records] + properties: + records: {type: array, items: {$ref: '#/components/schemas/Warehouse'}} + InventoryPage: + allOf: + - {$ref: '#/components/schemas/Pagination'} + - type: object + required: [records] + properties: + records: {type: array, items: {$ref: '#/components/schemas/ProductStock'}} + ProviderFailure: + type: object + required: [errorCode, retryable, egress] + properties: + egress: {$ref: '#/components/schemas/EgressMetadata'} + denial: + nullable: true + allOf: + - {$ref: '#/components/schemas/AuthorizationDenial'} + description: Well-formed deterministic authority denial, alongside egress; null for other failures. + errorCode: {type: string} + retryable: {type: boolean, description: Caller scheduling hint only; never authorizes implicit retries or reuse of an attempt. False for 429 and validation session failure.} + upstreamStatus: {type: integer, nullable: true} + sourceCode: {type: integer, nullable: true} + Envelope: + type: object + required: [code, message, data, timestamp] + properties: + code: {type: string, description: '200 success; A0401 identity; A0422 validation; B0001 or S0503 provider/configuration failure.'} + message: {type: string, description: 'Sanitized fixed local message, never raw supplier content.'} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time} + OrderResponse: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - type: object + properties: + data: + nullable: true + oneOf: + - {$ref: '#/components/schemas/OrderPage'} + - {$ref: '#/components/schemas/ProviderFailure'} + WarehouseResponse: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - type: object + properties: + data: + nullable: true + oneOf: + - {$ref: '#/components/schemas/WarehousePage'} + - {$ref: '#/components/schemas/ProviderFailure'} + DetailResponse: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - type: object + properties: + data: + nullable: true + oneOf: + - {$ref: '#/components/schemas/OrderDetail'} + - {$ref: '#/components/schemas/ProviderFailure'} + InventoryResponse: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - type: object + properties: + data: + nullable: true + oneOf: + - {$ref: '#/components/schemas/InventoryPage'} + - {$ref: '#/components/schemas/ProviderFailure'} diff --git a/integration-service/src/test/java/com/baiyue/integration/config/LingxingEgressPropertiesTest.java b/integration-service/src/test/java/com/baiyue/integration/config/LingxingEgressPropertiesTest.java new file mode 100644 index 000000000..ad3667d9b --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/config/LingxingEgressPropertiesTest.java @@ -0,0 +1,71 @@ +package com.baiyue.integration.config; + +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode; +import org.junit.jupiter.api.Test; +import java.time.Duration; +import java.util.List; +import static org.assertj.core.api.Assertions.*; + +class LingxingEgressPropertiesTest { + @Test void continuousBusinessFlagBindsOnlyWhenExplicitlyEnabled() { + String prefix = "baiyue.integration.lingxing-wms.egress"; + var input = new java.util.HashMap(); + input.put(prefix + ".collection-mode", "BUSINESS"); + var source = new org.springframework.boot.context.properties.source.MapConfigurationPropertySource(input); + var binder = new org.springframework.boot.context.properties.bind.Binder(source); + var target = org.springframework.boot.context.properties.bind.Bindable.of(LingxingEgressProperties.class); + assertThat(binder.bind(prefix, target).get().continuousBusinessCollectionEnabled()).isFalse(); + source.put(prefix + ".continuous-business-collection-enabled", "true"); + assertThat(binder.bind(prefix, target).get().continuousBusinessCollectionEnabled()).isTrue(); + } + private LingxingEgressProperties properties(CollectionMode mode, Duration interval, Duration cooldown) { + return new LingxingEgressProperties(mode, "test", null, null, null, null, interval, cooldown, false); + } + + @Test void businessAcceptsPositiveMillisecondIntervalsIncludingProbeSteps() { + for (long millis : new long[]{1, 1200, 1500, 2000, 3000, 5000, 300000}) { + assertThat(properties(CollectionMode.BUSINESS, Duration.ofMillis(millis), null).minStartInterval()) + .isEqualTo(Duration.ofMillis(millis)); + } + } + + @Test void businessRejectsSubMillisecondNonPositiveAndExcessiveIntervals() { + for (Duration interval : List.of(Duration.ofMillis(-1), Duration.ZERO, + Duration.ofNanos(999999), Duration.ofMinutes(5).plusMillis(1))) { + assertThatThrownBy(() -> properties(CollectionMode.BUSINESS, interval, null)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Test void validationRetainsFiveSecondMinimumAndAllModesDefaultToFiveSeconds() { + for (CollectionMode mode : CollectionMode.values()) { + assertThat(properties(mode, null, null).minStartInterval()).isEqualTo(Duration.ofSeconds(5)); + if (mode == CollectionMode.VALIDATION) { + assertThatThrownBy(() -> properties(mode, Duration.ofMillis(4999), null)) + .isInstanceOf(IllegalArgumentException.class); + assertThat(properties(mode, Duration.ofSeconds(5), null).minStartInterval()) + .isEqualTo(Duration.ofSeconds(5)); + } + } + assertThat(properties(null, null, null).collectionMode()).isEqualTo(CollectionMode.OFF); + } + + @Test void switchingBusinessOffCanRetainItsThreeSecondInterval() { + var business = properties(CollectionMode.BUSINESS, Duration.ofSeconds(3), null); + assertThat(properties(CollectionMode.OFF, business.minStartInterval(), null).minStartInterval()) + .isEqualTo(Duration.ofSeconds(3)); + assertThatThrownBy(() -> properties(CollectionMode.OFF, Duration.ZERO, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test void businessIntervalChangeDoesNotRelaxCooldownBounds() { + for (Duration cooldown : List.of(Duration.ofMillis(4999), Duration.ofDays(1).plusMillis(1))) { + assertThatThrownBy(() -> properties(CollectionMode.BUSINESS, Duration.ofMillis(1), cooldown)) + .isInstanceOf(IllegalArgumentException.class); + } + for (Duration cooldown : List.of(Duration.ofSeconds(5), Duration.ofDays(1))) { + assertThat(properties(CollectionMode.BUSINESS, Duration.ofMillis(1), cooldown).cooldownFallback()) + .isEqualTo(cooldown); + } + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/controller/LingxingEgressRuntimeControllerTest.java b/integration-service/src/test/java/com/baiyue/integration/controller/LingxingEgressRuntimeControllerTest.java new file mode 100644 index 000000000..d6c608107 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/controller/LingxingEgressRuntimeControllerTest.java @@ -0,0 +1,48 @@ +package com.baiyue.integration.controller; + +import com.baiyue.common.web.GlobalExceptionHandler; +import com.baiyue.integration.config.LingxingWmsProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressRuntimeSnapshot; +import com.baiyue.integration.provider.lingxing.LingxingEgressGate; +import com.baiyue.integration.security.InternalServiceTokenGuard; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class LingxingEgressRuntimeControllerTest { + @Test void internalTokenIsRequiredBeforeGateInspectionAndPublicPathDoesNotExist() throws Exception { + String token = UUID.randomUUID().toString();var guard = new InternalServiceTokenGuard(); + ReflectionTestUtils.setField(guard, "token", token);ReflectionTestUtils.invokeMethod(guard, "initializeToken"); + var gate = mock(LingxingEgressGate.class);var provider = new LingxingWmsProperties(false,null,null,null,null,null); + var converter = new MappingJackson2HttpMessageConverter(Jackson2ObjectMapperBuilder.json() + .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS).build()); + var mvc = MockMvcBuilders.standaloneSetup(new LingxingEgressRuntimeController(guard,gate,provider)) + .setMessageConverters(converter).setControllerAdvice(new GlobalExceptionHandler()).build(); + String path = "/internal/integrations/lingxing/wms/egress/runtime"; + for (String bad : new String[] {"", "wrong", "Bearer user-jwt"}) { + mvc.perform(get(path).header("X-Internal-Service-Token",bad)).andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401")); + } + verifyNoInteractions(gate); + mvc.perform(get("/api/integrations/lingxing/wms/egress/runtime")).andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(org.hamcrest.Matchers.not("200"))) + .andExpect(jsonPath("$.data.service").doesNotExist()); + verifyNoInteractions(gate); + var snapshot = new LingxingEgressRuntimeSnapshot(1,"integration",Instant.parse("2026-10-13T14:00:00Z"), + "OFF","test",false,null,false,null,5000,30,false,null); + when(gate.runtimeSnapshot(false)).thenReturn(snapshot); + mvc.perform(get(path).header("X-Internal-Service-Token",token)).andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("200")).andExpect(jsonPath("$.data.service").value("integration")) + .andExpect(jsonPath("$.data.observedAt").value("2026-10-13T14:00:00Z")) + .andExpect(jsonPath("$.data.mode").value("OFF")).andExpect(jsonPath("$.data.providerEnabled").value(false)) + .andExpect(jsonPath("$.data.verified").doesNotExist()).andExpect(jsonPath("$.data.grant").doesNotExist()); + verify(gate).runtimeSnapshot(false);verifyNoMoreInteractions(gate); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/controller/LingxingInternalControllerTest.java b/integration-service/src/test/java/com/baiyue/integration/controller/LingxingInternalControllerTest.java new file mode 100644 index 000000000..a6de263c6 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/controller/LingxingInternalControllerTest.java @@ -0,0 +1,163 @@ +package com.baiyue.integration.controller; + +import com.baiyue.common.web.GlobalExceptionHandler; +import com.baiyue.common.web.LongIdJacksonConfiguration; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.OrderSummary; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult; +import com.baiyue.integration.provider.lingxing.LingxingWmsClient; +import com.baiyue.integration.provider.lingxing.LingxingWmsException; +import com.baiyue.integration.security.InternalServiceTokenGuard; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class LingxingInternalControllerTest { + private static final String PATH = "/internal/integrations/lingxing/wms/outbound-orders/query"; + private static final String BODY = """ + {"startTime":"2026-09-12T08:00:00","endTime":"2026-10-12T08:00:00","current":1,"size":1} + """; + private static final String CONTEXT = "{\"sourceAccount\":\"demo\",\"executionPurpose\":\"VALIDATION\",\"rootTaskId\":\"root-1\",\"sessionId\":\"session-1\",\"attemptId\":\"attempt-1\",\"exclusiveGrantId\":\"grant-1\"}"; + private final String token = UUID.randomUUID().toString(); + private LingxingWmsClient client; + private MockMvc mvc; + + @BeforeEach + void setUp() { + var guard = new InternalServiceTokenGuard(); + ReflectionTestUtils.setField(guard, "token", token); + ReflectionTestUtils.invokeMethod(guard, "initializeToken"); + client = mock(LingxingWmsClient.class); + var builder = Jackson2ObjectMapperBuilder.json(); + try (var context = new AnnotationConfigApplicationContext(LongIdJacksonConfiguration.class)) { + context.getBean(Jackson2ObjectMapperBuilderCustomizer.class).customize(builder); + } + mvc = MockMvcBuilders.standaloneSetup(new LingxingInternalController(guard, client, builder.build())) + .setMessageConverters(new MappingJackson2HttpMessageConverter(builder.build())) + .setControllerAdvice(new GlobalExceptionHandler()).build(); + } + + @Test + void missingOrWrongInternalTokenCannotCallProvider() throws Exception { + mvc.perform(post(PATH).contentType(MediaType.APPLICATION_JSON).content(BODY)) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401")); + mvc.perform(post(PATH).header("X-Internal-Service-Token", "wrong") + .contentType(MediaType.APPLICATION_JSON).content(BODY)) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401")); + verifyNoInteractions(client); + } + + @ParameterizedTest + @CsvSource(value = { + "/warehouses/query|{\"current\":1,\"size\":1000}", + "/outbound-orders/locate|{\"deliveryNo\":\"DO-DEMO\",\"current\":1,\"size\":10}", + "/outbound-orders/detail|{\"warehouseCode\":\"W-DEMO\",\"customerCode\":\"C-DEMO\",\"deliveryNo\":\"DO-DEMO\"}", + "/product-inventory/query|{\"warehouseCode\":\"W-DEMO\",\"current\":1,\"size\":1000}" + }, delimiter = '|') + void everyNewEndpointRequiresInternalIdentity(String suffix, String request) throws Exception { + String path = "/internal/integrations/lingxing/wms" + suffix; + for (String invalidToken : new String[] {"", "wrong", "Bearer user-jwt"}) { + mvc.perform(post(path).header("X-Internal-Service-Token", invalidToken) + .contentType(MediaType.APPLICATION_JSON).content(request)) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401")); + } + verifyNoInteractions(client); + } + + @ParameterizedTest + @CsvSource(value = { + "/outbound-orders/query|{\"startTime\":\"2026-09-12T08:00:00\",\"endTime\":\"2026-10-12T08:00:00\",\"current\":1,\"size\":1}", + "/warehouses/query|{\"current\":1,\"size\":10}", + "/outbound-orders/locate|{\"deliveryNo\":\"DO-DEMO\",\"current\":1,\"size\":10}", + "/outbound-orders/detail|{\"warehouseCode\":\"W-DEMO\",\"customerCode\":\"C-DEMO\",\"deliveryNo\":\"DO-DEMO\"}", + "/product-inventory/query|{\"warehouseCode\":\"W-DEMO\",\"current\":1,\"size\":10}" + }, delimiter = '|') + void everyEndpointRejectsMissingControlContextBeforeProvider(String suffix, String request) throws Exception { + mvc.perform(post("/internal/integrations/lingxing/wms" + suffix).header("X-Internal-Service-Token", token) + .contentType(MediaType.APPLICATION_JSON).content(request)) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("B0001")) + .andExpect(jsonPath("$.data.errorCode").value("LINGXING_WMS_EGRESS_CONTEXT_INVALID")) + .andExpect(jsonPath("$.data.egress.actualHttpAttempts").value(0)); + verifyNoInteractions(client); + } + + @Test + void controlHeaderRejectsUnknownDuplicateAndTrailingFields() throws Exception { + for (String header : new String[] { + CONTEXT.replace("\"sourceAccount\":", "\"extra\":1,\"sourceAccount\":"), + CONTEXT.replace("\"sourceAccount\":", "\"sourceAccount\":\"other\",\"sourceAccount\":"), + CONTEXT + " {}", " ", "x".repeat(2049), "{}", "null", + CONTEXT.replace("VALIDATION", "OFF"), CONTEXT.replace("root-1", "root with spaces") + }) { + mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", header) + .contentType(MediaType.APPLICATION_JSON).content(BODY)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.errorCode").value("LINGXING_WMS_EGRESS_CONTEXT_INVALID")); + } + verifyNoInteractions(client); + } + + @Test + void inventoryRequiresWarehouseAndDetailRequiresAllThreeIdentifiers() throws Exception { + for (String suffix : new String[] {"/product-inventory/query", "/outbound-orders/detail"}) { + mvc.perform(post("/internal/integrations/lingxing/wms" + suffix) + .header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT).contentType(MediaType.APPLICATION_JSON) + .content("{\"current\":1,\"size\":1000,\"deliveryNo\":\"DO-DEMO\"}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0422")); + } + verifyNoInteractions(client); + } + + @Test + void authenticatedReadReturnsOnlyOutboundSummary() throws Exception { + when(client.queryPage(any(), any())).thenReturn(new PageResult( + List.of(new OrderSummary("TEST-OUT-001", "TEST-SRC-001", "TEST-CUSTOMER", "TEST-WH", 100, + "2026-10-01 08:00:00", "2026-10-02 08:00:00", null)), + 1L, 1, 1, 1, 1, 1, 200, Instant.parse("2026-10-12T00:00:00Z"), null)); + mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT) + .contentType(MediaType.APPLICATION_JSON).content(BODY)) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("200")) + .andExpect(jsonPath("$.data.total").isString()).andExpect(jsonPath("$.data.total").value("1")) + .andExpect(jsonPath("$.data.records[0].deliveryNo").value("TEST-OUT-001")) + .andExpect(jsonPath("$.data.records[0].recipient").doesNotExist()); + } + + @Test + void invalidPageCannotReachProvider() throws Exception { + mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT) + .contentType(MediaType.APPLICATION_JSON).content(BODY.replace("\"current\":1", "\"current\":0"))) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0422")); + verifyNoInteractions(client); + } + + @Test + void providerFailureRemainsAnExplicitBusinessFailure() throws Exception { + when(client.queryPage(any(), any())).thenThrow(new LingxingWmsException( + "LINGXING_WMS_HTTP_RATE_LIMITED", "领星 WMS 请求受到限流", true, 429, null)); + mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT) + .contentType(MediaType.APPLICATION_JSON).content(BODY)) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("S0503")) + .andExpect(jsonPath("$.data.errorCode").value("LINGXING_WMS_HTTP_RATE_LIMITED")) + .andExpect(jsonPath("$.data.upstreamStatus").value(429)) + .andExpect(jsonPath("$.data.records").doesNotExist()); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressGateTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressGateTest.java new file mode 100644 index 000000000..006c4955f --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressGateTest.java @@ -0,0 +1,384 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingEgressProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*; +import org.junit.jupiter.api.Test; +import java.net.URI; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.UnaryOperator; +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LingxingEgressGateTest { + @Test void debugTimingRecordsActualCallAndCompletionWithoutContextOrPayload() { + var logger = (ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger(LingxingEgressGate.class); + var previous = logger.getLevel(); + var appender = new ch.qos.logback.core.read.ListAppender(); + appender.start(); logger.addAppender(appender); logger.setLevel(ch.qos.logback.classic.Level.DEBUG); + try { + var gate = gate(); + Object body = new Object(); + assertThat(gate.execute(context("private-attempt"), Operation.OUTBOUND_DETAIL, HASH, handle -> { + handle.startHttp(); clock.advanceMillis(123); return body; + })).isSameAs(body); + assertThat(appender.list).hasSize(1); + assertThat(appender.list.get(0).getFormattedMessage()) + .contains("operation=OUTBOUND_DETAIL", "start=" + NOW, "end=" + NOW.plusMillis(123), + "upstreamStatus=200", "errorCode=null", "completionConfirmed=true") + .doesNotContain("demo-account", "private-attempt", "demo-grant", HASH); + ledger.failComplete = true; + var failure = assertThrows(LingxingWmsException.class, () -> gate.execute(context("private-failure"), + Operation.OUTBOUND_DETAIL, HASH, handle -> { + handle.startHttp(); clock.advanceMillis(50); throw rateLimit(); + })); + assertThat(failure.code()).isEqualTo("LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED"); + assertThat(appender.list).hasSize(2); + assertThat(appender.list.get(1).getFormattedMessage()) + .contains("upstreamStatus=429", "errorCode=RATE_LIMIT", "completionConfirmed=false") + .doesNotContain("private-failure", "synthetic rate limit", "demo-account", HASH); + logger.setLevel(ch.qos.logback.classic.Level.INFO); + ledger.failComplete = false; + gate().execute(context("debug-disabled"), Operation.WAREHOUSES, HASH, handle -> body); + assertThat(appender.list).hasSize(2); + } finally { + logger.detachAppender(appender); appender.stop(); logger.setLevel(previous); + } + } + static final Instant NOW = Instant.parse("2026-10-12T00:00:00Z"); + static final String HASH = "a".repeat(64); + private final MutableClock clock = new MutableClock(NOW); + private final LedgerAuthority ledger = new LedgerAuthority(clock); + private LingxingEgressGate gate() { return new LingxingEgressGate(props(CollectionMode.VALIDATION, "test"), ledger, clock, clock::advanceMillis); } + static EgressContext context(String attempt) { return new EgressContext("demo-account", CollectionMode.VALIDATION, "root", "session", attempt, "demo-grant"); } + static LingxingEgressProperties props(CollectionMode mode, String environment) { + return new LingxingEgressProperties(mode, environment, "demo-account", "demo-grant", NOW.plusSeconds(3600), URI.create("http://127.0.0.1"), null, null, false); + } + private LingxingEgressGate continuousGate(CollectionMode mode, String grant, Instant until) { + return new LingxingEgressGate(new LingxingEgressProperties(mode, "test", "demo-account", grant, until, + URI.create("http://127.0.0.1"), null, null, true), ledger, clock, clock::advanceMillis); + } + @Test void continuousBusinessCrossesOldGrantExpiryButKeepsFiniteSingleUsePermits() { + var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", NOW.minusSeconds(1)); + assertThat(gate.execute(businessContext("continuous"), Operation.WAREHOUSES, HASH, + h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1); + clock.advanceMillis(86400000); + var restarted = continuousGate(CollectionMode.BUSINESS, "demo-grant", null); + assertThat(restarted.execute(businessContext("next-day"), Operation.WAREHOUSES, HASH, + h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1); + ledger.mutation = p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, + clock.instant().plusSeconds(31), true, true); + assertThatThrownBy(() -> restarted.execute(businessContext("too-long"), Operation.WAREHOUSES, HASH, h -> true)) + .hasMessageContaining("BUDGET_PERMIT_INVALID"); + assertThat(ledger.completions).hasSize(2); + } + @Test void continuousFlagNeverReplacesMatchingGrantOrFiniteValidationWindow() { + for (String grant : Arrays.asList(null, "", "other-grant")) { + var gate = continuousGate(CollectionMode.BUSINESS, grant, null); + assertThatThrownBy(() -> gate.execute(businessContext("missing-grant"), Operation.WAREHOUSES, HASH, h -> true)) + .hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED"); + } + for (Instant until : Arrays.asList(null, NOW)) { + var gate = continuousGate(CollectionMode.VALIDATION, "demo-grant", until); + assertThatThrownBy(() -> gate.execute(context("validation"), Operation.WAREHOUSES, HASH, h -> true)) + .hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED"); + } + assertThat(ledger.authorizations).isZero(); + } + @Test void continuousBusinessStillPausesOnUnconfirmedCompletion() { + var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", null); + ledger.failComplete = true; + assertThatThrownBy(() -> gate.execute(businessContext("lost"), Operation.WAREHOUSES, HASH, + h -> { h.startHttp(); return true; })).hasMessageContaining("BUDGET_COMPLETION_UNCONFIRMED"); + ledger.failComplete = false; clock.advanceMillis(86400000); + assertThatThrownBy(() -> gate.execute(businessContext("after-lost"), Operation.WAREHOUSES, HASH, h -> true)) + .hasMessageContaining("ACCOUNT_PAUSED"); + assertThat(ledger.authorizations).isEqualTo(1); + } + @Test void manualBusinessWithContinuousFlagOffCrossesOldCutoffButRejectsExpiredSinglePermit() { + var gate = new LingxingEgressGate(new LingxingEgressProperties(CollectionMode.BUSINESS, "test", + "demo-account", "demo-grant", NOW, URI.create("http://127.0.0.1"), Duration.ofMillis(1200), null, false), + ledger, clock, clock::advanceMillis); + var manual = new EgressContext("demo-account", CollectionMode.BUSINESS, "6", "selfop-6", "manual-after-cutoff", "demo-grant"); + assertThat(gate.execute(manual, Operation.OUTBOUND_DETAIL, HASH, + h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1); + assertThat(ledger.completions).singleElement().satisfies(c -> assertThat(c.context()).isEqualTo(manual)); + ledger.mutation = p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, + clock.instant(), true, true); + assertThatThrownBy(() -> gate.execute(new EgressContext("demo-account", CollectionMode.BUSINESS, "6", "selfop-6", "manual-expired-permit", "demo-grant"), + Operation.OUTBOUND_DETAIL, HASH, h -> { h.startHttp(); return true; })).hasMessageContaining("BUDGET_PERMIT_INVALID"); + assertThat(ledger.authorizations).isEqualTo(2);assertThat(ledger.completions).hasSize(1); + } + @Test void manualBusinessWithoutGrantCutoffStillRejectsPermitExpiringWhileSigning() { + var gate = new LingxingEgressGate(new LingxingEgressProperties(CollectionMode.BUSINESS, "test", + "demo-account", "demo-grant", null, URI.create("http://127.0.0.1"), Duration.ofMillis(1200), null, false), + ledger, clock, clock::advanceMillis); + assertThatThrownBy(() -> gate.execute(businessContext("manual-signing-expired"), Operation.OUTBOUND_DETAIL, HASH, + h -> { clock.advanceMillis(30000); h.startHttp(); return true; })).hasMessageContaining("ATTEMPT_NOT_SENDABLE"); + assertThat(ledger.completions).singleElement().satisfies(c -> assertThat(c.actualHttpAttempts()).isZero()); + } + @Test void continuousBusinessRetains1200MillisSpacingAndOneHttpPerPermit() { + var gate = new LingxingEgressGate(new LingxingEgressProperties(CollectionMode.BUSINESS, "test", + "demo-account", "demo-grant", null, URI.create("http://127.0.0.1"), Duration.ofMillis(1200), null, true), + ledger, clock, clock::advanceMillis); + gate.execute(businessContext("first-spaced"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; }); + gate.execute(businessContext("second-spaced"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; }); + assertThat(Duration.between(NOW, clock.instant()).toMillis()).isGreaterThanOrEqualTo(1200); + assertThatThrownBy(() -> gate.execute(businessContext("one-send"), Operation.WAREHOUSES, HASH, + h -> { h.startHttp(); h.startHttp(); return true; })).hasMessageContaining("ATTEMPT_NOT_SENDABLE"); + assertThat(ledger.completions).hasSize(3).allSatisfy(c -> assertThat(c.actualHttpAttempts()).isEqualTo(1)); + } + @Test void continuousBusinessCannotSendWithExpiredPermitAfterSigning() { + var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", null); + assertThatThrownBy(() -> gate.execute(businessContext("expired-permit"), Operation.OUTBOUND_DETAIL, HASH, + h -> { clock.advanceMillis(30000); h.startHttp(); return true; })).hasMessageContaining("ATTEMPT_NOT_SENDABLE"); + assertThat(ledger.completions).singleElement().satisfies(c -> assertThat(c.actualHttpAttempts()).isZero()); + } + @Test void continuousBusinessRateCooldownStillRequiresFreshAuthorityApproval() { + var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", null); + assertThrows(LingxingWmsException.class, () -> gate.execute(businessContext("continuous-rate"), Operation.WAREHOUSES, HASH, + h -> { h.startHttp(); h.rateLimited(null); throw rateLimit(); })); + assertThatThrownBy(() -> gate.execute(businessContext("during-cooldown"), Operation.WAREHOUSES, HASH, h -> true)) + .hasMessageContaining("ACCOUNT_PAUSED"); + clock.advanceMillis(300000);ledger.denial = new AuthorizationDenial("ACCOUNT_REQUIRES_REVIEW", false, null); + assertThatThrownBy(() -> gate.execute(businessContext("authority-denied"), Operation.WAREHOUSES, HASH, h -> true)) + .hasMessageContaining("BUDGET_AUTHORIZATION_DENIED"); + assertThat(ledger.completions).hasSize(1); + ledger.denial = null; + assertThat(gate.execute(businessContext("fresh-approved"), Operation.WAREHOUSES, HASH, + h -> { h.startHttp(); return h.metadata(); }).accountPaused()).isFalse(); + assertThat(ledger.completions).hasSize(2); + } + @Test void failureCompletionPreservesNullableTransportAndSourceCodes() { + for (Integer status : new Integer[] {null, 503}) { + var gate = gate(); + var error = assertThrows(LingxingWmsException.class, () -> gate.execute(context("nullable-" + status), + Operation.WAREHOUSES, HASH, handle -> { + handle.startHttp(); + throw new LingxingWmsException("LINGXING_WMS_IO_FAILED", "固定失败摘要", true, status, null); + })); + assertThat(error.egress().actualHttpAttempts()).isEqualTo(1); + var completion = ledger.completions.get(ledger.completions.size() - 1); + assertThat(completion.actualHttpAttempts()).isEqualTo(1); + assertThat(completion.upstreamStatus()).isEqualTo(status); + assertThat(completion.sourceCode()).isNull(); + } + } + @Test void defaultOffAndUnknownBusinessEnvironmentsNeverAuthorize() { + var defaults = new LingxingEgressProperties(null, null, null, null, null, null, null, null, false); + assertThat(defaults.collectionMode()).isEqualTo(CollectionMode.OFF); + assertThat(defaults.continuousBusinessCollectionEnabled()).isFalse(); + assertThat(defaults.minStartInterval()).isEqualTo(Duration.ofSeconds(5)); + var off = new LingxingEgressGate(defaults, ledger, clock, clock::advanceMillis); + assertThatThrownBy(() -> off.execute(context("a"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; })).hasMessageContaining("EGRESS_OFF"); + for(String environment:List.of("dev","gray","staging","prod","TEST","")) { + var business = new LingxingEgressGate(props(CollectionMode.BUSINESS, environment), ledger, clock, clock::advanceMillis); + assertThatThrownBy(() -> business.execute(businessContext("rejected"), Operation.WAREHOUSES, HASH, h -> true)).hasMessageContaining("BUSINESS_ENVIRONMENT_REJECTED"); + } + assertThat(ledger.authorizations).isZero(); + } + @Test void testAndProductionBusinessUseTheSameSingleConsumedPermitAndCompletionProtocol() { + for(String environment:List.of("test","production")) { + var gate=new LingxingEgressGate(props(CollectionMode.BUSINESS,environment),ledger,clock,clock::advanceMillis); + var result=gate.execute(businessContext(environment+"-inventory"),Operation.PRODUCT_INVENTORY,HASH,handle->{handle.startHttp();return handle.metadata();}); + assertThat(result.actualHttpAttempts()).isEqualTo(1);assertThat(result.accountPaused()).isFalse(); + } + assertThat(ledger.authorizations).isEqualTo(2);assertThat(ledger.consumed).hasSize(2);assertThat(ledger.completions).hasSize(2); + assertThat(ledger.completions).allSatisfy(c->{assertThat(c.context().executionPurpose()).isEqualTo(CollectionMode.BUSINESS);assertThat(c.operation()).isEqualTo(Operation.PRODUCT_INVENTORY);assertThat(c.actualHttpAttempts()).isEqualTo(1);}); + } + @Test void testBusinessStillRejectsPurposeAccountAndGrantMismatchBeforeAuthorization() { + var gate=new LingxingEgressGate(props(CollectionMode.BUSINESS,"test"),ledger,clock,clock::advanceMillis); + assertThatThrownBy(()->gate.execute(context("wrong-purpose"),Operation.WAREHOUSES,HASH,h->true)).hasMessageContaining("EGRESS_CONTEXT_INVALID"); + assertThatThrownBy(()->gate.execute(new EgressContext("other-account",CollectionMode.BUSINESS,"root","session","wrong-account","demo-grant"),Operation.WAREHOUSES,HASH,h->true)).hasMessageContaining("EGRESS_CONTEXT_INVALID"); + assertThatThrownBy(()->gate.execute(new EgressContext("demo-account",CollectionMode.BUSINESS,"root","session","wrong-grant","other-grant"),Operation.WAREHOUSES,HASH,h->true)).hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED"); + assertThat(ledger.authorizations).isZero();assertThat(ledger.completions).isEmpty(); + } + @Test void testBusinessUnconfirmedCompletionStillRequiresManualReviewAfterTimePasses() { + var gate=new LingxingEgressGate(props(CollectionMode.BUSINESS,"test"),ledger,clock,clock::advanceMillis); + ledger.failComplete=true; + var failure=assertThrows(LingxingWmsException.class,()->gate.execute(businessContext("test-lost-ack"),Operation.PRODUCT_INVENTORY,HASH,h->{h.startHttp();return true;})); + assertThat(failure.code()).isEqualTo("LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED"); + assertThat(failure.egress().actualHttpAttempts()).isEqualTo(1);assertThat(failure.egress().accountPaused()).isTrue(); + clock.advanceMillis(600000);ledger.failComplete=false; + assertThatThrownBy(()->gate.execute(businessContext("test-after-uncertainty"),Operation.PRODUCT_INVENTORY,HASH,h->{h.startHttp();return true;})).hasMessageContaining("ACCOUNT_PAUSED"); + assertThat(ledger.authorizations).isEqualTo(1);assertThat(ledger.completions).hasSize(1); + } + @Test void proofAndContextAreServerBoundBeforeAuthorization() { + var contexts = Arrays.asList(null, new EgressContext("other", CollectionMode.VALIDATION, "root", "session", "a", "demo-grant"), + new EgressContext("demo-account", CollectionMode.BUSINESS, "root", "session", "a", "demo-grant"), + new EgressContext("demo-account", CollectionMode.VALIDATION, "root", "session", "a", "client-claimed-grant"), + new EgressContext("demo-account", CollectionMode.VALIDATION, "", "session", "a", "demo-grant"), + new EgressContext("demo-account", CollectionMode.VALIDATION, "root", "bad session", "a", "demo-grant")); + for (var c : contexts) assertThrows(LingxingWmsException.class, () -> gate().execute(c, Operation.WAREHOUSES, HASH, h -> true)); + clock.advanceMillis(3600000); + assertThrows(LingxingWmsException.class, () -> gate().execute(context("a"), Operation.WAREHOUSES, HASH, h -> true)); + assertThat(ledger.authorizations).isZero(); + } + @Test void budgetPermitMustBeOneConsumedExclusiveUnexpiredAndExactlyBound() { + List> mutations = List.of( + p -> null, + p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 2, p.expiresAt(), true, true), + p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, clock.instant(), true, true), + p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, clock.instant().plusSeconds(31), true, true), + p -> new AttemptPermit(context("different"), p.operation(), p.requestHash(), 1, p.expiresAt(), true, true), + p -> new AttemptPermit(p.context(), Operation.OUTBOUND_DETAIL, p.requestHash(), 1, p.expiresAt(), true, true), + p -> new AttemptPermit(p.context(), p.operation(), "b".repeat(64), 1, p.expiresAt(), true, true), + p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, p.expiresAt(), false, true), + p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, p.expiresAt(), true, false)); + var calls = new AtomicInteger(); int n = 0; + for (var mutation : mutations) { + ledger.mutation = mutation; var gate = gate(); var c = context("attempt-" + ++n); + assertThatThrownBy(() -> gate.execute(c, Operation.WAREHOUSES, HASH, h -> calls.incrementAndGet())).hasMessageContaining("BUDGET_PERMIT_INVALID"); + assertThatThrownBy(() -> gate.execute(context("next-" + c.attemptId()), Operation.WAREHOUSES, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED"); + } + assertThat(calls).hasValue(0); assertThat(ledger.completions).isEmpty(); + } + @Test void everyAttemptIsPersistentlyConsumedAndCannotReplayEvenAcrossGateInstances() { + var c = context("single-use"); + assertThat(gate().execute(c, Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1); + assertThat(ledger.consumed).containsExactly("single-use"); + var second = gate(); var sends = new AtomicInteger(); + assertThatThrownBy(() -> second.execute(c, Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return sends.incrementAndGet(); })).hasMessageContaining("BUDGET_AUTHORITY_UNAVAILABLE"); + assertThat(sends).hasValue(0); assertThat(ledger.completions).hasSize(1); + } + @Test void handleCanStartOnlyOnceAndExpiredHandleCannotSend() { + var error = assertThrows(LingxingWmsException.class, () -> gate().execute(context("once"), Operation.WAREHOUSES, HASH, + h -> { h.startHttp(); h.startHttp(); return true; })); + assertThat(error.code()).endsWith("ATTEMPT_NOT_SENDABLE"); + assertThat(error.egress().actualHttpAttempts()).isEqualTo(1); + error = assertThrows(LingxingWmsException.class, () -> gate().execute(context("expires"), Operation.WAREHOUSES, HASH, + h -> { clock.advanceMillis(30000); h.startHttp(); return true; })); + assertThat(error.egress().actualHttpAttempts()).isZero(); + assertThat(ledger.completions).extracting(CompletionRequest::actualHttpAttempts).containsExactly(1, 0); + } + @Test void authorizationAndCompletionFailuresPauseAllFurtherCalls() { + ledger.failAuthorize = true; var gate = gate(); + var error = assertThrows(LingxingWmsException.class, () -> gate.execute(context("unavailable"), Operation.WAREHOUSES, HASH, h -> true)); + assertThat(error.egress().actualHttpAttempts()).isZero(); assertThat(error.egress().accountPaused()).isTrue(); + assertThatThrownBy(() -> gate.execute(context("next"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED"); + ledger.failAuthorize = false; ledger.failComplete = true; var fresh = gate(); + error = assertThrows(LingxingWmsException.class, () -> fresh.execute(context("callback"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; })); + assertThat(error.code()).endsWith("BUDGET_COMPLETION_UNCONFIRMED"); + assertThat(error.egress().actualHttpAttempts()).isEqualTo(1); assertThat(error.egress().accountPaused()).isTrue(); + assertThatThrownBy(() -> fresh.execute(context("after-callback"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED"); + } + @Test void startsAreSerializedAndSpacedByAtLeastFiveSeconds() throws Exception { + var gate = gate(); var executor = Executors.newFixedThreadPool(2); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var starts = new CopyOnWriteArrayList(); + try { + var first = executor.submit(() -> gate.execute(context("first"), Operation.WAREHOUSES, HASH, h -> { + h.startHttp(); starts.add(clock.instant()); entered.countDown(); + try { if (!release.await(2, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } + catch (InterruptedException e) { throw new IllegalStateException(e); } + return true; + })); + assertThat(entered.await(2, TimeUnit.SECONDS)).isTrue(); + var secondReady = new CountDownLatch(1); + var second = executor.submit(() -> { secondReady.countDown(); return gate.execute(context("second"), Operation.OUTBOUND_DETAIL, HASH, + h -> { h.startHttp(); starts.add(clock.instant()); return true; }); }); + assertThat(secondReady.await(2, TimeUnit.SECONDS)).isTrue(); + assertThat(ledger.authorizations).isEqualTo(1); assertThat(second.isDone()).isFalse(); + release.countDown(); assertThat(first.get(2, TimeUnit.SECONDS)).isTrue(); assertThat(second.get(2, TimeUnit.SECONDS)).isTrue(); + assertThat(starts).hasSize(2); assertThat(Duration.between(starts.get(0), starts.get(1))).isGreaterThanOrEqualTo(Duration.ofSeconds(5)); + assertThat(ledger.completions).hasSize(2); + } finally { release.countDown(); executor.shutdownNow(); } + } + @Test void intervalCannotBeConfiguredBelowFiveSecondsAndPropertiesHideGrant() { + assertThatThrownBy(() -> new LingxingEgressProperties(CollectionMode.VALIDATION, null, null, null, null, null, Duration.ofMillis(4999), null, false)).isInstanceOf(IllegalArgumentException.class); + assertThat(props(CollectionMode.VALIDATION, "test").toString()).doesNotContain("demo-grant", "demo-account", "127.0.0.1"); + } + @Test void knownBudgetDenialDoesNotPermanentlyPauseOrConsumeHttp() { + var gate = gate(); ledger.denial = new AuthorizationDenial("VALIDATION_TOTAL_LIMIT", true, null); + var error = assertThrows(LingxingWmsException.class, () -> gate.execute(context("denied"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; })); + assertThat(error.denial()).isEqualTo(ledger.denial); + assertThat(error.egress().actualHttpAttempts()).isZero(); assertThat(error.egress().accountPaused()).isFalse(); + assertThat(ledger.consumed).isEmpty(); assertThat(ledger.completions).isEmpty(); + ledger.denial = null; + assertThat(gate.execute(context("fresh-session-attempt"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1); + } + private static EgressContext businessContext(String attempt) { + return new EgressContext("demo-account", CollectionMode.BUSINESS, "root", "session", attempt, "demo-grant"); + } + private LingxingWmsException rateLimit() { return new LingxingWmsException("RATE_LIMIT", "synthetic rate limit", false, 429, null); } + @Test void businessCooldownRequiresFreshDurablePermitBeforeRecovery() { + var gate = new LingxingEgressGate(props(CollectionMode.BUSINESS, "production"), ledger, clock, clock::advanceMillis); + assertThrows(LingxingWmsException.class, () -> gate.execute(businessContext("limited"), Operation.WAREHOUSES, HASH, h -> { + h.startHttp(); h.rateLimited(clock.instant().plusSeconds(600)); throw rateLimit(); + })); + int used = ledger.authorizations; + assertThatThrownBy(() -> gate.execute(businessContext("too-early"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED"); + assertThat(ledger.authorizations).isEqualTo(used); + clock.advanceMillis(600000); ledger.denial = new AuthorizationDenial("ACCOUNT_COOLDOWN", false, clock.instant().plusSeconds(60)); + var denied = assertThrows(LingxingWmsException.class, () -> gate.execute(businessContext("still-denied"), Operation.OUTBOUND_DETAIL, HASH, h -> { h.startHttp(); return true; })); + assertThat(denied.egress().actualHttpAttempts()).isZero(); assertThat(denied.egress().accountPaused()).isTrue(); + ledger.denial = null; + var accepted = gate.execute(businessContext("accepted"), Operation.OUTBOUND_DETAIL, HASH, h -> { h.startHttp(); return h.metadata(); }); + assertThat(accepted.actualHttpAttempts()).isEqualTo(1); assertThat(accepted.accountPaused()).isFalse(); assertThat(accepted.cooldownUntil()).isNull(); + assertThat(ledger.completions).hasSize(2); + } + @Test void committedRateCompletionWithLostAckReturnsUncertaintyInsteadOfRecoverableRateError() { + var gate=new LingxingEgressGate(props(CollectionMode.BUSINESS,"production"),ledger,clock,clock::advanceMillis); + ledger.failComplete=true; + var error=assertThrows(LingxingWmsException.class,()->gate.execute(businessContext("rate-lost-ack"),Operation.WAREHOUSES,HASH,handle->{ + handle.startHttp();handle.rateLimited(clock.instant().plusSeconds(600));throw rateLimit(); + })); + assertThat(error.code()).isEqualTo("LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED"); + assertThat(error.egress().actualHttpAttempts()).isEqualTo(1);assertThat(error.egress().accountPaused()).isTrue(); + assertThat(error.egress().cooldownUntil()).isEqualTo(NOW.plusSeconds(600)); + assertThat(ledger.completions).singleElement().satisfies(result->{ + assertThat(result.upstreamStatus()).isEqualTo(429);assertThat(result.actualHttpAttempts()).isEqualTo(1); + assertThat(result.accountPaused()).isTrue(); + }); + clock.advanceMillis(601000);ledger.failComplete=false; + assertThatThrownBy(()->gate.execute(businessContext("after-uncertain-ack"),Operation.WAREHOUSES,HASH,handle->true)).hasMessageContaining("ACCOUNT_PAUSED"); + assertThat(ledger.authorizations).isEqualTo(1); + } + @Test void validationRatePauseAndUnconfirmedCallbackNeverRecoverByTime() { + var gate = gate(); + assertThrows(LingxingWmsException.class, () -> gate.execute(context("limited"), Operation.WAREHOUSES, HASH, h -> { + h.startHttp(); h.rateLimited(null); throw rateLimit(); + })); + clock.advanceMillis(600000); + assertThatThrownBy(() -> gate.execute(context("next"), Operation.WAREHOUSES, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED"); + assertThat(ledger.authorizations).isEqualTo(1); + var business = new LingxingEgressGate(props(CollectionMode.BUSINESS, "production"), ledger, clock, clock::advanceMillis); + ledger.failComplete = true; + assertThrows(LingxingWmsException.class, () -> business.execute(businessContext("lost-complete"), Operation.WAREHOUSES, HASH, h -> { + h.startHttp(); h.rateLimited(null); throw rateLimit(); + })); + clock.advanceMillis(600000); ledger.failComplete = false; + assertThatThrownBy(() -> business.execute(businessContext("no-recover"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED"); + assertThat(ledger.authorizations).isEqualTo(2); + } + static final class MutableClock extends Clock { + private Instant now; + MutableClock(Instant now) { this.now = now; } + synchronized void advanceMillis(long millis) { now = now.plusMillis(millis); } + @Override public ZoneId getZone() { return ZoneOffset.UTC; } + @Override public Clock withZone(ZoneId zone) { return this; } + @Override public synchronized Instant instant() { return now; } + } + /** Synthetic durable single-use ledger, shared between independently constructed gates. */ + static class LedgerAuthority implements LingxingBudgetAuthority { + final Clock clock; + final Set consumed = new LinkedHashSet<>(); + final List completions = new ArrayList<>(); + volatile int authorizations; + boolean failAuthorize, failComplete; + AuthorizationDenial denial; + UnaryOperator mutation = UnaryOperator.identity(); + LedgerAuthority(Clock clock) { this.clock = clock; } + @Override public synchronized AttemptPermit authorize(AuthorizationRequest request) { + authorizations++; + if (denial != null) throw new LingxingBudgetDeniedException(denial); + if (failAuthorize || !consumed.add(request.context().attemptId())) throw new IllegalStateException("budget refused"); + return mutation.apply(new AttemptPermit(request.context(), request.operation(), request.requestHash(), 1, clock.instant().plusSeconds(30), true, true)); + } + @Override public synchronized void complete(CompletionRequest request) { + completions.add(request); if (failComplete) throw new IllegalStateException("completion not durable"); + } + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressRuntimeSnapshotTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressRuntimeSnapshotTest.java new file mode 100644 index 000000000..a87b6342f --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressRuntimeSnapshotTest.java @@ -0,0 +1,57 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingEgressProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.*; +import java.util.HexFormat; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class LingxingEgressRuntimeSnapshotTest { + private static final Instant NOW = Instant.parse("2026-10-13T14:00:00Z"); + private final LingxingBudgetAuthority authority = mock(LingxingBudgetAuthority.class); + private LingxingEgressGate gate(CollectionMode mode, String account, String grant, URI uri) { + return new LingxingEgressGate(new LingxingEgressProperties(mode, "test", account, grant, null, uri, + mode == CollectionMode.VALIDATION ? Duration.ofSeconds(5) : Duration.ofMillis(1200), null, true), + authority, Clock.fixed(NOW, ZoneOffset.UTC), millis -> { throw new AssertionError("DIAGNOSTIC_MUST_NOT_WAIT"); }); + } + private static String hash(String domain, String value) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(("selfop-runtime:v1:" + domain + "\n" + value).getBytes(StandardCharsets.UTF_8))); + } + @Test void sameGateReturnsActualBindingsWithDomainSeparatedExactRawFingerprints() throws Exception { + var snapshot = gate(CollectionMode.BUSINESS, "same-raw-value", "same-raw-value", URI.create("http://authority:9303")).runtimeSnapshot(true); + assertThat(snapshot.schemaVersion()).isEqualTo(1);assertThat(snapshot.service()).isEqualTo("integration"); + assertThat(snapshot.observedAt()).isEqualTo(NOW);assertThat(snapshot.mode()).isEqualTo("BUSINESS"); + assertThat(snapshot.environment()).isEqualTo("test");assertThat(snapshot.continuousEnabled()).isTrue(); + assertThat(snapshot.providerEnabled()).isTrue();assertThat(snapshot.grantPresent()).isTrue(); + assertThat(snapshot.intervalMs()).isEqualTo(1200);assertThat(snapshot.permitMaxSeconds()).isEqualTo(30); + assertThat(snapshot.sourceAccountFingerprint()).isEqualTo(hash("source-account", "same-raw-value")); + assertThat(snapshot.grantFingerprint()).isEqualTo(hash("grant", "same-raw-value")).isNotEqualTo(snapshot.sourceAccountFingerprint()); + assertThat(snapshot.budgetAuthorityFingerprint()).isEqualTo(hash("budget-authority", "http://authority:9303")); + assertThat(gate(CollectionMode.BUSINESS, " raw ", " raw ", null).runtimeSnapshot(false).grantFingerprint()) + .isEqualTo(hash("grant", " raw ")).isNotEqualTo(hash("grant", "raw")); + verifyNoInteractions(authority); + } + @Test void offAndValidationAreObservableAndAbsentOrBlankValuesAreNotHashed() { + for (String value : new String[] {null, "", " "}) { + var s = gate(CollectionMode.OFF, value, value, null).runtimeSnapshot(false); + assertThat(s.mode()).isEqualTo("OFF");assertThat(s.sourceAccountFingerprint()).isNull(); + assertThat(s.grantPresent()).isFalse();assertThat(s.grantFingerprint()).isNull(); + assertThat(s.budgetAuthorityFingerprint()).isNull();assertThat(s.providerEnabled()).isFalse(); + } + assertThat(gate(CollectionMode.VALIDATION, null, null, null).runtimeSnapshot(false).intervalMs()).isEqualTo(5000); + verifyNoInteractions(authority); + } + @Test void serializedSnapshotContainsNoRawIdentityAddressCredentialOrVerifiedClaim() throws Exception { + var raw = new ObjectMapper().findAndRegisterModules().writeValueAsString( + gate(CollectionMode.BUSINESS, "private-account", "private-grant", URI.create("http://private-host:9303")).runtimeSnapshot(true)); + assertThat(raw).doesNotContain("private-account", "private-grant", "private-host", "http://", "verified", "appSecret", "internalServiceToken"); + verifyNoInteractions(authority); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingHttpBudgetAuthorityTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingHttpBudgetAuthorityTest.java new file mode 100644 index 000000000..1df6dc50d --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingHttpBudgetAuthorityTest.java @@ -0,0 +1,143 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingEgressProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.io.IOException; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.*; +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** Loopback authority only; the persisted collector ledger is tested by its own service. */ +class LingxingHttpBudgetAuthorityTest { + private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + private final AtomicReference response = new AtomicReference<>(); + private final AtomicInteger status = new AtomicInteger(200); + private final AtomicInteger calls = new AtomicInteger(); + private final AtomicReference path = new AtomicReference<>(), token = new AtomicReference<>(), body = new AtomicReference<>(); + private HttpServer server; + private static final String TOKEN = "synthetic-internal-token-0123456789"; + private final AuthorizationRequest request = new AuthorizationRequest(LingxingEgressGateTest.context("attempt"), Operation.WAREHOUSES, LingxingEgressGateTest.HASH); + @BeforeEach void start() throws IOException { + response.set("{\"code\":\"200\",\"data\":true}"); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + calls.incrementAndGet(); path.set(exchange.getRequestURI().getPath()); + token.set(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token")); + body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + exchange.getResponseHeaders().set("Location", "/redirect-must-not-follow"); + byte[] bytes = response.get().getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status.get(), bytes.length); exchange.getResponseBody().write(bytes); exchange.close(); + }); + server.start(); + } + @AfterEach void stop() { server.stop(0); } + private LingxingHttpBudgetAuthority authority(String serviceToken) { + return new LingxingHttpBudgetAuthority(new LingxingEgressProperties(CollectionMode.VALIDATION, "test", "demo-account", "demo-grant", + LingxingEgressGateTest.NOW.plusSeconds(3600), URI.create("http://127.0.0.1:" + server.getAddress().getPort()), null, null, false), mapper, serviceToken); + } + private CompletionRequest completion() { return new CompletionRequest(request.context(), request.operation(), request.requestHash(), 1, null, 200, 200, false, null); } + private String permit() throws Exception { return mapper.writeValueAsString(new AttemptPermit(request.context(), request.operation(), request.requestHash(), 1, + LingxingEgressGateTest.NOW.plusSeconds(30), true, true)); } + @Test void fixedInternalEndpointsAndTokenCarryExactAuthorizationAndCompletionContext() throws Exception { + response.set("{\"code\":\"200\",\"data\":" + permit() + "}"); + var permit = authority(TOKEN).authorize(request); + assertThat(permit.context()).isEqualTo(request.context()); assertThat(permit.allowedAttempts()).isEqualTo(1); + assertThat(path.get()).isEqualTo("/internal/lastleg/self-operated-analytics/egress/authorize"); + assertThat(token.get()).isEqualTo(TOKEN); + assertThat(mapper.readTree(body.get())).isEqualTo(mapper.valueToTree(request)); + response.set("{\"code\":\"200\",\"data\":true}"); + authority(TOKEN).complete(completion()); + assertThat(path.get()).endsWith("/egress/complete"); assertThat(mapper.readTree(body.get())).isEqualTo(mapper.valueToTree(completion())); + assertThat(calls.get()).isEqualTo(2); + } + @ParameterizedTest @ValueSource(strings = {"false", "\"true\"", "1", "{}", "null"}) + void completionRequiresLiteralBooleanAcknowledgment(String data) { + response.set("{\"code\":\"200\",\"data\":" + data + "}"); + assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion())); + assertThat(calls.get()).isEqualTo(1); + } + @ParameterizedTest @ValueSource(strings = {"200", "\"201\"", "null"}) + void envelopeRequiresExactStringSuccessCode(String code) { + response.set("{\"code\":" + code + ",\"data\":true}"); + assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion())); + assertThat(calls.get()).isEqualTo(1); + } + @ParameterizedTest @ValueSource(ints = {302, 401, 429, 503}) + void statusErrorsNeverRetryOrFollowRedirectAndNeverEchoAuthorityBody(int code) { + status.set(code); response.set("private-fixture@example.test"); + var error = assertThrows(LingxingWmsException.class, () -> authority(TOKEN).authorize(request)); + assertThat(error.getCause()).isNull(); assertThat(error.getMessage()).doesNotContain("private-fixture", "synthetic-token"); + assertThat(calls.get()).isEqualTo(1); + } + @Test void missingTokenPreventsAllAuthorityHttp() { + assertThrows(LingxingWmsException.class, () -> authority("").authorize(request)); + assertThrows(LingxingWmsException.class, () -> authority(null).complete(completion())); + assertThat(calls.get()).isZero(); + } + @Test void missingServerConfiguredAuthorityPreventsHttp() { + var authority = new LingxingHttpBudgetAuthority(new LingxingEgressProperties(CollectionMode.VALIDATION, "test", "demo-account", "demo-grant", + LingxingEgressGateTest.NOW.plusSeconds(3600), null, null, null, false), mapper, "synthetic-token"); + assertThrows(LingxingWmsException.class, () -> authority.authorize(request)); assertThat(calls.get()).isZero(); + } + @Test void permitRejectsCoercedBooleansExtraFieldsAndInvalidAttemptCount() throws Exception { + String valid = permit(); + String[] invalid = {valid.replace("\"allowedAttempts\":1", "\"allowedAttempts\":2"), + valid.replace("\"allowedAttempts\":1", "\"allowedAttempts\":\"1\""), + valid.replace("\"exclusiveConfirmed\":true", "\"exclusiveConfirmed\":\"true\""), + valid.replace("\"budgetConsumed\":true", "\"budgetConsumed\":false"), + valid.substring(0, valid.length() - 1) + ",\"untrusted\":true}", + valid.replace("\"operation\":\"WAREHOUSES\"", "\"operation\":\"UNKNOWN\"")}; + for (var data : invalid) { + response.set("{\"code\":\"200\",\"data\":" + data + "}"); + assertThrows(LingxingWmsException.class, () -> authority(TOKEN).authorize(request)); + } + assertThat(calls.get()).isEqualTo(invalid.length); + } + @Test void trailingTokensAndMalformedResponseFailClosed() { + for (String value : new String[] {"{\"code\":\"200\",\"data\":true} {}", "[]", "{"}) { + response.set(value); assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion())); + } + assertThat(calls.get()).isEqualTo(3); + } + @Test void knownAuthorityDenialIsTypedAndDoesNotBecomeUnavailability() { + response.set("{\"code\":\"B0001\",\"data\":{\"errorCode\":\"SESSION_EXPIRED\",\"sampleDone\":true,\"waitUntil\":null}}"); + var error = assertThrows(LingxingBudgetDeniedException.class, () -> authority(TOKEN).authorize(request)); + assertThat(error.denial()).isEqualTo(new AuthorizationDenial("SESSION_EXPIRED", true, null)); + assertThat(calls.get()).isEqualTo(1); + response.set("{\"code\":\"B0001\",\"data\":{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":false,\"waitUntil\":\"2026-10-12T00:10:00Z\"}}"); + error = assertThrows(LingxingBudgetDeniedException.class, () -> authority(TOKEN).authorize(request)); + assertThat(error.denial().waitUntil()).isEqualTo(LingxingEgressGateTest.NOW.plusSeconds(600)); + assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion())); + } + @ParameterizedTest @ValueSource(strings = { + "{\"errorCode\":\"private@example.test\",\"sampleDone\":true,\"waitUntil\":null}", + "{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":\"true\",\"waitUntil\":null}", + "{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":true}", + "{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":true,\"waitUntil\":\"invalid\"}", + "{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":true,\"waitUntil\":null,\"extra\":true}" + }) + void malformedDenialFailsAsUnavailability(String data) { + response.set("{\"code\":\"B0001\",\"data\":" + data + "}"); + var error = assertThrows(LingxingWmsException.class, () -> authority(TOKEN).authorize(request)); + assertThat(error.code()).endsWith("BUDGET_AUTHORITY_UNAVAILABLE"); + assertThat(error.getMessage()).doesNotContain("private@example.test"); assertThat(calls.get()).isEqualTo(1); + } + @Test void allControlDtosRoundTripWithProductionTimeSerialization() throws Exception { + var permit = new AttemptPermit(request.context(), request.operation(), request.requestHash(), 1, LingxingEgressGateTest.NOW.plusSeconds(30), true, true); + var metadata = new AttemptMetadata("attempt", 1, true, LingxingEgressGateTest.NOW.plusSeconds(600)); + var denial = new AuthorizationDenial("ACCOUNT_COOLDOWN", false, metadata.cooldownUntil()); + for (var dto : new Object[] {request.context(), request, permit, completion(), metadata, denial}) { + assertThat(mapper.readValue(mapper.writeValueAsString(dto), dto.getClass())).isEqualTo(dto); + } + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingQuantityCaptureTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingQuantityCaptureTest.java new file mode 100644 index 000000000..d6b50bd31 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingQuantityCaptureTest.java @@ -0,0 +1,50 @@ +package com.baiyue.integration.provider.lingxing; +import static com.baiyue.integration.provider.lingxing.LingxingQuantityEvidence.*; +import static org.assertj.core.api.Assertions.*; +import com.baiyue.integration.config.LingxingQuantityCaptureProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import org.junit.jupiter.api.Test; +class LingxingQuantityCaptureTest { + final Instant now=Instant.parse("2026-10-14T00:00:00Z");final ObjectMapper json=new ObjectMapper(); + final EgressContext context=new EgressContext("account",CollectionMode.BUSINESS,"2","selfop-2","attempt","grant"); + View view(){return new View("diag","account",null,null,"ARMED",null,now,now.plusSeconds(36000),now.plusSeconds(21600),List.of("W"),"*","1",0,0,0,0,false,List.of());} + com.fasterxml.jackson.databind.JsonNode list(String id)throws Exception{return json.readTree("{\"records\":[{\"whCode\":\"W\",\"customerCode\":\"C\",\"deliveryNo\":\""+id+"\",\"productList\":[{\"productSku\":\"S\",\"qty\":10}]}]}");} + class Fake implements Sink {View state=view();List received=new ArrayList<>();String stopped;public View status(Status s){return state;}public Receipt observe(Event e){received.add(e);return new Receipt("ACCEPTED",null,state);}public View stop(Stop s){stopped=s.reason();return state;}} + LingxingQuantityCapture capture(Sink sink){return new LingxingQuantityCapture(new LingxingQuantityCaptureProperties(true,"account"),sink,Clock.fixed(now,ZoneOffset.UTC),false);} + @Test void observationDoesNotCallReceiverAndLocateNeverBinds()throws Exception{ + Fake sink=new Fake();try(var c=new TestClose(capture(sink))){c.value.pump();c.value.observe(list("a"),Operation.OUTBOUND_LOCATE,context,now);c.value.pump();assertThat(sink.received).isEmpty(); + c.value.observe(list("a"),Operation.OUTBOUND_QUERY,context,now);assertThat(sink.received).isEmpty();c.value.pump();assertThat(sink.received).hasSize(1);assertThat(sink.received.get(0).operation()).isEqualTo("OUTBOUND_QUERY");} + } + @Test void slowReceiverNeverBlocksNormalObservationAndFullQueueStopsOnlyDiagnosis()throws Exception{ + CountDownLatch entered=new CountDownLatch(1),release=new CountDownLatch(1);Fake sink=new Fake(){public Receipt observe(Event e){entered.countDown();try{release.await(3,TimeUnit.SECONDS);}catch(InterruptedException x){Thread.currentThread().interrupt();}return super.observe(e);}}; + var c=capture(sink);c.pump();c.observe(list("first"),Operation.OUTBOUND_QUERY,context,now);var worker=Executors.newSingleThreadExecutor();try{var pending=worker.submit(c::pump);assertThat(entered.await(1,TimeUnit.SECONDS)).isTrue(); + assertThatCode(()->{for(int i=0;i<100;i++)c.observe(list("o"+i),Operation.OUTBOUND_QUERY,context,now);}).doesNotThrowAnyException(); + release.countDown();pending.get(5,TimeUnit.SECONDS); + }finally{release.countDown();worker.shutdownNow();c.close();} + } + @Test void failureIsolatedAndNoRearmOfSameGrant()throws Exception{ + Fake sink=new Fake(){public Receipt observe(Event e){throw new IllegalStateException("receiver down");}};var c=capture(sink);try{c.pump();c.observe(list("a"),Operation.OUTBOUND_QUERY,context,now);assertThatCode(c::pump).doesNotThrowAnyException();assertThat(c.stopReason()).isEqualTo("INCOMPLETE");c.pump();assertThat(sink.stopped).isEqualTo("INCOMPLETE");}finally{c.close();} + } + @Test void selectedCandidatesRecoveredWithoutReobservingList()throws Exception{ + Fake sink=new Fake();sink.state=new View("diag","account","2","selfop-2","BOUND",null,now,now.plusSeconds(14400),now.plusSeconds(21600),List.of("W"),"*","1",1,0,1,100,true,List.of(new Key("W","C","a",false))); + var c=capture(sink);try{c.pump();c.observe(list("a"),Operation.OUTBOUND_QUERY,context,now);c.observe(json.readTree("{\"whCode\":\"W\",\"customerCode\":\"C\",\"deliveryNo\":\"a\",\"skuVOList\":[{\"productSku\":\"S\",\"deliveryQty\":10,\"actualDeliveryQty\":10}]}"),Operation.OUTBOUND_DETAIL,context,now);c.pump();assertThat(sink.received).hasSize(1);assertThat(sink.received.get(0).side()).isEqualTo("DETAIL");}finally{c.close();} + } + @Test void malformedOrOversizedSnapshotsAreIsolated()throws Exception{ + Fake sink=new Fake();var c=capture(sink);try{c.pump();var data=json.createObjectNode();var records=data.putArray("records");for(int i=0;i<101;i++)records.addObject();assertThatCode(()->c.observe(data,Operation.OUTBOUND_QUERY,context,now)).doesNotThrowAnyException();c.pump();assertThat(sink.stopped).isEqualTo("OBSERVER_FAILED");assertThat(sink.received).isEmpty();}finally{c.close();} + } + @Test void queueIsBoundedAndNoProducerWaitsForIt()throws Exception{ + Fake sink=new Fake();var c=capture(sink);try{c.pump();for(int i=0;i<100;i++)c.observe(list("o"+i),Operation.OUTBOUND_QUERY,context,now); + var detail=json.readTree("{\"whCode\":\"W\",\"customerCode\":\"C\",\"deliveryNo\":\"o0\",\"skuVOList\":[{\"productSku\":\"S\",\"deliveryQty\":10,\"actualDeliveryQty\":10}]}"); + c.observe(detail,Operation.OUTBOUND_DETAIL,context,now);assertThat(c.stopReason()).isEqualTo("QUEUE_FULL");c.pump();assertThat(sink.stopped).isEqualTo("QUEUE_FULL");assertThat(sink.received).isEmpty(); + }finally{c.close();} + } + @Test void diagnosticLockContentionNeverWaitsOnCollector()throws Exception{ + Fake sink=new Fake();var c=capture(sink);c.pump();var field=LingxingQuantityCapture.class.getDeclaredField("intake");field.setAccessible(true);var lock=(java.util.concurrent.locks.ReentrantLock)field.get(c); + var pool=Executors.newSingleThreadExecutor();lock.lock();try{var observation=pool.submit(()->{try{c.observe(list("a"),Operation.OUTBOUND_QUERY,context,now);}catch(Exception e){throw new RuntimeException(e);}});observation.get(500,TimeUnit.MILLISECONDS);assertThat(c.stopReason()).isEqualTo("OBSERVER_FAILED");}finally{lock.unlock();pool.shutdownNow();c.close();} + } + record TestClose(LingxingQuantityCapture value)implements AutoCloseable{public void close(){value.close();}} +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSinkTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSinkTest.java new file mode 100644 index 000000000..2592d581b --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSinkTest.java @@ -0,0 +1,32 @@ +package com.baiyue.integration.provider.lingxing; +import org.junit.jupiter.api.Test; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.Flow; +import static org.assertj.core.api.Assertions.*; +class LingxingQuantityHttpSinkTest { + @Test void internalWireContractAuthenticatesAndDoesNotRetry()throws Exception{ + var server=com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress("127.0.0.1",0),0);var calls=new java.util.concurrent.atomic.AtomicInteger();var header=new java.util.concurrent.atomic.AtomicReference();var path=new java.util.concurrent.atomic.AtomicReference(); + server.createContext("/internal/lastleg/self-operated-analytics/quantity-diagnostics/",exchange->{calls.incrementAndGet();header.set(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token"));path.set(exchange.getRequestURI().getPath());exchange.getRequestBody().readAllBytes();byte[] payload="{\"code\":\"200\",\"data\":null}".getBytes(java.nio.charset.StandardCharsets.UTF_8);exchange.sendResponseHeaders(200,payload.length);exchange.getResponseBody().write(payload);exchange.close();});server.start(); + try{ + var props=new com.baiyue.integration.config.LingxingEgressProperties(com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode.OFF,"test",null,null,null,java.net.URI.create("http://127.0.0.1:"+server.getAddress().getPort()),null,null,false); + String token="synthetic-internal-token-000000000000";var sink=new LingxingQuantityHttpSink(props,new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(),token); + assertThat(sink.status(new LingxingQuantityEvidence.Status("account","producer",true,false))).isNull();assertThat(header.get()).isEqualTo(token);assertThat(path.get()).endsWith("/status");assertThat(calls.get()).isEqualTo(1); + sink.stop(new LingxingQuantityEvidence.Stop("diag","account","producer","INCOMPLETE"));assertThat(path.get()).endsWith("/stop");assertThat(calls.get()).isEqualTo(2); + }finally{server.stop(0);} + } + @Test void bodyLimitCancelsBeforeRetainingOversizedResponse(){ + var body=new LingxingQuantityHttpSink.LimitedBody(16);boolean[] cancelled={false};body.onSubscribe(new Flow.Subscription(){public void request(long n){}public void cancel(){cancelled[0]=true;}}); + body.onNext(List.of(ByteBuffer.wrap(new byte[8])));body.onNext(List.of(ByteBuffer.wrap(new byte[9]))); + assertThat(cancelled[0]).isTrue();assertThat(body.getBody().toCompletableFuture()).isCompletedExceptionally(); + } + @Test void hundredFullUnicodeKeysFitTheBoundedResponse()throws Exception{ + var keys=new java.util.ArrayList();for(int i=0;i<100;i++)keys.add(new LingxingQuantityEvidence.Key("倉".repeat(64),"客".repeat(128),"単".repeat(125)+i,false)); + byte[] payload=new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsBytes(java.util.Map.of("code","200","data",java.util.Map.of("selected",keys))); + assertThat(payload.length).isGreaterThan(65536).isLessThan(262144); + var body=new LingxingQuantityHttpSink.LimitedBody(262144);body.onSubscribe(new Flow.Subscription(){public void request(long n){}public void cancel(){}});body.onNext(List.of(ByteBuffer.wrap(payload)));body.onComplete();assertThat(body.getBody().toCompletableFuture().join()).isEqualTo(payload); + } + @Test void boundedSuccessfulBodyIsExact(){ + var body=new LingxingQuantityHttpSink.LimitedBody(16);body.onSubscribe(new Flow.Subscription(){public void request(long n){}public void cancel(){}});body.onNext(List.of(ByteBuffer.wrap(new byte[]{1,2})));body.onComplete();assertThat(body.getBody().toCompletableFuture().join()).containsExactly(1,2); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java new file mode 100644 index 000000000..bbd3a6718 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java @@ -0,0 +1,424 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingWmsProperties; +import com.baiyue.integration.config.LingxingEgressProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** Synthetic loopback transport only; no supplier or application services. */ +class LingxingWmsClientTest { + @Test void warehouseFlowPageKeepsScopeAndUsesSharedPermit() throws Exception { + response.set(page("current","size",2,1,2,2,"{\"id\":\"F2\",\"productQuality\":0,\"customerCode\":\"C\",\"whCode\":\"W\",\"productSku\":\"SKU\",\"sourceNo\":\"SOURCE\",\"bookChangQty\":\"-1\"}")); + var request=new InventoryFlowPageRequest("W",LocalDateTime.parse("2026-10-01T00:00:00"),LocalDateTime.parse("2026-10-02T00:00:00"),2,1); + var result=client().queryInventoryFlowPage(request,context()); + assertThat(result.records().get(0).sourceNo()).isEqualTo("SOURCE"); + var business=mapper.readTree(body.get()).path("data"); + assertThat(business.path("whCode").asText()).isEqualTo("W"); + assertThat(business.has("customerCode")).isFalse();assertThat(business.has("productSku")).isFalse(); + assertThat(business.path("current").asInt()).isEqualTo(2); + assertThat(completion.get().operation()).isEqualTo(Operation.INVENTORY_FLOW); + assertThrows(LingxingWmsException.class,()->client().queryInventoryFlowPage(new InventoryFlowPageRequest("W",request.startTime(),request.endTime().plusSeconds(1),1,1000),context())); + assertThat(calls.get()).isEqualTo(1); + } + @Test void customerDirectoryKeepsNamesAndUsesAccountGate() { + response.set(page("page","pageSize",1,1000,1,1,"{\"customerCode\":\"C\",\"customerName\":\"示例货主\",\"status\":\"0\",\"telephoneNo\":\"not-forwarded\"}")); + var result=client().queryCustomers(new WarehouseQueryRequest(1,1000),context()); + assertThat(path.get()).isEqualTo("/openapi/v2/customer/list"); + assertThat(result.records()).containsExactly(new Customer("C","示例货主","0")); + assertThat(completion.get().operation()).isEqualTo(Operation.CUSTOMERS); + } + @Test void productDirectoryRetainsDeclaredDimensionsAndUnknownMeasurements() { + response.set(page("page","pageSize",1,100,1,1,"{\"customerCode\":\"C\",\"sku\":\"SKU\",\"productName\":\"商品\",\"length\":5,\"width\":11,\"height\":5,\"sizeUnit\":\"cm\",\"wmsLength\":\"\",\"wmsWidth\":\"\",\"wmsHeight\":\"\",\"wmsSizeUnit\":\"\"}")); + var result=client().queryProducts(new ProductQueryRequest(1,100),context()); + assertThat(result.records().get(0).length()).isEqualByComparingTo("5"); + assertThat(result.records().get(0).wmsLength()).isNull(); + assertThat(result.records().get(0).sku()).isEqualTo("SKU"); + assertThat(completion.get().operation()).isEqualTo(Operation.PRODUCTS); + assertThatThrownBy(()->client().queryProducts(new ProductQueryRequest(1,101),context())).isInstanceOf(LingxingWmsException.class); + assertThat(calls.get()).isEqualTo(1); + } + @Test void masterDataUsesSupplierResponsePaginationAndRejectsWrongPage() throws Exception { + response.set(page("page","pageSize",2,1,2,2,"{\"customerCode\":\"C\",\"sku\":\"SKU\"}")); + var result=client().queryProducts(new ProductQueryRequest(2,1),context()); + assertThat(result.current()).isEqualTo(2); + assertThat(result.sourceCurrent()).isEqualTo(2); + assertThat(result.sourceSize()).isEqualTo(1); + var request=mapper.readTree(body.get()).path("data"); + assertThat(request.path("current").asInt()).isEqualTo(2); + assertThat(request.path("size").asInt()).isEqualTo(1); + assertThat(request.has("page")).isFalse(); + response.set(page("page","pageSize",1,1,2,2,"{\"customerCode\":\"C\",\"sku\":\"SKU\"}")); + assertThatThrownBy(()->client().queryProducts(new ProductQueryRequest(2,1),context())) + .isInstanceOf(LingxingWmsException.class).hasMessageContaining("CURRENT_MISMATCH"); + } + private static final Instant NOW = Instant.parse("2026-10-12T00:00:00Z"); + private static final String PRIVATE = "private-secret@example.test"; + private static final String ORDER = """ + {"deliveryNo":"DO-DEMO","sourceNo":"SRC-DEMO","customerCode":"C-DEMO","whCode":"W-DEMO", + "status":100,"outboundTime":"2026-10-01 01:00:00"} + """; + private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + private final AtomicInteger calls = new AtomicInteger(); + private final AtomicInteger status = new AtomicInteger(200); + private final AtomicReference response = new AtomicReference<>(); + private final AtomicReference body = new AtomicReference<>(); + private final AtomicReference path = new AtomicReference<>(); + private HttpServer server; + private final AtomicInteger attempts = new AtomicInteger(); + private final AtomicReference retryAfter = new AtomicReference<>(); + private final AtomicReference completion = new AtomicReference<>(); + @BeforeEach void start() throws IOException { + response.set(page("current", "size", 1, 1, 1, 1, ORDER)); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/openapi/v2/", exchange -> { + calls.incrementAndGet(); path.set(exchange.getRequestURI().getPath()); + body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + byte[] bytes = response.get().getBytes(StandardCharsets.UTF_8); + if (retryAfter.get() != null) exchange.getResponseHeaders().set("Retry-After", retryAfter.get()); + exchange.sendResponseHeaders(status.get(), bytes.length); + exchange.getResponseBody().write(bytes); exchange.close(); + }); + server.start(); + } + @AfterEach void stop() { server.stop(0); } + @Test void observerFailureCannotChangeBusinessResponseOrIssueExtraRequests() { + var observer = org.mockito.Mockito.mock(LingxingQuantityCapture.class); + org.mockito.Mockito.doThrow(new IllegalStateException("diagnostic failure")).when(observer) + .observe(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(Operation.class), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + var c = client(); c.setQuantityCapture(observer); + assertThat(c.queryPage(query(1, 1), context()).records()).hasSize(1); + assertThat(calls.get()).isEqualTo(1); + } + @Test void deniedOrRateLimitedRequestsNeverReachObserver() { + var observer = org.mockito.Mockito.mock(LingxingQuantityCapture.class); + var disabled = client(CollectionMode.OFF, true, true); disabled.setQuantityCapture(observer); + assertThrows(LingxingWmsException.class, () -> disabled.queryPage(query(1, 1), context())); + assertThat(calls.get()).isZero(); + var enabled = client(); enabled.setQuantityCapture(observer); status.set(429); + assertThrows(LingxingWmsException.class, () -> enabled.queryPage(query(1, 1), context())); + org.mockito.Mockito.verifyNoInteractions(observer); assertThat(calls.get()).isEqualTo(1); + } + @Test void discoveryFiltersOutboundTimeAndUsesWhitelist() throws Exception { + var result = client().queryPage(query(1, 1), context()); + var data = mapper.readTree(body.get()).path("data"); + assertThat(data.size()).isEqualTo(6); + assertThat(data.path("timeType").asText()).isEqualTo("outboundTime"); + assertThat(data.path("status").intValue()).isEqualTo(100); + assertThat(data.has("whCode")).isFalse(); + assertThat(path.get()).isEqualTo("/openapi/v2/order/delivery/page"); + assertThat(result.observedAt()).isEqualTo(NOW); + assertThat(result.pages()).isEqualTo(1); + } + @Test void listProductsPreserveDecimalsZeroAndMissingQuantity() { + response.set(page("current","size",1,1,1,1,ORDER.strip().replace("}",""" + ,"productList":[{"productSku":"SKU","qty":2.123456},{"productSku":"ZERO","qty":0}, + {"productSku":"UNKNOWN"},{"qty":4}]}"""))); + var products=client().queryPage(query(1,1),context()).records().get(0).productList(); + assertThat(products).hasSize(4); + assertThat(products.get(0).qty()).isEqualByComparingTo("2.123456"); + assertThat(products.get(1).qty()).isEqualByComparingTo("0"); + assertThat(products.get(2).qty()).isNull();assertThat(products.get(3).productSku()).isNull(); + assertThat(calls.get()).isEqualTo(1); + } + @Test void cancelledLocateOmitsTimeAndStatusAndRejectsFuzzyMatch() throws Exception { + response.set(page("current", "size", 1, 1, 1, 1, ORDER.replace("100", "99").replace("2026-10-01 01:00:00", ""))); + var observer = org.mockito.Mockito.mock(LingxingQuantityCapture.class); + var c = client(); c.setQuantityCapture(observer); + var result = c.locate(new LocateRequest("DO-DEMO", 1, 1), context()); + org.mockito.Mockito.verifyNoInteractions(observer); + var data = mapper.readTree(body.get()).path("data"); + assertThat(data.size()).isEqualTo(3); + assertThat(data.has("status")).isFalse(); assertThat(data.has("startTime")).isFalse(); + assertThat(result.records().get(0).status()).isEqualTo(99); + assertThat(result.records().get(0).outboundTime()).isEmpty(); + response.set(page("current", "size", 1, 1, 1, 1, ORDER.replace("DO-DEMO", "DO-DEMO-X"))); + contract(() -> client().locate(new LocateRequest("DO-DEMO", 1, 1), context()), "LOCATE_DELIVERY_NO_MISMATCH"); + } + @Test void exactEmptyExceptionKeepsSourceSizeAndNeverAppliesToInventory() { + response.set(page("current", "size", 1, 10, 0, 0, "")); + var result = client().queryPage(query(1, 100), context()); + assertThat(result.records()).isEmpty(); assertThat(result.total()).isZero(); + assertThat(result.size()).isEqualTo(100); assertThat(result.sourceSize()).isEqualTo(10); + contract(() -> client().queryPage(query(1, 99), context()), "SIZE_MISMATCH"); + contract(() -> client().queryPage(query(2, 100), context()), "CURRENT_MISMATCH"); + contract(() -> client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 100), context()), "SIZE_MISMATCH"); + response.set(page("current", "size", 1, 10, 1, 1, ORDER)); + contract(() -> client().queryPage(query(1, 100), context()), "SIZE_MISMATCH"); + } + @Test void warehousesMapPageAndPageSizeWithoutTreatingTimezoneAsSource() throws Exception { + response.set(page("page", "pageSize", 1, 1000, 1, 1, """ + {"whCode":"W-DEMO","whNameCn":"Demo","warehouseType":1,"status":0,"timeZone":"America/New_York","address":"private"} + """)); + var result = client().queryWarehouses(new WarehouseQueryRequest(1, 1000), context()); + assertThat(result.sourceSize()).isEqualTo(1000); + assertThat(result.records().get(0).timeZone()).isEqualTo("America/New_York"); + assertThat(mapper.readTree(body.get()).path("data").size()).isEqualTo(2); + assertThat(path.get()).isEqualTo("/openapi/v2/warehouse/page"); + assertThat(mapper.writeValueAsString(result)).doesNotContain("address"); + } + @Test void detailPreservesDecimalLinesAndIgnoresPackageDuplicates() throws Exception { + response.set(""" + {"code":200,"data":{"deliveryNo":"DO-DEMO","customerCode":"C-DEMO","whCode":"W-DEMO","status":100, + "orderCreateTime":"2026-09-01 01:00:00","outboundTime":"2026-10-01 01:00:00", + "skuVOList":[{"productSku":"SKU","actualDeliveryQty":2.123456,"deliveryQty":6}, + {"productSku":"SKU","actualDeliveryQty":3,"deliveryQty":6}], + "packageList":[{"skuList":[{"actualDeliveryQty":"5"}]}],"email":"private"}} + """); + var result = client().queryDetail(detail(), context()); + assertThat(result.skuVOList()).hasSize(2); + assertThat(result.skuVOList().get(0).actualDeliveryQty()).isEqualByComparingTo("2.123456"); + assertThat(result.orderCreateTime()).isEqualTo("2026-09-01 01:00:00"); + assertThat(mapper.writeValueAsString(result.skuVOList())).doesNotContain("email", "packageList"); + assertThat(mapper.readTree(body.get()).path("data").toString()) + .isEqualTo("{\"whCode\":\"W-DEMO\",\"customerCode\":\"C-DEMO\",\"deliveryNo\":\"DO-DEMO\"}"); + response.set(response.get().replace("W-DEMO", "OTHER")); + contract(() -> client().queryDetail(detail(), context()), "DETAIL_IDENTITY_MISMATCH"); + } + @Test void cancelledDetailRetainsUnknownQuantityAndEmptyTime() { + response.set(""" + {"code":200,"data":{"deliveryNo":"DO-DEMO","customerCode":"C-DEMO","whCode":"W-DEMO","status":99, + "outboundTime":"","skuVOList":[{"productSku":"SKU","actualDeliveryQty":"","deliveryQty":1}]}} + """); + var result = client().queryDetail(detail(), context()); + assertThat(result.status()).isEqualTo(99); assertThat(result.outboundTime()).isEmpty(); + assertThat(result.skuVOList().get(0).actualDeliveryQty()).isNull(); + } + @Test void inventoryPreservesIndependentNumbersAndUnknownsAndRejectsDuplicates() throws Exception { + String stock = """ + {"customerCode":"C-DEMO","whCode":"W-DEMO","productSku":"SKU","productQuality":0, + "stkQty":120.123456,"freezeQty":20,"useStkQty":100,"transitQty":30,"innerBatchNo":"", + "stockCompare":{"stkQty":999},"phone":"private"} + """; + response.set(page("current", "size", 1, 1000, 1, 1, stock)); + var result = client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context()); + assertThat(mapper.readTree(body.get()).path("data").toString()) + .isEqualTo("{\"current\":1,\"size\":1000,\"whCode\":\"W-DEMO\",\"isHideInventory\":0}"); + var row = result.records().get(0); + assertThat(row.stkQty()).isEqualByComparingTo("120.123456"); + assertThat(row.useStkQty()).isEqualByComparingTo("100"); assertThat(row.transitQty()).isEqualByComparingTo("30"); + assertThat(row.innerBatchNo()).isEmpty(); + assertThat(mapper.writeValueAsString(result.records())).doesNotContain("phone", "stockCompare"); + response.set(page("current", "size", 1, 1000, 1, 1, stock.replace("120.123456", "0") + .replace("\"freezeQty\":20", "\"freezeQty\":\"\"").replace("\"useStkQty\":100", "\"useStkQty\":null") + .replace("\"transitQty\":30", "\"transitQty\":-1"))); + row = client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context()).records().get(0); + assertThat(row.stkQty()).isZero(); assertThat(row.freezeQty()).isNull(); assertThat(row.useStkQty()).isNull(); + assertThat(row.transitQty()).isEqualByComparingTo("-1"); + response.set(page("current", "size", 1, 1000, 2, 1, stock + "," + stock)); + contract(() -> client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context()), "DUPLICATE_INVENTORY_KEY"); + response.set(page("current", "size", 1, 1000, 1, 1, stock.replace("W-DEMO", "OTHER"))); + contract(() -> client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context()), "INVENTORY_WAREHOUSE_MISMATCH"); + } + @ParameterizedTest @ValueSource(strings = {"total", "pages", "current", "size"}) + void missingPaginationIsRejected(String field) { + response.set(page("current", "size", 1, 1, 1, 1, ORDER).replace("\"" + field + "\":1,", "")); + contract(() -> client().queryPage(query(1, 1), context()), ""); + } + @Test void shortPageAndWrongPageCountAreRejected() { + response.set(page("current", "size", 1, 2, 2, 1, ORDER)); + contract(() -> client().queryPage(query(1, 2), context()), "PAGE_ROW_COUNT_MISMATCH"); + response.set(page("current", "size", 1, 1, 1, 2, ORDER)); + contract(() -> client().queryPage(query(1, 1), context()), "PAGES_MISMATCH"); + } + @ParameterizedTest @ValueSource(ints = {401, 403, 302}) + void nonretryableHttpNeverEchoesOrRetries(int code) { + status.set(code); response.set(PRIVATE); + var error = assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context())); + assertThat(error.retryable()).isFalse(); assertThat(error.getCause()).isNull(); + assertThat(error.getMessage()).doesNotContain(PRIVATE); assertThat(calls.get()).isEqualTo(1); + } + @ParameterizedTest @ValueSource(ints = {429, 500, 503}) + void everyFailureHasOneHttpAttemptAndNeverImplicitRetry(int code) { + status.set(code); response.set(PRIVATE); + var error = assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context())); + assertThat(calls.get()).isEqualTo(1); + assertThat(error.getMessage()).doesNotContain(PRIVATE); + assertThat(error.egress().actualHttpAttempts()).isEqualTo(1); + assertThat(completion.get().actualHttpAttempts()).isEqualTo(1); + assertThat(completion.get().upstreamStatus()).isEqualTo(code); + } + @ParameterizedTest @ValueSource(strings = {"600", "Mon, 12 Oct 2026 00:10:00 GMT", "invalid"}) + void rateLimitPausesAccountAcrossAllEntrypointsAndReportsCooldown(String header) { + status.set(429); response.set(PRIVATE); retryAfter.set(header); + var client = client(); + var error = assertThrows(LingxingWmsException.class, () -> client.queryPage(query(1, 1), context())); + assertThat(error.egress().accountPaused()).isTrue(); + assertThat(error.egress().cooldownUntil()).isEqualTo(NOW.plusSeconds(header.equals("invalid") ? 300 : 600)); + assertThat(completion.get().accountPaused()).isTrue(); + assertThat(completion.get().cooldownUntil()).isEqualTo(error.egress().cooldownUntil()); + assertThatThrownBy(() -> client.locate(new LocateRequest("DO-DEMO", 1, 1), context())).hasMessageContaining("ACCOUNT_PAUSED"); + assertThatThrownBy(() -> client.queryWarehouses(new WarehouseQueryRequest(1, 1), context())).hasMessageContaining("ACCOUNT_PAUSED"); + assertThatThrownBy(() -> client.queryDetail(detail(), context())).hasMessageContaining("ACCOUNT_PAUSED"); + assertThatThrownBy(() -> client.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1), context())).hasMessageContaining("ACCOUNT_PAUSED"); + assertThat(calls.get()).isEqualTo(1); + } + @Test void validationRejectsLargePageBeforeHttpOrBudget() { + var c = client(CollectionMode.VALIDATION, true, true); + assertThatThrownBy(() -> c.queryPage(query(1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数"); + assertThatThrownBy(() -> c.locate(new LocateRequest("DO-DEMO", 1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数"); + assertThatThrownBy(() -> c.queryWarehouses(new WarehouseQueryRequest(1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数"); + assertThatThrownBy(() -> c.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数"); + assertThat(calls.get()).isZero(); assertThat(completion.get()).isNull(); + } + @Test void validationNeverRetriesEvenServerError() { + status.set(503); response.set(PRIVATE); + assertThrows(LingxingWmsException.class, () -> client(CollectionMode.VALIDATION, true, true) + .queryPage(query(1, 1), context(CollectionMode.VALIDATION))); + assertThat(calls.get()).isEqualTo(1); + } + @ParameterizedTest @ValueSource(strings = {"31001", "\"429\""}) + void businessRateLimitCodesPauseWithoutHttpRetry(String sourceCode) { + response.set("{\"code\":" + sourceCode + ",\"msg\":\"" + PRIVATE + "\"}"); + var c = client(); + var error = assertThrows(LingxingWmsException.class, () -> c.queryPage(query(1, 1), context())); + assertThat(error.egress().actualHttpAttempts()).isEqualTo(1); assertThat(error.egress().accountPaused()).isTrue(); + assertThat(completion.get().sourceCode()).isIn(31001, 429); + assertThatThrownBy(() -> c.queryDetail(detail(), context())).hasMessageContaining("ACCOUNT_PAUSED"); + assertThat(calls.get()).isEqualTo(1); assertThat(error.getMessage()).doesNotContain(PRIVATE); + } + @Test void allFiveEntrypointsRequireControlContext() { + var c = client(); + assertThrows(LingxingWmsException.class, () -> c.queryPage(query(1, 1), null)); + assertThrows(LingxingWmsException.class, () -> c.locate(new LocateRequest("DO-DEMO", 1, 1), null)); + assertThrows(LingxingWmsException.class, () -> c.queryDetail(detail(), null)); + assertThrows(LingxingWmsException.class, () -> c.queryWarehouses(new WarehouseQueryRequest(1, 1), null)); + assertThrows(LingxingWmsException.class, () -> c.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1), null)); + assertThat(calls.get()).isZero(); assertThat(completion.get()).isNull(); + } + @Test void allFiveEntrypointsFailClosedForOffMissingGrantOrBudget() { + for (var c : new LingxingWmsClient[] {client(CollectionMode.OFF, true, true), + client(CollectionMode.BUSINESS, false, true), client(CollectionMode.BUSINESS, true, false)}) { + assertThrows(LingxingWmsException.class, () -> c.queryPage(query(1, 1), context())); + assertThrows(LingxingWmsException.class, () -> c.locate(new LocateRequest("DO-DEMO", 1, 1), context())); + assertThrows(LingxingWmsException.class, () -> c.queryWarehouses(new WarehouseQueryRequest(1, 1), context())); + assertThrows(LingxingWmsException.class, () -> c.queryDetail(detail(), context())); + assertThrows(LingxingWmsException.class, () -> c.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1), context())); + } + assertThat(calls.get()).isZero(); assertThat(completion.get()).isNull(); + } + @ParameterizedTest @ValueSource(strings = {"\"11006\"", "11008", "\"200\"", "null"}) + void rejectedEnvelopesNeverEchoRawMessage(String code) { + response.set("{\"code\":" + code + ",\"msg\":\"" + PRIVATE + "\"}"); + var error = assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context())); + assertThat(error.getMessage()).doesNotContain(PRIVATE); assertThat(error.getCause()).isNull(); + assertThat(calls.get()).isEqualTo(1); + } + @ParameterizedTest @ValueSource(strings = {"false", "null", "\"true\""}) + void presentSuccessMustBeBooleanTrue(String success) { + response.set(page("current", "size", 1, 1, 1, 1, ORDER).replace("\"code\":200", "\"code\":200,\"success\":" + success)); + assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context())); + } + private InventoryFlowRequest flow() {return new InventoryFlowRequest("W-DEMO","C-DEMO","SKU-DEMO", + LocalDateTime.of(2026,9,14,8,0),LocalDateTime.of(2026,10,14,8,0));} + @Test void flowUsesExactScopeSinglePageAndPreservesSignedChangeAndLargeId() throws Exception { + response.set(page("current","size",1,1000,1,1,""" + {"id":"9223372036854775806","customerCode":"C-DEMO","whCode":"W-DEMO","productSku":"SKU-DEMO", + "productQuality":0,"bookQty":-3,"bookChangQty":"-3","closeQty":17,"createTime":"2026-10-13 12:00:00"} + """)); + var result=client().queryInventoryFlow(flow(),context()); + assertThat(path.get()).isEqualTo("/openapi/v2/stock/stockCellBook/page"); + var sent=mapper.readTree(body.get()).path("data"); + assertThat(sent.path("barcodeAccurate").asText()).isEqualTo("SKU-DEMO"); + assertThat(sent.path("size").asInt()).isEqualTo(1000);assertThat(sent.has("productQuality")).isFalse(); + assertThat(result.records().get(0).id()).isEqualTo("9223372036854775806"); + assertThat(result.records().get(0).productQuality()).isZero(); + assertThat(result.records().get(0).bookQty()).isEqualByComparingTo("-3"); + assertThat(result.records().get(0).bookChangQty()).isEqualTo("-3");assertThat(calls.get()).isEqualTo(1); + assertThat(completion.get().operation()).isEqualTo(Operation.INVENTORY_FLOW); + } + @Test void flowRejectsNumericIdInsteadOfCoercingUnverifiedSourceTypes() { + response.set(page("current","size",1,1000,1,1,""" + {"id":123,"customerCode":"C-DEMO","whCode":"W-DEMO","productSku":"SKU-DEMO", + "productQuality":0,"bookQty":-1,"bookChangQty":"-1","closeQty":9} + """)); + contract(()->client().queryInventoryFlow(flow(),context()),"TEXT_FIELD_TYPE_NUMBER"); + } + @Test void flowAcceptsVerifiedEmptyFirstPageAndPreservesSourcePagination() { + response.set(page("current","size",1,10,0,0,"")); + var result=client().queryInventoryFlow(flow(),context()); + assertThat(result.records()).isEmpty(); + assertThat(result.total()).isZero(); + assertThat(result.current()).isEqualTo(1); + assertThat(result.size()).isEqualTo(1000); + assertThat(result.sourceSize()).isEqualTo(10); + assertThat(calls.get()).isEqualTo(1); + assertThat(completion.get().operation()).isEqualTo(Operation.INVENTORY_FLOW); + } + @Test void flowEmptyExceptionDoesNotAcceptNonemptyOrMalformedPagination() { + var c=client(); + response.set(page("current","size",1,10,1,1,"{}")); + contract(()->c.queryInventoryFlow(flow(),context()),"SIZE_MISMATCH"); + response.set(page("current","size",2,10,0,0,"")); + contract(()->c.queryInventoryFlow(flow(),context()),"CURRENT_MISMATCH"); + response.set(page("current","size",1,10,0,1,"")); + contract(()->c.queryInventoryFlow(flow(),context()),"SIZE_MISMATCH"); + response.set(page("current","size",1,20,0,0,"")); + contract(()->c.queryInventoryFlow(flow(),context()),"SIZE_MISMATCH"); + } + @Test void flowEmptyExceptionDoesNotRelaxInventoryOrOtherOutboundSizes() { + var c=client();response.set(page("current","size",1,10,0,0,"")); + contract(()->c.queryInventory(new InventoryQueryRequest("W-DEMO",1,1000),context()),"SIZE_MISMATCH"); + contract(()->c.queryPage(query(1,20),context()),"SIZE_MISMATCH"); + } + @Test void flowPermissionDenialIsScopedAndDoesNotStopOrdinaryQueries() { + var c=client();response.set("{\"code\":11008,\"msg\":\"private\"}"); + var error=assertThrows(LingxingWmsException.class,()->c.queryInventoryFlow(flow(),context())); + assertThat(error.code()).isEqualTo("LINGXING_WMS_FLOW_PERMISSION_DENIED"); + assertThat(completion.get().accountPaused()).isFalse(); + response.set(page("current","size",1,1,1,1,ORDER)); + assertThat(c.queryPage(query(1,1),context()).records()).hasSize(1);assertThat(calls.get()).isEqualTo(2); + } + @Test void flowRejectsUnboundedWindowBeforeSendingAndWrongSkuAfterResponse() { + var q=flow();var c=client(); + assertThrows(LingxingWmsException.class,()->c.queryInventoryFlow(new InventoryFlowRequest(q.warehouseCode(),q.customerCode(),q.sku(),q.startTime(),q.startTime().plusDays(32)),context())); + assertThat(calls.get()).isZero(); + response.set(page("current","size",1,1000,1,1,"{\"whCode\":\"W-DEMO\",\"customerCode\":\"C-DEMO\",\"productSku\":\"OTHER\"}")); + contract(()->c.queryInventoryFlow(q,context()),"FLOW_IDENTITY_MISMATCH"); + } + private LingxingWmsClient client() { return client(CollectionMode.BUSINESS, true, true); } + private LingxingWmsClient client(CollectionMode mode, boolean grant, boolean budget) { + var clock = new LingxingEgressGateTest.MutableClock(NOW); + var p = new LingxingWmsProperties(true, URI.create("http://127.0.0.1:" + server.getAddress().getPort()), + "synthetic-key", "synthetic-secret", Duration.ofSeconds(2), Duration.ofSeconds(2)); + var props = new LingxingEgressProperties(mode, "production", "demo-account", grant ? "demo-grant" : null, + NOW.plusSeconds(3600), budget ? URI.create("http://127.0.0.1") : null, null, null, false); + var ledger = new LingxingEgressGateTest.LedgerAuthority(clock) { + @Override public void complete(CompletionRequest request) { super.complete(request); completion.set(request); } + }; + var gate = new LingxingEgressGate(props, ledger, clock, clock::advanceMillis); + return new LingxingWmsClient(p, mapper, clock, gate); + } + private EgressContext context() { return context(CollectionMode.BUSINESS); } + private EgressContext context(CollectionMode mode) { + return new EgressContext("demo-account", mode, "demo-root", "demo-session", "attempt-" + attempts.incrementAndGet(), "demo-grant"); + } + private static QueryRequest query(int current, int size) { + var end = LocalDateTime.of(2026, 10, 12, 8, 0); return new QueryRequest(end.minusMonths(1), end, current, size); + } + private static DetailRequest detail() { return new DetailRequest("W-DEMO", "C-DEMO", "DO-DEMO"); } + private static String page(String c, String s, int current, int size, long total, int pages, String rows) { + return "{\"code\":200,\"data\":{\"" + c + "\":" + current + ",\"" + s + "\":" + size + + ",\"total\":" + total + ",\"pages\":" + pages + ",\"records\":[" + rows + "]}}"; + } + private static void contract(Runnable action, String stage) { + var error = assertThrows(LingxingWmsException.class, action::run); + assertThat(error.code()).isEqualTo("LINGXING_WMS_INVALID_RESPONSE"); + assertThat(error.getMessage()).contains(stage).doesNotContain(PRIVATE); assertThat(error.retryable()).isFalse(); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsSignerTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsSignerTest.java new file mode 100644 index 000000000..a6eb7b538 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsSignerTest.java @@ -0,0 +1,90 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingWmsProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LingxingWmsSignerTest { + @Test + void matchesOfficialOmpParameterRenderingIncludingNamesPathAndBothSecrets() throws Exception { + var mapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + var signer = new LingxingWmsSigner(mapper); + var data = new LinkedHashMap(); + data.put("status", 0); + data.put("current", 1); + data.put("size", 10); + var signed = signer.sign("YOUR_APP_KEY", "YOUR_APP_SECRET", "/openapi/v2/customer/list", data, + Instant.ofEpochSecond(1790056664)); + var body = mapper.readTree(signed.body()); + String officialInput = "YOUR_APP_SECRET/openapi/v2/customer/listappKeyYOUR_APP_KEYdata{status=0, current=1, size=10}timestamp1790056664YOUR_APP_SECRET"; + assertThat(body.path("sign").textValue()).isEqualTo(hmac("YOUR_APP_SECRET", officialInput)); + var fieldNames = new ArrayList(); + body.path("data").fieldNames().forEachRemaining(fieldNames::add); + assertThat(fieldNames).containsExactly("status", "current", "size"); + assertThat(signed.body()).doesNotContain("\n", "YOUR_APP_SECRET", "authcode", "reqTime"); + assertThat(signed.toString()).doesNotContain("YOUR_APP_KEY", "YOUR_APP_SECRET", body.path("sign").textValue()); + } + + @Test + void nestedDataUsesTransmittedOrderAndMapListValueSyntax() throws Exception { + var mapper = new ObjectMapper(); + var signer = new LingxingWmsSigner(mapper); + var child = new LinkedHashMap(); + child.put("z", "中文"); + child.put("A", 2); + var data = new LinkedHashMap(); + data.put("items", List.of(child)); + var signed = signer.sign("key", "secret", "/openapi/v2/customer/list", data, Instant.ofEpochSecond(1770000000)); + String input = "secret/openapi/v2/customer/listappKeykeydata{items=[{z=中文, A=2}]}timestamp1770000000secret"; + assertThat(mapper.readTree(signed.body()).path("sign").textValue()).isEqualTo(hmac("secret", input)); + } + + @Test + void propertiesBindWmsOnlyAndRedactBothCredentials() { + var source = new MapConfigurationPropertySource(Map.of( + "baiyue.integration.lingxing-wms.enabled", "true", + "baiyue.integration.lingxing-wms.app-key", "example-key", + "baiyue.integration.lingxing-wms.app-secret", "example-secret")); + var properties = new Binder(source).bind("baiyue.integration.lingxing-wms", Bindable.of(LingxingWmsProperties.class)).get(); + assertThat(properties.enabled()).isTrue(); + assertThat(properties.hasCredentials()).isTrue(); + assertThat(properties.baseUri()).isEqualTo(URI.create("https://api.xlwms.com")); + assertThat(properties.connectTimeout()).isEqualTo(Duration.ofSeconds(5)); + assertThat(properties.requestTimeout()).isEqualTo(Duration.ofSeconds(15)); + assertThat(properties.toString()).doesNotContain("example-key", "example-secret"); + } + + @Test + void disabledIsDefaultAndPlainHttpOutsideLoopbackIsRejected() { + var properties = new LingxingWmsProperties(false, null, null, null, null, null); + assertThat(properties.enabled()).isFalse(); + assertThat(properties.hasCredentials()).isFalse(); + assertThatThrownBy(() -> new LingxingWmsProperties(true, URI.create("http://example.test"), "key", "secret", null, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + private static String hmac(String key, String input) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return HexFormat.of().formatHex(mac.doFinal(input.getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 96083fde7..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`。 @@ -22,6 +27,7 @@ | 功能 | 首个入口 | |---|---| | 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md` | +| 自营领星统计 | `selfoperated/analytics/`;平台入口 `controller/PlatformAnalyticsController.java`,契约见 `../../docs/自营业务/README.md`;权限源 `../iam/src/main/resources/db/selfoperated-analytics-permissions.sql`。 | | 订单查询与控制 / 导出 | `controller/LastlegOrderController.java`;异步导出见 `export/` 与 `lastleg-exports-v1.yaml` | | 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java` | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java` | @@ -105,17 +111,7 @@ ## 数据库表索引与注释约束 -| 业务域 | 表名 | 中文含义 | -|---|---|---| -| 车次外接 | `ll_fleet_external_booking` | 外接询价、约定报价与承运容量预订;历史进入车次历史快照 | -| 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件/发件、尾程订单、明细及状态历史 | -| 入仓计划与作业 | `ll_inbound_plan*`、`ll_inbound_pda*`、`ll_inbound_pallet_merge`、`ll_inbound_fba_appointment*` | 入仓计划、托盘分配、PDA 作业、合托及 FBA 预约 | -| 库存 | `ll_inventory_*`、`ll_inbound_stock_receipt`、`ll_return_stock_receipt` | 库存余额、仓位、流水、幂等及原入仓/退回实收来源;同托多票数量逐明细核平,重派按实际退回凭据扣减额度 | -| 派车与出库 | `ll_dispatch_*`、`ll_outbound_*`、`ll_delivery_order` | 派车、PDA 拣货、出库及派送 | -| 转仓与换标 | `ll_warehouse_transfer_*`、`ll_relabel_*` | 转仓单、库存转移、目标收货及换标 | -| 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 | -| 采购、卡车与本土散板 | `ll_overseas_procurement_*`、`ll_us_truck_*`、`ll_local_looseboard_*` | 海外采购、美国卡车运输及本土散板订单 | -| 通用资料 | `ll_overseas_quote*`、`ll_monthly_exchange_rate*`、`ll_vehicle` | 海外仓报价、月度汇率及车辆档案;不保存车辆派送履约历史 | +表名与中文含义按需查 `docs/business-index.md` 的“数据库表索引”;修改约束如下。 - 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义,不得只写英文、字段名或类型名。 - `SchemaChineseCommentContractTest` 自动扫描全部 `ll_` 表,无需维护表名白名单;新增字段必须同步更新基线 SQL、建表增量和存量备注迁移,增量脚本的 `ADD/MODIFY COLUMN` 不得丢失中文备注。 diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index 72fbaa3bf..0ec4d6a1e 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -44,7 +44,7 @@ | 派送完成回传 | `delivery/service/DeliveryCompletedOutboxService.java`;签收资料保存并自动完成时在业务事务内按关联 `sourceInboundId` 分别写 `OVERSEAS_DELIVERY_COMPLETED` Outbox,同车不同 FBA 派送单互不代完成;不保留人工完成入口 | | 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | | 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,排车和出库使用独立 PDA Controller | -| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;涉及箱唛换标时整张申请只能有一个 FBA 目的仓,服务端拒绝明细目的仓不一致的请求。普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核后等待客户查看照片确认。对外换标状态只投影为 `PENDING_PROCESSING/COMPLETED`(待处理/换标完成),`PROCESSING`、`CUSTOMER_CONFIRM_PENDING`、`READY_FOR_OUTBOUND` 只属于内部作业节点且不得直接出现在业务列表;客户确认后仅卡车派送进入 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 | +| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;涉及箱唛换标时整张申请只能有一个 FBA 目的仓,服务端拒绝明细目的仓不一致的请求。普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/PICKUP/AMAZON_PICKUP/CUSTOMER_PICKUP`,仅快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核后等待客户查看照片确认。对外换标状态只投影为 `PENDING_PROCESSING/COMPLETED`(待处理/换标完成),`PROCESSING`、`CUSTOMER_CONFIRM_PENDING`、`READY_FOR_OUTBOUND` 只属于内部作业节点且不得直接出现在业务列表;客户确认后仅平台卡车 `TRUCK` 进入 `WAIT_PRE_DISPATCH`;快递及普通自提、亚马逊自提、客户自提均直接创建换标出库单,普通库存仍复验实际托盘绑定,已分配仓储转运保留原转运出库链。客户确认前不得进入出库 | | PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token | | 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;按 `containerBookingId` 柜级接收,信封使用 `tenantId=0` 且门禁不存客户;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 | | 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,路线由当前已排托盘的 FBA 仓编码去重后按稳定顺序以英文逗号连接,预排与普通编辑请求均不接收路线;查询、预排与执行边界见“海外仓全流程开发路由” | @@ -66,3 +66,17 @@ | 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` | | 测试 | `src/test/java/com/baiyue/lastleg/` | | 真实事件消费验收 | `src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java`;覆盖真实 V2 建单、独立入仓单快照及下游转运/换标可消费性 | + +## 数据库表索引 + +| 业务域 | 表名 | 中文含义 | +|---|---|---| +| 车次外接 | `ll_fleet_external_booking` | 外接询价、约定报价与承运容量预订;历史进入车次历史快照 | +| 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件/发件、尾程订单、明细及状态历史 | +| 入仓计划与作业 | `ll_inbound_plan*`、`ll_inbound_pda*`、`ll_inbound_pallet_merge`、`ll_inbound_fba_appointment*` | 入仓计划、托盘分配、PDA 作业、合托及 FBA 预约 | +| 库存 | `ll_inventory_*`、`ll_inbound_stock_receipt`、`ll_return_stock_receipt` | 库存余额、仓位、流水、幂等及原入仓/退回实收来源;同托多票数量逐明细核平,重派按实际退回凭据扣减额度 | +| 派车与出库 | `ll_dispatch_*`、`ll_outbound_*`、`ll_delivery_order` | 派车、PDA 拣货、出库及派送 | +| 转仓与换标 | `ll_warehouse_transfer_*`、`ll_relabel_*` | 转仓单、库存转移、目标收货及换标 | +| 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 | +| 采购、卡车与本土散板 | `ll_overseas_procurement_*`、`ll_us_truck_*`、`ll_local_looseboard_*` | 海外采购、美国卡车运输及本土散板订单 | +| 通用资料 | `ll_overseas_quote*`、`ll_monthly_exchange_rate*`、`ll_vehicle` | 海外仓报价、月度汇率及车辆档案;不保存车辆派送履约历史 | diff --git a/lastleg-service/docs/lastleg-internal-modularization-baseline.md b/lastleg-service/docs/lastleg-internal-modularization-baseline.md index 3be67471e..68b4e6ef4 100644 --- a/lastleg-service/docs/lastleg-internal-modularization-baseline.md +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -7,11 +7,23 @@ 现行门禁为 `scripts/analyze-lastleg-dependencies.mjs`、对应 `.test.mjs` 和 `src/test/resources/architecture/lastleg-slice-dependencies.json`。下文第2至8节的规模、原安全投影和首切片说明保留为早期对比,不覆盖本节及当前正式统一认证契约。此次校正不修改既有跨业务流程,也不以新增模块级通配许可接受未知依赖。 - 统一认证使用common-security及Auth权威会话。旧6个LastlegSecurity投影类型不再要求存在,改为禁止恢复该切片及引用其退役类型;不存在的退役类被重新import也会失败。 -- 25条已核对的既有类引用逐条写入 `reviewedClassDependencies`,必须有完整源类、目标类及原因,拒绝通配符。尾程订单直接换标审核委托换标服务;退货实收转换标在同服务事务中建立申请及明细;仓储转运实测入仓同步计划/分配/收货事实并复用资源网关;PDA入口共用冻结仓身份;费用换算只由既有换算器读取月度汇率。此记录不授权同一模块其他类使用这些实现。 +- 已核对的既有类引用逐条写入 `reviewedClassDependencies`,必须有完整源类、目标类及原因,拒绝通配符。尾程订单直接换标审核委托换标服务;退货实收转换标在同服务事务中建立申请及明细;仓储转运实测入仓同步计划/分配/收货事实并复用资源网关;PDA入口共用冻结仓身份;费用换算只由既有换算器读取月度汇率。此记录不授权同一模块其他类使用这些实现。 - 未审阅的新增类引用仍按原切片门禁拦截;车辆应用层的向内依赖规则对所有引用执行,不能通过逐类记录绕过。fleet继续只消费车辆应用端口/服务,不读取车辆持久化适配器。 - 主源码禁止包循环。`DeliveryDocumentSnapshot` 是DTO快照解析和摘要装配,已从 `delivery/model` 移至 `delivery/service`,消除原DTO与model互相引用;枚举及公开JSON结构不变。不为循环设长期例外。 - 离线反向测试覆盖未授权新消费者、退役类型恢复/引用、通配记录、绕过车辆应用层和新增包环。通过门禁只证明这些结构规则,不证明消除了所有存量耦合或完成部署/业务验收。 +### 预约、车队与导出依赖收口(2026-10-12) + +- 预约规划只声明 `AppointmentSourceLinker` 端口;入仓域负责创建入口与关联明细实现,入仓请求声明自己的来源 DTO,再映射到规划模型。公开 JSON、事务及来源校验保持不变,规划包不反向依赖入仓实现。 +- 车队商业单使用本域的收货证据和签收快照;派送域实现车队声明的证据端口并提供完整签收事实,车队不再导入派送实体、Mapper 或完成策略。商业单编号保持原 LB/TR 规则,由本域生成;`fleet.business` 不反向依赖 `fleet.trip`。既有散板、卡车商业单的同库写入不在此次拆分范围,本次门禁通过不代表完成全域解耦。 +- 退货客户引用与转仓 FBA 目录各由本域内部客户端读取原权威服务,移除对车队客户端的借用;接口、令牌及 URL 配置不变。退货合箱共用 PDA 仓身份、转仓实收共用仓储资源网关,两条精确类边在基线中登记。 +- 平台导出作为组合入口,仅登记 `LastlegExportProviderService` 对 12 个公开查询服务与 DTO/投影的引用,不开放整个切片;车辆仍限应用层。排车规划适配器只使用车队公开规划和收货端口。采购对海外主数据的旧依赖已不存在,移除该旧许可及入向断言。 +- 反向检查继续拒绝新消费者、导出直连持久化、车辆应用层外向依赖和任何主源码包循环。分析器继续按既有显式 import、静态 import 及同包类型引用规则检查;本次不改变其识别范围。 + +### 平台自营统计边界 + +`selfoperated/analytics/` 在依赖分析器中作为独立 `selfoperated-analytics` 切片优先匹配,其 `outbound`、`inventory` 和 `model` 子包不归入客户出库、客户库存或尾程交接切片。该子域通过内部 HTTP 使用 Integration,持久化十张独立 `ll_selfop_*` 表,不引用现有客户履约实体或 Mapper。唯一基础能力引用为 `SelfopEgressController → security.InternalServiceTokenGuard`,复用内部令牌校验;基线仅登记此精确类边,不增加共享技术切片通配许可,包循环仍按同一门禁拒绝。 + ### 车次接入边界 车次关联客户排车单新增 `dispatch-delivery→fleet`:fleet声明 `FleetDispatchGateway`,dispatch内适配器锁定自身排车/出库/派送并同步车和司机快照;子排车修改与发车使用fleet公开的关联门禁。fleet不导入dispatch实体、Mapper或业务服务,因此不新增双向切片依赖。车次与客户单据的归属不变,关联和同步处于同一Lastleg事务;客户单据已经开工时不允许通过改派覆盖执行事实。 diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs index cd4f87a92..b051f695d 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs @@ -13,6 +13,7 @@ const sourceRoots = { const internalPrefix = 'com.baiyue.lastleg.'; const sliceRules = [ + ['selfoperated-analytics', /^com\.baiyue\.lastleg\.selfoperated\.analytics(?:\.|$)/], ['fleet', /^com\.baiyue\.lastleg\.fleet(?:\.|$)/], ['security-projection', /(?:^|\.)security\.(?:controller|dto|service)(?:\.|$)/], ['dispatch-delivery', /(?:^|\.)(?:dispatch|delivery|outbound)(?:\.|$)/], diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index d1adbc7cd..404ea68be 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -10,6 +10,29 @@ const analyzer = path.join(moduleRoot, 'scripts', 'analyze-lastleg-dependencies. const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'lastleg-slice-dependencies.json'); const vehiclePrefix = 'com.baiyue.lastleg.masterdata.vehicle'; +test('selfoperated analytics is independent of customer outbound and inventory slices', () => { + const report = analyze(); + const prefix = 'com.baiyue.lastleg.selfoperated.analytics.'; + const classes = report.classes.filter(entry => entry.fqcn.startsWith(prefix)); + assert.ok(classes.length > 0); + assert.ok(classes.some(entry => entry.packageName.endsWith('.inventory'))); + assert.ok(classes.some(entry => entry.packageName.endsWith('.outbound'))); + assert.ok(classes.every(entry => entry.slice === 'selfoperated-analytics')); + assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'selfoperated-analytics' + || edge.target === 'selfoperated-analytics'), [{ source: 'selfoperated-analytics', target: 'shared-technical', count: 2 }, { source: 'shared-technical', target: 'selfoperated-analytics', count: 1 }]); + assert.deepEqual(report.classDependencies.filter(edge => edge.source.startsWith(prefix) && !edge.target.startsWith(prefix)), [{ + source: prefix + 'diagnostic.QuantityDiagnosticController', target: 'com.baiyue.lastleg.security.InternalServiceTokenGuard', + }, { + source: prefix + 'request.SelfopEgressController', target: 'com.baiyue.lastleg.security.InternalServiceTokenGuard', + }]); + assert.deepEqual(report.packageSccs.filter(cycle => cycle.some(pkg => pkg.startsWith(prefix))), []); + const baseline = JSON.parse(readFileSync(contract, 'utf8')); + const source = classes.find(entry => entry.packageName.endsWith('.inventory')).fqcn; + const target = 'com.baiyue.lastleg.inventory.query.service.InventoryQueryService'; + report.classDependencies.push({ source, target }); + assert.ok(contractViolations(report, baseline).some(message => message.includes(`${source}->${target}`))); +}); + test('fleet stays a business slice and its policies have no infrastructure dependencies', () => { const report = analyze(); const classes = report.classes.filter(entry => entry.fqcn.startsWith('com.baiyue.lastleg.fleet.')); @@ -30,6 +53,10 @@ test('fleet stays a business slice and its policies have no infrastructure depen && edge.target.startsWith('com.baiyue.lastleg.fleet.')); assert.ok(dispatchEdges.length > 0); assert.ok(dispatchEdges.every(edge => /\.(FleetDispatchGateway|FleetDispatchBindingGuard|FleetTripExecutionService|FleetExecutionModels|FleetCompletionModels)$/.test(edge.target) + || edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningAdapter' + && /\.(FleetBookingIncomingGateway|FleetDispatchPlanningGateway)$/.test(edge.target) + || edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningMapper' + && edge.target === 'com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway' || edge.source === 'com.baiyue.lastleg.dispatch.fleet.PdaFleetHandoverController' && edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery'), 'dispatch must use fleet ports and guards, not its persistence mappers'); @@ -42,6 +69,7 @@ function analyze(sourceSet = 'main') { return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], { cwd: moduleRoot, encoding: 'utf8', + maxBuffer: 8 * 1024 * 1024, })); } @@ -85,7 +113,10 @@ test('vehicle master data remains the reviewed low-coupling first-slice candidat target: 'com.baiyue.lastleg.security.OverseasPermissions', }]); assert.ok(incoming.length > 0); - assert.ok(incoming.every(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && edge.target.startsWith(vehiclePrefix + '.application.'))); + assert.ok(incoming.every(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && edge.target.startsWith(vehiclePrefix + '.application.') + || edge.source === 'com.baiyue.lastleg.export.LastlegExportProviderService' + && [vehiclePrefix + '.application.VehicleApplicationService', vehiclePrefix + '.application.model.VehicleModels'].includes(edge.target)), + 'vehicle consumers use application contracts; the export composition has two exact read-only references'); assert.equal(report.packageSccs.some(component => component.some(name => name.startsWith(vehiclePrefix))), false); assert.equal(classes.some(entry => entry.kind === 'cross-service-adapter'), false); assert.equal(classes.some(entry => entry.imports.some(imported => /RestClient|\.client\./.test(imported))), false); @@ -126,6 +157,22 @@ test('reviewed references do not allow another consumer into the same slice', () assert.ok(contractViolations(report, baseline).some(message => message.includes(`${source}->${target}`))); }); +test('export composition approvals do not grant persistence access or new consumers', () => { + const baseline = JSON.parse(readFileSync(contract, 'utf8')); + const source = 'com.baiyue.lastleg.export.LastlegExportProviderService'; + const cases = [ + { source, target: vehiclePrefix + '.adapter.out.persistence.VehicleMapper' }, + { source, target: 'com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper' }, + { source: 'com.baiyue.lastleg.export.LastlegExportColumns', target: 'com.baiyue.lastleg.fleet.quote.FleetQuoteService' }, + ]; + for (const edge of cases) { + const report = analyze(); + report.classDependencies.push(edge); + assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`)), + 'each unreviewed consumer/target pair must still fail'); + } +}); + test('restoring a retired projection or importing its missing type still fails', () => { const baseline = JSON.parse(readFileSync(contract, 'utf8')); const report = analyze(); diff --git a/lastleg-service/scripts/selfoperated-test-env-acceptance.mjs b/lastleg-service/scripts/selfoperated-test-env-acceptance.mjs new file mode 100644 index 000000000..610dddf5d --- /dev/null +++ b/lastleg-service/scripts/selfoperated-test-env-acceptance.mjs @@ -0,0 +1,308 @@ +/** + * TEST-only Gateway acceptance. Default is a local plan; --execute performs login, + * read-only queries and, with --probe-off-command, one deliberately rejected INVENTORY + * command after verifying OFF. Never collects, publishes, creates fixtures or changes config. + * Credentials: node --env-file= BEFORE this script; this script does not read credential files. + * Required env: CHONGCHONG_ENV=TEST, CHONGCHONG_ADMIN_API_BASE_URL (ends /api), + * CHONGCHONG_ADMIN_USERNAME/PASSWORD, CHONGCHONG_MERCHANT_USERNAME/PASSWORD, + * SELFOP_READONLY_USERNAME/PASSWORD (read permission only), + * SELFOP_DENIED_USERNAME/PASSWORD (platform without read or sync permission). + * Example: node --env-file= ") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewService.java index d879d084e..fd9a8edb8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewService.java @@ -12,14 +12,17 @@ import java.util.stream.Collectors; public class AppointmentBatchPreviewService { private final AppointmentSourceService sources; private final AppointmentBatchPreviewMapper mapper; - public AppointmentBatchPreviewService(AppointmentSourceService sources,AppointmentBatchPreviewMapper mapper) { - this.sources=sources;this.mapper=mapper; + private final AppointmentCargoVerificationService verification; + public AppointmentBatchPreviewService(AppointmentSourceService sources,AppointmentBatchPreviewMapper mapper,AppointmentCargoVerificationService verification) { + this.sources=sources;this.mapper=mapper;this.verification=verification; } public record Preview(AppointmentPlanningPolicy.Group group,String originWarehouseName, String destinationWarehouseCode,String destinationTimezone,List sources, - List missingFields) { } + List missingFields,List cargoVerification) { } public Preview preview(List refs) { var resolved=sources.resolveBatch(refs); + var verified=resolved.stream().map(verification::inspect).toList(); + var bySource=verified.stream().collect(Collectors.toMap(v->v.source().sourceKey(),Function.identity())); var ids=resolved.stream().map(AppointmentSourceService.Source::orderId).filter(Objects::nonNull).distinct().toList(); var flags=ids.isEmpty()?java.util.Map.of():mapper.flags(ids).stream() .collect(Collectors.toMap(AppointmentBatchPreviewMapper.Flags::orderId,Function.identity())); @@ -30,17 +33,19 @@ public class AppointmentBatchPreviewService { else if(state!=null) { if(state.detained()) reason="货物暂扣,请先处理后再合批"; else if(state.mixedDestinations()) reason="货物含多个目的地,请先核对明细"; - else if(state.activeAppointment()) reason="货物已有有效预约,请编辑原预约"; } + var context=bySource.get(source.sourceKey()); + if(context.sourceChanged() || context.unknownAllocationCount()>0 || context.effectivePackages()!=null && context.allocatedPackages()>context.effectivePackages()) reason="来源或已有分配待核实,请先处理原安排"; + else if(context.availablePackages()!=null && context.availablePackages()<=0) reason="原来源箱数已全部分配,请继续已有批次"; // Arrival/warehouse ETA is not a cargo-ready window. Until an authoritative producer exists it stays unknown. return new AppointmentPlanningPolicy.Cargo(source.sourceKey(),source.customerId(),source.originWarehouseId(), - source.destinationKey(),"US","US",source.packages(),null,null,reason==null,reason); + source.destinationKey(),"US","US",context.availablePackages()==null?null:Math.toIntExact(context.availablePackages()),null,null,reason==null,reason); }).toList(); try { var group=AppointmentPlanningPolicy.group(cargo); return new Preview(group,resolved.get(0).originWarehouseName(),resolved.get(0).destinationWarehouseCode(), sources.destinationTimezone(resolved.get(0).destinationWarehouseCode()),resolved, - group.unknownQuantitySources()>0?List.of("READY_WINDOW_UNVERIFIED","CARGO_QUANTITY_REVIEW"):List.of("READY_WINDOW_UNVERIFIED")); + group.unknownQuantitySources()>0?List.of("READY_WINDOW_UNVERIFIED","CARGO_QUANTITY_REVIEW"):List.of("READY_WINDOW_UNVERIFIED"),verified); } catch(IllegalArgumentException error) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST,error.getMessage(),error); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoMapper.java index d19787cd8..0ebf79da8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoMapper.java @@ -35,7 +35,7 @@ public interface AppointmentCargoMapper { g.observed_at AS shipping_observed_at,g.provider_code AS shipping_provider,o.detained, EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.source_order_id=o.id AND p.customer_id=o.customer_id) AS has_inbound_plan, EXISTS(SELECT 1 FROM ll_inbound_fba_appointment_item i JOIN ll_inbound_fba_appointment a ON a.id=i.appointment_id - WHERE i.order_id=o.id AND i.customer_id=o.customer_id AND a.appointment_status NOT IN ('CANCELLED','FAILED')) AS has_active_appointment, + WHERE i.order_id=o.id AND i.customer_id=o.customer_id AND i.active=1 AND a.appointment_status NOT IN ('CANCELLED','FAILED')) AS has_active_appointment, EXISTS(SELECT 1 FROM ll_order_detail d WHERE d.order_id=o.id AND d.customer_id=o.customer_id AND d.destination_warehouse_code IS NOT NULL AND d.destination_warehouse_code<>o.fba_warehouse_code) AS mixed_destinations """ + FILTER + " ORDER BY o.created_at DESC,o.id DESC LIMIT #{size} OFFSET #{offset}") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerification.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerification.java new file mode 100644 index 000000000..29bd0dd38 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerification.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +/** Append-only appointment evidence; never updates an order or its declared cargo. */ +@Data +@TableName("ll_appointment_cargo_verification") +public class AppointmentCargoVerification { + @TableId private Long id; + private String customerId; + private String sourceKey; + private String destinationKey; + private String sourceVersion; + private Integer sourcePackages; + private Integer version; + private Integer packageCount; + private String shipmentReferencesJson; + private String remark; + private String operatorId; + private String operatorName; + private String commandKey; + private String requestHash; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationController.java new file mode 100644 index 000000000..28cc4049e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationController.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.security.OverseasPermissions; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/appointment-planning/sources/{sourceKey}/cargo-verification") +public class AppointmentCargoVerificationController { + private final AppointmentCargoVerificationService service; + public AppointmentCargoVerificationController(AppointmentCargoVerificationService service) {this.service=service;} + + @GetMapping + @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')") + public ApiResponse get(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String sourceKey,@RequestParam String customerId,@RequestParam(required=false) String destinationKey) { + AppointmentPlanningController.requirePlatform(user); + return ApiResponse.success(service.get(sourceKey,customerId,destinationKey)); + } + + @PostMapping + @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list') and hasAuthority('"+OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT+"')") + public ApiResponse save(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String sourceKey,@RequestParam String customerId,@Valid @RequestBody AppointmentCargoVerificationService.Save request) { + AppointmentPlanningController.requirePlatform(user); + return ApiResponse.success(service.save(sourceKey,customerId,request,user.userId().toString(),user.displayName()==null?user.username():user.displayName())); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationMapper.java new file mode 100644 index 000000000..dc4710531 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationMapper.java @@ -0,0 +1,52 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface AppointmentCargoVerificationMapper extends BaseMapper { + /** Same canonical source row used by allocation; flush pre-lock observations before re-reading. */ + @Select("SELECT id FROM ll_order WHERE id=#{orderId} FOR UPDATE") + @Options(flushCache = Options.FlushCachePolicy.TRUE, useCache = false) + Long lockSourceOrder(@Param("orderId") Long orderId); + + @Select("SELECT * FROM ll_appointment_cargo_verification WHERE customer_id=#{customerId} AND source_key=#{sourceKey} ORDER BY version DESC LIMIT 1") + AppointmentCargoVerification latest(@Param("customerId") String customerId,@Param("sourceKey") String sourceKey); + + @Select(""" + + """) + java.util.List latestBatch(@Param("refs") java.util.List refs); + + @Select("SELECT * FROM ll_appointment_cargo_verification WHERE customer_id=#{customerId} AND source_key=#{sourceKey} AND command_key=#{key}") + AppointmentCargoVerification command(@Param("customerId") String customerId,@Param("sourceKey") String sourceKey,@Param("key") String key); + + record Allocation(long packages, long unknownItems) { } + @Select(""" + SELECT COALESCE(SUM(package_count),0) AS packages, + COUNT(*)-COUNT(package_count) AS unknownItems + FROM ll_inbound_fba_appointment_item + WHERE customer_id=#{customerId} AND source_key=#{sourceKey} AND active=1 + """) + Allocation allocated(@Param("customerId") String customerId,@Param("sourceKey") String sourceKey); + + record SourceAllocation(String customerId,String sourceKey,long packages,long unknownItems) { } + @Select(""" + + """) + java.util.List allocatedBatch(@Param("refs") java.util.List refs); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java new file mode 100644 index 000000000..e0bc99167 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java @@ -0,0 +1,171 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.constraints.*; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.*; +import java.time.temporal.ChronoUnit; +import java.util.*; + +@Service +public class AppointmentCargoVerificationService { + private final AppointmentSourceService sources; + private final AppointmentCargoVerificationMapper mapper; + private final ObjectMapper json; + + public AppointmentCargoVerificationService(AppointmentSourceService sources, AppointmentCargoVerificationMapper mapper, + ObjectMapper json) { + this.sources=sources; this.mapper=mapper; this.json=json; + } + public record Save(@NotBlank @Size(max=160) String destinationKey, @Size(max=160) String expectedSourceVersion, + @Min(0) int expectedVersion, @Positive Integer packageCount, + @NotNull @Size(max=100) List<@NotBlank @Size(max=128) String> shipmentReferences, + @Size(max=1000) String remark, @NotBlank @Size(max=64) String idempotencyKey) { } + public record Verification(int version, String sourceVersion, String destinationKey, Integer sourcePackages, + Integer packageCount, List shipmentReferences, String remark, + String operatorName, Instant verifiedAt) { } + public record Context(AppointmentSourceService.Source source, Verification verification, + long allocatedPackages, long unknownAllocationCount, Integer effectivePackages, + Long availablePackages, boolean sourceChanged, List allowedActions, + List blockReasons) { } + + @Transactional(readOnly=true) + public Context get(String sourceKey,String customerId,String destinationKey) { + return inspect(sourceKey,customerId,destinationKey); + } + /** Caller-owned transaction: a handled read prerequisite must not mark the whole work scan rollback-only. */ + public Context inspect(String sourceKey,String customerId,String destinationKey) { + var source=sources.resolve(sourceKey,customerId,destinationKey); + return inspect(source); + } + public record Inspection(AppointmentCoverageService.SourceRef reference,Context context,ResponseStatusException error) { + public Context requireContext() { if(error!=null) throw error; return context; } + } + /** Shares source and warehouse reads within this call only; never caches mutable business facts across requests. */ + public List inspectBatch(List refs) { + var result=new ArrayList(); + for(int start=0;startr.error()==null).map(AppointmentSourceService.Resolution::reference).toList(); + var latest=new HashMap,AppointmentCargoVerification>(); + var allocations=new HashMap,AppointmentCargoVerificationMapper.Allocation>(); + if(!valid.isEmpty()) { + mapper.latestBatch(valid).forEach(r->latest.put(List.of(r.getCustomerId(),r.getSourceKey()),r)); + mapper.allocatedBatch(valid).forEach(r->allocations.put(List.of(r.customerId(),r.sourceKey()),new AppointmentCargoVerificationMapper.Allocation(r.packages(),r.unknownItems()))); + } + for(var resolution:resolved) { + var key=List.of(resolution.reference().customerId(),resolution.reference().sourceKey()); + result.add(new Inspection(resolution.reference(),resolution.error()==null?inspect(resolution.source(),latest.get(key),allocations.getOrDefault(key,new AppointmentCargoVerificationMapper.Allocation(0,0))):null,resolution.error())); + } + } + return List.copyOf(result); + } + /** Uses an already-authorized batched source resolution; never repeats warehouse HTTP lookups per row. */ + public Context inspect(AppointmentSourceService.Source source) { + var row=mapper.latest(source.customerId(),source.sourceKey()); + var allocation=mapper.allocated(source.customerId(),source.sourceKey()); + return inspect(source,row,allocation); + } + private Context inspect(AppointmentSourceService.Source source,AppointmentCargoVerification row,AppointmentCargoVerificationMapper.Allocation allocation) { + boolean changed=row!=null && changed(row,source); + Integer total=row==null?source.packages():row.getPackageCount(); + var reasons=new ArrayList<>(source.blockReasons()); + if(changed) reasons.add("原来源资料已变化,请重新核实预约货物资料"); + if(total==null || total<=0) reasons.add("箱数尚未核实"); + if(allocation.unknownItems()>0) reasons.add("已有预约的分配箱数待核实"); + if(total!=null && allocation.packages()>total) reasons.add("已有分配超过当前货量,请先复核原安排"); + Long available=changed || total==null || total<=0 || allocation.unknownItems()>0 || allocation.packages()>total + ?null:total.longValue()-allocation.packages(); + // Actions describe this endpoint's business capability; controller permissions remain mandatory. + var actions=source.canFollowup()?List.of("VERIFY_CARGO"):List.of(); + return new Context(source,row==null?null:view(row),allocation.packages(),allocation.unknownItems(),total, + available,changed,actions,List.copyOf(reasons)); + } + + @Transactional(isolation=Isolation.READ_COMMITTED) + public Verification save(String sourceKey,String customerId,Save request,String operatorId,String operatorName) { + validate(request,operatorId); + String hash=hash(customerId,sourceKey,operatorId,request); + var replay=mapper.command(customerId,sourceKey,request.idempotencyKey()); + if(replay!=null) return replay(replay,hash); + var refs=List.of(new AppointmentCoverageService.SourceRef(customerId,sourceKey,request.destinationKey())); + sources.lockForWrite(refs); + var before=sources.resolveBatch(refs).get(0); + if(before.orderId()!=null && mapper.lockSourceOrder(before.orderId())==null) throw conflict("原货源已变化,请重新查询"); + var source=sources.resolveBatch(refs).get(0); + if(!Objects.equals(before.orderId(),source.orderId())) throw conflict("货源关联已变化,请重新查询"); + // The order/booking lock serializes verification with allocation and invalidates pre-lock facts. + replay=mapper.command(customerId,sourceKey,request.idempotencyKey()); + if(replay!=null) return replay(replay,hash); + if(!source.canFollowup()) throw conflict("当前货源已完成、取消或尚未具备安排资格"); + if(!Objects.equals(source.sourceVersion(),request.expectedSourceVersion())) throw conflict("原来源版本已变化,请重新核实"); + var latest=mapper.latest(customerId,sourceKey); + if((latest==null?0:latest.getVersion())!=request.expectedVersion()) throw conflict("货物核实资料已更新,请刷新后提交"); + Integer count=request.packageCount(); + if(count!=null && source.packages()!=null && count>source.packages()) throw conflict("核实箱数不能超过原来源箱数,请先核对来源"); + var allocation=mapper.allocated(customerId,sourceKey); + if(allocation.packages()>0 && (count==null || count>(){}),row.getRemark(), + row.getOperatorName(),row.getCreatedAt().toInstant(ZoneOffset.UTC)); + } catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("核实资料不可读",error);} + } + private String hash(String customer,String source,String operator,Save request) { + try {return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((customer+"\n"+source+"\n"+operator+"\n"+encode(request)).getBytes(StandardCharsets.UTF_8)));} + catch(java.security.NoSuchAlgorithmException error) {throw new IllegalStateException(error);} + } + private String encode(Object value) { + try {return json.writeValueAsString(value);} catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("核实资料无法保存",error);} + } + private static void validate(Save request,String operator) { + if(request==null || clean(operator)==null || clean(request.idempotencyKey())==null || request.idempotencyKey().length()>64 + || clean(request.destinationKey())==null || request.destinationKey().length()>160 || request.expectedVersion()<0 + || request.packageCount()!=null && request.packageCount()<=0 || request.shipmentReferences()==null || request.shipmentReferences().size()>100 + || request.shipmentReferences().stream().anyMatch(ref->clean(ref)==null || ref.length()>128) + || request.remark()!=null && request.remark().length()>1000 || request.expectedSourceVersion()!=null && request.expectedSourceVersion().length()>160) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请核对货物箱数、货件号及保存版本"); + } + private static String clean(String value) {return value==null || value.isBlank()?null:value.trim();} + private static ResponseStatusException conflict(String message) {return new ResponseStatusException(HttpStatus.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapper.java index 18f87dec6..2f31d1832 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapper.java @@ -19,7 +19,7 @@ public interface AppointmentCoverageMapper { record Fact(Long orderId, String customerId, String appointmentNo, String status, LocalDateTime appointmentAt, LocalDateTime appointmentEndAt, LocalDateTime updatedAt, Long itemId, Integer packages, String warehouseCode,String sourceKey,String sourceVersion, - String confirmationNo,String timezone) { } + String confirmationNo,String timezone,Integer cargoVerificationVersion) { } @Select({""}) List appointments(@Param("sources") List sources); + + @Select({""}) + List verifications(@Param("sources") List sources); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageService.java index edff9311b..bd0212c61 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageService.java @@ -49,6 +49,7 @@ public class AppointmentCoverageService { var orders=unique.isEmpty() ? List.of() : mapper.sources(unique); var orderMap=orders.stream().collect(Collectors.toMap(s -> new AppointmentCoverageMapper.OrderRef(s.customerId(),s.orderId()),s -> s)); var facts=mapper.appointments(requested); + var verified=mapper.verifications(requested).stream().collect(Collectors.toMap(v->key(v.getCustomerId(),v.getSourceKey()),v->v)); var bySource=facts.stream().collect(Collectors.groupingBy(f -> key(f.customerId(),f.sourceKey()))); var output=new ArrayList(); for (var source:requested) { @@ -75,25 +76,30 @@ public class AppointmentCoverageService { output.add(empty(source,"DESTINATION_MISMATCH","DESTINATION",version(order.version()),true)); continue; } var distinct=new LinkedHashMap(); + var evidence=verified.get(key(source.customerId(),source.sourceKey())); + String currentVersion=(booking==null?"":"BOOKING:"+version(booking.recordVersion())+"/")+"ORDER:"+version(order.version()); + Integer packages=evidence==null?order.packages():evidence.getPackageCount(); var seen=new HashSet(); long confirmed=0, covered=0; boolean invalid=false, changed=false; + if(evidence!=null) changed=!Objects.equals(currentVersion,evidence.getSourceVersion()) + || !Objects.equals(source.destinationKey(),evidence.getDestinationKey()) || !Objects.equals(order.packages(),evidence.getSourcePackages()); for (var fact:relevant) { if (fact.orderId()!=null && !Objects.equals(order.orderId(),fact.orderId()) || !Objects.equals(order.fbaWarehouseCode(),fact.warehouseCode()) || fact.packages()==null || fact.packages()<=0 || !seen.add(fact.itemId())) { invalid=true; continue; } covered+=fact.packages(); boolean success="SUCCESS".equals(fact.status()) && fact.appointmentAt()!=null && !blank(fact.confirmationNo()) && !blank(fact.timezone()); if (success) confirmed+=fact.packages(); distinct.putIfAbsent(fact.appointmentNo(),new Appointment(fact.appointmentNo(),success ? fact.confirmationNo() : null,fact.status(),success ? fact.appointmentAt() : null,success ? fact.appointmentEndAt() : null,fact.timezone())); - String currentVersion=(booking==null?"":"BOOKING:"+version(booking.recordVersion())+"/")+"ORDER:"+version(order.version()); changed|=!Objects.equals(currentVersion,fact.sourceVersion()); + if(evidence!=null) changed|=!Objects.equals(evidence.getVersion(),fact.cargoVerificationVersion()); } var missing=new ArrayList(); String status; - if (invalid || order.packages()==null || order.packages()<=0 || covered>order.packages()) { status="REVIEW_REQUIRED"; missing.add("CARGO_QUANTITY_REVIEW"); } + if (invalid || packages==null || packages<=0 || covered>packages) { status="REVIEW_REQUIRED"; missing.add("CARGO_QUANTITY_REVIEW"); } else if (covered==0) { status="UNASSIGNED"; missing.add("RECEIVING_APPOINTMENT"); } - else if (covered"SUCCESS".equals(f.status()) && blank(f.timezone()))) missing.add("APPOINTMENT_TIMEZONE_UNVERIFIED"); - if (order.shipmentReferencesMissing()) missing.add("FBA_SHIPMENT_REFERENCES"); + if (evidence==null ? order.shipmentReferencesMissing() : !hasReferences(evidence.getShipmentReferencesJson())) missing.add("FBA_SHIPMENT_REFERENCES"); if (changed) missing.add("SOURCE_CHANGED_REVIEW"); output.add(new Coverage(source.customerId(),source.sourceKey(),source.destinationKey(),status,List.copyOf(distinct.values()),List.copyOf(missing), (booking==null ? "" : "BOOKING:"+version(booking.recordVersion())+"/")+"ORDER:"+version(order.version()),changed || invalid)); @@ -106,4 +112,13 @@ public class AppointmentCoverageService { private static String key(String customerId,String sourceKey) { return customerId+"\n"+sourceKey; } private static String version(Number version) { return version==null ? null : version.toString(); } private static boolean blank(String value) { return value==null || value.isBlank(); } + private static boolean hasReferences(String json) { + if(blank(json)) return false; + try { + var refs=new com.fasterxml.jackson.databind.ObjectMapper().readTree(json); + if(!refs.isArray() || refs.isEmpty()) return false; + for(var ref:refs) if(!ref.isTextual() || ref.asText().isBlank()) return false; + return true; + } catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("预约核实资料不可读",error);} + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningController.java index c87933150..752a0c99f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningController.java @@ -29,6 +29,20 @@ public class AppointmentPlanningController { } public record Resolved(AppointmentSourceService.Source source, AppointmentCoverageService.Coverage coverage) { } + @GetMapping("/sources/resolve-order") + @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')") + public ApiResponse resolveOrder(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam Long orderId, @RequestParam String customerId, @RequestParam(required=false) String destinationKey) { + requirePlatform(user); + if(orderId==null || orderId<=0 || customerId==null || customerId.isBlank() || customerId.length()>128) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择真实订单及所属客户"); + var ref=sources.canonicalOrders(java.util.List.of(new AppointmentExternalSourceGateway.OrderRef(customerId,orderId))).get(0); + var source=sources.resolve(ref.sourceKey(),customerId,destinationKey); + if(!orderId.equals(source.orderId())) throw new ResponseStatusException(HttpStatus.CONFLICT,"订单与原来源关联已变化,请刷新后核对"); + var result=coverage.query(java.util.List.of(new AppointmentCoverageService.SourceRef(source.customerId(),source.sourceKey(),source.destinationKey()))); + return ApiResponse.success(new Resolved(source,result.get(0))); + } + @GetMapping("/sources/resolve") @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')") public ApiResponse resolve(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceLinker.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceLinker.java index cc32dcb5f..3325e85c2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceLinker.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceLinker.java @@ -1,42 +1,6 @@ package com.baiyue.lastleg.appointmentplanning; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; -import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem; -import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper; -import com.baiyue.lastleg.mapper.LastlegOrderMapper; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; -import org.springframework.http.HttpStatus; -import java.util.List; -import java.util.Objects; - -/** Called by actual incoming materialization, never as a migration or historical data repair. */ -@Service -public class AppointmentSourceLinker { - private final LastlegOrderMapper orders; - private final InboundFbaAppointmentItemMapper items; - private final AppointmentExternalSourceGateway bookings; - public AppointmentSourceLinker(LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings) {this.orders=orders;this.items=items;this.bookings=bookings;} - @Transactional(propagation=Propagation.MANDATORY) - public void materialized(String customerId,String bookingNo,Long orderId) { - var ref=new AppointmentExternalSourceGateway.SourceRef(customerId,bookingNo); - bookings.lockForAppointment(List.of(ref)); - var matched=bookings.resolve(List.of(ref)); - if(matched.size()!=1 || !Objects.equals(matched.get(0).customerId(),customerId) || !Objects.equals(matched.get(0).bookingNo(),bookingNo) || !Objects.equals(matched.get(0).orderId(),orderId)) throw conflict("来货与原登记的归属不一致,请核对"); - var order=orderId==null?null:orders.selectById(orderId); - if(order==null || !Objects.equals(order.getCustomerId(),customerId)) throw conflict("来货尚未形成对应客户的真实货物"); - var rows=items.selectList(new LambdaQueryWrapper().eq(InboundFbaAppointmentItem::getSourceKey,"BOOKING:"+bookingNo) - .eq(InboundFbaAppointmentItem::getCustomerId,customerId).eq(InboundFbaAppointmentItem::getActive,true).last("FOR UPDATE")); - for(var row:rows) { - if(row.getOrderId()!=null && !row.getOrderId().equals(orderId)) throw conflict("原预约已关联其他来货,不能自动改绑"); - if(Objects.equals(row.getOrderId(),orderId) && Objects.equals(row.getInboundCode(),order.getInboundCode())) continue; - try {items.update(null,new LambdaUpdateWrapper().eq(InboundFbaAppointmentItem::getId,row.getId()).eq(InboundFbaAppointmentItem::getActive,true) - .set(InboundFbaAppointmentItem::getOrderId,orderId).set(InboundFbaAppointmentItem::getInboundCode,order.getInboundCode()));} - catch(org.springframework.dao.DuplicateKeyException duplicate){throw conflict("该来货已有其他有效预约,请先核对原安排");} - } - } - private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} +/** Associates a newly materialized source with its receiving appointment in the caller's transaction. */ +public interface AppointmentSourceLinker { + void materialized(String customerId, String bookingNo, Long orderId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java index 1aa501921..62757c22c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java @@ -60,12 +60,22 @@ public class AppointmentSourceService { } /** One catalog request and batched source reads, including mixed first-leg and external bookings. */ public List resolveBatch(List requested) { + return resolveBatch(requested,true).stream().map(Resolution::requireSource).toList(); + } + /** Read-only inspection keeps each source's prerequisite failure separate; infrastructure errors still escape. */ + public record Resolution(AppointmentCoverageService.SourceRef reference,Source source,ResponseStatusException error) { + public Source requireSource() { if(error!=null) throw error; return source; } + } + public List inspectBatch(List requested) { + return resolveBatch(requested,false); + } + private List resolveBatch(List requested,boolean strict) { if(requested==null || requested.isEmpty() || requested.size()>100) throw bad("每批请选择1至100票真实货源"); var keys=new LinkedHashSet(); var orderIds=new LinkedHashSet(); var bookingRefs=new java.util.ArrayList(); for(var ref:requested) { if(ref==null || blank(ref.sourceKey()) || ref.sourceKey().length()>160 || blank(ref.customerId()) || ref.customerId().length()>128) throw bad("请选择完整的真实货源"); - if(!keys.add(ref.sourceKey())) throw bad("同一来源货物不能重复选择"); + if(!keys.add(ref.sourceKey()) && strict) throw bad("同一来源货物不能重复选择"); if(ref.sourceKey().startsWith("ORDER:")) orderIds.add(orderId(ref.sourceKey())); else if(ref.sourceKey().startsWith("BOOKING:") && !blank(ref.sourceKey().substring(8))) bookingRefs.add(new AppointmentExternalSourceGateway.SourceRef(ref.customerId(),ref.sourceKey().substring(8))); else throw bad("不支持的货源类型"); @@ -77,17 +87,28 @@ public class AppointmentSourceService { if(booking.orderId()!=null) orderIds.add(booking.orderId()); } var orderRows=orderIds.isEmpty()?List.of():orders.selectBatchIds(orderIds); - var directOrders=requested.stream().filter(ref->ref.sourceKey().startsWith("ORDER:")).map(ref->new AppointmentExternalSourceGateway.OrderRef(ref.customerId(),orderId(ref.sourceKey()))).toList(); - if(!directOrders.isEmpty() && !external.resolveOrders(directOrders).isEmpty()) throw bad("该货物来自外接登记,请使用原登记来源继续预约"); + var directOrders=requested.stream().filter(ref->ref.sourceKey().startsWith("ORDER:")).map(ref->new AppointmentExternalSourceGateway.OrderRef(ref.customerId(),orderId(ref.sourceKey()))).distinct().toList(); + var externalOrders=directOrders.isEmpty()?Set.of():external.resolveOrders(directOrders).stream() + .map(s->new AppointmentExternalSourceGateway.OrderRef(s.customerId(),s.orderId())).collect(Collectors.toSet()); + if(strict && !externalOrders.isEmpty()) throw bad("该货物来自外接登记,请使用原登记来源继续预约"); var orderMap=orderRows.stream().collect(Collectors.toMap(com.baiyue.lastleg.entity.LastlegOrder::getId,Function.identity())); var transferRefs=orderRows.stream().filter(o->"WAREHOUSE_TRANSFER".equals(o.getOrderType())).map(o->new AppointmentCoverageMapper.OrderRef(o.getCustomerId(),o.getId())).toList(); var destinations=transferRefs.isEmpty()?Map.of():destinationFacts.sources(transferRefs).stream().collect(Collectors.toMap(AppointmentCoverageMapper.Source::orderId,Function.identity())); var containerIds=orderRows.stream().filter(o->"FIRST_LEG".equals(o.getOrderType())).map(com.baiyue.lastleg.entity.LastlegOrder::getSourceContainerBookingId).filter(Objects::nonNull).distinct().toList(); var gates=containerIds.isEmpty()?Map.of():shipping.selectBatchIds(containerIds).stream().collect(Collectors.toMap(g->g.getContainerBookingId(),g->g.getNodeSequence()!=null && g.getNodeSequence()>=40)); var catalog=catalog(); - var result=requested.stream().map(ref->resolve(ref.sourceKey(),ref.customerId(),ref.destinationKey(),orderMap,bookingMap,gates,catalog,destinations)).toList(); + var result=requested.stream().map(ref->{ + try { + if(ref.sourceKey().startsWith("ORDER:") && externalOrders.contains(new AppointmentExternalSourceGateway.OrderRef(ref.customerId(),orderId(ref.sourceKey())))) + throw bad("该货物来自外接登记,请使用原登记来源继续预约"); + return new Resolution(ref,resolve(ref.sourceKey(),ref.customerId(),ref.destinationKey(),orderMap,bookingMap,gates,catalog,destinations),null); + } catch(ResponseStatusException error) { + if(strict || error.getStatusCode().value()>=500) throw error; + return new Resolution(ref,null,error); + } + }).toList(); var materialized=new java.util.HashSet(); - for(var source:result) if(source.orderId()!=null && !materialized.add(source.orderId())) throw bad("同一货物存在重复来源,请保留原登记来源"); + if(strict) for(var resolution:result) { var source=resolution.requireSource(); if(source.orderId()!=null && !materialized.add(source.orderId())) throw bad("同一货物存在重复来源,请保留原登记来源"); } return result; } private Source resolve(String sourceKey,String customerId,String destinationKey, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkChanges.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkChanges.java new file mode 100644 index 000000000..8e57b4b66 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkChanges.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** Original business commands invalidate the manifest atomically with their business write. */ +@Component +public class AppointmentWorkChanges { + private final AppointmentWorkMapper mapper; + public AppointmentWorkChanges(AppointmentWorkMapper mapper) { this.mapper=mapper; } + @Transactional(propagation=Propagation.MANDATORY) + public void requirementsChanged() { + if(mapper.invalidateRequirements()!=1) throw AppointmentWorkPolicy.unavailable("工作事项同步守卫未初始化"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommandService.java new file mode 100644 index 000000000..00130f914 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommandService.java @@ -0,0 +1,177 @@ +package com.baiyue.lastleg.appointmentwork; + +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@Service +public class AppointmentWorkCommandService { + private final AppointmentWorkStore store; + private final AppointmentWorkFactGateway gateway; + private final AppointmentWorkSyncService sync; + private final AppointmentWorkQueryService queries; + public AppointmentWorkCommandService(AppointmentWorkStore store,AppointmentWorkFactGateway gateway,AppointmentWorkSyncService sync,AppointmentWorkQueryService queries) { + this.store=store; this.gateway=gateway; this.sync=sync; this.queries=queries; + } + @Transactional + public CommandResult execute(String key,CommandRequest request,Actor actor) { + if(request==null || request.command()==null || request.expectedVersion()<0 || request.idempotencyKey()==null || request.idempotencyKey().isBlank() || request.idempotencyKey().length()>64 || request.payload()==null || !request.payload().isObject()) throw bad("工作命令参数无效"); + String permission=switch(request.command()) { case SAVE_FOLLOWUP,RESOLVE_REVIEW->FOLLOWUP; default->MANAGE; }; + if(!actor.has(LIST) || !actor.has(permission)) throw forbidden("缺少工作操作权限"); + store.lock(); + var state=store.get(key); + var hash=digest(actor.id()+"\n"+request.command()+"\n"+request.expectedVersion()+"\n"+canonical(request.payload())); + var replay=store.replay(key,request.idempotencyKey()); + if(replay!=null) { + if(!Objects.equals(actor.id(),replay.getOperatorId()) || !Objects.equals(hash,replay.getRequestHash())) throw conflict("同一请求编号的操作者或内容不一致"); + if(state.getReadPermissions().isEmpty() || !state.getReadPermissions().stream().allMatch(actor::has)) throw forbidden("关联来源读取权限已失效"); + return store.read(replay.getResponseJson(),CommandResult.class); + } + var current=gateway.facts(state.getType()==Type.ROUTE?List.of():List.of(state)); var fact=current.get(key); + if(!canRead(state,fact,actor)) throw forbidden("无权读取关联业务来源"); + state.setReadPermissions(state.getType()==Type.ROUTE?List.of(ORDER_READ):List.copyOf(fact.readPermissions())); + if(state.getVersion()!=request.expectedVersion()) throw conflict("事项已更新,请刷新后保留输入重新提交"); + if(state.isRetired() || completed(fact)) throw conflict("该事项已完成或已替代,只能查看历史"); + var payload=request.payload(); var affected=new LinkedHashSet(); String summary; + switch(request.command()) { + case CONFIRM_ROUTE -> { + fields(payload,Set.of("flow","reason"),Set.of("flow")); + if(state.getType()!=Type.ROUTE) throw bad("只能在路线需求确认运输方式"); + Flow flow; try { flow=Flow.valueOf(text(payload,"flow",true,16)); } catch(IllegalArgumentException e) { throw bad("运输路线无效"); } + if(flow==Flow.UNKNOWN) throw bad("请选择中转或直送"); + var source=gateway.container(state.getContainerBookingId()); AppointmentWorkSyncService.validateSource(source); + if(!Objects.equals(state.getSourceVersion(),source.sourceVersion())) throw conflict("柜次来源版本变化,请先同步核对"); + if(source.establishedFlow()!=Flow.UNKNOWN && source.establishedFlow()!=flow) throw conflict("选择路线与原业务既定路线不一致"); + var existing=store.container(state.getContainerBookingId()); + if(existing.stream().anyMatch(s->s.getType()!=Type.ROUTE)) throw conflict("该柜已生成路线工作,不能切换抹去已有安排"); + affected.addAll(sync.generate(source,flow,existing,actor)); state.setFlow(flow); state.setRetired(true); state.setRetirementReason("ROUTE_CONFIRMED"); state.setReplacementKeys(List.copyOf(affected)); + summary="确认"+(flow==Flow.TRANSFER?"海外仓中转":"整柜直送")+";"+Objects.toString(text(payload,"reason",false,1000),""); + } + case SAVE_FOLLOWUP -> { + fields(payload,Set.of("ownerId","followupDate","note"),Set.of("ownerId","followupDate","note")); + state.setOwner(gateway.owner(text(payload,"ownerId",true,64),actor)); + state.setFollowupDate(date(payload.get("followupDate"))); + summary=text(payload,"note",true,1000); + // This command never clears pending acceptance, review, or original work requirements. + } + case RESOLVE_REVIEW -> { + fields(payload,Set.of("reviewVersion","note"),Set.of("reviewVersion","note")); + int version=integer(payload,"reviewVersion"); + if(!state.isReviewRequired() || state.getReviewVersion()!=version) throw conflict("影响版本已变化,请重新核对"); + if(changed(state,fact) || fact!=null && state.getObservedImpactToken()!=null + && !Objects.equals(state.getObservedImpactToken(),fact.impactToken())) throw conflict("原业务已有更新,须先同步最新影响"); + requireCurrentImpacts(state); + if(fact!=null && !fact.blockReasons().isEmpty()) throw conflict("原业务仍有阻断,请先处理原安排"); + summary=text(payload,"note",true,1000); state.setReviewRequired(false); state.setReviewReason(null); + } + case SAVE_REQUIREMENT -> { + fields(payload,Set.of("target","bookingRequired","receivingRequirements","reason"),Set.of("target","bookingRequired","receivingRequirements","reason")); + if(!requirementOwned(state,fact)) throw conflict("该计划由原业务维护,请从原安排入口处理"); + if(!payload.get("bookingRequired").isBoolean()) throw bad("预约要求须为布尔值"); + boolean bookingRequired=payload.get("bookingRequired").asBoolean(); + String requirements=text(payload,"receivingRequirements",false,1000),reason=text(payload,"reason",true,1000); + if(!bookingRequired && (state.getType()!=Type.DELIVERY || !"CUSTOMER".equals(state.getDestinationKind()) || requirements==null)) throw bad("仅客户仓可凭明确收货要求登记无需预约"); + Slot target=slot(payload.get("target")); validateSlot(target); + String authorityZone=fact!=null && fact.timezone()!=null?fact.timezone():state.getTimezone(); + if(target!=null && !Objects.equals(authorityZone,target.timezone())) throw conflict("作业地点时区尚未核实或已变化"); + boolean hadPlan=arranged(state,fact); + boolean change=!Objects.equals(target,state.getTarget()) || state.isBookingRequired()!=bookingRequired || !Objects.equals(requirements,state.getReceivingRequirements()); + state.setTarget(target); state.setBookingRequired(bookingRequired); state.setReceivingRequirements(requirements); state.setRequirementReason(reason); + if(hadPlan && change) affected.addAll(markDownstream(state,reason,actor)); + summary=reason; + } + case HANDOFF_RETURN -> { + fields(payload,Set.of("recipient","reason"),Set.of("recipient","reason")); requireReturn(state); + var recipient=responsibility(payload.get("recipient"),actor); + String reason=text(payload,"reason",true,1000); + var previous=state.getResponsibility(); + if(previous==null && fact!=null) previous=fact.responsibility(); + if(previous==null) previous=new Responsibility(ResponsibilityKind.EXTERNAL,null,"原责任方待核实"); + int version=state.getReturnHandoff()==null?1:state.getReturnHandoff().version()+1; + state.setReturnHandoff(new ReturnHandoff(version,previous,recipient,HandoffStatus.PENDING_ACCEPTANCE,reason)); + if(state.getOwner()==null) state.setOwner(new Owner(actor.id(),actor.name())); + var localZone=queries.current(state,fact,actor).timezone(); + state.setFollowupDate(LocalDate.now(localZone==null?REMINDER_ZONE:zone(localZone))); + summary="还空责任转交:"+previous.name()+" → "+recipient.name()+";待接手;"+reason; + } + case ACCEPT_RETURN -> { + fields(payload,Set.of("handoffVersion","note"),Set.of("handoffVersion","note")); requireReturn(state); + var handoff=state.getReturnHandoff(); + if(handoff==null || handoff.status()!=HandoffStatus.PENDING_ACCEPTANCE || handoff.version()!=integer(payload,"handoffVersion")) throw conflict("还空转交版本变化或已经接手"); + if(handoff.to().kind()==ResponsibilityKind.USER && !actor.id().equals(handoff.to().id())) throw forbidden("仅当前指定接手人可确认接手"); + String note=text(payload,"note",handoff.to().kind()!=ResponsibilityKind.USER,1000); + state.setResponsibility(handoff.to()); state.setReturnHandoff(new ReturnHandoff(handoff.version(),handoff.from(),handoff.to(),HandoffStatus.ACCEPTED,handoff.reason())); + if(handoff.to().kind()==ResponsibilityKind.USER) state.setOwner(new Owner(actor.id(),actor.name())); + summary="已确认接手:"+handoff.to().name()+";"+Objects.toString(note,"本人确认"); + } + default -> throw bad("不支持的工作命令"); + } + store.save(state); affected.add(key); + var result=new CommandResult(queries.current(state,fact,actor),List.copyOf(affected)); + store.history(state,actor,request.command().name(),summary,request.idempotencyKey(),hash,result); + return result; + } + private void requireCurrentImpacts(AppointmentWorkState state) { + store.requireInitialized(); + var prerequisites=AppointmentWorkSyncService.impactPrerequisites(state,store.all()); + if(prerequisites.isEmpty()) return; + var current=gateway.facts(prerequisites); + for(var prior:prerequisites) { + if(prior.getType()==Type.ROUTE) continue; + var fact=current.get(prior.getWorkItemKey()); + if(fact==null) throw unavailable("关联安排权威事实缺失,不能完成复核"); + if(prior.isObservedArranged() && prior.getObservedDependentImpactToken()!=null + && !Objects.equals(prior.getObservedDependentImpactToken(),fact.dependentImpactToken())) + throw conflict("关联安排已有新变化,须先同步最新影响再复核"); + } + } + private List markDownstream(AppointmentWorkState source,String reason,Actor actor) { + var states=store.all(); var keys=new LinkedHashSet(); AppointmentWorkSyncService.expandDependents(source.getWorkItemKey(),states,keys); keys.remove(source.getWorkItemKey()); + var candidates=states.stream().filter(s->keys.contains(s.getWorkItemKey()) && !s.isRetired()).toList(); var facts=gateway.facts(candidates); var affected=new ArrayList(); + for(var state:candidates) { var fact=facts.get(state.getWorkItemKey()); if(!completed(fact) && arranged(state,fact)) { + state.setReviewRequired(true); state.setReviewVersion(state.getReviewVersion()+1); state.setReviewReason(reason); store.save(state); + store.history(state,actor,"RELATED_CHANGE",reason,null,null,null); affected.add(state.getWorkItemKey()); + } } + return affected; + } + private Responsibility responsibility(JsonNode node,Actor actor) { + fields(node,Set.of("kind","id","name"),Set.of("kind","id","name")); + ResponsibilityKind kind; try { kind=ResponsibilityKind.valueOf(text(node,"kind",true,16)); } catch(IllegalArgumentException e) { throw bad("责任方类型无效"); } + var id=text(node,"id",false,64); var name=text(node,"name",true,200); + if(kind==ResponsibilityKind.USER) { var owner=gateway.owner(id,actor); return new Responsibility(kind,owner.id(),owner.name()); } + if(kind==ResponsibilityKind.SUPPLIER) { if(!actor.has("platform:supplier:list")) throw forbidden("缺少供应商读取权限"); return gateway.supplier(id); } + if(id!=null) throw bad("外部责任方不接受未经核验的内部标识"); + return new Responsibility(kind,null,name); + } + private static void requireReturn(AppointmentWorkState state) { if(state.getType()!=Type.EMPTY_RETURN) throw bad("仅还空事项可交接责任"); } + static void fields(JsonNode node,Set allowed,Set required) { + if(node==null || !node.isObject()) throw bad("命令payload须为对象"); + var names=new HashSet(); node.fieldNames().forEachRemaining(names::add); + if(!allowed.containsAll(names) || !names.containsAll(required)) throw bad("命令payload包含不支持的字段或缺少必需字段"); + } + static String text(JsonNode node,String field,boolean required,int max) { + var value=node.get(field); + if(value==null || value.isNull()) { if(required) throw bad("请填写"+field); return null; } + if(!value.isTextual() || value.textValue().length()>max) throw bad(field+"格式无效"); + var result=value.textValue().trim(); if(required && result.isEmpty()) throw bad("请填写"+field); return result.isEmpty()?null:result; + } + private int integer(JsonNode node,String name) { var value=node.get(name); if(value==null || !value.isIntegralNumber() || !value.canConvertToInt() || value.intValue()<0) throw bad(name+"必须为有效版本"); return value.intValue(); } + private LocalDate date(JsonNode value) { if(value==null || value.isNull()) return null; try { if(!value.isTextual()) throw bad("日期格式无效"); return LocalDate.parse(value.textValue()); } catch(java.time.DateTimeException e) { throw bad("日期格式无效"); } } + private Slot slot(JsonNode node) { + if(node==null || node.isNull()) return null; + fields(node,Set.of("date","startTime","endDate","endTime","timezone"),Set.of("date","startTime","endDate","endTime","timezone")); + try { return new Slot(date(node.get("date")),time(node.get("startTime")),date(node.get("endDate")),time(node.get("endTime")),text(node,"timezone",true,64)); } + catch(java.time.DateTimeException e) { throw bad("目标日期或时段格式无效"); } + } + private LocalTime time(JsonNode value) { if(value.isNull()) return null; if(!value.isTextual() || !value.textValue().matches("\\d{2}:\\d{2}")) throw bad("时段须为HH:mm"); return LocalTime.parse(value.textValue()); } + static String canonical(JsonNode node) { + if(node.isObject()) { var parts=new TreeMap(); node.fields().forEachRemaining(e->parts.put(e.getKey(),canonical(e.getValue()))); return parts.toString(); } + if(node.isArray()) { var parts=new ArrayList(); node.forEach(v->parts.add(canonical(v))); return parts.toString(); } + return node.toString(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommitmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommitmentService.java new file mode 100644 index 000000000..c6e8fe981 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommitmentService.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.appointmentwork; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import java.util.Objects; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; + +/** Audit baseline for semantic impacts. Written only by original business transactions, never by reads. */ +@Component +public class AppointmentWorkCommitmentService { + public record Commitment(Slot target,Slot confirmed,String assignment,String cargoToken,boolean cancelled) { } + private final AppointmentWorkMapper mapper; + private final ObjectMapper json; + public AppointmentWorkCommitmentService(AppointmentWorkMapper mapper,ObjectMapper json) { this.mapper=mapper; this.json=json; } + @Transactional(propagation=Propagation.MANDATORY) + public void record(String key,Commitment current) { + var row=mapper.lockCommitment(key); + try { + if(row==null) mapper.insertCommitment(key,json.writeValueAsString(current)); + else { + var previous=json.readValue(row.getStateJson(),Commitment.class); + mapper.updateCommitment(key,json.writeValueAsString(current),row.getImpactVersion()+(impacts(previous,current)?1:0)); + } + } catch(com.fasterxml.jackson.core.JsonProcessingException error) { throw new IllegalStateException("无法保存原业务承诺影响依据",error); } + } + public String impactToken(String key) { + var row=mapper.commitment(key); + return key+":"+(row==null?0:row.getImpactVersion()); + } + public java.util.Map impactTokens(java.util.List keys) { + var tokens=new java.util.HashMap(); + keys.forEach(key->tokens.put(key,key+":0")); + if(!keys.isEmpty()) mapper.commitments(keys).forEach(row->tokens.put(row.getEntityKey(),row.getEntityKey()+":"+row.getImpactVersion())); + return tokens; + } + public static boolean impacts(Commitment old,Commitment now) { + if(old.cancelled()!=now.cancelled()) return true; + boolean firstSameDayConfirmation=old.confirmed()==null && now.confirmed()!=null && old.target()!=null && old.target().date().equals(now.confirmed().date()); + if(old.target()!=null && !Objects.equals(old.target(),now.target()) && !(firstSameDayConfirmation && now.target()==null)) return true; + if(old.confirmed()!=null && !Objects.equals(old.confirmed(),now.confirmed())) return true; + if(old.confirmed()==null && now.confirmed()!=null && old.target()!=null && !old.target().date().equals(now.confirmed().date())) return true; + if(old.assignment()!=null && !Objects.equals(old.assignment(),now.assignment())) return true; + return old.cargoToken()!=null && !Objects.equals(old.cargoToken(),now.cargoToken()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkController.java new file mode 100644 index 000000000..7b18697f3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkController.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.appointmentwork; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; +import java.util.Set; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/appointment-work") +@PreAuthorize("hasAuthority('platform:appointment:list')") +public class AppointmentWorkController { + private final AppointmentWorkQueryService queries; + private final AppointmentWorkCommandService commands; + public AppointmentWorkController(AppointmentWorkQueryService queries,AppointmentWorkCommandService commands) { this.queries=queries; this.commands=commands; } + @GetMapping("/calendar") + public ApiResponse calendar(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam LocalDate from,@RequestParam LocalDate to,@RequestParam(required=false) Flow flow,@RequestParam(required=false) Set types) { + return ApiResponse.success(queries.calendar(from,to,flow,types,actor(user))); + } + @GetMapping("/summary") + public ApiResponse summary(@AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(queries.summary(actor(user))); } + @GetMapping("/items") + public ApiResponse> items(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(defaultValue="ALL") View view,@RequestParam(defaultValue="1") int page,@RequestParam(defaultValue="20") int size, + @RequestParam(required=false) LocalDate date,@RequestParam(required=false) Flow flow,@RequestParam(required=false) Set types, + @RequestParam(required=false) String containerBookingId,@RequestParam(required=false) Bucket bucket,@RequestParam(required=false) String keyword) { + return ApiResponse.success(queries.items(view,page,size,date,flow,types,containerBookingId,bucket,keyword,actor(user))); + } + @GetMapping("/items/resolve") + public ApiResponse resolve(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String batchKey,@RequestParam(required=false) String customerId) { + return ApiResponse.success(queries.resolveBatch(batchKey,customerId,actor(user))); + } + @GetMapping("/items/{workItemKey}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String workItemKey) { + return ApiResponse.success(queries.detail(workItemKey,actor(user))); + } + @PostMapping("/items/{workItemKey}/commands") + public ApiResponse command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String workItemKey,@Valid @RequestBody CommandRequest request) { + return ApiResponse.success(commands.execute(workItemKey,request,actor(user))); + } + static Actor actor(AuthenticatedUser user) { + if(user==null || user.userId()==null) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.UNAUTHORIZED); + if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()) || !user.permissions().contains(LIST)) throw forbidden("仅平台有权访问工作事项"); + return new Actor(user.userId().toString(),user.displayName()==null?user.username():user.displayName(),user.permissions()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDirectTransportProvider.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDirectTransportProvider.java new file mode 100644 index 000000000..47d2a4d22 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDirectTransportProvider.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +/** Direct transport uses terminal carrier arrangements and real destination unloading, never warehouse stock. */ +@Component +public class AppointmentWorkDirectTransportProvider implements BusinessProvider { + private final AppointmentWorkFirstlegClient firstleg; + private final String token; + public AppointmentWorkDirectTransportProvider(AppointmentWorkFirstlegClient firstleg,@Value("${baiyue.security.internal-service-token:}") String token) { this.firstleg=firstleg; this.token=token; } + public boolean supports(Type type,Flow flow) { return type==Type.TRANSPORT && flow==Flow.DIRECT; } + public List read(List requests) { + if(token==null || token.isBlank()) throw unavailable("内部服务身份未配置"); + var input=new ArrayList(); + for(var r:requests) { input.add(request(r,Type.PICKUP,"/carrier")); input.add(request(r,Type.DELIVERY,"/delivery")); } + var facts=new HashMap(); + for(int start=0;start{ + var carrier=facts.get(r.workItemKey()+"/carrier"); var delivery=facts.get(r.workItemKey()+"/delivery"); + var original=carrier.executionStatus()==ExecutionStatus.COMPLETED?delivery.action():carrier.action(); + var action=original==null?null:new Action(original.code(),original.label(),r.workItemKey(),original.target()); + var read=new LinkedHashSet<>(carrier.readPermissions());read.addAll(delivery.readPermissions()); + var missing=new LinkedHashSet<>(carrier.missingFields());missing.addAll(delivery.missingFields()); + var refs=new LinkedHashSet<>(carrier.references());refs.addAll(delivery.references()); + boolean done=delivery.executionStatus()==ExecutionStatus.COMPLETED; + return new Fact(r.workItemKey(),digest(carrier.fingerprint()+":"+delivery.fingerprint()),carrier.impactToken(),carrier.planExists(),carrier.planStatus(), + done?ExecutionStatus.COMPLETED:carrier.executionStatus()==ExecutionStatus.COMPLETED?ExecutionStatus.IN_PROGRESS:ExecutionStatus.PENDING, + carrier.target(),carrier.confirmed(),done?delivery.actual():null,done?delivery.timezone():carrier.timezone(),delivery.businessNo(),null, + List.copyOf(read),action==null?List.of():carrier.executionStatus()==ExecutionStatus.COMPLETED?delivery.actionPermissions():carrier.actionPermissions(),action, + done?List.of():carrier.executionStatus()==ExecutionStatus.COMPLETED?delivery.blockReasons():carrier.blockReasons(),List.copyOf(missing),List.copyOf(refs),carrier.responsibility(),null); + }).toList(); + } + private FactRequest request(FactRequest r,Type type,String suffix) { return new FactRequest(r.workItemKey()+suffix,type,r.flow(),r.containerBookingId(),r.requirementKey(),r.batchKey(),null,r.batchKeys(),r.containers(),r.sources()); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java new file mode 100644 index 000000000..0428cd0ff --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.appointmentwork; + +import jakarta.validation.constraints.*; +import java.util.List; + +/** Real dispatch allocation boundary, implemented by the original appointment domain. */ +public interface AppointmentWorkDispatchPort { + record Allocation(@NotNull Long planPalletId,@NotBlank String batchKey,@NotBlank String customerId, + @NotBlank String sourceKey,@NotBlank String destinationKey,@Positive int packageCount) { } + record PhysicalItem(Long itemId,Long planPalletId,String sourceStage,Integer packages) { } + record BatchSource(String sourceKey,String destinationKey,int packageCount) { } + record BatchReference(String batchKey,String batchNo,String customerId,List sources) { } + /** Current original allocations, grouped for authorized dispatches; historical copies are read-only evidence. */ + java.util.Map> references(List dispatchIds); + void bind(String customerId,long dispatchId,List items,List allocations); + void requireReplay(String customerId,long dispatchId,List allocations); + void requireDeparture(String dispatchNo); + /** Read-only explanation; departure must repeat the same authority checks while holding batch locks. */ + String departureBlockReason(String dispatchNo); + void removeItem(long dispatchId,long itemId); + void requireTripBinding(String dispatchNo,long tripId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java new file mode 100644 index 000000000..3f9e71762 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java @@ -0,0 +1,116 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@Component +public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway { + private final AppointmentWorkFirstlegClient firstleg; + private final List providers; + private final ObjectProvider directory; + private final ObjectProvider requirements; + private final ObjectProvider suppliers; + private final String token; + public AppointmentWorkFactAdapter(AppointmentWorkFirstlegClient firstleg, ObjectProvider providers, + ObjectProvider directory, ObjectProvider requirements, ObjectProvider suppliers, + @Value("${baiyue.security.internal-service-token:}") String token) { + this.firstleg=firstleg; this.providers=providers.orderedStream().toList(); this.directory=directory; this.requirements=requirements; this.suppliers=suppliers; this.token=token; + } + public SourcePage discover(String after, int size) { + requireToken(); + try { var response=firstleg.discover(token,after,size); + if(response==null || !response.successful() || response.data()==null) throw unavailable("已开船来源读取失败"); + var page=response.data(); + if(page.scopeVersion()==null || page.scopeVersion().isBlank() || page.containers()==null || page.containers().size()>size || !page.complete() && (page.nextCursor()==null || page.containers().isEmpty() || Objects.equals(after,page.nextCursor()))) + throw unavailable("已开船来源分页不完整"); + return page; + } catch(RuntimeException error) { throw unavailable("已开船来源不可用,不能计算完整工作范围",error); } + } + public ContainerSource container(String id) { + requireToken(); + try { var response=firstleg.container(token,id); + if(response==null || !response.successful() || response.data()==null || !id.equals(response.data().containerBookingId())) throw unavailable("柜次事实缺失"); + return response.data(); + } catch(RuntimeException error) { throw unavailable("柜次权威事实不可用",error); } + } + public Map facts(List states) { + var result=new LinkedHashMap(); + var remote=new ArrayList(); + var groups=new LinkedHashMap>(); + for(var state:states) { + if(state.getType()==Type.ROUTE) continue; + var request=new FactRequest(state.getWorkItemKey(),state.getType(),state.getFlow(),state.getContainerBookingId(),state.getRequirementKey(),state.getBatchKey(),state.getTripKey(),state.getBatchKeys(),state.getContainers(),state.getSources()); + if(state.getType()==Type.PICKUP || state.getType()==Type.EMPTY_RETURN || state.getType()==Type.DELIVERY && state.getFlow()==Flow.DIRECT) remote.add(request); + else { + var matches=providers.stream().filter(p->p.supports(state.getType(),state.getFlow())).toList(); + if(matches.size()!=1) throw unavailable("原业务事实适配尚未就绪:"+state.getType()); + groups.computeIfAbsent(matches.get(0),ignored->new ArrayList<>()).add(request); + } + } + for(int start=0; start{ + for(int start=0; start requirements(ContainerSource source,Flow effectiveFlow) { + var provider=requirements.getIfAvailable(); + if(provider==null) throw unavailable("原预约需求与批次清单适配尚未接入,不能生成不完整工作清单"); + var result=provider.requirements(source,effectiveFlow); + if(result==null || result.isEmpty()) throw unavailable("原预约需求清单缺失,不能以空清单完成路线生成"); + return List.copyOf(result); + } + static void append(Map target,List requests,List facts) { + if(facts==null || facts.size()!=requests.size()) throw unavailable("工作事实响应不完整"); + var requestByKey=new HashMap();requests.forEach(r->requestByKey.put(r.workItemKey(),r)); + var expected=new HashSet<>(requestByKey.keySet()); + for(var fact:facts) { + if(fact==null || !expected.remove(fact.workItemKey()) || fact.fingerprint()==null || fact.fingerprint().isBlank() || fact.impactToken()==null || fact.impactToken().isBlank() || fact.planStatus()==null || fact.executionStatus()==null + || fact.readPermissions()==null || fact.readPermissions().isEmpty() || fact.actionPermissions()==null || fact.blockReasons()==null || fact.missingFields()==null || fact.references()==null) + throw unavailable("工作事实缺少身份、版本或资格"); + if(fact.executionStatus()==ExecutionStatus.COMPLETED && fact.actual()==null) throw unavailable("完成事实缺少实际时间"); + var request=requestByKey.get(fact.workItemKey()); + if(request.type()==Type.TRANSPORT && request.flow()==Flow.TRANSFER && (fact.transportPlanImpactToken()==null || fact.transportPlanImpactToken().isBlank())) + throw unavailable("共享车次事实缺少独立计划影响版本"); + if(fact.actual()!=null) validateActual(fact.actual()); + if(!fact.planExists() && (fact.target()!=null || fact.confirmed()!=null || fact.planStatus()==PlanStatus.CONFIRMED)) throw unavailable("原计划存在性与计划结果冲突"); + validateSourceSlot(fact.target()); validateSourceSlot(fact.confirmed()); + if(fact.timezone()!=null) zone(fact.timezone()); + if(fact.actual()!=null && fact.actual().timezone()!=null) zone(fact.actual().timezone()); + if(fact.action()!=null && (fact.action().target()==null || fact.action().target() instanceof WorkTarget || fact.actionPermissions().isEmpty())) throw unavailable("原业务动作缺少独立权限或越过工作命令边界"); + if(target.putIfAbsent(fact.workItemKey(),fact)!=null) throw unavailable("工作事实重复"); + } + } + public Owner owner(String id,Actor actor) { + if(id==null || id.isBlank()) throw bad("请选择负责人"); + var resolver=directory.getIfAvailable(); + if(resolver==null) throw unavailable("负责人目录尚未接入,不能确认其他接手人"); + var owner=resolver.activePlatformOwner(id); + if(owner==null || !id.equals(owner.id())) throw bad("接手人不是有效平台操作人员"); + return owner; + } + public Responsibility supplier(String id) { + if(id==null || id.isBlank()) throw bad("请选择供应商责任方"); + var provider=suppliers.getIfAvailable(); + if(provider==null) throw unavailable("供应商权威目录适配尚未接入"); + var result=provider.activeSupplier(id); + if(result==null || result.kind()!=ResponsibilityKind.SUPPLIER || !id.equals(result.id()) || result.name()==null || result.name().isBlank()) throw bad("供应商责任方无效"); + return result; + } + private void requireToken() { if(token==null || token.isBlank()) throw unavailable("内部服务身份未配置"); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java new file mode 100644 index 000000000..e37882ed9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.appointmentwork; + +import java.time.Instant; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; + +/** Authority ports. A missing entity must be explicitly represented; missing responses are failures. */ +public interface AppointmentWorkFactGateway { + record Requirement(String requirementKey, String batchKey, String batchNo, String destinationKind, + String originName, String destinationName, String timezone, List references, + List replacesRequirementKeys, List sources) { } + record ContainerSource(String containerBookingId, String containerNo, Instant departedAt, java.time.LocalDateTime departedLocalAt, java.time.LocalDateTime sailedEvidenceLocalAt, + String sourceVersion, Flow establishedFlow, String timezone, List requirements) { } + record SourcePage(List containers, String nextCursor, boolean complete, String scopeVersion) { } + record FactRequest(String workItemKey, Type type, Flow flow, String containerBookingId, + String requirementKey, String batchKey, String tripKey, List batchKeys, List containers, + List sources) { } + /** impactToken changes only for real changes to an existing commitment, never first arrangement/assignment. */ + record Fact(String workItemKey, String fingerprint, String impactToken, boolean planExists, PlanStatus planStatus, + ExecutionStatus executionStatus, Slot target, Slot confirmed, Actual actual, String timezone, + String businessNo, String tripKey, List readPermissions, List actionPermissions, + Action action, List blockReasons, List missingFields, List references, + Responsibility responsibility, String transportPlanImpactToken) { + /** Shared fleet transport separates its own cargo impact from changes affecting receiving appointments. */ + public String dependentImpactToken() { return transportPlanImpactToken==null?impactToken:transportPlanImpactToken; } + public boolean arranged() { return planStatus == PlanStatus.CONFIRMED || planStatus == PlanStatus.NOT_REQUIRED || target != null || confirmed != null; } + } + SourcePage discover(String after, int size); + ContainerSource container(String containerBookingId); + /** Original appointment core supplies the complete current obligation/batch manifest, including explicit replacement keys. */ + List requirements(ContainerSource source,Flow effectiveFlow); + Map facts(List states); + Owner owner(String id, Actor actor); + Responsibility supplier(String id); + + /** Implemented in original Lastleg business domains; they may depend on this port, never vice versa. */ + interface BusinessProvider { + boolean supports(Type type, Flow flow); + List read(List requests); + } + /** IAM-backed active platform operators; display names supplied by callers are never trusted. */ + interface OwnerDirectory { Owner activePlatformOwner(String id); } + interface SupplierDirectory { Responsibility activeSupplier(String id); } + interface RequirementProvider { List requirements(ContainerSource source,Flow effectiveFlow); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFirstlegClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFirstlegClient.java new file mode 100644 index 000000000..4051cf1f5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFirstlegClient.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.appointmentwork; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; +import java.util.List; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; + +/** Provider contract pending original-domain integration; HTTP errors are never treated as empty facts. */ +@FeignClient(name="firstleg-service", contextId="appointmentWorkFirstlegClient", + url="${FIRSTLEG_SERVICE_URL:${baiyue.clients.firstleg.url:}}") +public interface AppointmentWorkFirstlegClient { + @GetMapping("/internal/firstleg/appointment-work/containers") + ApiResponse discover(@RequestHeader("X-Internal-Service-Token") String token, + @RequestParam(value="after", required=false) String after, @RequestParam("size") int size); + @GetMapping("/internal/firstleg/appointment-work/containers/{id}") + ApiResponse container(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable("id") String id); + @PostMapping("/internal/firstleg/appointment-work/facts") + ApiResponse> facts(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody List requests); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFollowupInheritance.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFollowupInheritance.java new file mode 100644 index 000000000..acf9cf65f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFollowupInheritance.java @@ -0,0 +1,72 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +/** A future work key may receive an audited follow-up seed before source discovery creates its work row. */ +@Component +public class AppointmentWorkFollowupInheritance { + public record Member(String batchKey,String customerId) { } + private final AppointmentWorkStore store; + private final AppointmentWorkFactGateway.OwnerDirectory directory; + public AppointmentWorkFollowupInheritance(AppointmentWorkStore store,AppointmentWorkFactGateway.OwnerDirectory directory) { + this.store=store;this.directory=directory; + } + public AppointmentWorkStore.Followup resolve(String ownerId,LocalDate nextFollowupDate) { + if(ownerId==null || ownerId.isBlank()) throw bad("请选择有效平台负责人"); + var owner=directory.activePlatformOwner(ownerId); + if(owner==null || !ownerId.equals(owner.id()) || owner.name()==null || owner.name().isBlank()) throw bad("负责人不是有效平台操作人员"); + return new AppointmentWorkStore.Followup(owner,nextFollowupDate); + } + @Transactional(propagation=Propagation.MANDATORY) + public void seed(Member original,Member later,AppointmentWorkStore.Followup followup,String operatorId) { + key(original);key(later); + if(!original.customerId().equals(later.customerId()) || original.batchKey().equals(later.batchKey())) throw bad("跟进继承必须来自同一客户的原批次和新批次"); + if(followup==null || followup.owner()==null || followup.owner().id()==null || followup.owner().id().isBlank() || followup.owner().name()==null || followup.owner().name().isBlank()) throw bad("后送跟进缺少真实负责人"); + store.lock();write(later,followup,operatorId); + } + @Transactional(propagation=Propagation.MANDATORY) + public void inherit(List originals,List successors,String operatorId) { + validate(originals);validate(successors);store.lock(); + var values=new HashMap>(); + for(var original:originals) { + var key=key(original);var state=store.find(key); + var current=state==null?store.inheritedFollowup(key):new AppointmentWorkStore.Followup(state.getOwner(),state.getFollowupDate()); + if(current!=null) values.computeIfAbsent(original.customerId(),ignored->new HashSet<>()).add(current); + } + // Validate every target before writing; caller transaction still supplies rollback for all original-domain writes. + for(var successor:successors) if(values.getOrDefault(successor.customerId(),Set.of()).size()>1) + throw conflict("合批来源的负责人或跟进日期不同,请先在原工作事项统一后再合批"); + for(var successor:successors) { + var current=values.getOrDefault(successor.customerId(),Set.of()); + if(!current.isEmpty()) write(successor,current.iterator().next(),operatorId); + } + } + private void write(Member target,AppointmentWorkStore.Followup followup,String operatorId) { + if(operatorId==null || operatorId.isBlank()) throw bad("跟进继承缺少真实操作人"); + var key=key(target);var state=store.find(key);var previous=store.inheritedFollowup(key); + if(state!=null) { + if(Objects.equals(state.getOwner(),followup.owner()) && Objects.equals(state.getFollowupDate(),followup.nextFollowupDate())) return; + throw conflict("目标工作已存在独立跟进,不能用继承覆盖"); + } + if(previous!=null) { + if(previous.equals(followup)) return; + throw conflict("目标批次已有不同的跟进继承记录"); + } + var future=new AppointmentWorkState();future.setWorkItemKey(key); + store.history(future,new Actor(operatorId,null,Set.of()),"FOLLOWUP_INHERITED","继承原送仓事项的负责人及跟进日期",null,null,followup); + } + private static String key(Member member) { + if(member==null || member.batchKey()==null || !member.batchKey().matches("FBA_APPOINTMENT:[1-9][0-9]*") || member.customerId()==null || member.customerId().isBlank()) throw bad("跟进继承缺少真实批次客户范围"); + return AppointmentWorkPolicy.key("BATCH:"+member.batchKey(),Type.DELIVERY,customerRequirement(member.batchKey(),member.customerId())); + } + private static void validate(List members) { + if(members==null || members.isEmpty() || members.size()>2000 || new HashSet<>(members).size()!=members.size()) throw bad("跟进继承范围须为1至2000个不重复客户批次"); + members.forEach(AppointmentWorkFollowupInheritance::key); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkInternalController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkInternalController.java new file mode 100644 index 000000000..cbdda7cc5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkInternalController.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.appointmentwork; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; + +/** Internal orchestration only, not exposed through the public gateway. */ +@RestController +@RequestMapping("/internal/lastleg/appointment-work") +public class AppointmentWorkInternalController { + private final InternalServiceTokenGuard guard; + private final AppointmentWorkSyncService sync; + private final AppointmentWorkRouteService routes; + public AppointmentWorkInternalController(InternalServiceTokenGuard guard,AppointmentWorkSyncService sync,AppointmentWorkRouteService routes) { this.guard=guard; this.sync=sync; this.routes=routes; } + @GetMapping("/containers/{id}/route") + public ApiResponse route(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@PathVariable String id) { + guard.verify(token); return ApiResponse.success(routes.route(id)); + } + @PostMapping("/synchronize") + public ApiResponse synchronize(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@Valid @RequestBody SyncRequest request) { + guard.verify(token); return ApiResponse.success(sync.synchronize(request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkMapper.java new file mode 100644 index 000000000..bb308b663 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkMapper.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.appointmentwork; + +import lombok.Data; +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface AppointmentWorkMapper { + @Data class Row { + private String workItemKey; + private String containerBookingId; + private int version; + private boolean retired; + private String stateJson; + } + @Data class Guard { + private int id; + private String scanCursor; + private boolean initialized; + private String scopeVersion; + private String scanScopeVersion; + private LocalDateTime completedAt; + private long requirementVersion; + private long synchronizedRequirementVersion; + private long scanRequirementVersion; + } + @Data class History { + private String id; + private String workItemKey; + private String operatorId; + private String operatorName; + private String action; + private String summary; + private String commandKey; + private String requestHash; + private String responseJson; + private LocalDateTime occurredAt; + } + @Data class CommitmentRow { + private String entityKey; + private String stateJson; + private long impactVersion; + } + @Select("SELECT * FROM ll_appointment_work_commitment WHERE entity_key=#{key} FOR UPDATE") CommitmentRow lockCommitment(String key); + @Select("SELECT * FROM ll_appointment_work_commitment WHERE entity_key=#{key}") CommitmentRow commitment(String key); + @Select("") + List commitments(@Param("keys") List keys); + @Insert("INSERT INTO ll_appointment_work_commitment(entity_key,state_json,impact_version) VALUES(#{key},#{json},0)") + int insertCommitment(@Param("key") String key,@Param("json") String json); + @Update("UPDATE ll_appointment_work_commitment SET state_json=#{json},impact_version=#{version} WHERE entity_key=#{key}") + int updateCommitment(@Param("key") String key,@Param("json") String json,@Param("version") long version); + @Select("SELECT * FROM ll_appointment_work_guard WHERE id=1 FOR UPDATE") Guard lockGuard(); + @Select("SELECT * FROM ll_appointment_work_guard WHERE id=1") Guard guard(); + @Update("UPDATE ll_appointment_work_guard SET scan_cursor=#{scanCursor},initialized=#{initialized},completed_at=#{completedAt},scope_version=#{scopeVersion},scan_scope_version=#{scanScopeVersion},synchronized_requirement_version=#{synchronizedRequirementVersion},scan_requirement_version=#{scanRequirementVersion} WHERE id=1") int saveGuard(Guard row); + @Update("UPDATE ll_appointment_work_guard SET requirement_version=requirement_version+1 WHERE id=1") int invalidateRequirements(); + @Select("SELECT * FROM ll_appointment_work WHERE work_item_key=#{key}") Row get(String key); + @Select("SELECT * FROM ll_appointment_work ORDER BY work_item_key LIMIT #{limit}") List all(int limit); + @Select("SELECT * FROM ll_appointment_work WHERE container_booking_id=#{id} ORDER BY work_item_key") List container(String id); + @Insert("INSERT INTO ll_appointment_work(work_item_key,container_booking_id,version,retired,state_json) VALUES(#{workItemKey},#{containerBookingId},#{version},#{retired},#{stateJson})") int insert(Row row); + @Update("UPDATE ll_appointment_work SET version=#{version},retired=#{retired},state_json=#{stateJson} WHERE work_item_key=#{workItemKey} AND version=#{version}-1") int update(Row row); + @Select("SELECT * FROM ll_appointment_work_history WHERE work_item_key=#{key} AND command_key=#{commandKey}") + History command(@Param("key") String key,@Param("commandKey") String commandKey); + @Insert(""" + INSERT INTO ll_appointment_work_history(id,work_item_key,operator_id,operator_name,action,summary,command_key,request_hash,response_json,occurred_at) + VALUES(#{id},#{workItemKey},#{operatorId},#{operatorName},#{action},#{summary},#{commandKey},#{requestHash},#{responseJson},#{occurredAt}) + """) int insertHistory(History row); + @Select("SELECT * FROM ll_appointment_work_history WHERE work_item_key=#{key} ORDER BY occurred_at DESC,id DESC LIMIT 1001") List selectHistory(@Param("key") String key); + @Select("SELECT * FROM ll_appointment_work_history WHERE work_item_key=#{key} AND action='FOLLOWUP_INHERITED' ORDER BY occurred_at DESC,id DESC LIMIT 1") + History inheritedFollowup(String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkModels.java new file mode 100644 index 000000000..6eca27ecd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkModels.java @@ -0,0 +1,77 @@ +package com.baiyue.lastleg.appointmentwork; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.*; +import java.time.*; +import java.util.*; + +/** Public v1 contracts. No editable cargo quantities or execution commands live here. */ +public final class AppointmentWorkModels { + private AppointmentWorkModels() { } + public enum Type { ROUTE, PICKUP, WAREHOUSE_RECEIPT, DELIVERY, TRANSPORT, EMPTY_RETURN } + public enum Flow { TRANSFER, DIRECT, UNKNOWN } + public enum PlanStatus { UNPLANNED, TARGET_SET, AWAITING_REPLY, CONFIRMED, NOT_REQUIRED } + public enum ExecutionStatus { PENDING, IN_PROGRESS, COMPLETED } + public enum DateBasis { ACTUAL, CONFIRMED, TARGET, NONE } + public enum View { ALL, FOLLOW_TODAY, OVERDUE, UNDATED, REVIEW } + public enum TargetKind { WORK_COMMAND, CARGO_VERIFICATION, FBA_APPOINTMENT, FIRSTLEG_CONTAINER, FLEET_TRIP, WAREHOUSE_RECEIPT, READ_ONLY } + public enum Command { CONFIRM_ROUTE, SAVE_FOLLOWUP, RESOLVE_REVIEW, SAVE_REQUIREMENT, HANDOFF_RETURN, ACCEPT_RETURN } + public enum Bucket { PENDING, CONFIRMED, NO_BOOKING_REQUIRED, REVIEW, COMPLETED } + public enum ActionCode { VIEW_DETAIL, SAVE_PLAN, REQUEST_BOOKING, RECORD_CONFIRMATION, CHANGE_BOOKING, CANCEL_BOOKING, VERIFY_CARGO, EDIT_BATCH, ARRANGE_TRANSPORT, RECORD_EXECUTION, CONFIRM_ROUTE, SAVE_FOLLOWUP, RESOLVE_REVIEW, SAVE_REQUIREMENT, HANDOFF_RETURN, ACCEPT_RETURN, VIEW_HISTORY } + public record Slot(LocalDate date, @com.fasterxml.jackson.databind.annotation.JsonSerialize(using=com.fasterxml.jackson.databind.ser.std.ToStringSerializer.class) LocalTime startTime, LocalDate endDate, + @com.fasterxml.jackson.databind.annotation.JsonSerialize(using=com.fasterxml.jackson.databind.ser.std.ToStringSerializer.class) LocalTime endTime, String timezone) { } + public record Actual(Instant occurredAt, LocalDateTime localOccurredAt, String timezone) { } + public record Owner(String id, String name) { } + public record ContainerReference(String containerBookingId, String bookingNo, String containerNo) { } + public record SourceReference(String customerId, String sourceKey, String destinationKey) { } + @JsonTypeInfo(use=JsonTypeInfo.Id.NAME, property="kind") + @JsonSubTypes({@JsonSubTypes.Type(value=WorkTarget.class,name="WORK_COMMAND"), @JsonSubTypes.Type(value=CargoTarget.class,name="CARGO_VERIFICATION"), + @JsonSubTypes.Type(value=AppointmentTarget.class,name="FBA_APPOINTMENT"), @JsonSubTypes.Type(value=ContainerTarget.class,name="FIRSTLEG_CONTAINER"), + @JsonSubTypes.Type(value=TripTarget.class,name="FLEET_TRIP"), @JsonSubTypes.Type(value=ReceiptTarget.class,name="WAREHOUSE_RECEIPT"), @JsonSubTypes.Type(value=ReadTarget.class,name="READ_ONLY")}) + public sealed interface ActionTarget permits WorkTarget,CargoTarget,AppointmentTarget,ContainerTarget,TripTarget,ReceiptTarget,ReadTarget { } + public record WorkTarget(Command command) implements ActionTarget { } + public record CargoTarget(SourceReference source) implements ActionTarget { } + public record AppointmentTarget(String appointmentId,String batchKey,List sources) implements ActionTarget { } + public record ContainerTarget(String containerBookingId) implements ActionTarget { } + public record TripTarget(String tripId,List batchKeys) implements ActionTarget { } + public record ReceiptTarget(String inboundPlanId,String inboundPlanNo,String customerId,String containerBookingId) implements ActionTarget { } + public record ReadTarget(List references) implements ActionTarget { } + public record Action(ActionCode code, String label, String targetWorkItemKey, ActionTarget target) { } + public record WorkItem(String workItemKey, int version, Type type, Flow flow, String title, + String businessNo, List containers, String requirementKey, + String batchKey, String batchNo, String originName, String destinationName, + PlanStatus planStatus, ExecutionStatus executionStatus, boolean reviewRequired, + Slot target, Slot confirmed, Actual actual, LocalDate calendarDate, DateBasis dateBasis, + String timezone, Owner owner, LocalDate followupDate, List overdueReasons, + List allowedActions, Action nextAction, List blockReasons, List missingFields) { } + public record BusinessReference(String kind, String key, String businessNo, String label) { } + public record HistoryEntry(Instant occurredAt, String operatorName, String action, String summary) { } + public record WorkDetail(WorkItem item, List relatedItems, + List references, List history, Review review, RequirementDetails requirement, ReturnHandoff returnHandoff) { } + public record BatchWorkCandidate(String customerId,String workItemKey,String batchNo) { } + public record BatchWorkResolution(String batchKey,String customerId,WorkDetail detail,List candidates) { } + public record Review(int version,List reasons) { } + public record RequirementDetails(boolean bookingRequired,String receivingRequirements,String reason) { } + public enum ResponsibilityKind { USER, SUPPLIER, EXTERNAL } + public record Responsibility(ResponsibilityKind kind,String id,String name) { } + public enum HandoffStatus { PENDING_ACCEPTANCE, ACCEPTED } + public record ReturnHandoff(int version,Responsibility from,Responsibility to,HandoffStatus status,String reason) { } + public record Summary(long all, long followToday, long overdue, long undated, long review) { } + public record BucketCounts(long pending, long confirmed, long noBookingRequired, long review, long completed) { } + public record DayAggregate(LocalDate date, long total, Map typeCounts, BucketCounts counts) { } + public record CalendarResult(LocalDate from, LocalDate to, long total, BucketCounts counts, List days) { } + public record CommandRequest(@NotNull Command command, @Min(0) int expectedVersion, + @NotBlank @Size(max=64) String idempotencyKey, @NotNull JsonNode payload) { } + public record CommandResult(WorkItem item, List affectedWorkItemKeys) { } + public record SyncRequest(@Size(max=128) String afterContainerBookingId, @Min(1) @Max(100) int size) { } + public record SyncResult(int sources, int created, String nextCursor, boolean complete) { } + public record Actor(String id, String name, Set permissions) { + public boolean has(String permission) { return permissions.contains(permission); } + } + public static final String LIST = "platform:appointment:list"; + public static final String FOLLOWUP = "platform:appointment:followup"; + public static final String MANAGE = "platform:appointment:manage-work"; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkOwnerClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkOwnerClient.java new file mode 100644 index 000000000..3f58df68f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkOwnerClient.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.appointmentwork; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; + +@FeignClient(name="iam-service",contextId="appointmentWorkOwnerClient",url="${baiyue.clients.iam.url:}") +public interface AppointmentWorkOwnerClient { + @GetMapping("/internal/iam/platform-owners/{authUserId}") + ApiResponse active(@RequestHeader("X-Internal-Service-Token") String token,@PathVariable("authUserId") String id); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkOwnerDirectory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkOwnerDirectory.java new file mode 100644 index 000000000..5b02e466b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkOwnerDirectory.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@Component +public class AppointmentWorkOwnerDirectory implements AppointmentWorkFactGateway.OwnerDirectory { + private final AppointmentWorkOwnerClient client; + private final String token; + public AppointmentWorkOwnerDirectory(AppointmentWorkOwnerClient client,@Value("${baiyue.security.internal-service-token:}") String token) { this.client=client; this.token=token; } + public AppointmentWorkModels.Owner activePlatformOwner(String id) { + if(token==null || token.isBlank()) throw unavailable("内部服务身份未配置"); + var response=client.active(token,id); + if(response==null || !response.successful() || response.data()==null) throw unavailable("平台人员权威目录读取失败"); + var owner=response.data(); + if(!id.equals(owner.id()) || owner.name()==null || owner.name().isBlank()) throw unavailable("平台人员目录返回身份或姓名不完整"); + return owner; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPolicy.java new file mode 100644 index 000000000..2fe920bbc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPolicy.java @@ -0,0 +1,174 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; + +public final class AppointmentWorkPolicy { + /** Explicit business fallback for reminder dates only; never used to fabricate actual/appointment time. */ + public static final ZoneId REMINDER_ZONE=ZoneId.of("America/Los_Angeles"); + public static final String ORDER_READ="platform:overseas-warehouse:last-mile-order:list"; + private AppointmentWorkPolicy() { } + public static String customerRequirement(String batchKey,String customerId) { + if(batchKey==null || customerId==null || customerId.isBlank()) throw unavailable("送仓需求缺少批次或客户身份"); + return batchKey+"|CUSTOMER:"+customerId; + } + public static String deliveryCustomer(List sources) { + var customers=sources.stream().map(SourceReference::customerId).distinct().toList(); + if(customers.size()!=1 || customers.get(0)==null || customers.get(0).isBlank()) throw unavailable("送仓事项须明确唯一客户货物范围"); + return customers.get(0); + } + public static String key(String containerId,Type type,String requirementKey) { + return "AW-"+type.name()+"-"+digest(containerId+"\n"+Objects.toString(requirementKey,"")).substring(0,32); + } + public static String digest(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } + catch(Exception e) { throw new IllegalStateException(e); } + } + public static void validateSlot(Slot slot) { + if(slot==null) return; + if(slot.date()==null || slot.timezone()==null || slot.timezone().isBlank()) throw bad("目标日期与地点时区必填"); + validateSourceSlot(slot); + var zone=zone(slot.timezone()); + for(int index=0; index<2; index++) { var time=index==0?slot.startTime():slot.endTime(); if(time==null) continue; + if(time.getSecond()!=0 || time.getNano()!=0) throw bad("时间精度须为分钟"); + if(zone.getRules().getValidOffsets(LocalDateTime.of(index==0?slot.date():slot.endDate(),time)).size()!=1) throw bad("当地时段存在夏令时跳变或歧义,请重新选择"); + } + } + /** Original local plans remain visible even when their source did not record a timezone. */ + public static void validateSourceSlot(Slot slot) { + if(slot==null) return; + if(slot.date()==null) throw unavailable("原计划缺少当地日期"); + if(slot.timezone()!=null) zone(slot.timezone()); + if((slot.endDate()==null)!=(slot.endTime()==null) || slot.startTime()==null && slot.endTime()!=null) throw bad("结束日期与时间须同时填写,并先明确开始时间"); + if(slot.endTime()!=null && !LocalDateTime.of(slot.endDate(),slot.endTime()).isAfter(LocalDateTime.of(slot.date(),slot.startTime()))) throw bad("完整结束时间必须晚于开始时间,不自动推断次日"); + } + public static void validateActual(Actual actual) { + if(actual.occurredAt()==null && actual.localOccurredAt()==null) throw unavailable("实际事实缺少发生时间"); + if(actual.timezone()!=null) { + var zone=zone(actual.timezone()); + if(actual.occurredAt()!=null && actual.localOccurredAt()!=null && !actual.occurredAt().atZone(zone).toLocalDateTime().equals(actual.localOccurredAt())) + throw unavailable("实际发生时刻与当地时间不一致"); + } + } + public static ZoneId zone(String value) { + if(value==null || !ZoneId.getAvailableZoneIds().contains(value)) throw bad("需要有效IANA地点时区"); + return ZoneId.of(value); + } + public static boolean pendingHandoff(AppointmentWorkState state) { + return state.getReturnHandoff()!=null && state.getReturnHandoff().status()==HandoffStatus.PENDING_ACCEPTANCE; + } + public static boolean canRead(AppointmentWorkState state,Fact fact,Actor actor) { + return actor.has(LIST) && (state.getType()==Type.ROUTE ? actor.has(ORDER_READ) : fact!=null && fact.readPermissions().stream().allMatch(actor::has)); + } + public static boolean arranged(AppointmentWorkState state,Fact fact) { + return state.getTarget()!=null || !state.isBookingRequired() || fact!=null && fact.arranged(); + } + public static boolean changed(AppointmentWorkState state,Fact fact) { + return fact!=null && state.getObservedFingerprint()!=null && !Objects.equals(state.getObservedFingerprint(),fact.fingerprint()); + } + public static boolean completed(Fact fact) { return fact!=null && fact.executionStatus()==ExecutionStatus.COMPLETED; } + public static WorkItem project(AppointmentWorkState state,Fact fact,Actor actor,Clock clock) { + boolean route=state.getType()==Type.ROUTE; + if(!route && fact==null) throw unavailable("缺少事项权威事实"); + var target=fact!=null && fact.planExists()?fact.target():state.getTarget(); + var confirmed=fact==null?null:fact.confirmed(); + var actual=fact==null?null:fact.actual(); + var execution=route?state.isRetired()?ExecutionStatus.COMPLETED:ExecutionStatus.PENDING:fact.executionStatus(); + var plan=!state.isBookingRequired()?PlanStatus.NOT_REQUIRED:fact!=null && fact.planExists()?fact.planStatus():target!=null?PlanStatus.TARGET_SET:PlanStatus.UNPLANNED; + var basis=actual!=null?DateBasis.ACTUAL:confirmed!=null?DateBasis.CONFIRMED:target!=null?DateBasis.TARGET:DateBasis.NONE; + String timezone=actual!=null?actual.timezone():confirmed!=null?confirmed.timezone():target!=null?target.timezone():fact!=null && fact.timezone()!=null?fact.timezone():state.getTimezone(); + LocalDate date=null; + var blocks=new ArrayList(); var missing=new ArrayList(); + if(fact!=null) { blocks.addAll(fact.blockReasons()); missing.addAll(fact.missingFields()); } + if(actual!=null) { + validateActual(actual); + if(timezone!=null && actual.occurredAt()!=null) date=actual.occurredAt().atZone(zone(timezone)).toLocalDate(); + else if(actual.localOccurredAt()!=null) date=actual.localOccurredAt().toLocalDate(); + if(timezone==null && !missing.contains("SOURCE_TIMEZONE")) missing.add("SOURCE_TIMEZONE"); + } + else if(confirmed!=null) { validateSourceSlot(confirmed); date=confirmed.date(); } + else if(target!=null) { validateSourceSlot(target); date=target.date(); } + if((confirmed!=null || target!=null) && timezone==null && !missing.contains("SOURCE_TIMEZONE")) missing.add("SOURCE_TIMEZONE"); + var today=LocalDate.now(clock.withZone(timezone==null?REMINDER_ZONE:zone(timezone))); + if(timezone==null) missing.add("跟进日期按业务默认时区America/Los_Angeles判断;作业时区待核实"); + if(state.getOwner()==null && execution!=ExecutionStatus.COMPLETED) missing.add("负责人待分配"); + if(state.getFollowupDate()==null && execution!=ExecutionStatus.COMPLETED && !state.isRetired()) missing.add("下次跟进日期待安排"); + var overdue=new ArrayList(); + if(execution!=ExecutionStatus.COMPLETED && !state.isRetired()) { + if(state.getFollowupDate()!=null && state.getFollowupDate().isBefore(today)) overdue.add("跟进日期已过"); + if(date!=null && date.isBefore(today)) overdue.add("作业日期已过,尚未完成"); + } + if(pendingHandoff(state) && execution!=ExecutionStatus.COMPLETED) blocks.add("还空接手尚未确认,继续跟进"); + if(changed(state,fact)) blocks.add("原业务事实已变化,须同步影响后再复核"); + var actions=new ArrayList(); + if(execution!=ExecutionStatus.COMPLETED && !state.isRetired()) { + if(actor.has(FOLLOWUP)) actions.add(work(state,Command.SAVE_FOLLOWUP,"记录回复与跟进")); + if(route && actor.has(MANAGE)) actions.add(work(state,Command.CONFIRM_ROUTE,"确认路线")); + if(state.isReviewRequired() && actor.has(FOLLOWUP) && !changed(state,fact)) actions.add(work(state,Command.RESOLVE_REVIEW,"复核安排")); + if(actor.has(MANAGE) && requirementOwned(state,fact)) actions.add(work(state,Command.SAVE_REQUIREMENT,"登记作业要求")); + if(state.getType()==Type.EMPTY_RETURN && actor.has(MANAGE)) { + actions.add(work(state,Command.HANDOFF_RETURN,"转交还空责任")); + if(pendingHandoff(state) && (state.getReturnHandoff().to().kind()!=ResponsibilityKind.USER || actor.id().equals(state.getReturnHandoff().to().id()))) actions.add(work(state,Command.ACCEPT_RETURN,"确认接手反馈")); + } + if(fact!=null && fact.action()!=null) { + boolean executionBlocked=fact.action().code()==ActionCode.RECORD_EXECUTION && (!fact.blockReasons().isEmpty() || state.getType()!=Type.EMPTY_RETURN && (state.isReviewRequired() || changed(state,fact))); + if(fact.actionPermissions().stream().allMatch(actor::has) && !executionBlocked) actions.add(fact.action()); + else if(!fact.actionPermissions().stream().allMatch(actor::has)) blocks.add("缺少原业务操作权限,可查看安排并联系有权限人员处理"); + } + } + var read=new Action(execution==ExecutionStatus.COMPLETED?ActionCode.VIEW_HISTORY:ActionCode.VIEW_DETAIL,"查看详情",state.getWorkItemKey(),new ReadTarget(state.getReferences())); + actions.add(read); + Action next=actions.stream().filter(a->a.code()==ActionCode.ACCEPT_RETURN).findFirst().orElse(null); + if(next==null && pendingHandoff(state)) next=actions.stream().filter(a->a.code()==ActionCode.SAVE_FOLLOWUP).findFirst().orElse(read); + if(next==null) next=actions.stream().filter(a->a.code()==ActionCode.CONFIRM_ROUTE || a.code()==ActionCode.RESOLVE_REVIEW).findFirst().orElse(null); + if(next==null && fact!=null && fact.action()!=null && actions.contains(fact.action())) next=fact.action(); + if(next==null) next=actions.stream().filter(a->a.code()==ActionCode.SAVE_REQUIREMENT || a.code()==ActionCode.SAVE_FOLLOWUP).findFirst().orElse(read); + var names=Map.of(Type.ROUTE,"确认路线",Type.PICKUP,"提柜",Type.WAREHOUSE_RECEIPT,"接仓卸柜",Type.DELIVERY,"送仓",Type.TRANSPORT,"用车",Type.EMPTY_RETURN,"还空柜"); + var businessNo=fact==null?null:fact.businessNo(); + var label=businessNo!=null?businessNo:state.getBatchNo()!=null?state.getBatchNo():state.getContainerNo()!=null?state.getContainerNo():"—"; + return new WorkItem(state.getWorkItemKey(),state.getVersion(),state.getType(),state.getFlow(),label+" · "+names.get(state.getType()),businessNo, + state.getContainers().isEmpty()?List.of(new ContainerReference(state.getContainerBookingId(),null,state.getContainerNo())):List.copyOf(state.getContainers()),state.getRequirementKey(),state.getBatchKey(),state.getBatchNo(), + state.getOriginName(),state.getDestinationName(),plan,execution,state.isReviewRequired() && execution!=ExecutionStatus.COMPLETED, + target,confirmed,actual,date,basis,timezone,state.getOwner(),state.getFollowupDate(),List.copyOf(overdue),List.copyOf(actions),next,List.copyOf(blocks),List.copyOf(missing)); + } + public static boolean requirementOwned(AppointmentWorkState state,Fact fact) { + return (fact==null || !fact.planExists()) && (state.getType()==Type.WAREHOUSE_RECEIPT || state.getType()==Type.EMPTY_RETURN || state.getType()==Type.DELIVERY && "CUSTOMER".equals(state.getDestinationKind())); + } + public static Action work(AppointmentWorkState state,Command command,String label) { + return new Action(ActionCode.valueOf(command.name()),label,state.getWorkItemKey(),new WorkTarget(command)); + } + public static boolean followToday(WorkItem item,Clock clock) { + var today=LocalDate.now(clock.withZone(item.timezone()==null?REMINDER_ZONE:zone(item.timezone()))); + return item.followupDate()!=null && !item.followupDate().isAfter(today) || item.blockReasons().contains("还空接手尚未确认,继续跟进"); + } + public static Bucket bucket(WorkItem item) { + return item.executionStatus()==ExecutionStatus.COMPLETED?Bucket.COMPLETED:item.reviewRequired()?Bucket.REVIEW: + item.planStatus()==PlanStatus.CONFIRMED?Bucket.CONFIRMED:item.planStatus()==PlanStatus.NOT_REQUIRED?Bucket.NO_BOOKING_REQUIRED:Bucket.PENDING; + } + public static BucketCounts counts(List items) { + var counts=new EnumMap(Bucket.class); items.forEach(i->counts.merge(bucket(i),1L,Long::sum)); + return new BucketCounts(counts.getOrDefault(Bucket.PENDING,0L),counts.getOrDefault(Bucket.CONFIRMED,0L),counts.getOrDefault(Bucket.NO_BOOKING_REQUIRED,0L),counts.getOrDefault(Bucket.REVIEW,0L),counts.getOrDefault(Bucket.COMPLETED,0L)); + } + public static List effectiveKeys(Collection keys,Map states) { + var pending=new ArrayDeque<>(keys); var seen=new HashSet(); var effective=new LinkedHashSet(); + while(!pending.isEmpty()) { + var key=pending.remove(); if(!seen.add(key)) continue; + var state=states.get(key); + if(state!=null && state.isRetired() && !state.getReplacementKeys().isEmpty()) pending.addAll(state.getReplacementKeys()); + else effective.add(key); + } + return List.copyOf(effective); + } + public static ResponseStatusException bad(String text) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,text); } + public static ResponseStatusException conflict(String text) { return new ResponseStatusException(HttpStatus.CONFLICT,text); } + public static ResponseStatusException missing(String text) { return new ResponseStatusException(HttpStatus.NOT_FOUND,text); } + public static ResponseStatusException forbidden(String text) { return new ResponseStatusException(HttpStatus.FORBIDDEN,text); } + public static ResponseStatusException unavailable(String text) { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,text); } + public static ResponseStatusException unavailable(String text,Throwable cause) { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,text,cause); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryService.java new file mode 100644 index 000000000..b8be03d3a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryService.java @@ -0,0 +1,157 @@ +package com.baiyue.lastleg.appointmentwork; + +import com.baiyue.common.core.api.PageResult; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.*; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@Service +@Transactional(readOnly=true) +public class AppointmentWorkQueryService { + private final AppointmentWorkStore store; + private final AppointmentWorkFactGateway facts; + private final Clock clock; + @org.springframework.beans.factory.annotation.Autowired + public AppointmentWorkQueryService(AppointmentWorkStore store,AppointmentWorkFactGateway facts) { this(store,facts,Clock.systemUTC()); } + AppointmentWorkQueryService(AppointmentWorkStore store,AppointmentWorkFactGateway facts,Clock clock) { this.store=store; this.facts=facts; this.clock=clock; } + record Snapshot(List states,Map facts,List items) { } + Snapshot snapshot(Actor actor) { + return snapshot(actor,s->true,true); + } + private Snapshot snapshot(Actor actor,Predicate selected,boolean includeNext) { + String scopeVersion=store.requireInitialized(); + if(!Objects.equals(scopeVersion,facts.discover(null,1).scopeVersion())) throw conflict("已开船工作范围变化,请先完成服务端同步"); + var all=store.all(); + var stateByKey=all.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity())); + var selectedKeys=all.stream().filter(s->!s.isRetired() && selected.test(s)).map(AppointmentWorkState::getWorkItemKey).collect(Collectors.toSet()); + var needed=new HashSet<>(selectedKeys); + if(includeNext) { + var pending=new ArrayDeque<>(selectedKeys); + while(!pending.isEmpty()) { + var state=stateByKey.get(pending.remove()); + for(var key:effectiveKeys(state.getPrerequisites(),stateByKey)) if(stateByKey.containsKey(key) && needed.add(key)) pending.add(key); + } + } + var states=all.stream().filter(s->!s.isRetired() && needed.contains(s.getWorkItemKey())).toList(); + var current=facts.facts(states); + var visible=states.stream().filter(s->canRead(s,current.get(s.getWorkItemKey()),actor)).toList(); + var items=visible.stream().map(s->project(s,current.get(s.getWorkItemKey()),actor,clock)).toList(); + var byKey=items.stream().collect(Collectors.toMap(WorkItem::workItemKey,Function.identity())); + return new Snapshot(all,current,items.stream().filter(i->selectedKeys.contains(i.workItemKey())) + .map(i->includeNext?withNext(i,next(i,byKey,stateByKey,new HashSet<>())):i).toList()); + } + private Action next(WorkItem item,Map items,Map states,Set visited) { + if(!visited.add(item.workItemKey()) || item.reviewRequired()) return item.nextAction(); + if(item.type()==Type.DELIVERY && item.executionStatus()!=ExecutionStatus.COMPLETED && item.blockReasons().isEmpty() + && (item.planStatus()==PlanStatus.CONFIRMED || item.planStatus()==PlanStatus.NOT_REQUIRED)) { + for(var key:effectiveKeys(states.get(item.workItemKey()).getPrerequisites(),states)) { + var transport=items.get(key); + if(transport==null || transport.type()!=Type.TRANSPORT || transport.executionStatus()==ExecutionStatus.COMPLETED) continue; + var action=transport.nextAction(); + if(action==null) continue; + if(action.code()==ActionCode.RESOLVE_REVIEW) + return new Action(action.code(),"先复核车辆安排",transport.workItemKey(),action.target()); + if(action.code()==ActionCode.ARRANGE_TRANSPORT && action.target() instanceof TripTarget target) + return new Action(action.code(),target.tripId()==null?"继续安排本批车辆":"核对本车发车安排",transport.workItemKey(),target); + } + } + if(item.blockReasons().isEmpty()) return item.nextAction(); + for(var key:effectiveKeys(states.get(item.workItemKey()).getPrerequisites(),states)) { + var prior=items.get(key); + if(prior!=null && prior.executionStatus()!=ExecutionStatus.COMPLETED && (prior.reviewRequired() || prior.planStatus()==PlanStatus.UNPLANNED || prior.planStatus()==PlanStatus.AWAITING_REPLY)) { + var action=next(prior,items,states,visited); + if(action!=null) return new Action(action.code(),"先"+action.label().replaceFirst("^先",""),action.targetWorkItemKey(),action.target()); + } + } + return item.nextAction(); + } + public Summary summary(Actor actor) { + var items=snapshot(actor,s->true,false).items().stream().filter(i->i.executionStatus()!=ExecutionStatus.COMPLETED).toList(); + return new Summary(items.size(),items.stream().filter(i->followToday(i,clock)).count(),items.stream().filter(i->!i.overdueReasons().isEmpty()).count(),items.stream().filter(i->i.calendarDate()==null).count(),items.stream().filter(WorkItem::reviewRequired).count()); + } + public CalendarResult calendar(LocalDate from,LocalDate to,Flow flow,Set types,Actor actor) { + if(from==null || to==null || to.isBefore(from) || ChronoUnit.DAYS.between(from,to)>=62) throw bad("日历范围须为1至62天"); + if(types!=null && types.contains(Type.ROUTE)) throw bad("路线待确认需求请从全局待办查看"); + var items=snapshot(actor,s->s.getType()!=Type.ROUTE && matches(s,flow,types,null),false).items().stream().filter(i->i.type()!=Type.ROUTE && i.calendarDate()!=null && !i.calendarDate().isBefore(from) && !i.calendarDate().isAfter(to)) + .filter(i->flow==null || i.flow()==flow).filter(i->types==null || types.contains(i.type())).toList(); + var groups=items.stream().collect(Collectors.groupingBy(WorkItem::calendarDate,TreeMap::new,Collectors.toList())); + var days=groups.entrySet().stream().map(e->new DayAggregate(e.getKey(),e.getValue().size(),e.getValue().stream().collect(Collectors.groupingBy(WorkItem::type,()->new EnumMap<>(Type.class),Collectors.counting())),counts(e.getValue()))).toList(); + return new CalendarResult(from,to,items.size(),counts(items),days); + } + public PageResult items(View view,int page,int size,LocalDate date,Flow flow,Set types,String containerId,Bucket bucket,String keyword,Actor actor) { + if(page<1 || size<1 || size>100 || keyword!=null && keyword.length()>128) throw bad("分页或搜索条件无效"); + var word=keyword==null?"":keyword.trim().toLowerCase(Locale.ROOT); + var items=snapshot(actor,s->matches(s,flow,types,containerId) && candidate(s,view),true).items().stream() + .filter(i->date!=null || i.executionStatus()!=ExecutionStatus.COMPLETED) + .filter(i->date==null || date.equals(i.calendarDate())) + .filter(i->flow==null || flow==i.flow()).filter(i->types==null || types.contains(i.type())) + .filter(i->containerId==null || i.containers().stream().anyMatch(c->containerId.equals(c.containerBookingId()))) + .filter(i->bucket==null || bucket==bucket(i)) + .filter(i->word.isEmpty() || searchable(i).contains(word)) + .filter(i->switch(view) { case ALL->true; case FOLLOW_TODAY->i.executionStatus()!=ExecutionStatus.COMPLETED && followToday(i,clock); case OVERDUE->!i.overdueReasons().isEmpty(); case UNDATED->i.calendarDate()==null; case REVIEW->i.reviewRequired(); }) + .sorted(Comparator.comparing(WorkItem::followupDate,Comparator.nullsLast(Comparator.naturalOrder())).thenComparing(WorkItem::workItemKey)).toList(); + long offset=(long)(page-1)*size; int start=(int)Math.min(offset,items.size()); + return new PageResult<>(items.subList(start,Math.min(start+size,items.size())),page,size,items.size(),(items.size()+size-1L)/size); + } + public BatchWorkResolution resolveBatch(String batchKey,String customerId,Actor actor) { + if(batchKey==null || !batchKey.matches("(?:FBA_APPOINTMENT|DISPATCH):[1-9][0-9]*")) throw bad("请提供原业务返回的完整批次键"); + if(customerId!=null && customerId.isBlank()) throw bad("客户身份不能为空"); + if(!Objects.equals(store.requireInitialized(),facts.discover(null,1).scopeVersion())) throw conflict("已开船工作范围变化,请先完成服务端同步"); + var matches=store.all().stream().filter(s->!s.isRetired() && s.getType()==Type.DELIVERY && batchKey.equals(s.getBatchKey())) + .filter(s->customerId==null || customerId.equals(deliveryCustomer(s.getSources()))).toList(); + if(matches.isEmpty()) throw missing("当前批次的送仓事项尚不存在,请先同步当前批次"); + var current=facts.facts(matches); + var visible=matches.stream().filter(s->canRead(s,current.get(s.getWorkItemKey()),actor)).toList(); + if(visible.isEmpty()) throw forbidden("无权读取关联来源"); + var candidates=visible.stream().map(s->new BatchWorkCandidate(deliveryCustomer(s.getSources()),s.getWorkItemKey(),s.getBatchNo())).sorted(Comparator.comparing(BatchWorkCandidate::customerId)).toList(); + if(candidates.stream().map(BatchWorkCandidate::customerId).distinct().count()!=candidates.size()) throw conflict("同一批次客户存在重复送仓事项"); + if(candidates.size()>1) return new BatchWorkResolution(batchKey,null,null,candidates); + var selected=candidates.get(0); + return new BatchWorkResolution(batchKey,selected.customerId(),detail(selected.workItemKey(),actor),candidates); + } + public WorkDetail detail(String key,Actor actor) { + var snapshot=snapshot(actor); var state=store.get(key); + var fact=snapshot.facts().get(key); + if(state.isRetired() && state.getType()!=Type.ROUTE) fact=facts.facts(List.of(state)).get(key); + if(!canRead(state,fact,actor)) throw forbidden("无权读取关联来源"); + var item=state.isRetired()?project(state,fact,actor,clock):snapshot.items().stream().filter(i->i.workItemKey().equals(key)).findFirst().orElseThrow(()->missing("事项不存在")); + var relatedKeys=new HashSet<>(state.getPrerequisites()); relatedKeys.addAll(state.getReplacementKeys()); + var statesByKey=snapshot.states().stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity())); + relatedKeys.addAll(effectiveKeys(relatedKeys,statesByKey)); + snapshot.states().stream().filter(s->effectiveKeys(s.getPrerequisites(),statesByKey).contains(key)).map(AppointmentWorkState::getWorkItemKey).forEach(relatedKeys::add); + var refs=new ArrayList<>(state.getReferences()); if(fact!=null) refs.addAll(fact.references()); + return new WorkDetail(item,snapshot.items().stream().filter(i->relatedKeys.contains(i.workItemKey())).toList(),refs.stream().distinct().toList(),store.history(key), + state.isReviewRequired()?new Review(state.getReviewVersion(),List.of(Objects.toString(state.getReviewReason(),"关联安排变化"))):null, + requirementOwned(state,fact)?new RequirementDetails(state.isBookingRequired(),Objects.toString(state.getReceivingRequirements(),""),state.getRequirementReason()):null,state.getReturnHandoff()); + } + WorkItem current(AppointmentWorkState state,Fact fact,Actor actor) { return project(state,fact,actor,clock); } + private boolean matches(AppointmentWorkState state,Flow flow,Set types,String containerId) { + return (flow==null || state.getFlow()==flow) && (types==null || types.contains(state.getType())) + && (containerId==null || (state.getContainers().isEmpty()?containerId.equals(state.getContainerBookingId()):state.getContainers().stream().anyMatch(c->containerId.equals(c.containerBookingId())))); + } + private boolean candidate(AppointmentWorkState state,View view) { + // Follow-up dates and handoffs belong to the work state. Use the latest possible local day, + // not its stored timezone: the authoritative fact can supply a different current zone. + // Exact local-day, completion and permission checks still run after the live fact read. + return switch(view) { + case FOLLOW_TODAY -> pendingHandoff(state) || state.getFollowupDate()!=null + && !state.getFollowupDate().isAfter(LocalDate.now(clock.withZone(ZoneOffset.MAX))); + case REVIEW -> state.isReviewRequired(); + default -> true; + }; + } + private String searchable(WorkItem i) { + return String.join(" ",Objects.toString(i.businessNo(),""),Objects.toString(i.batchNo(),""),Objects.toString(i.originName(),""),Objects.toString(i.destinationName(),""),i.containers().stream().map(c->Objects.toString(c.containerNo(),"")+" "+Objects.toString(c.bookingNo(),"")).collect(Collectors.joining(" "))).toLowerCase(Locale.ROOT); + } + private WorkItem withNext(WorkItem i,Action next) { + return new WorkItem(i.workItemKey(),i.version(),i.type(),i.flow(),i.title(),i.businessNo(),i.containers(),i.requirementKey(),i.batchKey(),i.batchNo(),i.originName(),i.destinationName(),i.planStatus(),i.executionStatus(),i.reviewRequired(),i.target(),i.confirmed(),i.actual(),i.calendarDate(),i.dateBasis(),i.timezone(),i.owner(),i.followupDate(),i.overdueReasons(),i.allowedActions(),next,i.blockReasons(),i.missingFields()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkRouteService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkRouteService.java new file mode 100644 index 000000000..835e9fffc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkRouteService.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; + +@Service +public class AppointmentWorkRouteService { + public record Route(Flow flow) { } + private final AppointmentWorkStore store; + public AppointmentWorkRouteService(AppointmentWorkStore store) { this.store=store; } + /** Local committed state only. No guard lock and no upstream HTTP calls. */ + @Transactional(readOnly=true) + public Route route(String container) { + var flows=store.container(container).stream().filter(s->!s.isRetired()).map(AppointmentWorkState::getFlow).filter(f->f!=null && f!=Flow.UNKNOWN).distinct().toList(); + if(flows.size()>1) throw AppointmentWorkPolicy.conflict("柜次持久路线冲突,不能执行原业务写入"); + return new Route(flows.isEmpty()?Flow.UNKNOWN:flows.get(0)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkState.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkState.java new file mode 100644 index 000000000..7dbe839f6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkState.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.appointmentwork; + +import lombok.Data; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; + +/** Work-owned state only. observedFingerprint is an audit baseline, never a second business plan. */ +@Data +public class AppointmentWorkState { + private String workItemKey; + private int version; + private Type type; + private Flow flow; + private String containerBookingId; + private String containerNo; + private String requirementKey; + private String batchKey; + private String batchNo; + private String tripKey; + private List batchKeys = new ArrayList<>(); + private List containers = new ArrayList<>(); + private String destinationKind; + private String originName; + private String destinationName; + private String timezone; + private String sourceVersion; + private Slot target; + private boolean bookingRequired = true; + private String receivingRequirements; + private String requirementReason; + private Owner owner; + private LocalDate followupDate; + private boolean reviewRequired; + private int reviewVersion; + private String reviewReason; + private String observedFingerprint; + private String observedImpactToken; + private String observedDependentImpactToken; + private boolean observedArranged; + private Responsibility responsibility; + private ReturnHandoff returnHandoff; + private boolean retired; + private String retirementReason; + private List replacementKeys = new ArrayList<>(); + private List prerequisites = new ArrayList<>(); + private List references = new ArrayList<>(); + private List sources = new ArrayList<>(); + private List readPermissions = new ArrayList<>(); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java new file mode 100644 index 000000000..64b174221 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java @@ -0,0 +1,64 @@ +package com.baiyue.lastleg.appointmentwork; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@Component +public class AppointmentWorkStore { + public static final int MAX_ITEMS=10000; + private final AppointmentWorkMapper mapper; + private final ObjectMapper json; + public AppointmentWorkStore(AppointmentWorkMapper mapper,ObjectMapper json) { this.mapper=mapper; this.json=json; } + public AppointmentWorkMapper.Guard lock() { + var guard=mapper.lockGuard(); if(guard==null) throw unavailable("工作事项DDL尚未集成"); return guard; + } + public String requireInitialized() { + var guard=mapper.guard(); + if(guard==null || !guard.isInitialized()) throw unavailable("完整工作范围尚未完成同步,不能显示零项"); + if(guard.getRequirementVersion()!=guard.getSynchronizedRequirementVersion()) throw conflict("预约批次或运输关联已变化,服务端正在协调完整工作清单"); + return guard.getScopeVersion(); + } + public void checkpoint(AppointmentWorkMapper.Guard guard) { mapper.saveGuard(guard); } + public List all() { + var rows=mapper.all(MAX_ITEMS+1); + if(rows.size()>MAX_ITEMS) throw conflict("工作事项超过当前全量核对上限,不能返回截断统计"); + return rows.stream().map(this::decode).toList(); + } + public List container(String id) { return all().stream().filter(s->id.equals(s.getContainerBookingId()) || s.getContainers().stream().anyMatch(c->id.equals(c.containerBookingId()))).toList(); } + public AppointmentWorkState get(String key) { + var row=mapper.get(key); if(row==null) throw missing("事项不存在"); return decode(row); + } + public AppointmentWorkState find(String key) { var row=mapper.get(key);return row==null?null:decode(row); } + public record Followup(Owner owner,LocalDate nextFollowupDate) { } + public Followup inheritedFollowup(String key) { + var row=mapper.inheritedFollowup(key);return row==null?null:read(row.getResponseJson(),Followup.class); + } + public void insert(AppointmentWorkState state) { mapper.insert(row(state)); } + public void save(AppointmentWorkState state) { + state.setVersion(state.getVersion()+1); + if(mapper.update(row(state))!=1) throw conflict("事项版本已变化,请刷新后处理"); + } + public AppointmentWorkMapper.History replay(String key,String commandKey) { return mapper.command(key,commandKey); } + public void history(AppointmentWorkState state,Actor actor,String action,String summary,String commandKey,String hash,Object result) { + var row=new AppointmentWorkMapper.History(); row.setId(UUID.randomUUID().toString()); row.setWorkItemKey(state.getWorkItemKey()); + row.setOperatorId(actor.id()); row.setOperatorName(actor.name()); row.setAction(action); row.setSummary(summary); + row.setCommandKey(commandKey); row.setRequestHash(hash); row.setResponseJson(result==null?null:write(result)); + row.setOccurredAt(LocalDateTime.now(ZoneOffset.UTC).truncatedTo(java.time.temporal.ChronoUnit.MILLIS)); mapper.insertHistory(row); + } + public List history(String key) { + var rows=mapper.selectHistory(key); + if(rows.size()>1000) throw conflict("事项历史超过详情上限,需接入历史分页后查看"); + return rows.stream().map(r->new HistoryEntry(r.getOccurredAt().toInstant(ZoneOffset.UTC),r.getOperatorName(),r.getAction(),r.getSummary())).toList(); + } + public String write(Object value) { try { return json.writeValueAsString(value); } catch(Exception e) { throw new IllegalStateException("工作事项序列化失败",e); } } + public T read(String value,Class type) { try { return json.readValue(value,type); } catch(Exception e) { throw new IllegalStateException("工作事项记录读取失败",e); } } + private AppointmentWorkState decode(AppointmentWorkMapper.Row row) { return read(row.getStateJson(),AppointmentWorkState.class); } + private AppointmentWorkMapper.Row row(AppointmentWorkState state) { + var row=new AppointmentWorkMapper.Row(); row.setWorkItemKey(state.getWorkItemKey()); row.setContainerBookingId(state.getContainerBookingId()); + row.setVersion(state.getVersion()); row.setRetired(state.isRetired()); row.setStateJson(write(state)); return row; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java new file mode 100644 index 000000000..d91ead756 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java @@ -0,0 +1,263 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.*; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@Service +public class AppointmentWorkSyncService { + private static final Actor SYSTEM=new Actor("appointment-work-sync","服务端工作范围同步",Set.of(LIST,ORDER_READ)); + private final AppointmentWorkStore store; + private final AppointmentWorkFactGateway gateway; + public AppointmentWorkSyncService(AppointmentWorkStore store,AppointmentWorkFactGateway gateway) { this.store=store; this.gateway=gateway; } + /** Called by trusted server orchestration, never by GET or by opening a particular container. */ + @Transactional + public SyncResult synchronize(SyncRequest request) { + if(request==null || request.size()<1 || request.size()>100) throw bad("同步批次须为1至100柜"); + var guard=store.lock(); + if(request.afterContainerBookingId()!=null && !Objects.equals(guard.getScanCursor(),request.afterContainerBookingId())) throw conflict("来源同步游标已变化,请从首批重新核对"); + var page=gateway.discover(request.afterContainerBookingId(),request.size()); + if(request.afterContainerBookingId()==null) { guard.setScanScopeVersion(page.scopeVersion()); guard.setScanRequirementVersion(guard.getRequirementVersion()); } + else if(!Objects.equals(guard.getScanScopeVersion(),page.scopeVersion())) throw conflict("来源范围扫描中变化,请从首批重试"); + else if(guard.getScanRequirementVersion()!=guard.getRequirementVersion()) throw conflict("预约需求扫描中变化,请从首批重试"); + int created=0; var ids=new HashSet(); + for(var source:page.containers()) { + validateSource(source); + if(!ids.add(source.containerBookingId())) throw unavailable("来源响应重复柜次"); + var existing=store.container(source.containerBookingId()); + if(existing.isEmpty()) { + if(source.establishedFlow()==Flow.UNKNOWN) { + var route=create(source,Type.ROUTE,null,Flow.UNKNOWN); route.setFollowupDate(LocalDate.now(route.getTimezone()==null?REMINDER_ZONE:zone(route.getTimezone()))); store.insert(route); created++; + } else created+=generate(source,source.establishedFlow(),existing,SYSTEM).size(); + } else { + var activeFlow=existing.stream().filter(s->s.getType()!=Type.ROUTE && !s.isRetired()).map(AppointmentWorkState::getFlow).findFirst().orElse(null); + if(activeFlow!=null) created+=generate(source,activeFlow,existing,SYSTEM).size(); + else for(var route:existing) if(route.getType()==Type.ROUTE && !route.isRetired()) { + if(source.establishedFlow()!=Flow.UNKNOWN) { + var generated=generate(source,source.establishedFlow(),existing,SYSTEM); created+=generated.size(); + route.setFlow(source.establishedFlow()); route.setRetired(true); route.setRetirementReason("ROUTE_CONFIRMED"); route.setReplacementKeys(generated); store.save(route); + } else if(!Objects.equals(route.getSourceVersion(),source.sourceVersion())) { + route.setSourceVersion(source.sourceVersion()); route.setContainerNo(source.containerNo()); route.setTimezone(source.timezone()); store.save(route); + } + } + } + } + // An existing row is never deleted because an order left the active source page. + // Empty return and other unfinished work continue to read their original authority. + var all=new ArrayList<>(store.all()); + var facts=gateway.facts(all.stream().filter(s->s.getType()!=Type.ROUTE && s.getTripKey()==null && !"BATCH_REPLACED".equals(s.getRetirementReason())).toList()); + reconcileTransport(all,facts); + all=new ArrayList<>(store.all()); + facts=gateway.facts(all.stream().filter(s->!s.isRetired()).toList()); + observe(all,facts,SYSTEM); + guard.setScanCursor(page.complete()?null:page.nextCursor()); + if(page.complete()) { guard.setInitialized(true); guard.setScopeVersion(page.scopeVersion()); guard.setSynchronizedRequirementVersion(guard.getRequirementVersion()); guard.setCompletedAt(LocalDateTime.now(ZoneOffset.UTC)); } + store.checkpoint(guard); + return new SyncResult(page.containers().size(),created,page.nextCursor(),page.complete()); + } + static void validateSource(ContainerSource source) { + if(source==null || source.containerBookingId()==null || source.containerBookingId().isBlank() || source.departedAt()==null && source.departedLocalAt()==null && source.sailedEvidenceLocalAt()==null || source.sourceVersion()==null || source.establishedFlow()==null || source.requirements()==null) + throw unavailable("已开船来源缺少身份或真实开船依据"); + if(source.timezone()!=null) zone(source.timezone()); + var seen=new HashSet(); var batches=new HashSet(); + for(var requirement:source.requirements()) { + if(requirement==null || requirement.requirementKey()==null || requirement.requirementKey().isBlank() || !seen.add(requirement.requirementKey()) || requirement.references()==null || requirement.replacesRequirementKeys()==null || requirement.sources()==null) + throw unavailable("送货需求缺少稳定身份或重复"); + if(requirement.batchKey()!=null) { + var identity=requirement.batchKey().startsWith("FBA_APPOINTMENT:")?customerRequirement(requirement.batchKey(),deliveryCustomer(requirement.sources())):requirement.batchKey(); + if(!batches.add(identity)) throw unavailable("同柜批次客户需求重复,必须由原域归并来源"); + } + if(requirement.timezone()!=null) zone(requirement.timezone()); + } + } + /** Caller holds work guard and validates the route against current original facts. */ + List generate(ContainerSource source,Flow flow,List existing,Actor actor) { + validateSource(source); + if(flow==Flow.UNKNOWN) throw conflict("真实送货路线尚未明确"); + var requirements=gateway.requirements(source,flow); + source=new ContainerSource(source.containerBookingId(),source.containerNo(),source.departedAt(),source.departedLocalAt(),source.sailedEvidenceLocalAt(),source.sourceVersion(),source.establishedFlow(),source.timezone(),requirements); + validateSource(source); + if(requirements.isEmpty()) throw conflict("真实送货需求尚未明确,不能生成空清单"); + var known=store.all().stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity(),(a,b)->a)); + var created=new ArrayList(); + for(var type:flow==Flow.TRANSFER?List.of(Type.PICKUP,Type.WAREHOUSE_RECEIPT,Type.EMPTY_RETURN):List.of(Type.PICKUP,Type.EMPTY_RETURN)) add(source,type,null,flow,known,created); + for(var requirement:source.requirements()) { + add(source,Type.DELIVERY,requirement,flow,known,created); + add(source,Type.TRANSPORT,requirement,flow,known,created); + } + for(var state:created) { + state.setOwner(actor.id().equals(SYSTEM.id())?null:new Owner(actor.id(),actor.name())); + state.setFollowupDate(LocalDate.now(state.getTimezone()==null?REMINDER_ZONE:zone(state.getTimezone()))); + var inherited=store.inheritedFollowup(state.getWorkItemKey()); + if(inherited!=null) { state.setOwner(inherited.owner());state.setFollowupDate(inherited.nextFollowupDate()); } + String pickup=key(source.containerBookingId(),Type.PICKUP,null),receipt=key(source.containerBookingId(),Type.WAREHOUSE_RECEIPT,null); + if(state.getType()==Type.WAREHOUSE_RECEIPT) state.setPrerequisites(List.of(pickup)); + if(state.getType()==Type.DELIVERY) state.setPrerequisites(List.of(flow==Flow.TRANSFER?receipt:pickup,requirementKey(source.containerBookingId(),Type.TRANSPORT,state.getRequirementKey(),state.getBatchKey()))); + if(state.getType()==Type.TRANSPORT) { + var prerequisites=new LinkedHashSet();prerequisites.add(flow==Flow.TRANSFER?receipt:pickup); + for(var r:requirements) if(Objects.equals(state.getBatchKey(),r.batchKey()) && (r.batchKey()!=null || Objects.equals(state.getRequirementKey(),r.requirementKey()))) + prerequisites.add(requirementKey(source.containerBookingId(),Type.DELIVERY,r.requirementKey(),r.batchKey())); + state.setPrerequisites(List.copyOf(prerequisites)); + } + if(state.getType()==Type.EMPTY_RETURN) { + String containerId=source.containerBookingId(); + state.setPrerequisites(flow==Flow.TRANSFER?List.of(receipt):source.requirements().stream().map(r->requirementKey(containerId,Type.DELIVERY,r.requirementKey(),r.batchKey())).toList()); + } + store.insert(state); store.history(state,actor,"GENERATED","根据已确认路线建立工作需求",null,null,null); + } + String generationContainerId=source.containerBookingId(); + // Rebuild each batch vehicle's customer edges from the complete current manifest. + // A shared vehicle retains every customer delivery; no last customer wins the edge. + for(var batch:requirements.stream().map(Requirement::batchKey).filter(Objects::nonNull).distinct().toList()) { + var vehicle=known.get(requirementKey(generationContainerId,Type.TRANSPORT,null,batch)); + var dependencies=new LinkedHashSet<>(vehicle.getPrerequisites()); + dependencies.removeIf(k->{var s=known.get(k);return s!=null && s.getType()==Type.DELIVERY && batch.equals(s.getBatchKey());}); + requirements.stream().filter(r->batch.equals(r.batchKey())).map(r->requirementKey(generationContainerId,Type.DELIVERY,r.requirementKey(),batch)).forEach(dependencies::add); + var sources=requirements.stream().filter(r->batch.equals(r.batchKey())).flatMap(r->r.sources().stream()).distinct().toList(); + if(!vehicle.getPrerequisites().equals(List.copyOf(dependencies)) || !vehicle.getSources().equals(sources)) { + vehicle.setPrerequisites(List.copyOf(dependencies));vehicle.setSources(sources);store.save(vehicle); + } + } + var activeKeys=new HashSet(); requirements.forEach(r->{ activeKeys.add(requirementKey(generationContainerId,Type.DELIVERY,r.requirementKey(),r.batchKey())); activeKeys.add(requirementKey(generationContainerId,Type.TRANSPORT,r.requirementKey(),r.batchKey())); }); + for(var old:existing) if(old.getRequirementKey()!=null && !"BATCH_REPLACED".equals(old.getRetirementReason()) && !activeKeys.contains(old.getWorkItemKey())) { + String containerId=source.containerBookingId(); + // A mutable original dispatch can remove this container's cargo while keeping other containers. + // This scan is a complete authority manifest for this container, including its remaining obligations. + var retained=old.getContainers().stream().filter(c->!containerId.equals(c.containerBookingId())).toList(); + if(old.getBatchKey()!=null && old.getBatchKey().startsWith("DISPATCH:") && !retained.isEmpty()) { + old.setContainers(retained); + if(containerId.equals(old.getContainerBookingId())) { old.setContainerBookingId(retained.get(0).containerBookingId());old.setContainerNo(retained.get(0).containerNo()); } + var removedDependency=key(containerId,flow==Flow.TRANSFER?Type.WAREHOUSE_RECEIPT:Type.PICKUP,null); + old.setPrerequisites(old.getPrerequisites().stream().filter(k->!removedDependency.equals(k)).toList()); + store.save(old);store.history(old,actor,"SOURCE_REMOVED","原排车已移除该柜实物,保留其他柜的当前关联",null,null,null);continue; + } + var replacements=requirements.stream().filter(r->r.replacesRequirementKeys().contains(old.getRequirementKey())) + .filter(r->old.getType()!=Type.DELIVERY || old.getSources().isEmpty() || r.sources().isEmpty() || r.sources().stream().anyMatch(s->old.getSources().stream().anyMatch(o->Objects.equals(o.customerId(),s.customerId())))) + .map(r->requirementKey(containerId,old.getType(),r.requirementKey(),r.batchKey())).distinct().toList(); + // Removed dispatch items no longer exist in the original allocation table. Its full current + // container manifest restores the unallocated demand or names the newly assigned dispatch. + if(replacements.isEmpty() && old.getBatchKey()!=null && old.getBatchKey().startsWith("DISPATCH:")) + replacements=requirements.stream().filter(r->"CUSTOMER".equals(r.destinationKind()) && r.sources().stream().anyMatch(old.getSources()::contains)).map(r->requirementKey(containerId,old.getType(),r.requirementKey(),r.batchKey())).distinct().toList(); + if(replacements.isEmpty()) throw conflict("原需求离开有效清单但没有明确替代关系,不能隐藏剩余工作"); + old.setRetired(true); old.setRetirementReason("BATCH_REPLACED"); old.setReplacementKeys(replacements); store.save(old); store.history(old,actor,"REPLACED","原预约域已提供完整替代需求",null,null,null); + } + return created.stream().map(AppointmentWorkState::getWorkItemKey).toList(); + } + private static String requirementKey(String container,Type type,String requirement,String batch) { + return batch==null?key(container,type,requirement):key("BATCH:"+batch,type,type==Type.DELIVERY && batch.startsWith("FBA_APPOINTMENT:")?requirement:null); + } + private void add(ContainerSource source,Type type,Requirement requirement,Flow flow,Map known,List created) { + var state=create(source,type,requirement,flow); var old=known.get(state.getWorkItemKey()); + if(old==null) { created.add(state); known.put(state.getWorkItemKey(),state); } + else if(requirement!=null) { + if("BATCH_REPLACED".equals(old.getRetirementReason())) { + // The full authority manifest explicitly reopens a cancelled obligation/restored original appointment. + // Repartitioned batches never appear here: the provider excludes replacement-bearing originals. + old.setRetired(false); old.setRetirementReason(null); old.setReplacementKeys(List.of()); store.save(old); + } + if(old.getFlow()!=flow) throw conflict("同一批次不能混合中转与直送路线"); + var containers=new ArrayList<>(old.getContainers()); + boolean update=!Objects.equals(old.getBatchNo(),requirement.batchNo()) || !Objects.equals(old.getOriginName(),requirement.originName()) || !Objects.equals(old.getDestinationName(),requirement.destinationName()) + || !Objects.equals(old.getDestinationKind(),requirement.destinationKind()) || !Objects.equals(old.getReferences(),requirement.references()) || !Objects.equals(old.getSources(),requirement.sources()) || type==Type.DELIVERY && !Objects.equals(old.getTimezone(),requirement.timezone()); + if(containers.stream().noneMatch(c->c.containerBookingId().equals(source.containerBookingId()))) { + containers.addAll(state.getContainers()); old.setContainers(List.copyOf(containers)); + var dependencies=new LinkedHashSet<>(old.getPrerequisites()); dependencies.add(key(source.containerBookingId(),flow==Flow.TRANSFER?Type.WAREHOUSE_RECEIPT:Type.PICKUP,null)); + old.setPrerequisites(List.copyOf(dependencies)); update=true; + } + old.setBatchNo(requirement.batchNo()); old.setOriginName(requirement.originName()); old.setDestinationName(requirement.destinationName()); old.setDestinationKind(requirement.destinationKind()); old.setReferences(requirement.references()); old.setSources(requirement.sources()); + if(type==Type.DELIVERY) old.setTimezone(requirement.timezone()); + if(update && !created.contains(old)) store.save(old); + } + } + private AppointmentWorkState create(ContainerSource source,Type type,Requirement requirement,Flow flow) { + var state=new AppointmentWorkState(); state.setWorkItemKey(requirement==null?key(source.containerBookingId(),type,null):requirementKey(source.containerBookingId(),type,requirement.requirementKey(),requirement.batchKey())); + state.setType(type); state.setFlow(flow); state.setContainerBookingId(source.containerBookingId()); state.setContainerNo(source.containerNo()); state.setSourceVersion(source.sourceVersion()); + state.setTimezone(type==Type.ROUTE?source.timezone():null); state.setContainers(List.of(new ContainerReference(source.containerBookingId(),null,source.containerNo()))); + if(requirement!=null) { state.setRequirementKey(type==Type.TRANSPORT && requirement.batchKey()!=null?requirement.batchKey():requirement.requirementKey()); state.setBatchKey(requirement.batchKey()); state.setBatchNo(requirement.batchNo()); state.setDestinationKind(requirement.destinationKind()); + state.setOriginName(requirement.originName()); state.setDestinationName(requirement.destinationName()); state.setTimezone(type==Type.DELIVERY?requirement.timezone():null); state.setReferences(requirement.references()); state.setSources(requirement.sources()); + if(requirement.batchKey()!=null) state.setBatchKeys(List.of(requirement.batchKey())); } + return state; + } + void observe(List states,Map facts,Actor actor) { + var affected=new LinkedHashSet(); + for(var state:states) { + if(state.isRetired()) continue; + var fact=facts.get(state.getWorkItemKey()); if(fact==null) continue; + if(fact!=null && state.getObservedImpactToken()!=null && !Objects.equals(state.getObservedImpactToken(),fact.impactToken()) && state.isObservedArranged()) { + affected.add(state.getWorkItemKey()); + if(state.getObservedDependentImpactToken()!=null && !Objects.equals(state.getObservedDependentImpactToken(),fact.dependentImpactToken())) + expandDependents(state.getWorkItemKey(),states,affected); + } + } + for(var state:states) { + if(state.isRetired()) continue; + var fact=facts.get(state.getWorkItemKey()); if(fact==null) continue; + boolean changed=changed(state,fact) || !Objects.equals(state.getObservedImpactToken(),fact.impactToken()) || !Objects.equals(state.getObservedDependentImpactToken(),fact.dependentImpactToken()) || state.isObservedArranged()!=fact.arranged() || !state.getReadPermissions().equals(fact.readPermissions()),baseline=state.getObservedFingerprint()==null; + boolean review=affected.contains(state.getWorkItemKey()) && arranged(state,fact) && !completed(fact); + if(review) { state.setReviewRequired(true); state.setReviewVersion(state.getReviewVersion()+1); state.setReviewReason("原业务时间、货量或执行关联发生变化,请核对已有安排"); } + if(changed || baseline || review) { + state.setObservedFingerprint(fact.fingerprint()); state.setObservedImpactToken(fact.impactToken()); state.setObservedDependentImpactToken(fact.dependentImpactToken()); state.setObservedArranged(fact.arranged()); state.setReadPermissions(List.copyOf(fact.readPermissions())); store.save(state); + store.history(state,actor,review?"IMPACT_OBSERVED":"BASELINE_OBSERVED",review?state.getReviewReason():"记录当前原业务基线,不生成执行事实",null,null,null); + } + } + } + static void expandDependents(String key,List states,Set affected) { + var queue=new ArrayDeque(); queue.add(key); var seen=new HashSet(); + var byKey=states.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity())); + while(!queue.isEmpty()) { var current=queue.remove(); if(!seen.add(current)) continue; + // A changed customer batch requires its shared vehicle to be checked. That does not + // make the vehicle's other customers' appointments change. A change originating in + // the vehicle itself still reaches all of its current dependent appointments. + var currentState=byKey.get(current); + if(!current.equals(key) && currentState!=null && currentState.getType()==Type.TRANSPORT) continue; + for(var candidate:states) if(!candidate.isRetired() && effectiveKeys(candidate.getPrerequisites(),byKey).contains(current) && affected.add(candidate.getWorkItemKey())) queue.add(candidate.getWorkItemKey()); + } + } + /** Reverse of the impact traversal: a shared vehicle is an authority, not a sibling bridge. */ + static List impactPrerequisites(AppointmentWorkState target,List states) { + var byKey=states.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity())); + var pending=new ArrayDeque<>(effectiveKeys(target.getPrerequisites(),byKey)); + var seen=new HashSet(); seen.add(target.getWorkItemKey()); + var result=new ArrayList(); + while(!pending.isEmpty()) { + var key=pending.remove(); if(!seen.add(key)) continue; + var state=byKey.get(key); + if(state==null || state.isRetired()) throw conflict("关联安排已变化,须先同步当前工作关系"); + result.add(state); + if(state.getType()!=Type.TRANSPORT) pending.addAll(effectiveKeys(state.getPrerequisites(),byKey)); + } + return List.copyOf(result); + } + private void reconcileTransport(List all,Map facts) { + var members=all.stream().filter(s->s.getType()==Type.TRANSPORT && s.getTripKey()==null && facts.containsKey(s.getWorkItemKey()) && facts.get(s.getWorkItemKey()).tripKey()!=null) + .collect(Collectors.groupingBy(s->facts.get(s.getWorkItemKey()).tripKey())); + var liveCanonical=new HashSet(); + for(var entry:members.entrySet()) { + var group=entry.getValue(); group.sort(Comparator.comparing(AppointmentWorkState::getWorkItemKey)); + String canonicalKey=key("TRIP:"+entry.getKey(),Type.TRANSPORT,null); liveCanonical.add(canonicalKey); + var canonical=all.stream().filter(s->s.getWorkItemKey().equals(canonicalKey)).findFirst().orElse(null); + boolean fresh=canonical==null; + if(fresh) { canonical=store.read(store.write(group.get(0)),AppointmentWorkState.class); canonical.setWorkItemKey(canonicalKey); canonical.setVersion(0); canonical.setTripKey(entry.getKey()); canonical.setRequirementKey(null); canonical.setBatchKey(null); canonical.setBatchNo(null); canonical.setReplacementKeys(List.of()); } + var containers=group.stream().flatMap(s->s.getContainers().stream()).distinct().sorted(Comparator.comparing(ContainerReference::containerBookingId)).toList(); + var batches=group.stream().map(AppointmentWorkState::getBatchKey).filter(Objects::nonNull).distinct().sorted().toList(); + var prerequisites=group.stream().flatMap(s->s.getPrerequisites().stream()).distinct().sorted().toList(); + boolean changed=canonical.isRetired() || !containers.equals(canonical.getContainers()) || !batches.equals(canonical.getBatchKeys()) || !prerequisites.equals(canonical.getPrerequisites()); + canonical.setContainers(containers); canonical.setBatchKeys(batches); canonical.setPrerequisites(prerequisites); canonical.setRetired(false); canonical.setRetirementReason(null); + canonical.setReferences(group.stream().flatMap(s->s.getReferences().stream()).distinct().toList()); + canonical.setSources(group.stream().flatMap(s->s.getSources().stream()).distinct().toList()); + if(fresh) store.insert(canonical); else if(changed) store.save(canonical); + for(var member:group) if(!member.isRetired() || !member.getReplacementKeys().equals(List.of(canonicalKey))) { member.setRetired(true); member.setRetirementReason("TRIP_LINKED"); member.setReplacementKeys(List.of(canonicalKey)); store.save(member); } + } + for(var state:all) { + if(state.getTripKey()!=null && !liveCanonical.contains(state.getWorkItemKey()) && !state.isRetired()) { state.setRetired(true); state.setRetirementReason("TRIP_UNLINKED"); store.save(state); } + if(state.getType()==Type.TRANSPORT && state.getTripKey()==null && "TRIP_LINKED".equals(state.getRetirementReason()) && facts.containsKey(state.getWorkItemKey()) && facts.get(state.getWorkItemKey()).tripKey()==null) { state.setRetired(false); state.setRetirementReason(null); state.setReplacementKeys(List.of()); store.save(state); } + } + // Keep requirement edges stable; query traversal follows retired transport replacement keys. + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSynchronizationJob.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSynchronizationJob.java new file mode 100644 index 000000000..9137ff83c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSynchronizationJob.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** Periodic full scan also compensates remote changes and process restarts; no GET performs writes. */ +@Component +@EnableScheduling +public class AppointmentWorkSynchronizationJob { + private static final Logger log=LoggerFactory.getLogger(AppointmentWorkSynchronizationJob.class); + private final AppointmentWorkSyncService synchronization; + private String cursor; + public AppointmentWorkSynchronizationJob(AppointmentWorkSyncService synchronization) { this.synchronization=synchronization; } + @Scheduled(initialDelayString="${baiyue.appointment-work.sync-initial-delay-ms:30000}",fixedDelayString="${baiyue.appointment-work.sync-delay-ms:10000}") + public void run() { + try { + var result=synchronization.synchronize(new AppointmentWorkModels.SyncRequest(cursor,100)); + cursor=result.complete()?null:result.nextCursor(); + } catch(RuntimeException error) { + cursor=null; + log.warn("Appointment work synchronization incomplete; next scan restarts from first page: {}",failureDiagnostic(error)); + } + } + /** Keep causes and code locations, never exception messages that may contain remote payloads or credentials. */ + static String failureDiagnostic(Throwable error) { + var causes=new java.util.ArrayList(); + var seen=java.util.Collections.newSetFromMap(new java.util.IdentityHashMap()); + for(var current=error;current!=null && causes.size()<6 && seen.add(current);current=current.getCause()) { + String status=current instanceof org.springframework.web.server.ResponseStatusException response?"["+response.getStatusCode().value()+"]":""; + String location=java.util.Arrays.stream(current.getStackTrace()) + .filter(frame->frame.getClassName().startsWith("com.baiyue.") && !frame.getClassName().equals(AppointmentWorkPolicy.class.getName())) + .findFirst().map(frame->"@"+frame.getClassName()+":"+frame.getLineNumber()).orElse(""); + causes.add(current.getClass().getSimpleName()+status+location); + } + return String.join(" <- ",causes); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java index b647f3a9b..1efa078ce 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java @@ -9,6 +9,7 @@ import java.time.Duration; @ConfigurationProperties(prefix = "baiyue.http.clients") public class LastlegRestClientProperties { private ClientTimeouts unloadPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); + private ClientTimeouts cargoDiscardPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); private ClientTimeouts relabelPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); private ClientTimeouts customerVisitFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); private ClientTimeouts procurementFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); @@ -18,6 +19,8 @@ public class LastlegRestClientProperties { public ClientTimeouts getUnloadPhotoFile() { return unloadPhotoFile; } public void setUnloadPhotoFile(ClientTimeouts value) { unloadPhotoFile = required(value, "unload-photo-file"); } + public ClientTimeouts getCargoDiscardPhotoFile() { return cargoDiscardPhotoFile; } + public void setCargoDiscardPhotoFile(ClientTimeouts value) { cargoDiscardPhotoFile = required(value, "cargo-discard-photo-file"); } public ClientTimeouts getRelabelPhotoFile() { return relabelPhotoFile; } public void setRelabelPhotoFile(ClientTimeouts value) { relabelPhotoFile = required(value, "relabel-photo-file"); } public ClientTimeouts getCustomerVisitFile() { return customerVisitFile; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/InternalCustomerOrderProgressController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/InternalCustomerOrderProgressController.java index 3b0f1f763..8ede1e4c2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/InternalCustomerOrderProgressController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/InternalCustomerOrderProgressController.java @@ -20,6 +20,6 @@ public class InternalCustomerOrderProgressController { @RequestHeader(value="X-Internal-Service-Token", required=false) String token, @Valid @RequestBody CustomerOrderProgress.Request request) { guard.verify(token); - return ApiResponse.success(service.read(request.customerId())); + return ApiResponse.success(service.read(request.customerId(), request.orderIds())); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/fleet/FleetPoolDeliveryAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/fleet/FleetPoolDeliveryAdapter.java new file mode 100644 index 000000000..be24cdf27 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/fleet/FleetPoolDeliveryAdapter.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.delivery.fleet; + +import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper; +import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy; +import com.baiyue.lastleg.fleet.business.FleetPoolDeliveryEvidence; +import org.springframework.stereotype.Component; +import java.util.ArrayList; +import java.util.HashSet; + +/** Same-transaction delivery proof; no persistence model escapes into fleet. */ +@Component +public class FleetPoolDeliveryAdapter implements FleetPoolDeliveryEvidence { + private final DeliverySourceEvidenceMapper evidence; + public FleetPoolDeliveryAdapter(DeliverySourceEvidenceMapper evidence){this.evidence=evidence;} + @Override public boolean fullySigned(String customerId,String incomingNo,long orderId,int expectedPackages) { + var dispatchIds=evidence.dispatchIds(customerId,orderId); + if(dispatchIds==null||dispatchIds.isEmpty()||new HashSet<>(dispatchIds).size()!=dispatchIds.size())return false; + var deliveries=new ArrayList(); + var picks=new ArrayList(); + var returns=new ArrayList(); + for(Long dispatchId:dispatchIds){ + if(dispatchId==null||dispatchId<=0)return false; + var d=evidence.deliveries(customerId,dispatchId);var p=evidence.picks(customerId,dispatchId); + var r=evidence.returns(customerId,dispatchId); + if(d==null||d.isEmpty()||p==null||p.isEmpty()||r==null)return false; + deliveries.addAll(d);picks.addAll(p);returns.addAll(r); + } + return SourceDeliveryCompletionPolicy.evaluate(new SourceDeliveryCompletionPolicy.Source(incomingNo,orderId,expectedPackages), + deliveries,picks,returns,true).fullySigned(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java index 21a80490b..dc7b541a0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java @@ -1,4 +1,5 @@ package com.baiyue.lastleg.delivery.service; +import com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.SignedDelivery; import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos; import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*; @@ -115,7 +116,9 @@ public class DeliveryDocumentService { orders.updateById(order); if (autoCompleted) { var completion = completeRelatedOrders(order, operatorId, now); - fleetPool.signed(order, completion.sourcePackages(), operatorId, now); + fleetPool.signed(new SignedDelivery( + order.getCustomerId(),order.getDispatchNo(),order.getDeliveryNo(),"DELIVERED".equals(order.getStatus()),order.getSignedAt()), + completion.sourcePackages(),operatorId,now); deliveryCompletedOutbox.publish(order, completion.orders(), completion.proofs(), now.atZone(executionZone).toInstant()); customerVisits.ensureForCompletedDelivery(order, now); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index c037053ad..793774c8b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -486,7 +486,6 @@ public class DeliveryTransferService { public List returnOutboundCandidates(String customerId, String warehouseId) { if (!StringUtils.hasText(warehouseId)) return List.of(); return returnCartons.findAvailable(required(customerId, "Customer is required"), warehouseId.trim()).stream() - .filter(carton -> !"PARCEL".equals(carton.getSourceUnitType())) .map(c -> new DeliveryResponses.ReturnOutboundCandidate(c.getSourceReturnNo(), c.getOverseasWarehouseId(), c.getCartonMark(), c.getPalletCode(), c.getFbaCartonMark(), c.getDestinationWarehouseCode(), c.getDestinationWarehouseName(), value(c.getTotalQuantity()), c.getStatus())).toList(); @@ -499,23 +498,16 @@ public class DeliveryTransferService { OutboundOrder existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); if (existing != null) return replayReturnOutbound(customerId, existing, requestHash); String warehouseId = request.overseasWarehouseId().trim(); - boolean itemMode = request.items() != null; - boolean skuMode = itemMode || StringUtils.hasText(request.skuCode()); - Map requestedItems = new LinkedHashMap<>(); - if (itemMode) { - if (request.items().isEmpty() || StringUtils.hasText(request.skuCode()) || request.quantity() != null || "PALLET".equals(request.unitType())) - throw conflict("散件明细出库不可同时提交统一SKU、数量或整托方式"); - for (var line : request.items()) { - if (line == null || !StringUtils.hasText(line.cartonMark()) || !StringUtils.hasText(line.skuCode()) || line.quantity() == null || line.quantity() <= 0 - || requestedItems.putIfAbsent(line.cartonMark().trim(), line) != null) throw conflict("散件出库明细不能为空、数量必须大于0且来源不可重复"); - } - } - if (request.quantity() != null && (!StringUtils.hasText(request.skuCode()) || request.quantity() <= 0 - || "PALLET".equals(request.unitType()))) throw conflict("按件数出库必须选择单SKU,不能同时整托出库"); + // A return outbound is always a physical package/carton operation. SKU and + // quantity are selection fields for the packing workflow, never a direct + // outbound command. Partial package/carton quantities must be materialized + // into a new labelled carton before they can be picked. + boolean itemMode = request.items() != null && !request.items().isEmpty(); + boolean skuMode = itemMode || StringUtils.hasText(request.skuCode()) || request.quantity() != null; + if (skuMode) + throw conflict("退货出库最小单位为包裹/箱;部分数量请先合箱并打印新箱标签后再出库"); List marks = request.cartonMarks().stream().map(String::trim).filter(StringUtils::hasText).distinct().toList(); if (marks.size() != request.cartonMarks().size()) throw conflict("箱唛不能为空且不可重复"); - if (itemMode && (marks.size() != requestedItems.size() || !requestedItems.keySet().containsAll(marks))) - throw conflict("散件出库明细与所选来源不一致"); LocalDateTime now = LocalDateTime.now(); List locked = new ArrayList<>(); for (String mark : marks.stream().sorted().toList()) { @@ -529,37 +521,18 @@ public class DeliveryTransferService { existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); if (existing != null) return replayReturnOutbound(customerId, existing, requestHash); for (ReturnInventoryCarton carton : locked) { - if (skuMode && !"PARCEL".equals(carton.getSourceUnitType())) - throw conflict("单SKU出库只允许散件库存;箱内库存请按箱或托盘出库"); - if ("PARCEL".equals(carton.getSourceUnitType()) && !skuMode) - throw conflict("包裹散件只能按 SKU 出库,不能按箱号或托盘出库"); + if ("PACKING_RESERVED".equals(carton.getStatus())) + throw conflict("该包裹/箱正在合箱,请先完成或取消合箱"); com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountGuard.requireConfirmed(carton); if (!"AVAILABLE".equals(carton.getStatus())) throw conflict("退货箱已被其他作业占用:" + carton.getCartonMark()); var contents = returnCartonItems.lockCarton(carton.getId()); if (contents.isEmpty() || contents.stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0)) throw conflict("退货箱缺少明细或已被合箱计划锁定:" + carton.getCartonMark()); - String selectedSku = itemMode ? requestedItems.get(carton.getCartonMark()).skuCode().trim() : trim(request.skuCode()); - if (skuMode && (contents.size() != 1 - || !selectedSku.equals(contents.get(0).getSkuCode()))) - throw conflict("所选散件来源必须仅包含指定SKU"); - if (itemMode && requestedItems.get(carton.getCartonMark()).quantity() > value(contents.get(0).getQuantity())) - throw conflict("出库件数超过所选散件来源可用数量:" + carton.getCartonMark()); if ("PALLET".equals(request.unitType()) && !StringUtils.hasText(carton.getPalletCode())) throw conflict("整托出库必须存在托盘绑定"); } - Map skuQuantities = new LinkedHashMap<>(); - if (itemMode) requestedItems.forEach((mark, line) -> skuQuantities.put(mark, line.quantity())); - else if (StringUtils.hasText(request.skuCode())) { - long available = locked.stream().mapToLong(c -> value(c.getTotalQuantity())).sum(); - long remaining = request.quantity() == null ? available : request.quantity(); - if (remaining > available) throw conflict("出库件数超过所选来源库存数量"); - for (ReturnInventoryCarton carton : locked) { - long selected = Math.min(remaining, value(carton.getTotalQuantity())); - if (selected > 0) skuQuantities.put(carton.getCartonMark(), selected); - remaining -= selected; - } - locked = locked.stream().filter(c -> skuQuantities.containsKey(c.getCartonMark())).toList(); - } + // No quantity allocation is performed here. The selected physical units + // are shipped whole; partials arrive only as newly-created merge cartons. if ("PALLET".equals(request.unitType())) { String pallet = locked.get(0).getPalletCode(); if (locked.stream().anyMatch(c -> !Objects.equals(pallet, c.getPalletCode()))) throw conflict("整托出库只能选择同一托盘"); @@ -580,9 +553,8 @@ public class DeliveryTransferService { OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId()); item.setPackageCount(1); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setCartonMarks(carton.getCartonMark()); item.setInboundCode(carton.getSourceReturnNo()); - // Packed stock is still picked by its physical carton mark; loose parcels use SKU scanning. - item.setSkuCode("PARCEL".equals(carton.getSourceUnitType()) ? itemMode ? requestedItems.get(carton.getCartonMark()).skuCode().trim() : trim(request.skuCode()) : null); - item.setReturnSkuQuantity(skuQuantities.get(carton.getCartonMark())); + // Packages and cartons are picked by their physical barcode. + item.setSkuCode(null); item.setReturnSkuQuantity(null); item.setPalletNo("PALLET".equals(request.unitType()) ? carton.getPalletCode() : null); item.setCreatedAt(now); outboundPallets.insert(item); carton.setStatus("OUTBOUND_PENDING"); carton.setUpdatedAt(now); returnCartons.updateById(carton); @@ -1186,6 +1158,7 @@ public class DeliveryTransferService { required(request == null ? null : request.reason(), "取消原因不能为空"); OutboundOrder observed = outboundOrders.findGlobalByNo(trim(outboundNo)); if (observed == null) throw notFound("Outbound order not found"); + if ("DISCARD_OUTBOUND".equals(observed.getOutboundType())) throw conflict("请通过废弃单取消,废弃出库单随申请同步取消"); // Match dispatch picking and relabel confirmation lock order before locking outbound. DispatchOrder dispatch = observed.getDispatchOrderId() == null ? null : dispatchOrders.lockById(observed.getCustomerId(), observed.getDispatchOrderId()); @@ -1453,6 +1426,7 @@ public class DeliveryTransferService { new LambdaQueryWrapper().eq(StringUtils.hasText(customerId), OutboundOrder::getCustomerId, customerId) .like(StringUtils.hasText(query.documentNo()), OutboundOrder::getOutboundNo, query.documentNo()) .like(StringUtils.hasText(query.lxCode()), OutboundOrder::getLxCodes, query.lxCode()) + .ne(StringUtils.hasText(query.lxCode()), OutboundOrder::getOutboundType, "DISCARD_OUTBOUND") .eq(StringUtils.hasText(query.warehouseId()), OutboundOrder::getOverseasWarehouseId, query.warehouseId()) .eq(StringUtils.hasText(query.status()), OutboundOrder::getStatus, query.status()) .eq(StringUtils.hasText(query.outboundType()), OutboundOrder::getOutboundType, query.outboundType()) @@ -1504,10 +1478,18 @@ public class DeliveryTransferService { && !Objects.equals(delivery.getDestinationWarehouseCode(), item.getDestinationWarehouseCode())) continue; ReturnInventoryCarton carton = returnCartons.findCustomerMark(customerId, item.getCartonMarks()); if (carton == null) throw conflict("退货派送单来源箱记录缺失"); - for (var line : returnCartonItems.listCarton(carton.getId())) + boolean relabelCarton = "RELABEL_OUTBOUND".equals(sourceOutbound.getOutboundType()); + boolean firstSku = true; + // Match receipt cargo semantics: stable first SKU carries the physical box once, never per piece. + for (var line : returnCartonItems.listCarton(carton.getId())) { cargo.add(new DeliveryResponses.DeliverySku(item.getInboundCode(), delivery.getCustomerName(), - carton.getDestinationWarehouseCode(), delivery.getDeliveryAddress(), null, null, - line.getSkuCode(), line.getProductNameZh(), 1, null, null, line.getQuantity())); + relabelCarton ? item.getDestinationWarehouseCode() : carton.getDestinationWarehouseCode(), + delivery.getDeliveryAddress(), null, null, + line.getSkuCode(), line.getProductNameZh(), !relabelCarton || firstSku ? 1 : 0, + relabelCarton && firstSku ? item.getGrossWeightKg() : null, + relabelCarton && firstSku ? item.getVolumeCbm() : null, line.getQuantity())); + firstSku = false; + } } return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), cargo, feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId()).stream().map(this::feeRow).toList(), @@ -1638,7 +1620,7 @@ public class DeliveryTransferService { d.getDeliveryNo(), d.getStatus(), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), value(d.getPalletCount()), value(d.getPackageCount()))).toList(), replayed); } - private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); } + private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); } private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) { return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(), item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java index 60d987237..560138fe0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java @@ -52,6 +52,16 @@ public class CargoDiscardController { return ApiResponse.success(service.detail(scope(user, customerId), discardNo)); } + @GetMapping("/{discardNo}/photos/files/{fileId}/download") + @org.springframework.security.access.prepost.PreAuthorize("hasAuthority('platform:overseas-warehouse:discard:list')") + public org.springframework.http.ResponseEntity downloadPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String discardNo, @PathVariable long fileId) { + var file = service.downloadPhoto(scope(user, customerId), discardNo, fileId); + return org.springframework.http.ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()) + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, + org.springframework.http.ContentDisposition.inline().filename(file.fileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString()) + .contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).body(file.content()); + } @PostMapping("/{discardNo}/review") public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable String discardNo, @@ -67,13 +77,6 @@ public class CargoDiscardController { return ApiResponse.success(service.cancel(scope(user, customerId), discardNo, operator(user), true)); } - @PostMapping("/{discardNo}/complete") - public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String customerId, @PathVariable String discardNo) { - requireApprovalPermission(user); - return ApiResponse.success(service.complete(scope(user, customerId), discardNo, operator(user))); - } - private String scope(AuthenticatedUser user, String requested) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java index f49e899da..300108ecc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java @@ -36,6 +36,22 @@ public class MerchantCargoDiscardController { @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String discardNo) { return ApiResponse.success(service.detail(customer(user), discardNo)); } + @GetMapping("/{discardNo}/photos/files/{fileId}/download") + @org.springframework.security.access.prepost.PreAuthorize("hasAuthority('merchant:overseas-warehouse:discard:list')") + public org.springframework.http.ResponseEntity downloadPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String discardNo, @PathVariable long fileId) { + var file = service.downloadPhoto(customer(user), discardNo, fileId); + return org.springframework.http.ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()) + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, + org.springframework.http.ContentDisposition.inline().filename(file.fileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString()) + .contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).body(file.content()); + } + @PostMapping("/{discardNo}/confirm-completion") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CONFIRM + "')") + public ApiResponse confirmCompletion(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String discardNo) { + return ApiResponse.success(service.confirmCompletion(customer(user), discardNo, operator(user))); + } @PostMapping("/{discardNo}/cancel") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CANCEL + "')") public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java index f368e7224..8cbac94b8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java @@ -20,7 +20,14 @@ public final class CargoDiscardRequests { @Size(max = 1000) String remark) {} public record Item(Long orderId, Long returnCartonId, @NotBlank @Size(max = 128) String skuCode, - @Positive int discardPackages) {} + @Positive int discardPackages, Long inventoryPositionId, @Size(max = 5000) List<@NotNull @Positive Long> cartonIds) { + public Item(Long orderId, Long returnCartonId, String skuCode, int discardPackages, Long inventoryPositionId) { + this(orderId, returnCartonId, skuCode, discardPackages, inventoryPositionId, null); + } + public Item(Long orderId, Long returnCartonId, String skuCode, int discardPackages) { + this(orderId, returnCartonId, skuCode, discardPackages, null, null); + } + } public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java index 14fb36737..efde17c22 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java @@ -11,21 +11,45 @@ public final class CargoDiscardResponses { String inboundCode, String inboundOrderNo, String overseasWarehouseId, String overseasWarehouseName, LocalDateTime inboundCreatedAt, long availablePackages, - List skus) {} + List skus, Long inventoryPositionId, String palletNo, List cartons, boolean cartonSelectionSupported, String cartonSelectionBlockReason) { + public Candidate(Long orderId, String sourceType, Long returnCartonId, String cartonMark, + String inboundCode, String inboundOrderNo, String overseasWarehouseId, + String overseasWarehouseName, LocalDateTime inboundCreatedAt, long availablePackages, + List skus, Long inventoryPositionId, String palletNo) { + this(orderId, sourceType, returnCartonId, cartonMark, inboundCode, inboundOrderNo, + overseasWarehouseId, overseasWarehouseName, inboundCreatedAt, availablePackages, skus, + inventoryPositionId, palletNo, List.of(), false, "当前库存缺少可核对的箱级记录,可按全部库存废弃"); + } + public Candidate(Long orderId, String sourceType, Long returnCartonId, String cartonMark, + String inboundCode, String inboundOrderNo, String overseasWarehouseId, + String overseasWarehouseName, LocalDateTime inboundCreatedAt, long availablePackages, + List skus) { + this(orderId, sourceType, returnCartonId, cartonMark, inboundCode, inboundOrderNo, + overseasWarehouseId, overseasWarehouseName, inboundCreatedAt, availablePackages, skus, null, null); + } + } + public record Box(Long cartonId, String cartonMark, String palletNo, List skus) {} public record CandidateSku(String skuCode, String productName, long availablePackages) {} public record Summary(Long id, String customerId, String discardNo, String inboundCodes, String overseasWarehouseId, String overseasWarehouseName, int totalPackages, BigDecimal totalFee, String currency, String status, - String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt) {} + String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo, LocalDateTime customerConfirmedAt) {} public record Item(Long id, Long orderId, String inboundCode, String skuCode, - String productName, int availablePackagesSnapshot, int discardPackages) {} + String productName, int availablePackagesSnapshot, int discardPackages, List cartonMarks) { + public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages) { + this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, List.of()); + } + } public record History(String fromStatus, String toStatus, String operatorId, String remark, LocalDateTime createdAt) {} + public record Photo(Long fileId, String palletNo, String fileName, String contentType, Long sizeBytes, + String operatorId, LocalDateTime createdAt, LocalDateTime submittedAt) {} + public record Detail(Summary summary, BigDecimal feePerPackage, String remark, String reviewedBy, String reviewRemark, List items, - List histories) {} + List histories, List photos) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java index 369ece1f4..0997f55a0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java @@ -28,6 +28,7 @@ public class CargoDiscardApplication { private String createdBy; private String reviewedBy; private String reviewRemark; + private LocalDateTime customerConfirmedAt; private LocalDateTime reviewedAt; private LocalDateTime createdAt; private LocalDateTime updatedAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardCarton.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardCarton.java index b8319c505..2e4f89bbb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardCarton.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardCarton.java @@ -15,6 +15,7 @@ public class CargoDiscardCarton { private Long itemId; private String customerId; private Long cartonId; + private String sourceType; private String cartonMark; private LocalDateTime createdAt; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java index bd21d4164..eb89faa2f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java @@ -16,6 +16,9 @@ public class CargoDiscardItem { private Long orderId; private String sourceType; private Long returnCartonId; + private Long inventoryPositionId; + private String cartonMarksJson; + private Boolean cartonScanRequired; private String inboundCode; private Long profileId; private Long skuInventoryId; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardPhoto.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardPhoto.java new file mode 100644 index 000000000..3fe05d37d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardPhoto.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.discard.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_cargo_discard_photo") +public class CargoDiscardPhoto { + @TableId private Long id; + private String customerId; + private Long applicationId; + private String palletNo; + private Long fileId; + private String fileName; + private String contentType; + private Long sizeBytes; + private String operatorId; + private LocalDateTime createdAt; + private LocalDateTime submittedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/gateway/CargoDiscardPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/gateway/CargoDiscardPhotoFileGateway.java new file mode 100644 index 000000000..b27f510ce --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/gateway/CargoDiscardPhotoFileGateway.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.discard.gateway; + +import org.springframework.web.multipart.MultipartFile; + +public interface CargoDiscardPhotoFileGateway { + UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file); + void validate(String customerId, long taskId, long fileId); + DownloadedFile download(String customerId, long taskId, long fileId); + + record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { } + record DownloadedFile(byte[] content, String fileName, String contentType) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/gateway/HttpCargoDiscardPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/gateway/HttpCargoDiscardPhotoFileGateway.java new file mode 100644 index 000000000..c84bc2290 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/gateway/HttpCargoDiscardPhotoFileGateway.java @@ -0,0 +1,135 @@ +package com.baiyue.lastleg.discard.gateway; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; + +@Component +public class HttpCargoDiscardPhotoFileGateway implements CargoDiscardPhotoFileGateway { + private static final String BUSINESS_TYPE = "OVERSEAS_CARGO_DISCARD_PHOTO"; + @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + private RestClient client; + + @PostConstruct + void initializeClient() { + client = restClientFactory.build(restClientBuilder, baseUrl, + restClientProperties.getCargoDiscardPhotoFile()); + } + + @Override + public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) { + long tenantId = tenantId(customerId); + MultipartBodyBuilder body = new MultipartBodyBuilder(); + try { + body.part("file", new ByteArrayResource(file.getBytes()) { + @Override public String getFilename() { return file.getOriginalFilename(); } + }).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM + : MediaType.parseMediaType(file.getContentType())); + } catch (IOException exception) { + throw new IllegalArgumentException("Cannot read cargo-discard photo", exception); + } + MetadataEnvelope response = client.post() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", + tenantId, BUSINESS_TYPE, taskId) + .header("X-Internal-Service-Token", internalToken) + .header("X-Operator-Id", String.valueOf(operatorId)) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "Cargo discard photo upload failed"); + }).body(MetadataEnvelope.class); + requireSuccess(response, "Cargo discard photo upload failed"); + Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "File service returned no cargo-discard photo metadata"); + } + return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), + data.sizeBytes() == null ? 0L : data.sizeBytes()); + } + + @Override + public void validate(String customerId, long taskId, long fileId) { + long tenantId = tenantId(customerId); + MetadataEnvelope response = client.get() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}", + tenantId, BUSINESS_TYPE, taskId, fileId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "Cargo discard photo does not exist or its ownership is invalid"); + }).body(MetadataEnvelope.class); + requireSuccess(response, "Cargo discard photo does not exist or its ownership is invalid"); + Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null || data.fileId() != fileId + || data.tenantId() == null || data.tenantId() != tenantId + || !BUSINESS_TYPE.equals(data.businessType()) + || !String.valueOf(taskId).equals(data.businessId())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Cargo discard photo metadata does not match the PDA task"); + } + } + + @Override + public DownloadedFile download(String customerId, long taskId, long fileId) { + validate(customerId, taskId, fileId); + ResponseEntity response = client.get() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content", + tenantId(customerId), BUSINESS_TYPE, taskId, fileId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.NOT_FOUND : HttpStatus.SERVICE_UNAVAILABLE, "Cargo discard photo download failed"); + }).toEntity(byte[].class); + String fileName = response.getHeaders().getContentDisposition().getFilename(); + String contentType = response.getHeaders().getContentType() == null + ? MediaType.APPLICATION_OCTET_STREAM_VALUE : response.getHeaders().getContentType().toString(); + return new DownloadedFile(response.getBody() == null ? new byte[0] : response.getBody(), + fileName == null ? "cargo-discard-photo" : fileName, contentType); + } + + private static long tenantId(String customerId) { + try { return Long.parseLong(customerId); } + catch (NumberFormatException exception) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Customer ID must be numeric", exception); + } + } + + private static void requireSuccess(MetadataEnvelope response, String fallback) { + if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback); + if ("200".equals(response.code())) return; + HttpStatus status = switch (String.valueOf(response.code())) { + case "A0400", "A0422" -> HttpStatus.BAD_REQUEST; + case "A0403" -> HttpStatus.FORBIDDEN; + case "A0404" -> HttpStatus.NOT_FOUND; + default -> HttpStatus.SERVICE_UNAVAILABLE; + }; + throw new ResponseStatusException(status, + response.message() == null || response.message().isBlank() ? fallback : response.message()); + } + + private record MetadataEnvelope(String code, String message, Metadata data) { } + private record Metadata(Long fileId, Long tenantId, String businessType, String businessId, + String fileName, String contentType, Long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java index 3b314f9bc..7c53ccf11 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java @@ -19,4 +19,7 @@ public interface CargoDiscardApplicationMapper extends BaseMapper0)=1 + ORDER BY id + """) + List genericCartons(@Param("customerId") String customerId, @Param("positionId") Long positionId); + + @Update("UPDATE ll_warehouse_transfer_carton SET status=#{toStatus},updated_at=NOW(3) " + + "WHERE customer_id=#{customerId} AND id=#{cartonId} AND status=#{fromStatus}") + int changeTransferCartonStatus(@Param("customerId") String customerId, @Param("cartonId") Long cartonId, + @Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus); + + @Update("UPDATE ll_inbound_plan_carton SET status=#{toStatus},version=version+1,updated_at=NOW(3) " + + "WHERE customer_id=#{customerId} AND id=#{cartonId} AND status=#{fromStatus}") + int changeGenericCartonStatus(@Param("customerId") String customerId, @Param("cartonId") Long cartonId, + @Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus); + + @Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} AND c.status='AVAILABLE' ORDER BY c.profile_id,c.id") + List availableOrderCartons(@Param("customerId") String customerId, @Param("orderId") Long orderId); + + @Select("") + List lockSelected(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("skuCode") String skuCode, @Param("cartonIds") List cartonIds); + + @Select("") + List genericPositions(@Param("customerId") String customerId, @Param("keyword") String keyword); + + @Select("SELECT COUNT(*) FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND inventory_position_id=#{positionId}") + long isDropshipPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId); + + @Select("SELECT DISTINCT carton_mark FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND position_id=#{positionId} " + + "AND business_type IN ('STOCK_IN','RETURN_STOCK_IN') AND carton_mark IS NOT NULL AND carton_mark<>'' ORDER BY carton_mark") + List looseCartonMarks(@Param("customerId") String customerId, @Param("positionId") Long positionId); @Select("") - long countActiveSources(@Param("keys") List keys,@Param("orderIds") List orderIds,@Param("excludedId") Long excludedId); + record Allocation(long packages,long count,long unknownCount) { } + @Select("") + Allocation allocation(@Param("customerId") String customerId,@Param("sourceKey") String sourceKey, + @Param("orderId") Long orderId,@Param("excludedId") Long excludedId); + + @Select({""}) + List relatedBatchIds(@Param("sources") List sources); @Select("") List selectedLines(@Param("lineIds") List lineIds); - @Select("") - long countActiveAppointments(@Param("lineIds") List lineIds, @Param("excludedId") Long excludedId); - @Select(""" - SELECT COUNT(*) FROM ll_inbound_fba_appointment_item i - JOIN ll_dispatch_item di ON di.order_id=i.order_id AND di.customer_id=i.customer_id - JOIN ll_dispatch_order d ON d.id=di.dispatch_order_id AND d.customer_id=di.customer_id - WHERE i.appointment_id=#{appointmentId} AND d.status<>'CANCELLED' + SELECT COUNT(DISTINCT d.id) FROM ll_dispatch_appointment_allocation a + JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.customer_id=a.customer_id + WHERE a.batch_key=CONCAT('FBA_APPOINTMENT:',#{appointmentId}) AND d.status<>'CANCELLED' """) long countActiveDispatches(@Param("appointmentId") Long appointmentId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java index a5e454b58..d52eb554e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java @@ -13,6 +13,7 @@ import java.util.List; @Mapper public interface InboundPlanMapper extends BaseMapper { + String APPOINTMENT_STATUS="CASE WHEN (SELECT COUNT(DISTINCT current_item.appointment_id) FROM ll_inbound_fba_appointment_item current_item WHERE current_item.order_id=p.source_order_id AND current_item.customer_id=p.customer_id AND current_item.active=1)>1 THEN 'MULTIPLE' ELSE COALESCE((SELECT a.appointment_status FROM ll_inbound_fba_appointment_item ai JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id WHERE ai.order_id=p.source_order_id AND ai.customer_id=p.customer_id AND a.replacement_nos_json IS NULL ORDER BY ai.active DESC,a.created_at DESC,a.id DESC LIMIT 1),'NOT_APPOINTED') END"; String PAGE_FROM = " FROM ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id AND o.customer_id=p.customer_id " + "LEFT JOIN ll_inbound_plan_line l ON l.plan_id=p.id AND l.order_id=p.source_order_id " + "WHERE p.customer_id=#{customerId}" + @@ -23,8 +24,7 @@ public interface InboundPlanMapper extends BaseMapper { " AND l.destination_warehouse_code=#{q.destinationWarehouseCode}" + " AND l.fba_warehouse_code=#{q.fbaWarehouseCode}" + " " + - "AND EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id " + - "WHERE ai.order_id=p.source_order_id AND ai.customer_id=#{customerId} AND a.appointment_status=#{q.appointmentStatus})" + + "AND ("+APPOINTMENT_STATUS+")=#{q.appointmentStatus}" + " AND NOT EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai " + "WHERE ai.order_id=p.source_order_id AND ai.customer_id=p.customer_id AND ai.active=1)" + " AND p.created_at >=#{q.createdFrom}" + @@ -34,9 +34,7 @@ public interface InboundPlanMapper extends BaseMapper { "l.inbound_code,p.container_no,o.overseas_warehouse_id,o.overseas_warehouse_name," + "l.destination_warehouse_id,l.destination_warehouse_code,l.destination_warehouse_name,l.fba_warehouse_code," + "COALESCE(l.required_package_count,o.package_count,0) package_count,p.total_volume_cbm,p.pallet_count,p.status," + - "COALESCE((SELECT a.appointment_status FROM ll_inbound_fba_appointment_item ai " + - "JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id WHERE ai.order_id=p.source_order_id AND ai.customer_id=p.customer_id " + - "ORDER BY a.created_at DESC,a.id DESC LIMIT 1),'NOT_APPOINTED') appointment_status,p.created_at,p.updated_at" + + APPOINTMENT_STATUS+" appointment_status,p.created_at,p.updated_at" + PAGE_FROM + " ORDER BY p.created_at DESC,p.id DESC LIMIT #{limit} OFFSET #{offset}") List pageRows(@Param("customerId") String customerId, @Param("q") InboundPlanRequests.Query query, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java index 13729a99f..2e24d2207 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java @@ -10,6 +10,8 @@ import java.util.List; @Mapper public interface InboundPlanQueryMapper { + String ACTIVE_APPOINTMENT_COUNT="(SELECT COUNT(DISTINCT current_item.appointment_id) FROM ll_inbound_fba_appointment_item current_item WHERE current_item.order_id=l.order_id AND current_item.customer_id=p.customer_id AND current_item.fba_warehouse_code=l.fba_warehouse_code AND current_item.active=1)"; + String APPOINTMENT_STATUS="CASE WHEN "+ACTIVE_APPOINTMENT_COUNT+">1 THEN 'MULTIPLE' ELSE a.appointment_status END"; String SCHEDULED_DISPATCH = "EXISTS (SELECT 1 FROM ll_dispatch_item di" + " JOIN ll_dispatch_order dispatch ON dispatch.id=di.dispatch_order_id" + " AND dispatch.customer_id=di.customer_id" + @@ -24,7 +26,7 @@ public interface InboundPlanQueryMapper { " AND ai.id=(SELECT ai2.id FROM ll_inbound_fba_appointment_item ai2" + " JOIN ll_inbound_fba_appointment a2 ON a2.id=ai2.appointment_id" + " WHERE ai2.order_id=l.order_id AND ai2.customer_id=p.customer_id AND ai2.fba_warehouse_code=l.fba_warehouse_code" + - " ORDER BY a2.created_at DESC,ai2.id DESC LIMIT 1)" + + " AND a2.replacement_nos_json IS NULL ORDER BY ai2.active DESC,a2.created_at DESC,ai2.id DESC LIMIT 1)" + " LEFT JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id" + " WHERE 1=1" + " AND p.plan_no LIKE CONCAT('%',#{q.planNo},'%')" + @@ -36,7 +38,7 @@ public interface InboundPlanQueryMapper { " AND (l.destination_warehouse_id LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_code LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_name LIKE CONCAT('%',#{q.warehouse},'%'))" + " AND l.fba_warehouse_code LIKE CONCAT('%',#{q.fbaWarehouseCode},'%')" + " AND (ai.id IS NULL OR ai.active=0)" + - " AND a.appointment_status=#{q.appointmentStatus}" + + " AND ("+APPOINTMENT_STATUS+")=#{q.appointmentStatus}" + " AND " + SCHEDULED_DISPATCH + "" + " AND NOT " + SCHEDULED_DISPATCH + "" + " AND p.created_at >=#{q.createdFrom}" + @@ -55,8 +57,8 @@ public interface InboundPlanQueryMapper { "(SELECT i.net_weight_kg FROM ll_inbound_order i WHERE i.id=l.order_id AND i.customer_id=p.customer_id),0) netWeightKg," + "l.volume_cbm volumeCbm," + "l.planned_pallet_count palletCount,p.assigned_flag assigned,p.merge_required mergeAllowed," + - "p.urgent_flag urgent,p.status status,a.appointment_no appointmentNo," + - "a.appointment_status appointmentStatus,a.appointment_at appointmentAt," + + "p.urgent_flag urgent,p.status status,CASE WHEN "+ACTIVE_APPOINTMENT_COUNT+">1 THEN NULL ELSE a.appointment_no END appointmentNo," + + APPOINTMENT_STATUS+" appointmentStatus,CASE WHEN "+ACTIVE_APPOINTMENT_COUNT+">1 THEN NULL ELSE a.appointment_at END appointmentAt," + "NULL expectedLoadingAt," + "CASE WHEN " + SCHEDULED_DISPATCH + " THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END dispatchStatus,p.created_at createdAt,p.version version" + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index b4998a5e3..2963f9be1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -102,6 +102,7 @@ public class ReceivingContainerService { @Resource private ContainerLoadingEvidenceMapper domesticEvidence; @Resource private InboundPdaWorkflowService workflow; @Resource private ObjectMapper sourceObjectMapper; + @Resource private com.baiyue.lastleg.inboundplan.work.AppointmentWorkReceivingGuard workRouteGuard; private ObjectMapper objectMapper; @@ -638,6 +639,7 @@ public class ReceivingContainerService { ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(key) : sessions.findActiveByOperator(key); if (session == null) throw notFound("Current receiving container session was not found"); + if (lock) workRouteGuard.requireWarehouseFlow(session.getSourceContainerBookingId()); requireStableSessionFacts(session, lock); return session; } @@ -646,6 +648,7 @@ public class ReceivingContainerService { long overseasWarehouseId, boolean lock) { String bookingId = trim(sourceContainerBookingId); if (bookingId == null) throw conflict("sourceContainerBookingId is required"); + if (lock) workRouteGuard.requireWarehouseFlow(bookingId); LambdaQueryWrapper query = new LambdaQueryWrapper() .eq(LastlegOrder::getContainerNo, containerNo) .eq(LastlegOrder::getSourceContainerBookingId, bookingId) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentExecutionPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentExecutionPort.java new file mode 100644 index 000000000..9bf6eae1e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentExecutionPort.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.inboundplan.receiving; + +import java.util.List; + +/** Original execution evidence, scoped by customer; work projections cannot authorize rebooking. */ +public interface FbaAppointmentExecutionPort { + record Member(String batchKey,String customerId) { } + record Eligibility(Member member,boolean completed,boolean canRebook,String reason) { } + record Scope(String version,List members) { } + Scope inspect(List members); + /** Locks original trips and selected customers' dispatch/execution rows before core batch locks. */ + Scope lockAndInspect(List members,String expectedVersion); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentTransportPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentTransportPort.java new file mode 100644 index 000000000..17bff899f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentTransportPort.java @@ -0,0 +1,18 @@ +package com.baiyue.lastleg.inboundplan.receiving; + +import java.util.List; + +/** Original Dispatch/Fleet authority; implementations never invent physical pallet splits. */ +public interface FbaAppointmentTransportPort { + record Vehicle(String tripNo, Integer version, String dispatchNo, String customerId, + String batchKey, String status, int packageCount) { } + record Scope(String version, List vehicles, List blockReasons) { } + record Move(String batchKey, String customerId, String sourceKey, String destinationKey, + int originalPackages, int retainedPackages, String laterBatchKey) { } + record Result(List vehicles, List nextActions) { } + Scope inspect(List batchKeys); + /** Lock order: trip -> dispatch -> outbound/PDA -> appointment; called BEFORE core locks batches. */ + Scope lockAndInspect(List batchKeys, String expectedVersion); + /** Same transaction as core allocation. Throw on failure so physical/allocation changes roll back together. */ + Result apply(Scope locked, List moves, String operator, String reason); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentWorkPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentWorkPort.java new file mode 100644 index 000000000..7b2857867 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentWorkPort.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.inboundplan.receiving; + +import java.time.LocalDate; +import java.util.List; + +/** Work domain owns the verified responsible person and durable follow-up inheritance. */ +public interface FbaAppointmentWorkPort { + record Member(String batchKey,String customerId) { } + record Followup(String ownerId,String ownerName,LocalDate nextFollowupDate) { } + Followup resolveFollowup(String ownerId,LocalDate nextFollowupDate); + void seedFollowup(Member original,Member later,Followup followup,String operatorId); + void inheritFollowup(List originals,List successors,String operatorId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingContracts.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingContracts.java new file mode 100644 index 000000000..f0931e32d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingContracts.java @@ -0,0 +1,64 @@ +package com.baiyue.lastleg.inboundplan.receiving; + +import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails; +import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.time.LocalDate; +import java.util.List; + +/** Receiving coverage is separate from a stable cargo batch and from shared vehicles. */ +public final class FbaReceivingContracts { + private FbaReceivingContracts() { } + public record Member(String batchKey, String appointmentNo, int batchVersion, String customerId, + List sources, String customerName) { } + public record Source(String sourceKey, String destinationKey, int packageCount, + String sourceVersion, Integer verificationVersion, String inboundCode) { } + public record Agreement(String agreementKey, int version, String status, String destinationKey, + FbaAppointmentDetails details, String timezone, List members, + String warehouseEvidence) { } + public record Excluded(Member member, String reason, String currentAgreementKey) { } + public record Pending(String changeKey, int version, String status, ChangeIntent intent, + List originalMembers, List originalAgreements,FbaAppointmentWorkPort.Followup followup) { } + public record CustomerPlan(String batchKey,String customerId,FbaAppointmentDetails details,String timezone) { } + public record BatchReceiving(String batchKey, List agreements, List pendingChanges,List plans) { } + public record Context(FbaAppointmentResponse batch, List agreements, + List pendingChanges, FbaAppointmentTransportPort.Scope transport, + List allowedActions, List blockReasons,FbaAppointmentExecutionPort.Scope executionScope,List plans) { } + public record GroupContext(Agreement agreement, List eligibleMembers, List excludedMembers, + FbaAppointmentTransportPort.Scope transport,FbaAppointmentExecutionPort.Scope executionScope) { } + public record Selection(@NotBlank String batchKey, @Min(0) int batchVersion, + @NotBlank String customerId) { } + public record Plan(@NotBlank @Size(max=64) String idempotencyKey,@NotNull @Valid Selection member, + @NotNull @Valid FbaAppointmentDetails details,@NotBlank String executionVersion) { } + public record ExecutionQuery(@NotEmpty @Size(max=200) List members) { } + public record CargoVersion(@NotBlank String sourceKey,@NotBlank String destinationKey, + @NotBlank String expectedSourceVersion,@Min(1) Integer expectedVerificationVersion) { } + public record RefreshCargo(@NotBlank @Size(max=64) String idempotencyKey,@NotNull @Valid Selection member, + @NotEmpty @Size(max=100) List<@NotNull @Valid CargoVersion> sources, + @NotBlank String executionVersion) { } + public record Quantity(@NotBlank String sourceKey, @NotBlank String destinationKey, + @Min(0) int retainedPackages, @NotBlank String expectedSourceVersion, + Integer expectedVerificationVersion) { } + public record ChangeIntent(@NotBlank String batchKey, @Min(0) int batchVersion, + @NotBlank String customerId, @NotEmpty @Valid List quantities, + LocalDate laterDate, @NotBlank @Size(max=64) String ownerId, + LocalDate nextFollowupDate, @NotBlank @Size(max=1000) String reason, + @NotBlank String transportVersion) { } + public record Propose(@NotBlank @Size(max=64) String idempotencyKey, @NotNull @Valid ChangeIntent intent) { } + public record Resolve(@NotBlank @Size(max=64) String idempotencyKey, @Min(0) int version, + @NotBlank String decision, @Size(max=1000) String warehouseEvidence, + @NotBlank String transportVersion) { } + public record Booking(@NotEmpty @Valid List members, @NotNull @Valid FbaAppointmentDetails details, + @NotBlank @Size(max=1000) String warehouseEvidence) { } + public record Book(@NotBlank @Size(max=64) String idempotencyKey, @NotEmpty @Valid List bookings, + String cancelledAgreementKey, Integer cancelledAgreementVersion, + @NotBlank String executionVersion) { } + public record GroupChange(@NotBlank @Size(max=64) String idempotencyKey, @Min(0) int version, + @NotBlank String action, @Valid FbaAppointmentDetails details, + @NotBlank @Size(max=1000) String warehouseEvidence, + @NotBlank String transportVersion) { } + public record Step(String step, String status, String targetKey, String message) { } + public record Result(String changeKey, String status, List batches, + List agreements, List steps, List nextActions) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingController.java new file mode 100644 index 000000000..aee698762 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingController.java @@ -0,0 +1,52 @@ +package com.baiyue.lastleg.inboundplan.receiving; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +import static com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/appointment-receiving") +public class FbaReceivingController { + private static final String READ="hasAuthority('platform:appointment:list')"; + private static final String EDIT="hasAuthority('platform:overseas-warehouse:inbound-order:fba-appointment')"; + private static final String DELETE="hasAuthority('platform:appointment:delete')"; + private final FbaReceivingService service; + public FbaReceivingController(FbaReceivingService service) {this.service=service;} + @GetMapping("/batches/{batchKey}") @PreAuthorize(READ) + public ApiResponse context(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String batchKey) {platform(user);return ApiResponse.success(service.context(batchKey));} + @GetMapping("/agreements/{agreementKey}") @PreAuthorize(READ) + public ApiResponse group(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String agreementKey) {platform(user);return ApiResponse.success(service.groupContext(agreementKey));} + @PostMapping("/execution-scope") @PreAuthorize(READ) + public ApiResponse executionScope(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody ExecutionQuery request) {platform(user);return ApiResponse.success(service.executionScope(request));} + @PostMapping("/changes") @PreAuthorize(EDIT+" and "+DELETE) + public ApiResponse propose(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody Propose request) {return ApiResponse.success(service.propose(platform(user),request));} + @PostMapping("/changes/{changeKey}/resolve") @PreAuthorize(EDIT+" and "+DELETE) + public ApiResponse resolve(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String changeKey,@Valid @RequestBody Resolve request) {return ApiResponse.success(service.resolve(platform(user),changeKey,request));} + @PostMapping("/bookings") @PreAuthorize(EDIT) + public ApiResponse book(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody Book request) {return ApiResponse.success(service.book(platform(user),request));} + @PostMapping("/plans") @PreAuthorize(EDIT) + public ApiResponse plan(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody Plan request) {return ApiResponse.success(service.plan(platform(user),request));} + @PostMapping("/cargo-refresh") @PreAuthorize(EDIT) + public ApiResponse refreshCargo(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody RefreshCargo request) {return ApiResponse.success(service.refreshCargo(platform(user),request));} + @PostMapping("/agreements/{agreementKey}/reschedule") @PreAuthorize(EDIT) + public ApiResponse reschedule(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String agreementKey,@Valid @RequestBody GroupChange request) { + var operator=platform(user);if(!"RESCHEDULE".equals(request.action())) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"此入口只允许改期"); + return ApiResponse.success(service.changeGroup(operator,agreementKey,request)); + } + @PostMapping("/agreements/{agreementKey}/cancel") @PreAuthorize(DELETE) + public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String agreementKey,@Valid @RequestBody GroupChange request) { + var operator=platform(user);if(!"CANCEL".equals(request.action())) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"此入口只允许取消"); + return ApiResponse.success(service.changeGroup(operator,agreementKey,request)); + } + private static String platform(AuthenticatedUser user) { + if(user==null || user.userId()==null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) throw new ResponseStatusException(HttpStatus.FORBIDDEN); + return String.valueOf(user.userId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingMapper.java new file mode 100644 index 000000000..446cffaea --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingMapper.java @@ -0,0 +1,66 @@ +package com.baiyue.lastleg.inboundplan.receiving; + +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface FbaReceivingMapper { + record GroupRow(String agreementKey, int version, String status, String destinationKey, String confirmationNo, String snapshotJson) { } + record ChangeRow(String changeKey, int version, String status, String batchKey, String customerId, String snapshotJson, String resultJson) { } + record BatchJson(String batchKey,String snapshotJson) { } + record CustomerLabel(String customerId,String customerName) { } + record PlanRow(String batchKey,String customerId,String snapshotJson) { } + @Select("") + List plans(@Param("keys") List keys); + @Delete("DELETE FROM ll_fba_receiving_plan WHERE batch_key=#{batch} AND customer_id=#{customer}") + int deletePlan(@Param("batch") String batch,@Param("customer") String customer); + @Insert("INSERT INTO ll_fba_receiving_plan(batch_key,customer_id,snapshot_json) VALUES(#{batchKey},#{customerId},#{snapshotJson})") + int insertPlan(PlanRow row); + @Select("SELECT DISTINCT i.customer_id,o.customer_name FROM ll_inbound_fba_appointment_item i JOIN ll_order o ON o.id=i.order_id AND o.customer_id=i.customer_id WHERE CONCAT('FBA_APPOINTMENT:',i.appointment_id)=#{batch} AND i.active=1") + List labels(String batch); + record LiveSource(String customerId,String sourceKey,String destinationKey,Integer packageCount) { } + @Select("SELECT customer_id,source_key,CONCAT('FBA:',fba_warehouse_code) AS destination_key,package_count FROM ll_inbound_fba_appointment_item WHERE CONCAT('FBA_APPOINTMENT:',appointment_id)=#{batch} AND active=1") + List liveSources(String batch); + @Select("") + List currentGroups(@Param("keys") List keys); + @Select("") + List pendingGroups(@Param("keys") List keys); + @Select("SELECT agreement_key,version,status,destination_key,confirmation_no,snapshot_json FROM ll_fba_receiving_agreement WHERE agreement_key=#{key}") + GroupRow group(String key); + @Select("SELECT agreement_key,version,status,destination_key,confirmation_no,snapshot_json FROM ll_fba_receiving_agreement WHERE agreement_key=#{key} FOR UPDATE") + @Options(flushCache=Options.FlushCachePolicy.TRUE,useCache=false) + GroupRow lockGroup(String key); + @Insert("INSERT INTO ll_fba_receiving_agreement(agreement_key,version,status,destination_key,confirmation_no,snapshot_json) VALUES(#{agreementKey},#{version},#{status},#{destinationKey},#{confirmationNo},#{snapshotJson})") + int insertGroup(GroupRow row); + @Update("UPDATE ll_fba_receiving_agreement SET version=#{version},status=#{status},confirmation_no=#{confirmationNo},snapshot_json=#{snapshotJson} WHERE agreement_key=#{agreementKey}") + int updateGroup(GroupRow row); + @Insert("INSERT INTO ll_fba_receiving_revision(agreement_key,version,snapshot_json) VALUES(#{agreementKey},#{version},#{snapshotJson})") + int revision(GroupRow row); + @Select("SELECT DISTINCT agreement_key FROM ll_fba_receiving_member WHERE batch_key=#{batch} ORDER BY agreement_key") + List groupKeys(String batch); + @Select("SELECT agreement_key FROM ll_fba_receiving_member WHERE batch_key=#{batch} AND customer_id=#{customer} AND active_ref IS NOT NULL") + String currentKey(@Param("batch") String batch,@Param("customer") String customer); + @Insert("INSERT INTO ll_fba_receiving_member(agreement_key,batch_key,customer_id,active_ref) VALUES(#{group},#{batch},#{customer},#{activeRef})") + int addMember(@Param("group") String group,@Param("batch") String batch,@Param("customer") String customer,@Param("activeRef") String activeRef); + @Update("UPDATE ll_fba_receiving_member SET active_ref=NULL WHERE agreement_key=#{group}") + int releaseGroup(String group); + @Update("UPDATE ll_fba_receiving_member SET active_ref=NULL WHERE agreement_key=#{group} AND batch_key=#{batch} AND customer_id=#{customer}") + int releaseMember(@Param("group") String group,@Param("batch") String batch,@Param("customer") String customer); + @Select("SELECT change_key,version,status,batch_key,customer_id,snapshot_json,result_json FROM ll_fba_receiving_change WHERE change_key=#{key}") + ChangeRow change(String key); + @Select("SELECT change_key,version,status,batch_key,customer_id,snapshot_json,result_json FROM ll_fba_receiving_change WHERE change_key=#{key} FOR UPDATE") + @Options(flushCache=Options.FlushCachePolicy.TRUE,useCache=false) + ChangeRow lockChange(String key); + @Select("SELECT change_key,version,status,batch_key,customer_id,snapshot_json,result_json FROM ll_fba_receiving_change WHERE batch_key=#{batch} AND status='WAITING' ORDER BY change_key") + List pending(String batch); + @Select("SELECT result_json FROM ll_fba_receiving_change WHERE batch_key=#{batch} AND status='APPLIED' ORDER BY change_key") + List appliedResults(String batch); + @Select("SELECT snapshot_json FROM ll_fba_receiving_change WHERE batch_key=#{batch} AND status='APPLIED' ORDER BY change_key") + List appliedChanges(String batch); + @Select("SELECT COUNT(*) FROM ll_fba_receiving_change WHERE batch_key=#{batch} AND customer_id=#{customer} AND status='WAITING'") + long pendingCount(@Param("batch") String batch,@Param("customer") String customer); + @Insert("INSERT INTO ll_fba_receiving_change(change_key,version,status,batch_key,customer_id,snapshot_json,active_ref) VALUES(#{changeKey},#{version},#{status},#{batchKey},#{customerId},#{snapshotJson},CONCAT(#{batchKey},'/',#{customerId}))") + int insertChange(ChangeRow row); + @Update("UPDATE ll_fba_receiving_change SET version=#{version},status=#{status},snapshot_json=#{snapshotJson},result_json=#{resultJson},active_ref=NULL WHERE change_key=#{changeKey}") + int finishChange(ChangeRow row); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingService.java new file mode 100644 index 000000000..0edb7b049 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingService.java @@ -0,0 +1,284 @@ +package com.baiyue.lastleg.inboundplan.receiving; + +import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; +import com.baiyue.lastleg.inboundplan.mapper.FbaAppointmentCommandMapper; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.time.*; +import java.util.*; +import java.util.function.Supplier; +import static com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*; +import static com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.conflict; + +@Service +@Transactional(isolation=Isolation.READ_COMMITTED) +public class FbaReceivingService { + private final FbaReceivingStore store; + private final InboundFbaAppointmentService batches; + private final FbaAppointmentTransportPort transport; + private final FbaAppointmentCommandMapper commands; + private final FbaAppointmentWorkPort work; + private final FbaAppointmentExecutionPort execution; + public FbaReceivingService(FbaReceivingStore store,InboundFbaAppointmentService batches,FbaAppointmentTransportPort transport,FbaAppointmentCommandMapper commands,FbaAppointmentWorkPort work,FbaAppointmentExecutionPort execution) { + this.store=store;this.batches=batches;this.transport=transport;this.commands=commands;this.work=work;this.execution=execution; + } + @Transactional(readOnly=true) + public FbaAppointmentExecutionPort.Scope executionScope(ExecutionQuery request) { return execution.inspect(request.members()); } + @Transactional(readOnly=true) + public Context context(String key) { + var batch=batches.batchSnapshots(List.of(key)).get(0);var pending=store.pending(key); + var agreements=store.agreements(key); + var scope=transport.inspect(List.of(key));var blocks=new ArrayList<>(scope.blockReasons()); + if(!batch.replacementAppointmentNos().isEmpty()) blocks.add("原批次已全部转入后续安排,请进入当前批次"); + var actions=new ArrayList(); + var customers=batch.items().stream().map(FbaAppointmentResponse.Item::customerId).collect(java.util.stream.Collectors.toSet()); + var covered=agreements.stream().filter(a->"ACTIVE".equals(a.status())).flatMap(a->a.members().stream()).filter(m->key.equals(m.batchKey())).map(Member::customerId).collect(java.util.stream.Collectors.toSet()); + var waiting=pending.stream().map(p->p.intent().customerId()).collect(java.util.stream.Collectors.toSet()); + var executionScope=execution.inspect(customers.stream().sorted().map(c->new FbaAppointmentExecutionPort.Member(key,c)).toList()); + if(blocks.isEmpty()) { + if(customers.stream().anyMatch(c->!waiting.contains(c))) actions.add("PROPOSE_CUSTOMER_CHANGE"); + } + if(batch.replacementAppointmentNos().isEmpty() && executionScope.members().stream().anyMatch(e->e.canRebook() && !e.completed() && !covered.contains(e.member().customerId()) && !waiting.contains(e.member().customerId()))) { + actions.add("BOOK_RECEIVING");actions.add("PLAN_RECEIVING");actions.add("REFRESH_CARGO"); + } + return new Context(batch,agreements,pending,scope,List.copyOf(actions),List.copyOf(blocks),executionScope,store.plans(key)); + } + @Transactional(readOnly=true) + public GroupContext groupContext(String key) { + var group=store.agreement(key,false);var eligible=new ArrayList();var excluded=new ArrayList(); + var executionScope=execution.inspect(group.members().stream().map(m->new FbaAppointmentExecutionPort.Member(m.batchKey(),m.customerId())).toList()); + for(var old:group.members()) { + var snapshot=batches.batchSnapshots(List.of(old.batchKey())).get(0); + var current=store.currentAgreement(old.batchKey(),old.customerId()); + var member=snapshot.replacementAppointmentNos().isEmpty()?store.members(snapshot).stream().filter(m->m.customerId().equals(old.customerId())).findFirst().orElse(null):null; + String reason=!snapshot.replacementAppointmentNos().isEmpty()?"原批次已转入其他批次":member==null?"客户已移出本批": + store.hasPending(old.batchKey(),old.customerId())?"正在处理其他待确认变化": + current!=null && !current.agreementKey().equals(key)?"已另行预约":null; + var eligibility=executionScope.members().stream().filter(e->e.member().batchKey().equals(old.batchKey()) && e.member().customerId().equals(old.customerId())).findFirst().orElseThrow(()->conflict("客户执行资格读取不完整")); + if(reason==null && (eligibility.completed() || !eligibility.canRebook())) reason=eligibility.completed()?"本客户货物已实际履约完成":eligibility.reason(); + if(reason==null && !eligibility.canRebook()) reason="本客户已开始执行,请从原运输异常处理"; + if(reason==null) eligible.add(member);else excluded.add(new Excluded(old,reason,current==null?null:current.agreementKey())); + } + var transportMembers="CANCELLED".equals(group.status()) && !eligible.isEmpty()?eligible:group.members(); + var scope=transport.inspect(transportMembers.stream().map(Member::batchKey).distinct().sorted().toList()); + // Executed authority may block all further ordinary changes; no synthetic completion here. + var bookingScope=execution.inspect(eligible.stream().map(m->new FbaAppointmentExecutionPort.Member(m.batchKey(),m.customerId())).toList()); + return new GroupContext(group,List.copyOf(eligible),List.copyOf(excluded),scope,bookingScope); + } + public Result propose(String operator,Propose request) { + return command("RECEIVING_PROPOSE",operator,request.idempotencyKey(),request,()->{ + var intent=request.intent();var followup=work.resolveFollowup(intent.ownerId(),intent.nextFollowupDate()); + var scope=transport.lockAndInspect(List.of(intent.batchKey()),intent.transportVersion());requireMutable(scope); + var batch=batches.lockReceivingBatch(intent.batchKey());requireVersion(batch,intent.batchVersion()); + recheckTransport(List.of(intent.batchKey()),scope); + if(store.hasPending(intent.batchKey(),intent.customerId())) throw conflict("本客户已有待确认变化,请继续原记录"); + var member=store.members(batch).stream().filter(m->m.customerId().equals(intent.customerId())).findFirst().orElseThrow(()->bad("客户不属于本批次")); + validateQuantities(member,intent); + var group=store.currentAgreement(batch.batchKey(),intent.customerId()); + if(group!=null) {group=store.agreement(group.agreementKey(),true);requireActive(group);} + var pending=new Pending("CH-"+UUID.randomUUID().toString().replace("-","").toUpperCase(Locale.ROOT),0,"WAITING",intent,List.of(member),group==null?List.of():List.of(group),followup); + store.save(pending); + batches.receivingChanged(List.of(batch)); + return new Result(pending.changeKey(),"WAITING",List.of(batch),pending.originalAgreements(),List.of(new Step("RECORD_CHANGE","SUCCESS",pending.changeKey(),"原安排及数量保留,待确认变化已阻止相关发车")),List.of("CONTINUE_WAREHOUSE_CONFIRMATION")); + }); + } + public Result resolve(String operator,String changeKey,Resolve request) { + return command("RECEIVING_RESOLVE:"+changeKey,operator,request.idempotencyKey(),request,()->{ + var initial=store.change(changeKey,false);var intent=initial.intent(); + // Withdrawing only removes the pending proposal. It must remain possible after + // picking starts, without editing cargo, the appointment, or vehicle facts. + var withdrawing="WITHDRAW".equals(request.decision()); + var followup=withdrawing?null:work.resolveFollowup(intent.ownerId(),intent.nextFollowupDate()); + var scope=withdrawing?null:transport.lockAndInspect(List.of(intent.batchKey()),request.transportVersion()); + if(!withdrawing) requireMutable(scope); + var batch=batches.lockReceivingBatch(intent.batchKey());var pending=store.change(changeKey,true); + if(pending.version()!=request.version() || !"WAITING".equals(pending.status())) throw conflict("变化已被处理,请重新读取当前结果"); + if(withdrawing) { + var result=new Result(changeKey,"WITHDRAWN",List.of(batch),pending.originalAgreements(),List.of(new Step("WITHDRAW_CHANGE","SUCCESS",changeKey,"变化已撤回,原安排保留")),List.of("REVIEW_TRANSPORT")); + store.finish(pending,"WITHDRAWN",result);batches.receivingChanged(List.of(batch));return result; + } + if(!"CONFIRM".equals(request.decision()) || blank(request.warehouseEvidence())) throw bad("请填写仓库明确同意保留量及移出量的回复依据"); + requireVersion(batch,intent.batchVersion()); + Agreement original=null; + for(var expected:pending.originalAgreements()) { + var current=store.agreement(expected.agreementKey(),true); + if(current.version()!=expected.version() || !"ACTIVE".equals(current.status())) throw conflict("仓库预约覆盖已变化,不能沿旧回复处理"); + original=current; + } + var currentKey=store.currentAgreement(intent.batchKey(),intent.customerId()); + if(!Objects.equals(currentKey==null?null:currentKey.agreementKey(),original==null?null:original.agreementKey())) throw conflict("本客户有效预约关系已变化"); + var changed=batches.moveReceivingCustomer(operator,intent);var retained=changed.get(0);var later=changed.get(1); + work.seedFollowup(new FbaAppointmentWorkPort.Member(intent.batchKey(),intent.customerId()),new FbaAppointmentWorkPort.Member(later.batchKey(),intent.customerId()),followup,operator); + var movements=new ArrayList(); + for(var source:pending.originalMembers().get(0).sources()) { + var q=intent.quantities().stream().filter(v->v.sourceKey().equals(source.sourceKey()) && v.destinationKey().equals(source.destinationKey())).findFirst().orElseThrow(); + movements.add(new FbaAppointmentTransportPort.Move(intent.batchKey(),intent.customerId(),source.sourceKey(),source.destinationKey(),source.packageCount(),q.retainedPackages(),later.batchKey())); + } + var physical=transport.apply(scope,List.copyOf(movements),operator,intent.reason()); + var agreements=new ArrayList(); + if(original!=null) { + var members=new ArrayList<>(original.members());members.removeIf(m->m.batchKey().equals(intent.batchKey()) && m.customerId().equals(intent.customerId())); + var remaining=store.members(retained).stream().filter(m->m.customerId().equals(intent.customerId())).findFirst(); + if(remaining.isPresent()) members.add(remaining.get());else store.releaseMember(original.agreementKey(),intent.batchKey(),intent.customerId()); + agreements.add(members.isEmpty()?store.cancel(original,"SUPERSEDED",request.warehouseEvidence()):store.update(original,members,original.details(),request.warehouseEvidence())); + } + var projected=retained.items().isEmpty()?retained:batches.projectReceiving(intent.batchKey(),false); + var steps=List.of(new Step("ALLOCATE_CARGO","SUCCESS",later.batchKey(),"原来源总量不变,后送量已落在新批次"),new Step("ADJUST_TRANSPORT","SUCCESS",intent.batchKey(),"原车辆按实际保留货物调整")); + var actions=new ArrayList();actions.add("BOOK_LATER_BATCH");actions.addAll(physical.nextActions()); + var result=new Result(changeKey,"APPLIED",List.of(projected,later),List.copyOf(agreements),steps,List.copyOf(actions)); + store.finish(pending,"APPLIED",result);batches.receivingChanged(result.batches());return result; + }); + } + public Result refreshCargo(String operator,RefreshCargo request) { + return command("RECEIVING_REFRESH_CARGO",operator,request.idempotencyKey(),request,()->{ + var selection=request.member();var selected=List.of(new FbaAppointmentExecutionPort.Member(selection.batchKey(),selection.customerId())); + var locked=execution.lockAndInspect(selected,request.executionVersion());requireRebookable(selected,locked); + var batch=batches.lockReceivingBatch(selection.batchKey());requireVersion(batch,selection.batchVersion()); + var current=execution.inspect(selected); + if(!Objects.equals(locked.version(),current.version())) throw conflict("客户执行关联已变化,请重新核对当前范围"); + requireRebookable(selected,current); + if(store.hasPending(selection.batchKey(),selection.customerId())) throw conflict("本客户有待确认变化,请先处理或撤回原变化再核对资料"); + if(store.currentAgreement(selection.batchKey(),selection.customerId())!=null) throw conflict("本客户已有正式确认,不能直接采用新货物资料,请先处理其有效预约"); + var refreshed=batches.refreshReceivingCargo(selection.batchKey(),selection.customerId(),request.sources()); + boolean changed=refreshed.version()!=batch.version(); + return new Result(null,changed?"APPLIED":"UNCHANGED",List.of(refreshed),List.of(),changed?List.of(new Step("REFRESH_CARGO","SUCCESS",batch.batchKey(),"本客户已采用核对后的最新资料,批次数量及其他客户安排保留")):List.of(),List.of("CONTINUE_CUSTOMER_PLAN")); + }); + } + public Result plan(String operator,Plan request) { + return command("RECEIVING_PLAN",operator,request.idempotencyKey(),request,()->{ + var selection=request.member();var memberKey=new FbaAppointmentExecutionPort.Member(selection.batchKey(),selection.customerId()); + var selected=List.of(memberKey);var locked=execution.lockAndInspect(selected,request.executionVersion());requireRebookable(selected,locked); + var batch=batches.lockReceivingBatch(selection.batchKey());requireVersion(batch,selection.batchVersion()); + var current=execution.inspect(selected); + if(!Objects.equals(locked.version(),current.version())) throw conflict("客户执行关联已变化,请重新核对当前范围"); + requireRebookable(selected,current); + if(store.hasPending(selection.batchKey(),selection.customerId())) throw conflict("本客户有待确认变化,请先处理原变化"); + if(store.currentAgreement(selection.batchKey(),selection.customerId())!=null) throw conflict("本客户已有正式确认,请使用明确范围的改期或取消操作"); + var member=store.member(batch,selection.customerId());if(member==null) throw conflict("客户已移出原批次"); + var timezone=batches.validateReceivingPlan(member,request.details()); + var plan=new CustomerPlan(batch.batchKey(),selection.customerId(),FbaReceivingStore.normalized(request.details()),timezone); + if(plan.equals(store.plan(batch.batchKey(),selection.customerId()))) return new Result(null,"UNCHANGED",List.of(batch),List.of(),List.of(),List.of("CONTINUE_CUSTOMER_PLAN")); + store.freezeUnconfirmedPlans(batch); + store.savePlan(plan);var projected=batches.projectReceiving(batch.batchKey(),false); + return new Result(null,"APPLIED",List.of(projected),List.of(),List.of(new Step("PLAN_RECEIVING","SUCCESS",batch.batchKey(),"仅本客户计划已保存,其他客户安排及货量保留")),List.of("CONTINUE_CUSTOMER_PLAN")); + }); + } + public Result book(String operator,Book request) { + return command("RECEIVING_BOOK",operator,request.idempotencyKey(),request,()->{ + if(request.bookings()==null || request.bookings().isEmpty() || request.bookings().size()>100) throw bad("请选择需要重新预约的客户"); + var selections=request.bookings().stream().flatMap(b->b.members().stream()).toList(); + var keys=selections.stream().map(Selection::batchKey).distinct().sorted().toList(); + var executionMembers=selections.stream().map(s->new FbaAppointmentExecutionPort.Member(s.batchKey(),s.customerId())).distinct().toList(); + var executionScope=execution.lockAndInspect(executionMembers,request.executionVersion()); + requireRebookable(executionMembers,executionScope); + var snapshots=lock(keys); + // An unbound customer can acquire a new dispatch before we obtain the batch lock. + // Binding shares that batch lock, so rereading here closes the gap without reversing locks. + var currentExecution=execution.inspect(executionMembers); + if(!Objects.equals(executionScope.version(),currentExecution.version())) throw conflict("客户执行关联已变化,请重新核对当前范围"); + requireRebookable(executionMembers,currentExecution); + snapshots.values().forEach(store::freezeUnconfirmedPlans); + Agreement cancelled=null; + if(request.cancelledAgreementKey()!=null) { + cancelled=store.agreement(request.cancelledAgreementKey(),true); + if(!"CANCELLED".equals(cancelled.status()) || !Objects.equals(cancelled.version(),request.cancelledAgreementVersion())) throw conflict("取消组版本已变化,请重新读取待重约范围"); + } + var seen=new HashSet();var saved=new ArrayList(); + for(var booking:request.bookings()) { + if(blank(booking.warehouseEvidence()) || booking.members()==null || booking.members().isEmpty()) throw bad("请记录仓库确认覆盖本次客户和货量的依据"); + var members=new ArrayList(); + for(var selection:booking.members()) { + if(!seen.add(selection.batchKey()+"/"+selection.customerId())) throw bad("同客户批次不能重复预约"); + var batch=snapshots.get(selection.batchKey());requireVersion(batch,selection.batchVersion()); + if(store.hasPending(selection.batchKey(),selection.customerId())) throw conflict("该客户有其他待确认变化,不能直接重约"); + if(store.currentAgreement(selection.batchKey(),selection.customerId())!=null) throw conflict("该客户已有有效收货预约,请明确变更当前预约范围"); + if(cancelled!=null && cancelled.members().stream().noneMatch(m->m.batchKey().equals(selection.batchKey()) && m.customerId().equals(selection.customerId()))) throw conflict("所选客户不属于该取消组的原覆盖范围"); + members.add(store.members(batch).stream().filter(m->m.customerId().equals(selection.customerId())).findFirst().orElseThrow(()->conflict("客户已移出原批次"))); + } + var timezone=batches.validateReceiving(members,booking.details());saved.add(store.create(members,booking.details(),timezone,booking.warehouseEvidence())); + members.forEach(m->store.deletePlan(m.batchKey(),m.customerId())); + } + var projected=keys.stream().map(k->batches.projectReceiving(k,false)).toList(); + return new Result(null,"APPLIED",projected,List.copyOf(saved),saved.stream().map(a->new Step("BOOK_RECEIVING","SUCCESS",a.agreementKey(),"使用原批次形成新的有效收货预约" )).toList(),List.of("REVIEW_TRANSPORT")); + }); + } + public Result changeGroup(String operator,String key,GroupChange request) { + return command("RECEIVING_GROUP:"+key,operator,request.idempotencyKey(),request,()->{ + var initial=store.agreement(key,false);var keys=initial.members().stream().map(Member::batchKey).distinct().sorted().toList(); + var scope=transport.lockAndInspect(keys,request.transportVersion());requireMutable(scope);var snapshots=lock(keys); + recheckTransport(keys,scope); + var group=store.agreement(key,true);requireActive(group); + if(group.version()!=request.version()) throw conflict("共同预约范围已变化,请重新核对全部客户"); + var members=new ArrayList(); + for(var old:group.members()) { + if(store.hasPending(old.batchKey(),old.customerId())) throw conflict("当前成员有待确认变化,请先处理其实际范围"); + var current=store.currentAgreement(old.batchKey(),old.customerId()); + if(current==null || !key.equals(current.agreementKey())) throw conflict("成员已退出本预约,请重新读取范围"); + members.add(store.members(snapshots.get(old.batchKey())).stream().filter(m->m.customerId().equals(old.customerId())).findFirst().orElseThrow(()->conflict("当前覆盖货物已变化"))); + } + if(blank(request.warehouseEvidence())) throw bad("请记录本次仓库回复依据"); + Agreement changed; + if("CANCEL".equals(request.action())) { + changed=store.cancel(group,"CANCELLED",request.warehouseEvidence()); + members.forEach(m->store.deletePlan(m.batchKey(),m.customerId())); + } + else if("RESCHEDULE".equals(request.action())) { + batches.validateReceiving(members,request.details()); + if(Objects.equals(group.details(),request.details())) return new Result(null,"UNCHANGED",List.copyOf(snapshots.values()),List.of(group),List.of(),List.of("REVIEW_TRANSPORT")); + changed=store.update(group,members,request.details(),request.warehouseEvidence()); + if(changed.equals(group)) return new Result(null,"UNCHANGED",List.copyOf(snapshots.values()),List.of(group),List.of(),List.of("REVIEW_TRANSPORT")); + } else throw bad("请选择整份改期或整份取消"); + var projected=keys.stream().map(k->batches.projectReceiving(k,"CANCEL".equals(request.action()))).toList(); + return new Result(null,"APPLIED",projected,List.of(changed),List.of(new Step(request.action(),"SUCCESS",key,"按服务端当前覆盖成员处理,车辆和批次数量保留")),List.of("CANCEL".equals(request.action())?"REBOOK_GROUP":"REVIEW_TRANSPORT")); + }); + } + private Map lock(List keys) {var result=new LinkedHashMap();for(var key:keys) result.put(key,batches.lockReceivingBatch(key));return result;} + private void recheckTransport(List keys,FbaAppointmentTransportPort.Scope locked) { + // Original bindings also lock the batch. Re-read after acquiring it to reject a new + // association created between the initial transport locks and the batch lock. + var current=transport.inspect(keys); + if(!Objects.equals(locked.version(),current.version())) throw conflict("运输关联已变化,请重新核对当前范围"); + requireMutable(current); + } + private static void requireRebookable(List members,FbaAppointmentExecutionPort.Scope scope) { + if(scope==null || scope.members()==null || !new HashSet<>(members).equals(scope.members().stream().map(FbaAppointmentExecutionPort.Eligibility::member).collect(java.util.stream.Collectors.toSet()))) throw conflict("客户执行资格读取不完整"); + for(var member:scope.members()) if(member.completed() || !member.canRebook()) throw conflict(member.completed()?"已完成货物不能重新预约":blank(member.reason())?"本客户已开始执行,请从原运输异常处理":member.reason()); + } + private static void validateQuantities(Member member,ChangeIntent intent) { + if(intent.quantities()==null || intent.quantities().size()!=member.sources().size()) throw bad("请填写本客户每票原来源的本次先送箱数"); + var seen=new HashSet();boolean changed=false; + for(var q:intent.quantities()) { + if(!seen.add(q.sourceKey()+"/"+q.destinationKey())) throw bad("来源不可重复"); + var source=member.sources().stream().filter(s->s.sourceKey().equals(q.sourceKey()) && s.destinationKey().equals(q.destinationKey())).findFirst().orElseThrow(()->bad("所选来源不属于本客户批次")); + if(q.retainedPackages()<0 || q.retainedPackages()>source.packageCount()) throw bad("本次先送量须在零至本批箱数之间"); + if(!Objects.equals(q.expectedSourceVersion(),source.sourceVersion()) || !Objects.equals(q.expectedVerificationVersion(),source.verificationVersion())) throw conflict("来源或核实版本已变化"); + changed|=q.retainedPackages()!=source.packageCount(); + } + if(!changed) throw bad("本次货量没有变化,无需保存调整"); + } + private Result command(String operation,String operator,String key,Object request,Supplier action) { + if(blank(operator)||blank(key)||key.length()>64) throw bad("操作人及请求标识不能为空"); + String hash; + try {hash=HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest((operation+"\n"+operator+"\n"+store.write(request)).getBytes(java.nio.charset.StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);} + var existing=commands.find(key);if(existing!=null) return replay(existing,hash); + try {commands.begin(key,hash,operation,operator,LocalDateTime.now(ZoneOffset.UTC));}catch(DuplicateKeyException duplicate){return replay(commands.find(key),hash);} + try { + var result=action.get(); + if(commands.finish(key,result.batches().isEmpty()?null:result.batches().get(0).appointmentNo(),store.write(result))!=1) throw conflict("命令结果未保存"); + return result; + } catch(DuplicateKeyException duplicate) {throw conflict("当前目的仓约号或客户覆盖已被其他预约使用,请重新读取范围");} + } + private Result replay(FbaAppointmentCommandMapper.Command row,String hash) { + if(row==null || !hash.equals(row.requestHash()) || row.responseJson()==null) throw conflict("同一请求标识的内容不同或结果尚未就绪"); + return store.read(row.responseJson(),Result.class); + } + private static void requireVersion(FbaAppointmentResponse batch,int version) {if(batch.version()!=version) throw conflict("批次已更新,请刷新后重新核对");} + private static void requireActive(Agreement group) {if(!"ACTIVE".equals(group.status())) throw conflict("原收货预约已失效,请使用重新预约入口");} + private static void requireMutable(FbaAppointmentTransportPort.Scope scope) {if(!scope.blockReasons().isEmpty()) throw conflict(String.join(";",scope.blockReasons()));} + private static boolean blank(String s) {return s==null || s.isBlank();} + private static ResponseStatusException bad(String message) {return new ResponseStatusException(HttpStatus.BAD_REQUEST,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingStore.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingStore.java new file mode 100644 index 000000000..daed8dea8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingStore.java @@ -0,0 +1,147 @@ +package com.baiyue.lastleg.inboundplan.receiving; + +import com.baiyue.lastleg.inboundplan.dto.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; +import static com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*; + +/** Persisted receiving authority. All mutations run inside the caller's batch-locked transaction. */ +@Component +public class FbaReceivingStore { + private final FbaReceivingMapper mapper; + private final ObjectMapper json; + public FbaReceivingStore(FbaReceivingMapper mapper,ObjectMapper json) { this.mapper=mapper;this.json=json; } + public boolean hasPending(String batchKey,String customerId) { return mapper.pendingCount(batchKey,customerId)>0; } + /** Three bounded queries for calendar/work providers; never a per-batch query loop. */ + public List snapshots(List batchKeys) { + if(batchKeys==null || batchKeys.isEmpty()) return List.of(); + if(batchKeys.size()>200 || batchKeys.stream().anyMatch(k->k==null || !k.matches("FBA_APPOINTMENT:[1-9][0-9]*"))) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"每次最多读取200个有效批次"); + var groups=new HashMap>();var pending=new HashMap>();var plans=new HashMap>(); + for(var row:mapper.currentGroups(batchKeys)) groups.computeIfAbsent(row.batchKey(),k->new ArrayList<>()).add(read(row.snapshotJson(),Agreement.class)); + for(var row:mapper.pendingGroups(batchKeys)) pending.computeIfAbsent(row.batchKey(),k->new ArrayList<>()).add(read(row.snapshotJson(),Pending.class)); + for(var row:mapper.plans(batchKeys)) plans.computeIfAbsent(row.batchKey(),k->new ArrayList<>()).add(read(row.snapshotJson(),CustomerPlan.class)); + return batchKeys.stream().distinct().map(k->new BatchReceiving(k,List.copyOf(groups.getOrDefault(k,List.of())),List.copyOf(pending.getOrDefault(k,List.of())),List.copyOf(plans.getOrDefault(k,List.of())))).toList(); + } + public List plans(String batchKey) { return mapper.plans(List.of(batchKey)).stream().map(r->read(r.snapshotJson(),CustomerPlan.class)).toList(); } + public CustomerPlan plan(String batchKey,String customerId) { return plans(batchKey).stream().filter(p->customerId.equals(p.customerId())).findFirst().orElse(null); } + public CustomerPlan savePlan(CustomerPlan input) { + var saved=new CustomerPlan(input.batchKey(),input.customerId(),normalized(input.details()),input.timezone()); + mapper.deletePlan(saved.batchKey(),saved.customerId()); + mapper.insertPlan(new FbaReceivingMapper.PlanRow(saved.batchKey(),saved.customerId(),write(saved)));return saved; + } + public void deletePlan(String batchKey,String customerId) { mapper.deletePlan(batchKey,customerId); } + /** Preserve each still-unconfirmed customer's old target before changing the aggregate header. */ + public void freezeUnconfirmedPlans(FbaAppointmentResponse batch) { + if(!Set.of("PENDING","FAILED").contains(batch.appointmentStatus())) return; + var existing=plans(batch.batchKey()).stream().map(CustomerPlan::customerId).collect(java.util.stream.Collectors.toSet()); + var covered=agreements(batch.batchKey()).stream().filter(a->"ACTIVE".equals(a.status())).flatMap(a->a.members().stream()).filter(m->batch.batchKey().equals(m.batchKey())).map(Member::customerId).collect(java.util.stream.Collectors.toSet()); + var details=new FbaAppointmentDetails(batch.plannedDate(),batch.plannedAt(),batch.plannedEndAt(),null,null,null,batch.appointmentStatus(),batch.appointmentRemark(),null,null); + for(var customer:batch.items().stream().map(FbaAppointmentResponse.Item::customerId).distinct().toList()) + if(!existing.contains(customer) && !covered.contains(customer)) savePlan(new CustomerPlan(batch.batchKey(),customer,details,batch.timezone())); + } + public Agreement currentAgreement(String batchKey,String customerId) { + var key=mapper.currentKey(batchKey,customerId);return key==null?null:agreement(key,false); + } + public List agreements(String batchKey) { return mapper.groupKeys(batchKey).stream().map(k->agreement(k,false)).toList(); } + public List pending(String batchKey) { return mapper.pending(batchKey).stream().map(r->read(r.snapshotJson(),Pending.class)).toList(); } + public List successorKeys(String batchKey) { + return mapper.appliedResults(batchKey).stream().map(r->read(r,Result.class)).flatMap(r->r.batches().stream()) + .map(FbaAppointmentResponse::batchKey).filter(k->!k.equals(batchKey)).distinct().toList(); + } + public List originalSources(String batchKey) { + return mapper.appliedChanges(batchKey).stream().map(r->read(r,Pending.class)).flatMap(p->p.originalMembers().stream()) + .flatMap(m->m.sources().stream().map(s->new com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef(m.customerId(),s.sourceKey(),s.destinationKey()))).distinct().toList(); + } + public Agreement agreement(String key,boolean lock) { + var row=lock?mapper.lockGroup(key):mapper.group(key); + if(row==null) throw new ResponseStatusException(HttpStatus.NOT_FOUND,"收货预约不存在"); + return read(row.snapshotJson(),Agreement.class); + } + public List members(FbaAppointmentResponse batch) { + return members(batch,null); + } + public Member member(FbaAppointmentResponse batch,String customerId) {return members(batch,customerId).stream().findFirst().orElse(null);} + private List members(FbaAppointmentResponse batch,String customerId) { + var labels=new HashMap();for(var label:mapper.labels(batch.batchKey())) if(label.customerName()!=null) labels.putIfAbsent(label.customerId(),label.customerName()); + var grouped=new LinkedHashMap>(); + for(var i:batch.items()) { + if(customerId!=null && !customerId.equals(i.customerId())) continue; + if(i.packageCount()==null || i.packageCount()<=0) throw conflict("请先核实本批各票箱数,再调整收货范围"); + grouped.computeIfAbsent(i.customerId(),ignored->new ArrayList<>()).add(new Source(i.sourceKey(),i.destinationKey(),i.packageCount(),i.sourceVersion(),i.cargoVerificationVersion(),i.inboundCode())); + } + return grouped.entrySet().stream().map(e->new Member(batch.batchKey(),batch.appointmentNo(),batch.version(),e.getKey(),List.copyOf(e.getValue()),labels.get(e.getKey()))).toList(); + } + public Agreement create(List members,FbaAppointmentDetails details,String timezone,String evidence) { + details=normalized(details); + String destination=members.get(0).sources().get(0).destinationKey(); + var group=new Agreement("RA-"+UUID.randomUUID().toString().replace("-","").toUpperCase(Locale.ROOT),0,"ACTIVE",destination,details,timezone,List.copyOf(members),evidence); + try { + mapper.insertGroup(row(group));mapper.revision(row(group)); + for(var member:members) mapper.addMember(group.agreementKey(),member.batchKey(),member.customerId(),member.batchKey()+"/"+member.customerId()); + } catch(org.springframework.dao.DuplicateKeyException duplicate) {throw conflict("该目的仓约号或客户覆盖已被其他有效预约使用");} + return group; + } + public Agreement update(Agreement old,List members,FbaAppointmentDetails details,String evidence) { + details=normalized(details); + if(Objects.equals(old.details(),details) && coverage(old.members()).equals(coverage(members))) return old; + var group=new Agreement(old.agreementKey(),old.version()+1,old.status(),old.destinationKey(),details,old.timezone(),List.copyOf(members),evidence); + try {mapper.updateGroup(row(group));mapper.revision(row(group));} catch(org.springframework.dao.DuplicateKeyException duplicate) {throw conflict("该目的仓约号已被其他有效预约使用");}return group; + } + public Agreement cancel(Agreement old,String status,String evidence) { + var group=new Agreement(old.agreementKey(),old.version()+1,status,old.destinationKey(),old.details(),old.timezone(),old.members(),evidence); + mapper.releaseGroup(old.agreementKey());mapper.updateGroup(row(group));mapper.revision(row(group));return group; + } + public void releaseMember(String group,String batch,String customer) { mapper.releaseMember(group,batch,customer); } + public void assertLegacyEditable(String batchKey) { + assertRepartitionable(batchKey); + if(!plans(batchKey).isEmpty()) throw conflict("本批已有按客户维护的计划,请按客户继续处理,不能整批覆盖或取消"); + } + public void assertRepartitionable(String batchKey) { + if(!pending(batchKey).isEmpty()) throw conflict("本批有待仓库确认的变化,请从变化记录继续处理"); + var active=agreements(batchKey).stream().filter(a->"ACTIVE".equals(a.status())).toList(); + if(active.size()>1 || active.stream().anyMatch(a->a.members().stream().anyMatch(m->!m.batchKey().equals(batchKey)))) + throw conflict("本批属于独立收货或跨批共同预约,请使用明确范围的收货预约操作"); + if(!active.isEmpty()) { + var actual=new HashSet<>(mapper.liveSources(batchKey)); + var covered=new HashSet(); + for(var member:active.get(0).members()) { + if(!Objects.equals(mapper.currentKey(batchKey,member.customerId()),active.get(0).agreementKey())) throw conflict("本批客户覆盖关系已变化,请按当前收货范围操作"); + for(var source:member.sources()) covered.add(new FbaReceivingMapper.LiveSource(member.customerId(),source.sourceKey(),source.destinationKey(),source.packageCount())); + } + if(!actual.equals(covered)) throw conflict("本批仅部分客户已取得确认,请分别处理当前客户或明确共同预约范围"); + } + } + /** Existing single-batch commands produce the same receiving authority; no read-time reconstruction. */ + public void synchronize(Object result) { + if(!(result instanceof FbaAppointmentResponse batch)) return; + var active=agreements(batch.batchKey()).stream().filter(a->"ACTIVE".equals(a.status())).toList(); + if("SUCCESS".equals(batch.appointmentStatus())) { + var details=new FbaAppointmentDetails(batch.plannedDate(),batch.plannedAt(),batch.plannedEndAt(),batch.confirmationNo(),batch.appointmentAt(),batch.appointmentEndAt(),"SUCCESS",batch.appointmentRemark(),null,null); + if(active.isEmpty()) create(members(batch),details,batch.timezone(),"登记收货方正式确认"); + else update(active.get(0),members(batch),details,"登记收货方正式确认"); + } else for(var agreement:active) cancel(agreement,"CANCELLED".equals(batch.appointmentStatus())?"CANCELLED":"SUPERSEDED","原预约结果已变更"); + } + public Pending change(String key,boolean lock) { + var row=lock?mapper.lockChange(key):mapper.change(key); + if(row==null) throw new ResponseStatusException(HttpStatus.NOT_FOUND,"预约变化记录不存在"); + return read(row.snapshotJson(),Pending.class); + } + public void save(Pending change) { mapper.insertChange(new FbaReceivingMapper.ChangeRow(change.changeKey(),change.version(),change.status(),change.intent().batchKey(),change.intent().customerId(),write(change),null)); } + public void finish(Pending old,String status,Result result) { + var updated=new Pending(old.changeKey(),old.version()+1,status,old.intent(),old.originalMembers(),old.originalAgreements(),old.followup()); + mapper.finishChange(new FbaReceivingMapper.ChangeRow(old.changeKey(),updated.version(),status,old.intent().batchKey(),old.intent().customerId(),write(updated),write(result))); + } + private static List coverage(List members) { + return members.stream().flatMap(m->m.sources().stream().map(s->List.of(m.batchKey(),m.customerId(),s.sourceKey(),s.destinationKey(),s.packageCount(),Objects.toString(s.sourceVersion(),""),Objects.toString(s.verificationVersion(),"")).toString())).sorted().toList(); + } + static FbaAppointmentDetails normalized(FbaAppointmentDetails d) { + return new FbaAppointmentDetails(d.plannedAt()==null?d.plannedDate():d.plannedAt().toLocalDate(),d.plannedAt(),d.plannedEndAt(),d.confirmationNo()==null?null:d.confirmationNo().trim(),d.appointmentAt(),d.appointmentEndAt(),d.appointmentStatus(),d.appointmentRemark()==null?null:d.appointmentRemark().trim(),d.startOffset(),d.endOffset()); + } + private FbaReceivingMapper.GroupRow row(Agreement a) { return new FbaReceivingMapper.GroupRow(a.agreementKey(),a.version(),a.status(),a.destinationKey(),"ACTIVE".equals(a.status())?a.details().confirmationNo():null,write(a)); } + public String write(Object value) {try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("预约记录无法保存",e);} } + public T read(String value,Class type) {try{return json.readValue(value,type);}catch(Exception e){throw new IllegalStateException("预约记录无法读取",e);} } + static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT,message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundAppointmentSourceLinker.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundAppointmentSourceLinker.java new file mode 100644 index 000000000..fbc023c9d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundAppointmentSourceLinker.java @@ -0,0 +1,46 @@ +package com.baiyue.lastleg.inboundplan.service; + +import com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker; +import com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +import java.util.List; +import java.util.Objects; + +/** Called by actual incoming materialization, never as a migration or historical data repair. */ +@Service +public class InboundAppointmentSourceLinker implements AppointmentSourceLinker { + private final LastlegOrderMapper orders; + private final InboundFbaAppointmentItemMapper items; + private final AppointmentExternalSourceGateway bookings; + public InboundAppointmentSourceLinker(LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings) {this.orders=orders;this.items=items;this.bookings=bookings;} + @Override + @Transactional(propagation=Propagation.MANDATORY) + public void materialized(String customerId,String bookingNo,Long orderId) { + var ref=new AppointmentExternalSourceGateway.SourceRef(customerId,bookingNo); + bookings.lockForAppointment(List.of(ref)); + var matched=bookings.resolve(List.of(ref)); + if(matched.size()!=1 || !Objects.equals(matched.get(0).customerId(),customerId) || !Objects.equals(matched.get(0).bookingNo(),bookingNo) || !Objects.equals(matched.get(0).orderId(),orderId)) throw conflict("来货与原登记的归属不一致,请核对"); + var order=orderId==null?null:orders.selectById(orderId); + if(order==null || !Objects.equals(order.getCustomerId(),customerId)) throw conflict("来货尚未形成对应客户的真实货物"); + var rows=items.selectList(new LambdaQueryWrapper().eq(InboundFbaAppointmentItem::getSourceKey,"BOOKING:"+bookingNo) + .eq(InboundFbaAppointmentItem::getCustomerId,customerId).eq(InboundFbaAppointmentItem::getActive,true).last("FOR UPDATE")); + for(var row:rows) { + if(row.getOrderId()!=null && !row.getOrderId().equals(orderId)) throw conflict("原预约已关联其他来货,不能自动改绑"); + if(Objects.equals(row.getOrderId(),orderId) && Objects.equals(row.getInboundCode(),order.getInboundCode())) continue; + try {items.update(null,new LambdaUpdateWrapper().eq(InboundFbaAppointmentItem::getId,row.getId()).eq(InboundFbaAppointmentItem::getActive,true) + .set(InboundFbaAppointmentItem::getOrderId,orderId).set(InboundFbaAppointmentItem::getInboundCode,order.getInboundCode()));} + catch(org.springframework.dao.DuplicateKeyException duplicate){throw conflict("该来货已有其他有效预约,请先核对原安排");} + } + } + private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java index 5e5a52ebc..cec90b860 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java @@ -29,13 +29,18 @@ public class InboundFbaAppointmentService { @Resource private FbaAppointmentCommandMapper commands; @Resource private AppointmentSourceService sources; @Resource private AppointmentBatchPreviewMapper flags; + @Resource private AppointmentCargoVerificationService verification; @Resource private ObjectMapper json; + @Resource private com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving; + @Resource private com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort receivingWork; + @Resource private com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges workChanges; @Transactional(readOnly=true) public List calendar(LocalDate from,LocalDate to) { if(from==null || to==null || !to.isAfter(from) || ChronoUnit.DAYS.between(from,to)>42) throw bad("日历查询范围必须为1至42天"); var query=new LambdaQueryWrapper() - .notIn(InboundFbaAppointment::getAppointmentStatus,List.of("CANCELLED","FAILED")) + .isNull(InboundFbaAppointment::getReplacementNosJson) + .ne(InboundFbaAppointment::getAppointmentStatus,"CANCELLED") .apply("COALESCE(appointment_at,planned_at,planned_date)<{1} AND (COALESCE(appointment_at,planned_at,planned_date)>={0} OR CASE WHEN appointment_at IS NOT NULL THEN appointment_end_at WHEN planned_at IS NOT NULL THEN planned_end_at ELSE NULL END>{0})",from.atStartOfDay(),to.atStartOfDay()) .orderByAsc(InboundFbaAppointment::getId); return responses(appointmentMapper.selectList(query)); @@ -44,9 +49,9 @@ public class InboundFbaAppointmentService { public PageResult unscheduled(int page,int size) { if(page<1 || size<1 || size>100) throw bad("分页参数无效"); var query=new LambdaQueryWrapper() + .isNull(InboundFbaAppointment::getReplacementNosJson) .isNull(InboundFbaAppointment::getAppointmentAt).isNull(InboundFbaAppointment::getPlannedAt) - .isNull(InboundFbaAppointment::getPlannedDate) - .notIn(InboundFbaAppointment::getAppointmentStatus,List.of("CANCELLED","FAILED")); + .isNull(InboundFbaAppointment::getPlannedDate); long total=appointmentMapper.selectCount(query); var rows=appointmentMapper.selectList(query.orderByDesc(InboundFbaAppointment::getCreatedAt) .last("LIMIT "+size+" OFFSET "+(long)(page-1)*size)); @@ -66,10 +71,13 @@ public class InboundFbaAppointmentService { } @Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public FbaAppointmentResponse createFromSources(String operatorId,SourceFbaAppointmentRequest request) { - return command("CREATE_FROM_SOURCES",operatorId,request.idempotencyKey(),request,()->create(operatorId,request.sources(),request.originWarehouseId(),request.fbaWarehouseCode(),request.details())); + return command("CREATE_FROM_SOURCES",operatorId,request.idempotencyKey(),request,()->create(operatorId, + request.sources()==null?null:request.sources().stream().map(source->source==null?null: + new AppointmentCoverageService.SourceRef(source.customerId(),source.sourceKey(),source.destinationKey())).toList(), + request.originWarehouseId(),request.fbaWarehouseCode(),request.details())); } private FbaAppointmentResponse create(String operator,List refs,String expectedOrigin,String expectedDestination,FbaAppointmentDetails details) { - var resolved=resolveForWrite(refs,null); + var resolved=resolveForWrite(refs); var group=group(resolved); if(clean(expectedOrigin)==null || clean(expectedDestination)==null) throw bad("请选择接仓点和目的仓后重新核对货物"); if(!Objects.equals(group.originWarehouseId(),expectedOrigin) || !Objects.equals(group.destinationKey(),"FBA:"+expectedDestination)) throw conflict("接仓点或目的仓已变化,请重新核对预约货物"); @@ -80,11 +88,12 @@ public class InboundFbaAppointmentService { appointment.setFbaWarehouseCode(resolved.get(0).destinationWarehouseCode()); appointment.setSelectedCount(resolved.size());appointment.setVersion(0); appointment.setCreatedBy(operator);appointment.setCreatedAt(now());appointment.setUpdatedAt(appointment.getCreatedAt()); - apply(appointment,details,resolved); + var evidence=verification(resolved); + apply(appointment,details,resolved,evidence); if(Set.of("FAILED","CANCELLED").contains(appointment.getAppointmentStatus())) throw bad("新安排请先保存待确认或登记已取得的正式确认"); try {appointmentMapper.insert(appointment);} catch(DuplicateKeyException duplicate){throw conflict("该目的仓的外部约号已有记录,请继续原预约");} - writeItems(appointment,resolved,List.of()); + writeItems(appointment,resolved,List.of(),evidence,Map.of()); return response(appointment,items(appointment.getId())); } @@ -93,28 +102,132 @@ public class InboundFbaAppointmentService { var appointment=find(appointmentNo,false);if(appointment==null) throw missing(); return response(appointment,items(appointment.getId())); } + /** Internal read boundary. Caller authorizes source access; includes cancelled/replaced history so demand is not silently erased. */ + @Transactional(readOnly=true) + public List relatedBatchSnapshots(List refs) { + if(refs==null || refs.isEmpty()) return List.of(); + if(refs.size()>100 || refs.stream().anyMatch(ref->ref==null || clean(ref.customerId())==null || clean(ref.sourceKey())==null || clean(ref.destinationKey())==null)) throw bad("每次最多读取100个完整原来源的关联批次"); + var ids=queryMapper.relatedBatchIds(refs.stream().distinct().toList()); + if(ids.size()>1000) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"关联批次超过读取范围,请缩小原来源范围后继续核对"); + return ids.isEmpty()?List.of():responses(appointmentMapper.selectBatchIds(ids)); + } + @Transactional(readOnly=true) + public FbaAppointmentBatchContext batchContext(String key) { + final long id; + try {id=Long.parseLong(key.substring("FBA_APPOINTMENT:".length()));if(id<=0 || !key.equals(batchKey(id))) throw new IllegalArgumentException();} + catch(RuntimeException error) {throw bad("批次关联键无效");} + var appointment=appointmentMapper.selectById(id);if(appointment==null) throw missing(); + var rows=items(id);if(appointment.getReplacementNosJson()==null) rows=rows.stream().filter(i->Boolean.TRUE.equals(i.getActive())).toList();var snapshot=response(appointment,rows); + var originalRefs=originalSourceRefs(snapshot); + var reasons=new ArrayList();var actions=new ArrayList(); + if(appointment.getReplacementNosJson()!=null) reasons.add("原批次已被分批或合并,请进入替代批次"); + if(!"PENDING".equals(appointment.getAppointmentStatus())) reasons.add("仅未取得正式确认的活动批次可分批或合并"); + if(queryMapper.countActiveDispatches(id)>0) reasons.add("已关联排车,请先处理运输安排"); + try {receiving.assertRepartitionable(key);} catch(ResponseStatusException error) { + if(error.getStatusCode().value()!=409) throw error; + reasons.add(error.getReason()); + } + if(!reasons.isEmpty()) return new FbaAppointmentBatchContext(snapshot,List.of(),List.of(),List.copyOf(reasons),currentBatches(snapshot,originalRefs),originalRefs); + var refs=rows.stream().map(item->new AppointmentCoverageService.SourceRef(item.getCustomerId(),item.getSourceKey(),"FBA:"+appointment.getFbaWarehouseCode())).toList(); + var resolved=sources.resolveBatch(refs);var evidence=verification(resolved); + if(resolved.stream().anyMatch(s->!Objects.equals(s.originWarehouseId(),appointment.getOriginWarehouseId()) || !Objects.equals(s.destinationWarehouseCode(),appointment.getFbaWarehouseCode()))) reasons.add("原来源起讫点已变化,请先复核原批次"); + var originalItems=rows.stream().collect(Collectors.toMap(InboundFbaAppointmentItem::getSourceKey,item->item)); + var allocations=new ArrayList(); + for(var source:resolved) { + var item=originalItems.get(source.sourceKey());var verified=evidence.get(source.sourceKey()); + var other=queryMapper.allocation(source.customerId(),source.sourceKey(),source.orderId(),id); + Integer total=quantity(source,evidence),count=item.getPackageCount(); + if(count==null && total!=null && other.unknownCount()==0 && other.packages()0 || (long)count+other.packages()>total) reasons.add("原来源箱数或分配总量待核实"); + allocations.add(new FbaAppointmentBatchContext.SourceAllocation(source.customerId(),source.sourceKey(),source.destinationKey(),count, + source.packages(),source.sourceVersion(),verified==null?null:verified.version(),verified==null?List.of():verified.shipmentReferences())); + } + if(resolved.stream().allMatch(AppointmentSourceService.Source::canFollowup)) actions.add("VERIFY_CARGO"); + if(reasons.isEmpty()) { + if(allocations.stream().mapToLong(a->a.packageCount()).sum()>1) actions.add("SPLIT"); + actions.add("MERGE"); + } + return new FbaAppointmentBatchContext(snapshot,List.copyOf(allocations),List.copyOf(actions),reasons.stream().distinct().toList(),currentBatches(snapshot,originalRefs),originalRefs); + } + private List originalSourceRefs(FbaAppointmentResponse original) { + var refs=new LinkedHashSet(); + original.items().forEach(item->refs.add(new FbaAppointmentBatchContext.SourceRef(item.customerId(),item.sourceKey(),item.destinationKey()))); + receiving.originalSources(original.batchKey()).forEach(ref->refs.add(new FbaAppointmentBatchContext.SourceRef(ref.customerId(),ref.sourceKey(),ref.destinationKey()))); + return List.copyOf(refs); + } + /** Follow all replacement branches. Shared merge descendants are returned once, never just the first branch. */ + private List currentBatches(FbaAppointmentResponse original,List originalRefs) { + var sourceRefs=new HashSet<>(originalRefs); + var graph=new LinkedHashMap();graph.put(original.batchKey(),original); + var pending=new LinkedHashSet<>(original.replacementBatchKeys()); + pending.addAll(receiving.successorKeys(original.batchKey())); + while(!pending.isEmpty()) { + if(graph.size()+pending.size()>1000) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"批次去向超过读取范围,请缩小范围后核对"); + var keys=new ArrayList<>(pending);pending.clear(); + for(int start=0;start(batch.replacementBatchKeys());nextKeys.addAll(receiving.successorKeys(batch.batchKey())); + for(var next:nextKeys) if(!graph.containsKey(next)) pending.add(next); + } + } + var visiting=new HashSet();var visited=new HashSet(); + verifyReplacementGraph(original.batchKey(),graph,visiting,visited); + return graph.values().stream().filter(batch->batch.replacementAppointmentNos().isEmpty()) + .filter(batch->batch.items().stream().anyMatch(item->sourceRefs.contains(new FbaAppointmentBatchContext.SourceRef(item.customerId(),item.sourceKey(),item.destinationKey())))) + .toList(); + } + private static void verifyReplacementGraph(String key,Map graph,Set visiting,Set visited) { + if(visited.contains(key)) return; + if(!visiting.add(key)) throw conflict("批次去向存在循环,请核对原业务记录"); + for(var next:graph.get(key).replacementBatchKeys()) verifyReplacementGraph(next,graph,visiting,visited); + visiting.remove(key);visited.add(key); + } + /** Original persisted appointment facts only; reading work status must not run split/merge eligibility. */ + @Transactional(readOnly=true) + public List batchSnapshots(List keys) { + if(keys==null || keys.isEmpty()) return List.of(); + if(keys.size()>200) throw bad("每次最多读取200个预约批次"); + var ids=keys.stream().map(key->{ + try {long id=Long.parseLong(key.substring("FBA_APPOINTMENT:".length()));if(id>0 && key.equals(batchKey(id))) return id;} + catch(RuntimeException ignored) { } + throw bad("批次关联键无效"); + }).distinct().toList(); + var rows=appointmentMapper.selectBatchIds(ids); + if(rows.size()!=ids.size()) throw missing(); + return responses(rows); + } @Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public FbaAppointmentResponse update(String operatorId,String appointmentNo,UpdateFbaAppointmentRequest request) { if ("CANCELLED".equals(status(request.details().appointmentStatus()))) throw bad("取消预约请使用取消操作并填写实际取消原因"); return command("UPDATE:"+appointmentNo,operatorId,request.idempotencyKey(),request,()->{ var appointment=find(appointmentNo,true);if(appointment==null) throw missing(); + requireCurrent(appointment); + receiving.assertLegacyEditable(batchKey(appointment.getId())); if(!Objects.equals(appointment.getVersion(),request.version())) throw conflict("预约已更新,请重新读取后操作"); if(queryMapper.countActiveDispatches(appointment.getId())>0) throw conflict("已关联有效排车,请先核对并处理运输安排后再改约"); - var current=items(appointment.getId()); + var current=items(appointment.getId()).stream().filter(i->Boolean.TRUE.equals(i.getActive())).toList(); + var before=response(appointment,current); String status=status(request.details().appointmentStatus()); + if("SUCCESS".equals(appointment.getAppointmentStatus()) && !"SUCCESS".equals(status)) throw bad("已确认预约不能直接退回待确认或未约成,请使用取消操作并记录原因"); if("FAILED".equals(status)) { if(clean(request.details().appointmentRemark())==null) throw bad("未约成时请填写原因"); - // Keep the last planned and confirmed times as historical facts. + // Failure releases receiving coverage, not the cargo allocation owned by this batch. appointment.setAppointmentStatus(status);appointment.setAppointmentRemark(request.details().appointmentRemark().trim()); - current.forEach(item->{item.setActive(false);itemMapper.updateById(item);}); + clearConfirmation(appointment); } else { var refs=current.stream().map(item->new AppointmentCoverageService.SourceRef(item.getCustomerId(),item.getSourceKey(),"FBA:"+appointment.getFbaWarehouseCode())).toList(); - var resolved=resolveForWrite(refs,appointment.getId()); + var resolved=resolveForWrite(refs); if(!Objects.equals(group(resolved).originWarehouseId(),appointment.getOriginWarehouseId())) throw conflict("接仓点已变化,请重新核对安排"); if("SUCCESS".equals(appointment.getAppointmentStatus()) && !"SUCCESS".equals(status)) throw bad("已确认预约不能直接退回待确认,请记录取消原因后重新安排"); - apply(appointment,request.details(),resolved); - writeItems(appointment,resolved,current); + var evidence=verification(resolved); + apply(appointment,request.details(),resolved,evidence); + writeItems(appointment,resolved,current,evidence,Map.of()); } + if(before.equals(response(appointment,items(appointment.getId())))) return before; appointment.setVersion(request.version()+1);appointment.setUpdatedAt(now()); if(appointmentMapper.update(appointment,new LambdaQueryWrapper().eq(InboundFbaAppointment::getId,appointment.getId()).eq(InboundFbaAppointment::getVersion,request.version()))!=1) throw conflict("预约版本已变化"); return response(appointment,items(appointment.getId())); @@ -127,16 +240,293 @@ public class InboundFbaAppointmentService { var cancellation=new CancelFbaAppointmentRequest(version,request.cancelReason().trim()); command("CANCEL:"+appointmentNo,operatorId,"CANCEL:"+appointmentNo+":"+version,cancellation,()->{ var appointment=find(appointmentNo,true);if(appointment==null) throw missing(); + requireCurrent(appointment); + receiving.assertLegacyEditable(batchKey(appointment.getId())); if(!Objects.equals(appointment.getVersion(),version)) throw conflict("预约已更新,请重新读取后操作"); if(queryMapper.countActiveDispatches(appointment.getId())>0) throw conflict("预约货物已关联排车,不允许取消,请先处理关联排车"); appointment.setAppointmentStatus("CANCELLED"); - appointment.setVersion(version+1);appointment.setUpdatedAt(now());appointmentMapper.updateById(appointment); - var rows=items(appointment.getId());rows.forEach(item->{item.setActive(false);itemMapper.updateById(item);}); - return response(appointment,rows); + appointment.setVersion(version+1);appointment.setUpdatedAt(now()); + var rows=items(appointment.getId()); + // Persist the cancelled plan in the immutable command history; the active demand has no valid date/confirmation. + var cancelledSnapshot=response(appointment,rows); + clearConfirmation(appointment); + appointment.setPlannedDate(null);appointment.setPlannedAt(null);appointment.setPlannedEndAt(null); + appointmentMapper.updateById(appointment); + return cancelledSnapshot; }); } - private List resolveForWrite(List refs,Long excludedId) { + private static void clearConfirmation(InboundFbaAppointment appointment) { + appointment.setConfirmationNo(null);appointment.setAppointmentAt(null);appointment.setAppointmentEndAt(null); + appointment.setAppointmentStartUtc(null);appointment.setAppointmentEndUtc(null); + } + + @Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED) + public RepartitionFbaAppointmentsResponse repartition(String operator,RepartitionFbaAppointmentsRequest request) { + validateRepartition(request); + return executeCommand("REPARTITION",operator,request.idempotencyKey(),request,RepartitionFbaAppointmentsResponse.class, + result->result.appointments().get(0).appointmentNo(),()->{ + var originals=new ArrayList(); + var originalItems=new LinkedHashMap>(); + var refs=new LinkedHashMap(); + var intents=request.originals().stream().collect(Collectors.toMap(RepartitionFbaAppointmentsRequest.Original::appointmentNo,i->i)); + var ordered=request.originals().stream().map(i->{var row=find(i.appointmentNo(),false);if(row==null)throw missing();return row;}) + .sorted(Comparator.comparing(row->batchKey(row.getId()))).toList(); + for(var candidate:ordered) { + var intent=intents.get(candidate.getAppointmentNo()); + var row=find(intent.appointmentNo(),true);if(row==null) throw missing(); + requireCurrent(row); + if(!Objects.equals(row.getVersion(),intent.version())) throw conflict("原预约已更新,请重新核对分批资料"); + if(!"PENDING".equals(row.getAppointmentStatus()) || row.getConfirmationNo()!=null || row.getAppointmentAt()!=null) + throw conflict("仅未取得正式确认的批次可以分批或合并,请先处理已确认预约"); + if(queryMapper.countActiveDispatches(row.getId())>0) throw conflict("原批次已有关联排车,请先处理运输安排"); + if(!originals.isEmpty() && (!Objects.equals(originals.get(0).getOriginWarehouseId(),row.getOriginWarehouseId()) + || !Objects.equals(originals.get(0).getFbaWarehouseCode(),row.getFbaWarehouseCode()))) throw bad("仅同一起运仓、同一目的仓可以合并"); + receiving.assertRepartitionable(batchKey(row.getId())); + var items=items(row.getId()).stream().filter(i->Boolean.TRUE.equals(i.getActive())).toList(); + if(items.isEmpty() || items.stream().anyMatch(item->!Boolean.TRUE.equals(item.getActive()))) throw conflict("原批次货物已变化,请重新核对"); + for(var item:items) { + var ref=new AppointmentCoverageService.SourceRef(item.getCustomerId(),item.getSourceKey(),"FBA:"+row.getFbaWarehouseCode()); + var previous=refs.putIfAbsent(item.getSourceKey(),ref); + if(previous!=null && !previous.equals(ref)) throw conflict("同一原来源的客户或目的地不一致"); + } + originals.add(row);originalItems.put(row.getId(),items); + } + if(refs.size()>100) throw bad("一次最多重新分配100票原来源"); + var resolved=resolveForWrite(List.copyOf(refs.values())); + var bySource=resolved.stream().collect(Collectors.toMap(AppointmentSourceService.Source::sourceKey,s->s)); + var grouped=group(resolved); + if(!Objects.equals(grouped.originWarehouseId(),originals.get(0).getOriginWarehouseId()) + || !Objects.equals(grouped.destinationKey(),"FBA:"+originals.get(0).getFbaWarehouseCode())) throw conflict("原来源起讫点已变化,请先复核原批次"); + var evidence=verification(resolved); + var totals=new LinkedHashMap(); + for(var rows:originalItems.values()) for(var item:rows) { + var source=bySource.get(item.getSourceKey()); + if(!Objects.equals(item.getSourceVersion(),source.sourceVersion())) throw conflict("原来源版本已变化,请先复核原批次"); + Integer count=item.getPackageCount(); + if(count==null) { + var other=queryMapper.allocation(source.customerId(),source.sourceKey(),source.orderId(),item.getAppointmentId()); + var total=quantity(source,evidence); + if(total==null || other.unknownCount()>0 || other.packages()>=total) throw conflict("原批次箱数尚未核实,不能分批"); + count=Math.toIntExact(total-other.packages()); + } + if(count<=0) throw conflict("原批次箱数无效,请核实"); + totals.merge(source.sourceKey(),count.longValue(),Long::sum); + } + var allocated=new LinkedHashMap(); + for(var batch:request.batches()) for(var allocation:batch.allocations()) { + var source=bySource.get(allocation.sourceKey()); + if(source==null || !Objects.equals(source.customerId(),allocation.customerId())) throw bad("新批次只能使用原批次的真实来源及客户归属"); + var verified=evidence.get(source.sourceKey()); + if(!Objects.equals(source.sourceVersion(),allocation.expectedSourceVersion()) + || !Objects.equals(verified==null?null:verified.version(),allocation.expectedVerificationVersion())) throw conflict("所选来源或货物核实版本已变化,请刷新后重新分配"); + allocated.merge(source.sourceKey(),allocation.packageCount().longValue(),Long::sum); + } + if(!totals.equals(allocated)) throw bad("分批或合并前后每票原来源的分配箱数必须完全相等"); + // Release only these old batches while holding every original source lock. All inserts and replacements roll back together. + originalItems.values().forEach(rows->rows.forEach(item->{item.setActive(false);itemMapper.updateById(item);})); + var replacements=new ArrayList(); + for(var batch:request.batches()) { + var rows=batch.allocations().stream().map(a->bySource.get(a.sourceKey())).toList(); + var quantities=batch.allocations().stream().collect(Collectors.toMap(RepartitionFbaAppointmentsRequest.Allocation::sourceKey,RepartitionFbaAppointmentsRequest.Allocation::packageCount)); + var row=new InboundFbaAppointment();row.setId(IdWorker.getId()); + row.setAppointmentNo("AP-"+LocalDate.now(ZoneOffset.UTC).toString().replace("-","")+"-"+UUID.randomUUID().toString().replace("-","").substring(0,12).toUpperCase(Locale.ROOT)); + row.setOriginWarehouseId(grouped.originWarehouseId());row.setFbaWarehouseCode(rows.get(0).destinationWarehouseCode());row.setSelectedCount(rows.size());row.setVersion(0); + row.setCreatedBy(operator);row.setCreatedAt(now());row.setUpdatedAt(row.getCreatedAt()); + apply(row,new FbaAppointmentDetails(batch.plannedDate(),null,null,null,null,null,"PENDING",batch.remark(),null,null),rows,evidence); + appointmentMapper.insert(row);writeItems(row,rows,List.of(),evidence,quantities); + replacements.add(response(row,items(row.getId()))); + } + String replacementsJson,replacementKeysJson; + try {replacementsJson=json.writeValueAsString(replacements.stream().map(FbaAppointmentResponse::appointmentNo).toList()); + replacementKeysJson=json.writeValueAsString(replacements.stream().map(FbaAppointmentResponse::batchKey).toList());} + catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("分批关系无法保存",error);} + for(var row:originals) { + row.setReplacementNosJson(replacementsJson);row.setReplacementKeysJson(replacementKeysJson);row.setRepartitionReason(request.reason().trim());row.setVersion(row.getVersion()+1);row.setUpdatedAt(now()); + if(appointmentMapper.updateById(row)!=1) throw conflict("原批次替代关系未保存"); + } + var oldMembers=originalItems.entrySet().stream().flatMap(e->e.getValue().stream().map(i->new com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.Member(batchKey(e.getKey()),i.getCustomerId()))).distinct().toList(); + var newMembers=replacements.stream().flatMap(b->b.items().stream().map(i->new com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.Member(b.batchKey(),i.customerId()))).distinct().toList(); + receivingWork.inheritFollowup(oldMembers,newMembers,operator); + return new RepartitionFbaAppointmentsResponse(originals.stream().map(row->batchKey(row.getId())).toList(),originals.stream().map(InboundFbaAppointment::getAppointmentNo).toList(),List.copyOf(replacements)); + }); + } + private static void validateRepartition(RepartitionFbaAppointmentsRequest request) { + if(request==null || request.originals()==null || request.batches()==null || request.originals().isEmpty() || request.batches().isEmpty() + || request.originals().size()>20 || request.batches().size()>20 || clean(request.reason())==null || request.reason().length()>1000 + || clean(request.idempotencyKey())==null || request.idempotencyKey().length()>64) throw bad("请填写有效原批次、新批次、调整原因和请求标识"); + if(!(request.originals().size()==1 && request.batches().size()>1 || request.originals().size()>1 && request.batches().size()==1)) + throw bad("分批须为一批分为多批,合并须为多批合为一批"); + var originals=new HashSet(); + for(var row:request.originals()) if(row==null || clean(row.appointmentNo())==null || row.version()==null || row.version()<0 || !originals.add(row.appointmentNo())) throw bad("请选择不重复的原预约及有效版本"); + for(var batch:request.batches()) { + if(batch==null || batch.allocations()==null || batch.allocations().isEmpty() || batch.allocations().size()>100 || batch.remark()!=null && batch.remark().length()>1000) throw bad("每批请选择1至100票来源"); + var keys=new HashSet(); + for(var row:batch.allocations()) if(row==null || clean(row.customerId())==null || clean(row.sourceKey())==null + || row.packageCount()==null || row.packageCount()<=0 || row.expectedVerificationVersion()!=null && row.expectedVerificationVersion()<1 + || !keys.add(row.sourceKey())) throw bad("同批来源不可重复,每票箱数必须为正整数"); + } + } + + /** Receiving orchestration acquires transport locks first, then invokes this boundary. */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public FbaAppointmentResponse lockReceivingBatch(String key) { + long id=parseBatchKey(key); + var row=appointmentMapper.selectOne(new LambdaQueryWrapper().eq(InboundFbaAppointment::getId,id).last("FOR UPDATE")); + if(row==null) throw missing();requireCurrent(row); + return response(row,items(id)); + } + + /** Explicit adoption updates only the chosen customer's evidence, never their allocated amount. */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public FbaAppointmentResponse refreshReceivingCargo(String key,String customerId,List expected) { + var snapshot=lockReceivingBatch(key);var row=appointmentMapper.selectById(snapshot.id()); + var current=items(row.getId()).stream().filter(i->Boolean.TRUE.equals(i.getActive())).toList(); + var selected=current.stream().filter(i->Objects.equals(customerId,i.getCustomerId())).toList(); + if(selected.isEmpty() || expected==null || expected.size()!=selected.size() || expected.size()>100) throw bad("请完整核对本客户当前批次的每票来源资料"); + var versions=new HashMap(); + for(var value:expected) { + if(value==null || clean(value.sourceKey())==null || clean(value.destinationKey())==null || clean(value.expectedSourceVersion())==null || value.expectedVerificationVersion()!=null && value.expectedVerificationVersion()<1 || versions.putIfAbsent(value.sourceKey(),value)!=null) + throw bad("来源不能重复,必须明确当前来源及核实版本"); + } + var refs=selected.stream().map(i->new AppointmentCoverageService.SourceRef(i.getCustomerId(),i.getSourceKey(),"FBA:"+i.getFbaWarehouseCode())).toList(); + var resolved=resolveForWrite(refs);var evidence=verification(resolved); + var bySource=resolved.stream().collect(Collectors.toMap(AppointmentSourceService.Source::sourceKey,s->s)); + for(var item:selected) { + var requested=versions.get(item.getSourceKey());var source=bySource.get(item.getSourceKey()); + var verified=source==null?null:evidence.get(source.sourceKey()); + if(requested==null || source==null || !Objects.equals(customerId,source.customerId()) || !Objects.equals("FBA:"+item.getFbaWarehouseCode(),source.destinationKey()) + || !Objects.equals(source.destinationKey(),requested.destinationKey()) || !Objects.equals(row.getOriginWarehouseId(),source.originWarehouseId()) + || !Objects.equals(source.sourceVersion(),requested.expectedSourceVersion()) || !Objects.equals(verified==null?null:verified.version(),requested.expectedVerificationVersion())) + throw conflict("本票归属、目的地或最新资料版本已变化,请重新预览后确认"); + var total=quantity(source,evidence);var count=item.getPackageCount(); + var other=queryMapper.allocation(customerId,source.sourceKey(),source.orderId(),row.getId()); + if(count==null || count<=0 || total==null || other.unknownCount()>0 || (long)count+other.packages()>total) throw conflict("最新核实量不足以覆盖当前全部批次,请先处理数量差异,不能直接采用资料"); + item.setSourceVersion(source.sourceVersion());item.setCargoVerificationVersion(verified==null?null:verified.version()); + try {item.setShipmentReferencesJson(verified==null?null:json.writeValueAsString(verified.shipmentReferences()));} + catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("核实货件资料无法保存",error);} + } + if(snapshot.equals(response(row,current))) return snapshot; + selected.forEach(itemMapper::updateById); + row.setVersion(row.getVersion()+1);row.setUpdatedAt(now());appointmentMapper.updateById(row); + var result=response(row,current);workChanges.changed(result);return result; + } + + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public String validateReceiving(List members,FbaAppointmentDetails details) { + if(members==null || members.isEmpty() || members.size()>100 || details==null || !"SUCCESS".equals(details.appointmentStatus())) throw bad("请填写明确覆盖货物的正式收货确认"); + return validateReceivingDetails(members,details); + } + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public String validateReceivingPlan(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Member member,FbaAppointmentDetails details) { + if(details==null || !Set.of("PENDING","FAILED").contains(details.appointmentStatus())) throw bad("客户计划只允许待确认或未约成,正式确认请使用登记确认操作"); + if(details.confirmationNo()!=null || details.appointmentAt()!=null || details.appointmentEndAt()!=null) throw bad("客户计划不能填写外部约号或确认时段"); + if(details.plannedAt()==null && (clean(details.startOffset())!=null || clean(details.endOffset())!=null)) throw bad("未填写计划开始时刻,不能指定计划时差"); + if("FAILED".equals(details.appointmentStatus()) && clean(details.appointmentRemark())==null) throw bad("未约成时请填写原因"); + var timezone=validateReceivingDetails(List.of(member),details); + if(details.plannedDate()!=null || details.plannedAt()!=null) { + if(timezone==null || !ZoneId.getAvailableZoneIds().contains(timezone)) throw bad("填写目标日期前请补齐目的仓IANA时区"); + try {ZoneId.of(timezone);} catch(DateTimeException error) {throw bad("目的仓IANA时区无效");} + } + if(details.plannedAt()!=null) { + try {AppointmentTimePolicy.resolve(details.plannedAt(),details.plannedEndAt(),timezone,offset(details.startOffset()),offset(details.endOffset()));} + catch(IllegalArgumentException | DateTimeException error) {throw bad(error.getMessage());} + } + return timezone; + } + private String validateReceivingDetails(List members,FbaAppointmentDetails details) { + var refs=members.stream().flatMap(m->m.sources().stream().map(s->new AppointmentCoverageService.SourceRef(m.customerId(),s.sourceKey(),s.destinationKey()))).distinct().toList(); + var resolved=resolveForWrite(refs);var evidence=verification(resolved);var bySource=resolved.stream().collect(Collectors.toMap(AppointmentSourceService.Source::sourceKey,s->s)); + for(var m:members) for(var s:m.sources()) { + var current=bySource.get(s.sourceKey());var verified=evidence.get(s.sourceKey()); + if(current==null || !Objects.equals(current.customerId(),m.customerId()) || !Objects.equals(current.destinationKey(),s.destinationKey()) + || !Objects.equals(current.sourceVersion(),s.sourceVersion()) || !Objects.equals(verified==null?null:verified.version(),s.verificationVersion())) throw conflict("原来源或核实版本已变化,请先核对货物"); + } + var grouped=group(resolved);var row=new InboundFbaAppointment();row.setOriginWarehouseId(grouped.originWarehouseId());row.setFbaWarehouseCode(resolved.get(0).destinationWarehouseCode()); + apply(row,details,resolved,evidence);return row.getTimezone(); + } + + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public List moveReceivingCustomer(String operator,com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.ChangeIntent intent) { + var original=find(lockReceivingBatch(intent.batchKey()).appointmentNo(),false); + if(!Objects.equals(original.getVersion(),intent.batchVersion())) throw conflict("原批次版本已变化,请重新核对调整范围"); + var all=items(original.getId()).stream().filter(i->Boolean.TRUE.equals(i.getActive())).toList(); + receiving.freezeUnconfirmedPlans(response(original,all)); + var selected=all.stream().filter(i->intent.customerId().equals(i.getCustomerId())).toList(); + if(selected.isEmpty() || selected.size()!=intent.quantities().size()) throw bad("请完整选择本客户的各票货物"); + var amounts=new HashMap(); + for(var q:intent.quantities()) if(amounts.put(q.sourceKey(),q)!=null) throw bad("逐票来源不能重复"); + var refs=selected.stream().map(i->new AppointmentCoverageService.SourceRef(i.getCustomerId(),i.getSourceKey(),"FBA:"+i.getFbaWarehouseCode())).toList(); + var resolved=resolveForWrite(refs);var evidence=verification(resolved);var bySource=resolved.stream().collect(Collectors.toMap(AppointmentSourceService.Source::sourceKey,s->s)); + var laterAmounts=new LinkedHashMap(); + for(var item:selected) { + var q=amounts.get(item.getSourceKey());var src=bySource.get(item.getSourceKey());var v=evidence.get(item.getSourceKey()); + if(q==null || src==null || !Objects.equals(src.destinationKey(),q.destinationKey()) || !Objects.equals(src.sourceVersion(),q.expectedSourceVersion()) + || !Objects.equals(v==null?null:v.version(),q.expectedVerificationVersion()) || !Objects.equals(src.sourceVersion(),item.getSourceVersion())) throw conflict("原来源或核实资料版本已变化"); + Integer total=quantity(src,evidence),count=item.getPackageCount(); + var other=queryMapper.allocation(src.customerId(),src.sourceKey(),src.orderId(),original.getId()); + if(count==null || total==null || other.unknownCount()>0 || (long)count+other.packages()>total || q.retainedPackages()<0 || q.retainedPackages()>count) throw conflict("本次保留量及原来源分配总量不一致"); + int later=count-q.retainedPackages();if(later>0) laterAmounts.put(item.getSourceKey(),later); + item.setPackageCount(q.retainedPackages());item.setActive(q.retainedPackages()>0);itemMapper.updateById(item); + } + if(laterAmounts.isEmpty()) throw bad("本次货量没有变化,无需创建变化记录"); + var laterRows=resolved.stream().filter(s->laterAmounts.containsKey(s.sourceKey())).toList(); + var later=new InboundFbaAppointment();later.setId(IdWorker.getId());later.setAppointmentNo("AP-"+LocalDate.now(ZoneOffset.UTC).toString().replace("-","")+"-"+UUID.randomUUID().toString().replace("-","").substring(0,12).toUpperCase(Locale.ROOT)); + later.setOriginWarehouseId(original.getOriginWarehouseId());later.setFbaWarehouseCode(original.getFbaWarehouseCode());later.setVersion(0);later.setSelectedCount(laterRows.size());later.setCreatedBy(operator);later.setCreatedAt(now());later.setUpdatedAt(now()); + apply(later,new FbaAppointmentDetails(intent.laterDate(),null,null,null,null,null,"PENDING",intent.reason(),null,null),laterRows,evidence); + appointmentMapper.insert(later);writeItems(later,laterRows,List.of(),evidence,laterAmounts); + var retained=all.stream().filter(i->Boolean.TRUE.equals(i.getActive())).toList(); + if(retained.stream().noneMatch(i->intent.customerId().equals(i.getCustomerId()))) receiving.deletePlan(intent.batchKey(),intent.customerId()); + original.setSelectedCount(retained.size());original.setVersion(original.getVersion()+1);original.setUpdatedAt(now()); + if(retained.isEmpty()) {original.setReplacementNosJson(receiving.write(List.of(later.getAppointmentNo())));original.setReplacementKeysJson(receiving.write(List.of(batchKey(later.getId()))));original.setRepartitionReason(intent.reason());} + appointmentMapper.updateById(original); + return List.of(response(original,retained),response(later,items(later.getId()))); + } + + /** Header is only an aggregate projection; per-customer receiving identity remains authoritative. */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public FbaAppointmentResponse projectReceiving(String key,boolean cancelled) { + var row=appointmentMapper.selectById(parseBatchKey(key));var rows=items(row.getId()).stream().filter(i->Boolean.TRUE.equals(i.getActive())).toList(); + var groups=rows.stream().map(InboundFbaAppointmentItem::getCustomerId).distinct().map(c->receiving.currentAgreement(key,c)).toList(); + var active=groups.stream().filter(Objects::nonNull).toList(); + clearConfirmation(row); + if(!groups.isEmpty() && groups.size()==active.size() && active.stream().map(a->a.agreementKey()).distinct().count()==1) { + var a=active.get(0);var d=a.details();row.setAppointmentStatus("SUCCESS");row.setConfirmationNo(d.confirmationNo());row.setAppointmentAt(d.appointmentAt());row.setAppointmentEndAt(d.appointmentEndAt());row.setTimezone(a.timezone()); + var window=AppointmentTimePolicy.resolve(d.appointmentAt(),d.appointmentEndAt(),a.timezone(),offset(d.startOffset()),offset(d.endOffset())); + row.setAppointmentStartUtc(LocalDateTime.ofInstant(window.start(),ZoneOffset.UTC));row.setAppointmentEndUtc(window.end()==null?null:LocalDateTime.ofInstant(window.end(),ZoneOffset.UTC)); + row.setPlannedDate(d.plannedDate());row.setPlannedAt(d.plannedAt());row.setPlannedEndAt(d.plannedEndAt()); + } else { + var plans=receiving.plans(key).stream().collect(Collectors.toMap(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.CustomerPlan::customerId,p->p)); + var customerIds=rows.stream().map(InboundFbaAppointmentItem::getCustomerId).distinct().toList(); + var details=new ArrayList();var zones=new ArrayList(); + for(int i=0;i"FAILED".equals(d.appointmentStatus())); + row.setAppointmentStatus(failed?"FAILED":cancelled && active.isEmpty() && plans.isEmpty()?"CANCELLED":"PENDING"); + row.setPlannedDate(null);row.setPlannedAt(null);row.setPlannedEndAt(null);row.setAppointmentRemark(null); + if(complete) { + var first=details.get(0);var zone=zones.get(0); + if(details.stream().allMatch(d->Objects.equals(d.plannedDate(),first.plannedDate()) && Objects.equals(d.plannedAt(),first.plannedAt()) && Objects.equals(d.plannedEndAt(),first.plannedEndAt())) && zones.stream().allMatch(z->Objects.equals(z,zone))) { + row.setPlannedDate(first.plannedDate());row.setPlannedAt(first.plannedAt());row.setPlannedEndAt(first.plannedEndAt());row.setTimezone(zone); + } + if(details.stream().allMatch(first::equals)) row.setAppointmentRemark(first.appointmentRemark()); + } + } + row.setVersion(row.getVersion()+1);row.setUpdatedAt(now());appointmentMapper.updateById(row); + var snapshot=response(row,rows);workChanges.changed(snapshot);return snapshot; + } + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void receivingChanged(List snapshots) {snapshots.forEach(workChanges::changed);} + private static long parseBatchKey(String key) { + try {long id=Long.parseLong(key.substring("FBA_APPOINTMENT:".length()));if(id>0 && key.equals(batchKey(id)))return id;} catch(RuntimeException ignored) { } + throw bad("批次关联键无效"); + } + + private List resolveForWrite(List refs) { sources.lockForWrite(refs); var resolved=sources.resolveBatch(refs); var ids=resolved.stream().map(AppointmentSourceService.Source::orderId).filter(Objects::nonNull).distinct().sorted().toList(); @@ -150,14 +540,26 @@ public class InboundFbaAppointmentService { if(states.size()!=ids.size() || states.stream().anyMatch(s->s.detained() || s.mixedDestinations())) throw conflict("货物暂扣或含多个目的地,请先核对"); } if(resolved.stream().anyMatch(s->!s.canFollowup())) throw conflict("所选来源不再具备安排资格"); - if(queryMapper.countActiveSources(resolved.stream().map(AppointmentSourceService.Source::sourceKey).toList(),ids,excludedId)>0) throw conflict("所选货物已有有效预约安排,请继续原单"); return resolved; } private AppointmentPlanningPolicy.Group group(List rows) { try { return AppointmentPlanningPolicy.group(rows.stream().map(s->new AppointmentPlanningPolicy.Cargo(s.sourceKey(),s.customerId(),s.originWarehouseId(),s.destinationKey(),"US","US",s.packages(),null,null,s.canFollowup(),String.join(";",s.blockReasons()))).toList()); } catch(IllegalArgumentException error) {throw bad(error.getMessage());} } - private void apply(InboundFbaAppointment appointment,FbaAppointmentDetails details,List rows) { + private Map verification(List rows) { + var result=new HashMap(); + for(var source:rows) { + var current=verification.current(source); + if(current!=null) result.put(source.sourceKey(),current); + } + return result; + } + private static Integer quantity(AppointmentSourceService.Source source,Map evidence) { + var verified=evidence.get(source.sourceKey()); + return verified==null?source.packages():verified.packageCount(); + } + private void apply(InboundFbaAppointment appointment,FbaAppointmentDetails details,List rows, + Map evidence) { String status=status(details.appointmentStatus()); String timezone=sources.destinationTimezone(appointment.getFbaWarehouseCode()); if(details.plannedAt()==null && details.plannedEndAt()!=null || details.plannedAt()!=null && details.plannedEndAt()!=null && !details.plannedEndAt().isAfter(details.plannedAt())) throw bad("计划结束时间必须晚于开始时间"); @@ -168,7 +570,11 @@ public class InboundFbaAppointmentService { if("SUCCESS".equals(status)) { if(clean(details.confirmationNo())==null || details.appointmentAt()==null) throw bad("登记对方确认时请填写外部约号和确认时间"); var ids=rows.stream().map(AppointmentSourceService.Source::orderId).filter(Objects::nonNull).distinct().toList(); - if(ids.size()!=rows.size() || rows.stream().anyMatch(s->s.packages()==null || s.packages()<=0) || queryMapper.countMissingShipmentReferences(ids)>0) throw bad("正式确认前请核实货量并补齐真实FBA货件号或追踪号"); + var unverifiedIds=rows.stream().filter(s->!evidence.containsKey(s.sourceKey())).map(AppointmentSourceService.Source::orderId).filter(Objects::nonNull).distinct().toList(); + if(ids.size()!=rows.size() || rows.stream().anyMatch(s->quantity(s,evidence)==null || quantity(s,evidence)<=0) + || evidence.values().stream().anyMatch(v->v.shipmentReferences().isEmpty()) + || !unverifiedIds.isEmpty() && queryMapper.countMissingShipmentReferences(unverifiedIds)>0) + throw bad("正式确认前请核实货量并补齐各票FBA货件号或追踪号"); final AppointmentTimePolicy.Window window; try {window=AppointmentTimePolicy.resolve(details.appointmentAt(),details.appointmentEndAt(),timezone,offset(details.startOffset()),offset(details.endOffset()));} catch(IllegalArgumentException | DateTimeException error) {throw bad(error.getMessage());} @@ -179,32 +585,55 @@ public class InboundFbaAppointmentService { appointment.setConfirmationNo(null);appointment.setAppointmentAt(null);appointment.setAppointmentEndAt(null);appointment.setAppointmentStartUtc(null);appointment.setAppointmentEndUtc(null); } } - private void writeItems(InboundFbaAppointment appointment,List rows,List previous) { + private void writeItems(InboundFbaAppointment appointment,List rows,List previous, + Map evidence,Map requested) { var existing=previous.stream().collect(Collectors.toMap(InboundFbaAppointmentItem::getSourceKey,item->item)); for(var source:rows) { var item=existing.get(source.sourceKey());boolean fresh=item==null; + var allocated=queryMapper.allocation(source.customerId(),source.sourceKey(),source.orderId(),appointment.getId()); + Integer total=quantity(source,evidence); + if(allocated.unknownCount()>0) throw conflict("原来源已有箱数待核实的安排,请先核对原预约"); + Integer count=requested.get(source.sourceKey()); + if(count==null && !fresh) count=item.getPackageCount(); + if(count==null && total!=null) { + long remaining=total.longValue()-allocated.packages(); + if(remaining<=0) throw conflict("原来源箱数已分配完,请继续已有预约"); + count=Math.toIntExact(remaining); + } + if(total==null && (count!=null || allocated.count()>0)) throw conflict("原来源总箱数待核实,不能继续分配"); + if(count!=null && (count<=0 || (long)count+allocated.packages()>total)) throw conflict("本批及其他活动预约的分配总量超过原来源可分配箱数"); if(fresh){item=new InboundFbaAppointmentItem();item.setId(IdWorker.getId());item.setAppointmentId(appointment.getId());item.setCreatedAt(now());} item.setSourceKey(source.sourceKey());item.setSourceVersion(source.sourceVersion());item.setCustomerId(source.customerId());item.setOrderId(source.orderId());item.setInboundCode(source.inboundCode()); - item.setFbaWarehouseCode(source.destinationWarehouseCode());item.setPackageCount(source.packages());item.setActive(true); + item.setFbaWarehouseCode(source.destinationWarehouseCode());item.setPackageCount(count);item.setActive(true); + var verified=evidence.get(source.sourceKey()); + item.setCargoVerificationVersion(verified==null?null:verified.version()); + try {item.setShipmentReferencesJson(verified==null?null:json.writeValueAsString(verified.shipmentReferences()));} + catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("核实货件资料无法保存",error);} try {if(fresh)itemMapper.insert(item);else itemMapper.updateById(item);} catch(DuplicateKeyException duplicate){throw conflict("所选货物刚被其他安排占用,请刷新后核对");} } } private FbaAppointmentResponse command(String operation,String operator,String key,Object request,Supplier action) { + return executeCommand(operation,operator,key,request,FbaAppointmentResponse.class,FbaAppointmentResponse::appointmentNo,action); + } + private T executeCommand(String operation,String operator,String key,Object request,Class type, + java.util.function.Function appointmentNumber,Supplier action) { if(clean(operator)==null || clean(key)==null || key.length()>128) throw bad("缺少操作人或有效请求标识"); final String hash; try {hash=HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest((operation+"\n"+operator+"\n"+json.writeValueAsString(request)).getBytes(java.nio.charset.StandardCharsets.UTF_8)));} catch(Exception error){throw new IllegalStateException("无法记录预约命令",error);} var existing=commands.find(key); - if(existing!=null)return replay(existing,hash); + if(existing!=null)return replay(existing,hash,type); try {commands.begin(key,hash,operation,operator,now());} - catch(DuplicateKeyException duplicate){return replay(commands.find(key),hash);} + catch(DuplicateKeyException duplicate){return replay(commands.find(key),hash,type);} var response=action.get(); - try {if(commands.finish(key,response.appointmentNo(),commandSnapshot(response,request))!=1)throw conflict("预约命令结果未能保存");} + receiving.synchronize(response); + workChanges.changed(response); + try {if(commands.finish(key,appointmentNumber.apply(response),commandSnapshot(response,request))!=1)throw conflict("预约命令结果未能保存");} catch(com.fasterxml.jackson.core.JsonProcessingException error){throw new IllegalStateException("无法保存预约命令结果",error);} return response; } - private String commandSnapshot(FbaAppointmentResponse response,Object request) throws com.fasterxml.jackson.core.JsonProcessingException { + private String commandSnapshot(Object response,Object request) throws com.fasterxml.jackson.core.JsonProcessingException { var node=json.valueToTree(response); if(request instanceof CancelFbaAppointmentRequest cancellation) ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("cancelReason",cancellation.cancelReason()); return json.writeValueAsString(node); @@ -238,9 +667,9 @@ public class InboundFbaAppointmentService { }).toList(); return new PageResult<>(records,page,size,total,(total+size-1)/size); } - private FbaAppointmentResponse replay(FbaAppointmentCommandMapper.Command value,String hash) { + private T replay(FbaAppointmentCommandMapper.Command value,String hash,Class type) { if(value==null || !hash.equals(value.requestHash()) || value.responseJson()==null)throw conflict("重复请求内容不一致或结果尚未就绪,请重新核对"); - try {return readSnapshot(value.responseJson());} + try {var node=(com.fasterxml.jackson.databind.node.ObjectNode)json.readTree(value.responseJson());node.remove("cancelReason");return json.treeToValue(node,type);} catch(com.fasterxml.jackson.core.JsonProcessingException error){throw new IllegalStateException("预约命令记录不可读",error);} } private InboundFbaAppointment find(String number,boolean lock) { @@ -254,10 +683,23 @@ public class InboundFbaAppointmentService { return rows.stream().map(row->response(row,grouped.getOrDefault(row.getId(),List.of()))).toList(); } private FbaAppointmentResponse response(InboundFbaAppointment row,List items) { + if(row.getReplacementNosJson()==null) items=items.stream().filter(i->Boolean.TRUE.equals(i.getActive())).toList(); return new FbaAppointmentResponse(row.getId(),row.getAppointmentNo(),row.getConfirmationNo(),row.getPlannedDate(),row.getPlannedAt(),row.getPlannedEndAt(),row.getAppointmentAt(),row.getAppointmentEndAt(), utc(row.getAppointmentStartUtc()),utc(row.getAppointmentEndUtc()),row.getTimezone(),row.getAppointmentStatus(),row.getAppointmentRemark(),row.getOriginWarehouseId(),row.getFbaWarehouseCode(),row.getSelectedCount(),row.getCreatedBy(),row.getCreatedAt(),row.getUpdatedAt(), - items.stream().map(item->new FbaAppointmentResponse.Item(item.getId(),item.getSourceKey(),item.getSourceVersion(),item.getOrderId(),item.getInboundCode(),item.getFbaWarehouseCode(),item.getCustomerId(),item.getPackageCount())).toList(),row.getVersion()); + items.stream().map(item->new FbaAppointmentResponse.Item(item.getId(),item.getSourceKey(),item.getSourceVersion(),item.getOrderId(),item.getInboundCode(),item.getFbaWarehouseCode(),item.getCustomerId(),item.getPackageCount(),item.getCargoVerificationVersion(),shipmentReferences(item),"FBA:"+item.getFbaWarehouseCode())).toList(),row.getVersion(),stringList(row.getReplacementNosJson()),row.getRepartitionReason(),batchKey(row.getId()),stringList(row.getReplacementKeysJson())); } + private List shipmentReferences(InboundFbaAppointmentItem item) { + return stringList(item.getShipmentReferencesJson()); + } + private List stringList(String value) { + if(value==null) return List.of(); + try {return json.readValue(value,new com.fasterxml.jackson.core.type.TypeReference>(){});} + catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("预约货件快照不可读",error);} + } + private static void requireCurrent(InboundFbaAppointment appointment) { + if(appointment.getReplacementNosJson()!=null) throw conflict("该批次已被分批或合并,请进入替代批次继续操作"); + } + public static String batchKey(Long id) {return "FBA_APPOINTMENT:"+id;} private static String clean(String value){return value==null || value.isBlank()?null:value.trim();} private static String status(String value){try{return FbaAppointmentStatus.valueOf(value.trim().toUpperCase(Locale.ROOT)).name();}catch(RuntimeException error){throw bad("请选择有效预约状态");}} private static ZoneOffset offset(String value){return clean(value)==null?null:ZoneOffset.of(value);} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkAppointmentChanges.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkAppointmentChanges.java new file mode 100644 index 000000000..5f3a6cad0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkAppointmentChanges.java @@ -0,0 +1,69 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentwork.*; +import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; +import com.baiyue.lastleg.inboundplan.dto.RepartitionFbaAppointmentsResponse; +import org.springframework.stereotype.Component; +import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore; +import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; + +@Component +public class AppointmentWorkAppointmentChanges { + private final AppointmentWorkCommitmentService commitments; + private final AppointmentWorkChanges changes; + private final FbaReceivingStore receiving; + public AppointmentWorkAppointmentChanges(AppointmentWorkCommitmentService commitments,AppointmentWorkChanges changes,FbaReceivingStore receiving) { this.commitments=commitments; this.changes=changes;this.receiving=receiving; } + public void changed(Object response) { + changes.requirementsChanged(); + if(response instanceof FbaAppointmentResponse appointment) record(appointment); + else if(response instanceof RepartitionFbaAppointmentsResponse partition) partition.appointments().forEach(this::record); + else throw new IllegalArgumentException("预约命令缺少原业务响应"); + } + private void record(FbaAppointmentResponse a) { + var snapshot=receiving.snapshots(List.of(a.batchKey())).get(0); + for(var customer:a.items().stream().map(i->i.customerId()).distinct().sorted().toList()) { + var view=receiving(a,customer,snapshot); + var cargo=a.items().stream().filter(i->customer.equals(i.customerId())).map(i->List.of(i.customerId(),i.sourceKey(),Objects.toString(i.destinationKey(),""),Objects.toString(i.packageCount(),""),Objects.toString(i.shipmentReferences(),""))).sorted(Comparator.comparing(Object::toString)).toList(); + commitments.record(AppointmentWorkPolicy.customerRequirement(a.batchKey(),customer),new AppointmentWorkCommitmentService.Commitment(view.target(),view.confirmed(),null,AppointmentWorkPolicy.digest(cargo.toString()),view.cancelled())); + } + } + record Receiving(Agreement agreement,Pending pending,Slot target,Slot confirmed,String timezone,boolean cancelled,String status) { } + static Receiving receiving(FbaAppointmentResponse batch,String customer,BatchReceiving snapshot) { + if(snapshot==null || !batch.batchKey().equals(snapshot.batchKey())) throw AppointmentWorkPolicy.unavailable("收货预约批量读取不完整"); + var groups=snapshot.agreements().stream().filter(a->"ACTIVE".equals(a.status()) && a.members().stream().anyMatch(m->batch.batchKey().equals(m.batchKey()) && customer.equals(m.customerId()))).toList(); + if(groups.size()>1) throw AppointmentWorkPolicy.unavailable("同一客户批次存在多个有效收货预约"); + var waiting=snapshot.pendingChanges().stream().filter(p->"WAITING".equals(p.status()) && batch.batchKey().equals(p.intent().batchKey()) && customer.equals(p.intent().customerId())).toList(); + if(waiting.size()>1) throw AppointmentWorkPolicy.unavailable("同一客户批次存在多个待确认变化"); + var agreement=groups.isEmpty()?null:groups.get(0); + var plans=snapshot.plans().stream().filter(p->batch.batchKey().equals(p.batchKey()) && customer.equals(p.customerId())).toList(); + if(plans.size()>1) throw AppointmentWorkPolicy.unavailable("同一客户批次存在多个独立计划"); + boolean independentlyCovered=snapshot.agreements().stream().anyMatch(a->"ACTIVE".equals(a.status()) && a.members().stream().anyMatch(m->batch.batchKey().equals(m.batchKey()))); + String status=null,timezone=null;Slot target=null,confirmed=null; + if(agreement!=null) { + var details=agreement.details();timezone=agreement.timezone(); + if(details==null || !"SUCCESS".equals(details.appointmentStatus()) || details.appointmentAt()==null) throw AppointmentWorkPolicy.unavailable("有效收货预约缺少正式确认时间"); + target=target(details,timezone);status=details.appointmentStatus(); + confirmed=slot(details.appointmentAt(),details.appointmentEndAt(),timezone); + } else if(!plans.isEmpty()) { + var plan=plans.get(0);var details=plan.details();timezone=plan.timezone(); + if(details==null || !Set.of("PENDING","FAILED").contains(Objects.toString(details.appointmentStatus(),"")) || details.confirmationNo()!=null || details.appointmentAt()!=null || details.appointmentEndAt()!=null) + throw AppointmentWorkPolicy.unavailable("客户独立计划包含无效状态或正式确认字段"); + target=target(details,timezone);status=details.appointmentStatus(); + } else if(!independentlyCovered) { + // Only a batch still without independent receiving coverage can supply its original shared plan. + status=batch.appointmentStatus();timezone=batch.timezone();target="CANCELLED".equals(status)?null:target(batch); + } + return new Receiving(agreement,waiting.isEmpty()?null:waiting.get(0),target,confirmed,timezone,"CANCELLED".equals(status),status); + } + private static Slot target(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails details,String timezone) { + return details.plannedAt()!=null?slot(details.plannedAt(),details.plannedEndAt(),timezone):details.plannedDate()==null?null:new Slot(details.plannedDate(),null,null,null,timezone); + } + public static Slot target(FbaAppointmentResponse a) { + return a.plannedAt()!=null?slot(a.plannedAt(),a.plannedEndAt(),a.timezone()):a.plannedDate()==null?null:new Slot(a.plannedDate(),null,null,null,a.timezone()); + } + public static Slot confirmed(FbaAppointmentResponse a) { return "SUCCESS".equals(a.appointmentStatus())?slot(a.appointmentAt(),a.appointmentEndAt(),a.timezone()):null; } + public static Slot slot(LocalDateTime start,LocalDateTime end,String timezone) { return start==null?null:new Slot(start.toLocalDate(),start.toLocalTime(),end==null?null:end.toLocalDate(),end==null?null:end.toLocalTime(),timezone); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryMapper.java new file mode 100644 index 000000000..8b4a7e76b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryMapper.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.inboundplan.work; + +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface AppointmentWorkDeliveryMapper { + record CustomerDelivery(long orderId,String customerId,long pickedPackages,String deliveryNo,String status,Integer packages,Integer signedPackages,LocalDateTime signedAt,LocalDateTime plannedDeliveryAt) { } + String CUSTOMER_SELECT="SELECT o.id order_id,o.customer_id,SUM(a.packages) picked_packages,x.delivery_no,x.status,x.package_count packages,x.signed_packages,x.signed_at,x.planned_delivery_at FROM ll_order o JOIN ll_dispatch_pda_pick_order a ON a.order_id=o.id AND a.customer_id=o.customer_id JOIN ll_dispatch_pda_pick p ON p.id=a.pick_id AND p.customer_id=a.customer_id JOIN ll_dispatch_item i ON i.id=p.dispatch_item_id AND i.customer_id=p.customer_id JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id AND d.status<>'CANCELLED' JOIN ll_delivery_order x ON x.dispatch_order_id=i.dispatch_order_id AND x.customer_id=i.customer_id AND x.destination_type='PRIVATE' AND x.delivery_address=i.delivery_address AND (x.postal_code=i.postal_code OR (x.postal_code IS NULL AND i.postal_code IS NULL)) "; + String CUSTOMER_GROUP=" GROUP BY o.id,o.customer_id,x.id,x.delivery_no,x.status,x.package_count,x.signed_packages,x.signed_at,x.planned_delivery_at ORDER BY o.id,x.id LIMIT 1001"; + @Select(CUSTOMER_SELECT+" WHERE o.source_container_booking_id=#{container} AND o.source_inbound_id=#{inbound}"+CUSTOMER_GROUP) + List customer(@Param("container") String container,@Param("inbound") String inbound); + @Select(CUSTOMER_SELECT+" WHERE CONCAT('DISPATCH:',i.dispatch_order_id)=#{batch}"+CUSTOMER_GROUP) + List privateBatch(String batch); + record Delivery(String customerId,String sourceKey,String destinationKey,Integer allocatedPackages,String dispatchStatus,String deliveryNo,String status,Integer packages,Integer signedPackages,LocalDateTime signedAt) { } + @Select("SELECT b.customer_id,b.source_key,b.destination_key,b.package_count allocated_packages,d.status dispatch_status,x.delivery_no,x.status,x.package_count packages,x.signed_packages,x.signed_at FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id AND d.status<>'CANCELLED' LEFT JOIN ll_delivery_order x ON x.dispatch_order_id=b.dispatch_id AND x.customer_id=b.customer_id AND CONCAT('FBA:',x.destination_warehouse_code)=b.destination_key WHERE b.batch_key=#{batch} ORDER BY b.dispatch_item_id,b.source_key,x.id LIMIT 1001") + List batch(String batch); + record BatchDelivery(String batchKey,String customerId,String sourceKey,String destinationKey,Integer allocatedPackages,String dispatchStatus,String deliveryNo,String status,Integer packages,Integer signedPackages,LocalDateTime signedAt) { + public Delivery delivery() { return new Delivery(customerId,sourceKey,destinationKey,allocatedPackages,dispatchStatus,deliveryNo,status,packages,signedPackages,signedAt); } + } + @Select(""" + + """) + List batches(@Param("batches") List batches); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProvider.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProvider.java new file mode 100644 index 000000000..bf80098b6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProvider.java @@ -0,0 +1,158 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentplanning.*; +import com.baiyue.lastleg.appointmentwork.*; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; +import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore; +import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.BatchReceiving; +import com.baiyue.lastleg.security.OverseasPermissions; +import org.springframework.stereotype.Component; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; +import static com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges.*; + +@Component +public class AppointmentWorkDeliveryProvider implements AppointmentWorkFactGateway.BusinessProvider { + private final InboundFbaAppointmentService appointments; + private final AppointmentCargoVerificationService cargo; + private final AppointmentWorkCommitmentService commitments; + private final AppointmentWorkDeliveryMapper deliveries; + private final AppointmentWorkSourceMapper sourceMapping; + private final FbaReceivingStore receiving; + public AppointmentWorkDeliveryProvider(InboundFbaAppointmentService appointments,AppointmentCargoVerificationService cargo,AppointmentWorkCommitmentService commitments,AppointmentWorkDeliveryMapper deliveries,AppointmentWorkSourceMapper sourceMapping,FbaReceivingStore receiving) { + this.appointments=appointments; this.cargo=cargo; this.commitments=commitments; this.deliveries=deliveries; this.sourceMapping=sourceMapping;this.receiving=receiving; + } + public boolean supports(Type type,Flow flow) { return type==Type.DELIVERY && flow==Flow.TRANSFER; } + public List read(List requests) { + var mapped=new HashMap>(); + var privateSources=new HashMap>(); + var containers=requests.stream().filter(r->r.batchKey()==null && r.requirementKey()!=null && r.requirementKey().startsWith("INBOUND:")).map(FactRequest::containerBookingId).distinct().toList(); + if(!containers.isEmpty()) sourceMapping.containerSourcesBatch(containers).forEach(s->mapped.computeIfAbsent(s.containerBookingId(),ignored->new ArrayList<>()).add(s.source())); + for(var request:requests) if(request.batchKey()==null && request.requirementKey()!=null && request.requirementKey().startsWith("INBOUND:")) { + var sources=mapped.getOrDefault(request.containerBookingId(),List.of()); + if(sources.size()>1000) throw unavailable("柜次尾程来源超过完整读取上限"); + var related=sources.stream().filter(s->request.requirementKey().equals("INBOUND:"+s.sourceInboundId())).toList(); + if(!related.isEmpty() && related.stream().allMatch(s->"PRIVATE".equals(s.destinationType()))) privateSources.put(request.workItemKey(),related); + } + var refs=requests.stream().filter(r->r.batchKey()==null && !privateSources.containsKey(r.workItemKey())).flatMap(r->r.sources().stream()) + .map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).distinct().toList(); + var inspections=new HashMap(); + if(!refs.isEmpty()) cargo.inspectBatch(refs).forEach(i->inspections.put(i.reference(),i)); + var batches=requests.stream().map(FactRequest::batchKey).filter(k->k!=null && !k.startsWith("DISPATCH:")).distinct().toList(); + var snapshots=new HashMap(); + var deliveryRows=new HashMap>(); + Map impacts=Map.of(); + var receivingSnapshots=new HashMap(); + if(!batches.isEmpty()) { + appointments.batchSnapshots(batches).forEach(a->snapshots.put(a.batchKey(),a)); + deliveries.batches(batches).forEach(d->deliveryRows.computeIfAbsent(d.batchKey(),ignored->new ArrayList<>()).add(d.delivery())); + receiving.snapshots(batches).forEach(s->receivingSnapshots.put(s.batchKey(),s)); + impacts=commitments.impactTokens(requests.stream().filter(r->r.batchKey()!=null && r.batchKey().startsWith("FBA_APPOINTMENT:")).map(r->customerRequirement(r.batchKey(),deliveryCustomer(r.sources()))).distinct().toList()); + } + var context=new BatchFacts(snapshots,deliveryRows,impacts,receivingSnapshots); + return requests.stream().map(r->read(r,privateSources.get(r.workItemKey()),inspections,context)).toList(); + } + private record BatchFacts(Map appointments,Map> deliveries,Map impacts,Map receiving) { } + private Fact read(FactRequest request,List privateSources,Map inspections,BatchFacts context) { + if(request.batchKey()==null) return privateSources!=null?customer(request,privateSources):unbatched(request,inspections); + if(request.batchKey().startsWith("DISPATCH:")) return customerBatch(request); + var a=context.appointments().get(request.batchKey()); + var customer=deliveryCustomer(request.sources()); + var impact=context.impacts().get(customerRequirement(request.batchKey(),customer)); + if(a==null || impact==null) throw unavailable("预约批次事实批量读取不完整"); + var receiving=receiving(a,customer,context.receiving().get(a.batchKey())); + boolean unsuccessful=receiving.agreement()==null && (receiving.cancelled() || "FAILED".equals(receiving.status())); + var target=receiving.target();var confirmed=receiving.confirmed(); + var customerItems=a.items().stream().filter(i->customer.equals(i.customerId())).toList(); + var sourceRefs=customerItems.stream().map(i->new SourceReference(i.customerId(),i.sourceKey(),i.destinationKey())).distinct().toList(); + if(customerItems.isEmpty() || !new HashSet<>(sourceRefs).equals(new HashSet<>(request.sources()))) throw conflict("当前客户批次来源已变化,请先同步工作清单"); + var batchRows=context.deliveries().getOrDefault(a.batchKey(),List.of()); + if(batchRows.size()>1000) throw unavailable("批次派送事实超过完整读取上限"); + var rows=batchRows.stream().filter(d->customer.equals(d.customerId())).toList(); + boolean complete=!rows.isEmpty() && rows.stream().allMatch(d->d.status()!=null && Set.of("DELIVERED","RETURN_PENDING","RETURNED").contains(d.status()) && d.signedAt()!=null && d.packages()!=null && d.packages().equals(d.signedPackages())); + // Every original source allocation must have real dispatch coverage, not just one signed fragment. + for(var item:customerItems) { + long allocated=rows.stream().filter(d->item.customerId().equals(d.customerId()) && item.sourceKey().equals(d.sourceKey()) && item.destinationKey().equals(d.destinationKey())).mapToLong(d->d.allocatedPackages()).sum(); + if(item.packageCount()==null || allocated!=item.packageCount()) complete=false; + } + if(rows.stream().anyMatch(d->sourceRefs.stream().noneMatch(s->Objects.equals(s.sourceKey(),d.sourceKey()) && Objects.equals(s.destinationKey(),d.destinationKey())))) complete=false; + var actual=complete?new Actual(null,rows.stream().map(AppointmentWorkDeliveryMapper.Delivery::signedAt).max(Comparator.naturalOrder()).orElseThrow(),null):null; + var execution=complete?ExecutionStatus.COMPLETED:rows.stream().anyMatch(d->"DEPARTED".equals(d.dispatchStatus()) || "PARTIAL_DEPARTED".equals(d.dispatchStatus()))?ExecutionStatus.IN_PROGRESS:ExecutionStatus.PENDING; + var blocks=new ArrayList(); + if(unsuccessful) blocks.add("原预约已取消或未约成,需求须继续安排"); + if(!a.replacementBatchKeys().isEmpty()) blocks.add("原批次已替代,请进入新批次"); + if(receiving.pending()!=null) blocks.add("本客户收货范围变化待仓库明确回复,暂不可发车"); + if(execution==ExecutionStatus.IN_PROGRESS) blocks.add("本客户运输已执行,普通改约已停止,请从原运输异常处理"); + var action=execution!=ExecutionStatus.PENDING?null:new Action(receiving.pending()!=null?ActionCode.CHANGE_BOOKING:confirmed==null?ActionCode.RECORD_CONFIRMATION:ActionCode.CHANGE_BOOKING,receiving.pending()!=null?"继续处理待确认变化":unsuccessful?"沿原批次重新预约":confirmed==null?"维护原预约并登记确认":"核对或变更原预约",request.workItemKey(),new AppointmentTarget(a.id().toString(),a.batchKey(),sourceRefs)); + var references=new ArrayList(); references.add(new BusinessReference("FBA_APPOINTMENT",a.batchKey(),a.appointmentNo(),"送仓预约")); + if(receiving.pending()!=null) references.add(new BusinessReference("RECEIVING_CHANGE",receiving.pending().changeKey(),null,"待仓库确认的变化")); + rows.stream().filter(d->d.deliveryNo()!=null).map(d->new BusinessReference("DELIVERY",d.deliveryNo(),d.deliveryNo(),"实际派送")).distinct().forEach(references::add); + return new Fact(request.workItemKey(),digest(customerItems+":"+target+":"+confirmed+":"+receiving.pending()+":"+receiving.status()+":"+rows),impact,true, + receiving.pending()!=null?PlanStatus.AWAITING_REPLY:confirmed!=null?PlanStatus.CONFIRMED:target!=null?PlanStatus.TARGET_SET:PlanStatus.UNPLANNED,execution,target,confirmed,actual,receiving.timezone(),a.appointmentNo(),null, + List.of(ORDER_READ),action==null?List.of():receiving.pending()!=null?List.of(OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT,"platform:appointment:delete"):List.of(OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT),action,blocks, + actual!=null?List.of("SOURCE_TIMEZONE"):List.of(),List.copyOf(references),null,null); + } + private Fact unbatched(FactRequest request,Map inspections) { + var blocks=new ArrayList(); var missing=new ArrayList(); Action action=null; + var fingerprints=new ArrayList(); + if(request.sources().isEmpty()) { blocks.add("尾程货源尚未建立,请先核对整柜来源与入仓计划"); missing.add("LASTLEG_SOURCE"); } + else { + for(var source:request.sources()) { + try { + var inspection=inspections.get(new AppointmentCoverageService.SourceRef(source.customerId(),source.sourceKey(),source.destinationKey())); + if(inspection==null) throw unavailable("预约货源批量读取不完整"); + var context=inspection.requireContext(); + fingerprints.add(context.toString()); blocks.addAll(context.blockReasons()); + } catch(org.springframework.web.server.ResponseStatusException error) { + if(!AppointmentWorkSourceAvailability.unresolved(error)) throw error; + fingerprints.add(source+":"+error.getReason());blocks.add(error.getReason());missing.add("SOURCE_CONFIGURATION"); + } + } + var first=request.sources().get(0); + if(missing.isEmpty()) action=new Action(ActionCode.VERIFY_CARGO,"核实货物并建立预约",request.workItemKey(),new CargoTarget(first)); + } + return new Fact(request.workItemKey(),digest(request.requirementKey()+":"+fingerprints),"UNBATCHED",false,PlanStatus.UNPLANNED,ExecutionStatus.PENDING, + null,null,null,null,null,null,List.of(ORDER_READ),action==null?List.of():List.of(OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT),action, + blocks.stream().distinct().toList(),missing,List.of(),null,null); + } + private Fact customer(FactRequest request,List sources) { + var rows=deliveries.customer(request.containerBookingId(),request.requirementKey().substring(8)); + var expected=sources.stream().map(s->new Expected(s.id(),s.customerId(),s.packageCount()==null?null:s.packageCount().longValue())).toList(); + return customerFact(request,rows,expected,null); + } + private record Expected(long orderId,String customerId,Long packages) { } + private Fact customerBatch(FactRequest request) { + var sources=sourceMapping.privateBatch(request.batchKey()); + if(sources.size()>1000) throw unavailable("客户仓实物批次超过完整读取上限"); + // A complete scan may not have reached this changed dispatch's container yet. Represent its + // removal explicitly so compensation can advance to that page and restore the original demand. + if(sources.isEmpty() || sources.stream().anyMatch(s->"CANCELLED".equals(s.status()))) + return new Fact(request.workItemKey(),digest("REMOVED:"+sources),"REMOVED",false,PlanStatus.UNPLANNED,ExecutionStatus.PENDING,null,null,null,null, + sources.isEmpty()?null:sources.get(0).dispatchNo(),null,List.of(ORDER_READ),List.of(),null,List.of("原客户仓排车已取消或移除实物,待完整需求同步"),List.of(),List.of(),null,null); + return customerFact(request,deliveries.privateBatch(request.batchKey()),sources.stream().map(s->new Expected(s.orderId(),s.customerId(),s.packages())).toList(),sources.get(0).dispatchNo()); + } + private Fact customerFact(FactRequest request,List rows,List sources,String dispatchNo) { + if(rows.size()>1000) throw unavailable("客户仓派送事实超过完整读取上限"); + boolean complete=!rows.isEmpty() && rows.stream().allMatch(d->d.status()!=null && Set.of("DELIVERED","RETURN_PENDING","RETURNED").contains(d.status()) && d.signedAt()!=null && d.packages()!=null && d.packages().equals(d.signedPackages())); + for(var source:sources) { + long delivered=rows.stream().filter(r->r.orderId()==source.orderId() && source.customerId().equals(r.customerId())).mapToLong(AppointmentWorkDeliveryMapper.CustomerDelivery::pickedPackages).sum(); + if(source.packages()==null || source.packages()<=0 || delivered!=source.packages()) complete=false; + } + if(rows.stream().anyMatch(r->sources.stream().noneMatch(s->s.orderId()==r.orderId() && s.customerId().equals(r.customerId())))) complete=false; + var dates=rows.stream().map(AppointmentWorkDeliveryMapper.CustomerDelivery::plannedDeliveryAt).filter(Objects::nonNull).distinct().sorted().toList(); + var target=dates.isEmpty()?null:slot(dates.get(0),null,null); + var actual=complete?new Actual(null,rows.stream().map(AppointmentWorkDeliveryMapper.CustomerDelivery::signedAt).max(Comparator.naturalOrder()).orElseThrow(),null):null; + var refs=new ArrayList(); + if(dispatchNo!=null) refs.add(new BusinessReference("DISPATCH",request.batchKey(),dispatchNo,"客户仓排车")); + rows.stream().map(r->new BusinessReference("DELIVERY",r.deliveryNo(),r.deliveryNo(),"客户仓实际派送")).distinct().forEach(refs::add); + sources.stream().map(s->new BusinessReference("CUSTOMER",s.customerId(),null,"所属客户")).distinct().forEach(refs::add); + var action=rows.isEmpty() || complete?null:new Action(ActionCode.VIEW_DETAIL,"查看原派送进度",request.workItemKey(),new ReadTarget(List.copyOf(refs))); + return new Fact(request.workItemKey(),digest(sources+":"+rows),digest(sources+":"+dates),!rows.isEmpty(),target==null?PlanStatus.UNPLANNED:PlanStatus.TARGET_SET, + complete?ExecutionStatus.COMPLETED:rows.isEmpty()?ExecutionStatus.PENDING:ExecutionStatus.IN_PROGRESS,target,null,actual,null,dispatchNo!=null?dispatchNo:rows.size()==1?rows.get(0).deliveryNo():null,null, + List.of(ORDER_READ),action==null?List.of():List.of("platform:overseas-warehouse:delivery:list"),action, + dates.size()>1?List.of("同一收货需求存在多个派送日期,请在原派送单核对"):List.of(),target!=null || actual!=null?List.of("SOURCE_TIMEZONE"):List.of(),List.copyOf(refs),null,null); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java new file mode 100644 index 000000000..5bc4fbd0a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java @@ -0,0 +1,197 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentplanning.*; +import com.baiyue.lastleg.appointmentwork.*; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.*; +import java.util.*; +import java.util.stream.Collectors; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@Component +@Transactional(propagation=Propagation.MANDATORY) +public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPort { + @Transactional(readOnly=true,propagation=Propagation.SUPPORTS) + public Map> references(List dispatchIds) { + if(dispatchIds==null || dispatchIds.isEmpty()) return Map.of(); + if(dispatchIds.stream().anyMatch(id->id==null || id<=0)) throw bad("排车引用缺少真实排车身份"); + var rows=mapper.references(dispatchIds.stream().distinct().sorted().toList()); + if(rows.size()>20000) throw unavailable("排车批次引用超过完整读取上限"); + record Group(long dispatchId,String batchKey,String batchNo,String customerId) { } + var groups=new LinkedHashMap,Integer>>(); + for(var row:rows) { + if(!dispatchIds.contains(row.dispatchId()) || row.batchKey()==null || row.customerId()==null || row.sourceKey()==null || row.destinationKey()==null || row.packages()<=0) + throw unavailable("原排车逐票批次引用不完整"); + groups.computeIfAbsent(new Group(row.dispatchId(),row.batchKey(),row.batchNo(),row.customerId()),ignored->new LinkedHashMap<>()) + .merge(List.of(row.sourceKey(),row.destinationKey()),row.packages(),Math::addExact); + } + var result=new LinkedHashMap>();dispatchIds.forEach(id->result.put(id,new ArrayList<>())); + groups.forEach((group,sources)->result.get(group.dispatchId()).add(new BatchReference(group.batchKey(),group.batchNo(),group.customerId(), + sources.entrySet().stream().map(e->new BatchSource(e.getKey().get(0),e.getKey().get(1),e.getValue())).toList()))); + result.replaceAll((id,refs)->List.copyOf(refs));return Collections.unmodifiableMap(result); + } + private final AppointmentWorkDispatchMapper mapper; + private final InboundFbaAppointmentService appointments; + private final AppointmentSourceService sources; + private final AppointmentWorkChanges changes; + private final com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving; + public AppointmentWorkDispatchBinding(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources,AppointmentWorkChanges changes, + com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving) { + this.mapper=mapper; this.appointments=appointments; this.sources=sources; this.changes=changes;this.receiving=receiving; + } + public void bind(String customer,long dispatch,List items,List allocations) { + if(allocations==null) throw bad("批次实物分配清单必填"); + if(allocations.isEmpty()) { + if(mapper.byDispatch(dispatch).stream().anyMatch(a->a.batchKey().startsWith("FBA_APPOINTMENT:"))) throw conflict("已关联预约批次,修改实物时须同时提交完整批次分配"); + for(var item:items) if(item.planPalletId()!=null) requireNoBatch(customer,item.planPalletId()); + var privateRows=new ArrayList(); + for(var item:items) if(item.planPalletId()!=null) privateRows.addAll(privateAllocation(customer,dispatch,item)); + if(!privateRows.isEmpty() || mapper.count(dispatch)>0) { mapper.remove(dispatch);privateRows.forEach(mapper::insert);changes.requirementsChanged(); } + return; + } + if(allocations.size()>1000) throw bad("批次实物分配最多1000条"); + var byPallet=items.stream().filter(i->i.planPalletId()!=null).collect(Collectors.toMap(PhysicalItem::planPalletId,i->i)); + var seen=new HashSet(); + for(var row:allocations) { + if(row==null || row.packageCount()<=0 || !customer.equals(row.customerId()) || !byPallet.containsKey(row.planPalletId())) throw conflict("批次分配必须属于本客户已选择的实物托盘"); + if(!seen.add(row.planPalletId()+"/"+row.batchKey()+"/"+row.sourceKey()+"/"+row.destinationKey())) throw bad("批次实物分配重复"); + } + var batches=new TreeSet<>(allocations.stream().map(Allocation::batchKey).toList()); + for(var batch:batches) lock(batch); + var persisted=new ArrayList(); + for(var item:byPallet.values()) { + var selected=allocations.stream().filter(a->item.planPalletId().equals(a.planPalletId())).toList(); + if(selected.isEmpty()) { + var privateRows=privateAllocation(customer,dispatch,item); + if(privateRows.isEmpty()) throw conflict("批次排车须明确每个托盘的逐票实物分配"); + persisted.addAll(privateRows);continue; + } + validatePhysical(customer,item,selected,"RECEIVED".equals(item.sourceStage()),false); + for(var a:selected) persisted.add(new AppointmentWorkDispatchMapper.Bound(dispatch,item.itemId(),item.planPalletId(),a.batchKey(),customer,a.sourceKey(),a.destinationKey(),a.packageCount())); + } + for(var batch:batches) { + var context=appointments.batchContext(batch); var appointment=context.appointment(); + // Pre-arrangement reserves real cargo only. requireReceiving separately gates actual departure per customer. + if(!appointment.replacementBatchKeys().isEmpty()) throw conflict("不能绑定已被替代的预约批次"); + requireCurrentAllocation(appointment,customer,dispatch,allocations.stream().filter(a->batch.equals(a.batchKey())).toList()); + } + mapper.remove(dispatch); persisted.forEach(mapper::insert); changes.requirementsChanged(); + } + public void requireReplay(String customer,long dispatch,List allocations) { + if(allocations==null || allocations.stream().anyMatch(a->a==null || !customer.equals(a.customerId()))) throw conflict("重放排车的逐票批次分配不一致"); + var original=mapper.byDispatch(dispatch).stream().filter(a->!a.batchKey().startsWith("DISPATCH:")) + .map(a->new Allocation(a.palletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packages())).toList(); + if(allocations.size()!=original.size() || !new HashSet<>(allocations).equals(new HashSet<>(original))) throw conflict("原排车已存在,不能通过重放请求改写批次分配"); + } + private void validatePhysical(String customer,PhysicalItem item,List selected,boolean received,boolean picked) { + var physical=picked?mapper.pickedPhysical(item.itemId(),customer):mapper.physical(item.planPalletId(),customer); + if(physical.isEmpty()) throw conflict("托盘缺少真实逐票分配,不能按整票或整托总数猜测批次"); + var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources); + var remaining=new HashMap(); + for(int i=0;in!=0)) throw conflict("批次逐票分配必须与所选托盘实际货物完全一致"); + if(item.packages()!=null && selected.stream().mapToLong(Allocation::packageCount).sum()!=item.packages()) throw conflict("逐票分配与所选托盘箱数不一致"); + } + public void requireDeparture(String dispatchNo) { checkDeparture(dispatchNo,true); } + public String departureBlockReason(String dispatchNo) { + try { checkDeparture(dispatchNo,false);return null; } + catch(org.springframework.web.server.ResponseStatusException error) { + if(error.getStatusCode().value()!=409) throw error; + return error.getReason(); + } + } + private void checkDeparture(String dispatchNo,boolean locked) { + var rows=mapper.bindings(dispatchNo); + var linked=rows.stream().map(AppointmentWorkDispatchMapper.Bound::palletId).collect(Collectors.toSet()); + for(var item:mapper.dispatchPhysical(dispatchNo)) if(item.planPalletId()!=null && !linked.contains(item.planPalletId())) { + requireNoBatch(item.customerId(),item.planPalletId()); + if(mapper.physical(item.planPalletId(),item.customerId()).stream().anyMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("CUSTOMER:"))) throw conflict("客户仓排车缺少逐票实物批次关联,请重新核对排车"); + } + for(var batch:rows.stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).distinct().sorted().toList()) { + if(batch.startsWith("DISPATCH:")) continue; + if(locked) lock(batch); + var a=appointments.batchContext(batch).appointment(); + if(!a.replacementBatchKeys().isEmpty()) throw conflict("关联批次已被替代,请重新核对实物批次后发车"); + var allocated=rows.stream().filter(row->batch.equals(row.batchKey())).toList(); + var dispatchIds=allocated.stream().map(AppointmentWorkDispatchMapper.Bound::dispatchId).distinct().toList(); + if(dispatchIds.size()!=1) throw conflict("排车批次关联不一致,请重新核对实物分配"); + for(var customer:allocated.stream().map(AppointmentWorkDispatchMapper.Bound::customerId).distinct().toList()) { + requireReceiving(batch,customer,allocated.stream().filter(row->customer.equals(row.customerId())).toList()); + requireCurrentAllocation(a,customer,dispatchIds.get(0),allocated.stream().filter(row->customer.equals(row.customerId())) + .map(row->new Allocation(row.palletId(),row.batchKey(),row.customerId(),row.sourceKey(),row.destinationKey(),row.packages())).toList()); + } + } + for(var group:rows.stream().collect(Collectors.groupingBy(AppointmentWorkDispatchMapper.Bound::palletId)).values()) { + var first=group.get(0); + validatePhysical(first.customerId(),new PhysicalItem(first.itemId(),first.palletId(),"RECEIVED",null), + group.stream().map(a->new Allocation(a.palletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packages())).toList(),true,true); + } + } + private void requireReceiving(String batch,String customer,List rows) { + if(receiving.hasPending(batch,customer)) throw conflict("本客户有待仓库确认的变化,保留原安排但禁止发车,请先完成明确回复"); + var agreement=receiving.currentAgreement(batch,customer); + if(agreement==null || !"ACTIVE".equals(agreement.status()) || agreement.details()==null || !"SUCCESS".equals(agreement.details().appointmentStatus()) + || agreement.details().appointmentAt()==null) throw conflict("本客户尚无有效收货确认,请先完成当前预约后发车"); + var members=agreement.members().stream().filter(m->batch.equals(m.batchKey()) && customer.equals(m.customerId())).toList(); + if(members.size()!=1) throw conflict("当前收货确认未唯一覆盖本客户批次,请重新核对"); + for(var group:rows.stream().collect(Collectors.groupingBy(r->List.of(r.sourceKey(),r.destinationKey()))).values()) { + var first=group.get(0); + var sources=members.get(0).sources().stream().filter(s->first.sourceKey().equals(s.sourceKey()) && first.destinationKey().equals(s.destinationKey())).toList(); + if(sources.size()!=1 || mapper.allocated(batch,customer,first.sourceKey(),first.destinationKey(),first.dispatchId()) + +group.stream().mapToLong(AppointmentWorkDispatchMapper.Bound::packages).sum()>sources.get(0).packageCount()) + throw conflict("当前收货确认未完整覆盖本次逐票实物,请重新核对"); + } + } + private void requireCurrentAllocation(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse appointment,String customer,long dispatch,List allocations) { + var grouped=allocations.stream().collect(Collectors.groupingBy(a->List.of(a.sourceKey(),a.destinationKey()))); + for(var group:grouped.values()) { + var ref=group.get(0); + var originals=appointment.items().stream().filter(i->customer.equals(i.customerId()) && ref.sourceKey().equals(i.sourceKey()) && ref.destinationKey().equals(i.destinationKey())).toList(); + if(originals.size()!=1) throw conflict("实物来源不属于当前预约批次的唯一分配,请重新核对"); + var original=originals.get(0); + long count=group.stream().mapToLong(Allocation::packageCount).sum(); + if(group.stream().anyMatch(a->a.packageCount()<=0) || original.packageCount()==null + || mapper.allocated(appointment.batchKey(),customer,ref.sourceKey(),ref.destinationKey(),dispatch)+count>original.packageCount()) + throw conflict("所选实物超过当前批次逐票分配量,请重新核对"); + } + } + private void requireNoBatch(String customer,long pallet) { + var physical=mapper.physical(pallet,customer).stream().filter(p->p.destinationKey()!=null && p.destinationKey().startsWith("FBA:")).toList(); + var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources); + var refs=physical.stream().map(p->new AppointmentCoverageService.SourceRef(customer,AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey(),p.destinationKey())).distinct().toList(); + if(!refs.isEmpty() && appointments.relatedBatchSnapshots(refs).stream().anyMatch(a->a.replacementBatchKeys().isEmpty())) + throw conflict("所选实物已有预约批次,必须明确逐批逐票分配后才能排车或发车"); + } + public void removeItem(long dispatchId,long itemId) { mapper.removeItem(dispatchId,itemId); changes.requirementsChanged(); } + public void requireTripBinding(String dispatchNo,long tripId) { + for(var batch:mapper.bindings(dispatchNo).stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).distinct().sorted().toList()) { + if(batch.startsWith("DISPATCH:")) continue; + lock(batch); + if(mapper.trips(batch).stream().anyMatch(id->id!=tripId)) throw conflict("同一预约批次已关联其他车次,请先在原预约分批后分别安排车辆"); + } + } + private List privateAllocation(String customer,long dispatch,PhysicalItem item) { + var physical=mapper.physical(item.planPalletId(),customer); + if(physical.isEmpty() || physical.stream().noneMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("CUSTOMER:"))) return List.of(); + if(physical.stream().anyMatch(p->p.destinationKey()==null || !p.destinationKey().startsWith("CUSTOMER:"))) throw conflict("客户仓实物不能混入FBA批次,请先核对托盘目的地"); + var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources); var rows=new ArrayList(); + for(var part:physical) { + var count="RECEIVED".equals(item.sourceStage())?part.countedPackages():part.plannedPackages(); + if(count==null || count<=0) throw conflict("客户仓逐票实物数量尚未核实"); + rows.add(new AppointmentWorkDispatchMapper.Bound(dispatch,item.itemId(),item.planPalletId(),"DISPATCH:"+dispatch,customer,AppointmentWorkPhysicalSources.ref(canonical,part).sourceKey(),part.destinationKey(),count)); + } + if(item.packages()==null || rows.stream().mapToLong(AppointmentWorkDispatchMapper.Bound::packages).sum()!=item.packages()) throw conflict("客户仓逐票实物分配与所选托盘箱数不一致"); + return rows; + } + private void lock(String batch) { + if(batch==null || !batch.startsWith("FBA_APPOINTMENT:")) throw bad("预约批次键无效"); + try { long id=Long.parseLong(batch.substring("FBA_APPOINTMENT:".length())); if(!batch.equals("FBA_APPOINTMENT:"+id) || mapper.lockAppointment(id)==null) throw conflict("预约批次不存在"); } + catch(NumberFormatException | IndexOutOfBoundsException error) { throw bad("预约批次键无效"); } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidateController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidateController.java new file mode 100644 index 000000000..2baf1a182 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidateController.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import org.springframework.web.bind.annotation.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/appointment-planning/batches") +public class AppointmentWorkDispatchCandidateController { + private final AppointmentWorkDispatchCandidates service; + public AppointmentWorkDispatchCandidateController(AppointmentWorkDispatchCandidates service) { this.service=service; } + @GetMapping("/{batchKey}/dispatch-candidates") + @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list') and hasAuthority('platform:overseas-warehouse:inbound-order:list')") + public ApiResponse candidates(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String batchKey) { + if(user==null || user.userId()==null || !"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) throw forbidden("仅平台操作人员可读取批次实物"); + return ApiResponse.success(service.read(batchKey)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java new file mode 100644 index 000000000..c0a15bd6d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java @@ -0,0 +1,48 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentplanning.*; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.Allocation; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@Service +public class AppointmentWorkDispatchCandidates { + public record Candidate(long planPalletId,String palletNo,long planId,String planNo,String customerId,String sourceStage,Integer packageCount,List allocations,List blockReasons) { } + public record Result(String batchKey,Integer version,List candidates,List blockReasons) { } + private final AppointmentWorkDispatchMapper mapper; + private final InboundFbaAppointmentService appointments; + private final AppointmentSourceService sources; + public AppointmentWorkDispatchCandidates(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources) { this.mapper=mapper; this.appointments=appointments; this.sources=sources; } + @Transactional(readOnly=true) + public Result read(String batch) { + var appointment=appointments.batchContext(batch).appointment(); + // Receiving outcome does not release this batch's cargo or decide another customer's physical eligibility. + if(!appointment.replacementBatchKeys().isEmpty()) return new Result(batch,appointment.version(),List.of(),List.of("原批次已替代,不能选择实物")); + var candidates=mapper.candidates(appointment.id()); + if(candidates.size()>500) throw conflict("本批实物超过500托,请先按原预约分批"); + var output=new ArrayList(); + for(var candidate:candidates) { + var reasons=new ArrayList(); var allocations=new ArrayList(); + if(candidate.locked()) reasons.add("托盘已被其他排车或草稿占用"); + var physical=mapper.physical(candidate.planPalletId(),candidate.customerId()); + var refs=AppointmentWorkPhysicalSources.canonical(physical,sources); + Integer packages=0; + for(int i=0;ia.customerId().equals(part.customerId()) && a.sourceKey().equals(ref.sourceKey()) && a.destinationKey().equals(part.destinationKey())).findFirst().orElse(null); + if(item==null) { reasons.add("托盘含本批之外的货物,请先拆托或选择完整的关联批次"); continue; } + long used=mapper.allocated(batch,part.customerId(),ref.sourceKey(),part.destinationKey(),-1); + if(item.packageCount()==null || used+count>item.packageCount()) reasons.add("本托货量超过该来源本批剩余可排量,请先核对分批或拆托"); + allocations.add(new Allocation(candidate.planPalletId(),batch,part.customerId(),ref.sourceKey(),part.destinationKey(),count)); + } + if(physical.isEmpty()) { packages=null; reasons.add("缺少逐票实物分配"); } + output.add(new Candidate(candidate.planPalletId(),candidate.palletNo(),candidate.planId(),candidate.planNo(),candidate.customerId(),candidate.sourceStage(),packages,List.copyOf(allocations),reasons.stream().distinct().toList())); + } + return new Result(batch,appointment.version(),List.copyOf(output),List.of()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java new file mode 100644 index 000000000..72ade2a4f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java @@ -0,0 +1,80 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface AppointmentWorkDispatchMapper { + record Physical(Long orderId,String customerId,String destinationKey,Integer plannedPackages,Integer countedPackages) { } + record Bound(long dispatchId,long itemId,long palletId,String batchKey,String customerId,String sourceKey,String destinationKey,int packages) { } + record Candidate(long planPalletId,String palletNo,long planId,String planNo,String customerId,String sourceStage,boolean locked) { } + record DispatchPhysical(long itemId,Long planPalletId,String customerId,String sourceStage,Integer packages) { } + record TransportRow(long dispatchId,String dispatchNo,String customerId,String dispatchStatus,Integer pickedPackages,Integer shippedPackages, + java.time.LocalDateTime updatedAt,long itemId,long palletId,Integer itemPackages,String sourceStage, + String batchKey,String sourceKey,String destinationKey,int packages,String tripNo,Long tripVersion,String tripStatus, + Long outboundId,String outboundStatus,String taskStatus) { } + record ReceivingSource(String batchKey,String customerId,String sourceKey,String destinationKey,Integer packages) { } + record ReferenceRow(long dispatchId,String batchKey,String batchNo,String customerId,String sourceKey,String destinationKey,int packages) { } + @Select(""" + + """) + List references(@Param("ids") List ids); + @Select(""" + + """) + @Options(flushCache=Options.FlushCachePolicy.TRUE,useCache=false) + List receivingSources(@Param("batches") List batches); + @Select(""" + + """) + @Options(flushCache=Options.FlushCachePolicy.TRUE,useCache=false) + List transportRows(@Param("batches") List batches,@Param("trips") List trips); + @Select("SELECT i.id item_id,i.plan_pallet_id,i.customer_id,i.source_stage,i.package_count packages FROM ll_dispatch_item i JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id WHERE d.dispatch_no=#{number}") List dispatchPhysical(String number); + @Select("SELECT DISTINCT p.id plan_pallet_id,p.pallet_no,p.plan_id,n.plan_no,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END source_stage,EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE k.plan_pallet_id=p.id AND k.status='ACTIVE') locked FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan n ON n.id=p.plan_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id JOIN ll_inbound_fba_appointment_item i ON i.order_id=l.order_id AND i.customer_id=l.customer_id WHERE i.appointment_id=#{id} ORDER BY p.id LIMIT 501") + List candidates(long id); + @Select("SELECT l.order_id,l.customer_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,SUM(a.planned_package_count) planned_packages,SUM(a.counted_package_count) counted_packages FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id WHERE a.pallet_id=#{pallet} AND a.customer_id=#{customer} GROUP BY l.order_id,l.customer_id,l.destination_type,l.fba_warehouse_code") + List physical(@Param("pallet") long pallet,@Param("customer") String customer); + @Select("SELECT a.order_id,a.customer_id,CASE WHEN i.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',a.order_id) ELSE CONCAT('FBA:',i.fba_warehouse_code) END destination_key,NULL planned_packages,SUM(a.packages) counted_packages FROM ll_dispatch_pda_pick_order a JOIN ll_dispatch_pda_pick p ON p.id=a.pick_id AND p.customer_id=a.customer_id JOIN ll_dispatch_item i ON i.id=p.dispatch_item_id AND i.customer_id=p.customer_id WHERE i.id=#{item} AND a.customer_id=#{customer} GROUP BY a.order_id,a.customer_id,i.destination_type,i.fba_warehouse_code") + List pickedPhysical(@Param("item") long item,@Param("customer") String customer); + @Select("SELECT * FROM ll_inbound_fba_appointment WHERE id=#{id} FOR UPDATE") InboundFbaAppointment lockAppointment(long id); + @Select("SELECT COALESCE(SUM(b.package_count),0) FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id WHERE b.batch_key=#{batch} AND b.customer_id=#{customer} AND b.source_key=#{source} AND b.destination_key=#{destination} AND b.dispatch_id<>#{dispatch} AND d.status<>'CANCELLED'") + long allocated(@Param("batch") String batch,@Param("customer") String customer,@Param("source") String source,@Param("destination") String destination,@Param("dispatch") long dispatch); + @Delete("DELETE FROM ll_dispatch_appointment_allocation WHERE dispatch_id=#{dispatch}") int remove(long dispatch); + @Insert("INSERT INTO ll_dispatch_appointment_allocation(dispatch_id,dispatch_item_id,plan_pallet_id,batch_key,customer_id,source_key,destination_key,package_count) VALUES(#{dispatchId},#{itemId},#{palletId},#{batchKey},#{customerId},#{sourceKey},#{destinationKey},#{packages})") + int insert(Bound row); + @Select("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation WHERE dispatch_id=#{id}") int count(long id); + @Delete("DELETE FROM ll_dispatch_appointment_allocation WHERE dispatch_id=#{dispatch} AND dispatch_item_id=#{item}") int removeItem(@Param("dispatch") long dispatch,@Param("item") long item); + @Select("SELECT DISTINCT t.trip_id FROM ll_dispatch_appointment_allocation a JOIN ll_fleet_trip_dispatch t ON t.dispatch_id=a.dispatch_id JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.status<>'CANCELLED' WHERE a.batch_key=#{batch}") List trips(String batch); + @Select("SELECT b.dispatch_id,b.dispatch_item_id item_id,b.plan_pallet_id pallet_id,b.batch_key,b.customer_id,b.source_key,b.destination_key,b.package_count packages FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id WHERE d.dispatch_no=#{number}") + List bindings(String number); + @Select("SELECT dispatch_id,dispatch_item_id item_id,plan_pallet_id pallet_id,batch_key,customer_id,source_key,destination_key,package_count packages FROM ll_dispatch_appointment_allocation WHERE dispatch_id=#{id}") List byDispatch(long id); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkPhysicalSources.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkPhysicalSources.java new file mode 100644 index 000000000..7050a31cc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkPhysicalSources.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentplanning.*; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +final class AppointmentWorkPhysicalSources { + private AppointmentWorkPhysicalSources() { } + static Map canonical(List rows,AppointmentSourceService sources) { + var identities=rows.stream().map(p->new AppointmentExternalSourceGateway.OrderRef(p.customerId(),p.orderId())).distinct().toList(); + if(identities.isEmpty()) return Map.of(); + var refs=sources.canonicalOrders(identities); + if(refs.size()!=identities.size()) throw unavailable("逐票来源规范化响应不完整"); + var result=new LinkedHashMap(); + for(int i=0;i refs,AppointmentWorkDispatchMapper.Physical row) { + return refs.get(new AppointmentExternalSourceGateway.OrderRef(row.customerId(),row.orderId())); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingGuard.java new file mode 100644 index 000000000..42582a945 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingGuard.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentwork.AppointmentWorkFirstlegClient; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.Flow; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@Component +public class AppointmentWorkReceivingGuard { + private final AppointmentWorkFirstlegClient firstleg; + private final String token; + private final com.baiyue.lastleg.appointmentwork.AppointmentWorkRouteService routes; + public AppointmentWorkReceivingGuard(AppointmentWorkFirstlegClient firstleg,com.baiyue.lastleg.appointmentwork.AppointmentWorkRouteService routes,@Value("${baiyue.security.internal-service-token:}") String token) { this.firstleg=firstleg; this.routes=routes; this.token=token; } + public void requireWarehouseFlow(String container) { + if(routes.route(container).flow()==Flow.DIRECT) throw conflict("该柜已确认直送路线,不能进入海外仓收货"); + if(token==null || token.isBlank()) throw unavailable("内部服务身份未配置,无法核对收货路线"); + final com.baiyue.common.core.api.ApiResponse response; + try { response=firstleg.container(token,container); } + catch(RuntimeException error) { throw unavailable("无法核对柜次原业务路线",error); } + if(response==null || !response.successful() || response.data()==null || !container.equals(response.data().containerBookingId())) throw unavailable("柜次原业务路线缺失"); + if(response.data().establishedFlow()==Flow.DIRECT) throw conflict("该柜已有直送卸货事实,不能进入海外仓收货"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingMapper.java new file mode 100644 index 000000000..c3cf9a918 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingMapper.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface AppointmentWorkReceivingMapper { + class ContainerPlan extends InboundPlan { + private String sourceContainerBookingId; + public String getSourceContainerBookingId() { return sourceContainerBookingId; } + public void setSourceContainerBookingId(String value) { sourceContainerBookingId=value; } + } + @Select(""" + + """) + List plans(@Param("containers") List containers); + @Select(""" + + """) + List sessions(@Param("containers") List containers); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingProvider.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingProvider.java new file mode 100644 index 000000000..f88b37f01 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingProvider.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway; +import com.baiyue.lastleg.security.OverseasPermissions; +import org.springframework.stereotype.Component; +import java.util.*; +import java.util.stream.Collectors; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@Component +public class AppointmentWorkReceivingProvider implements AppointmentWorkFactGateway.BusinessProvider { + private final AppointmentWorkReceivingMapper mapper; + public AppointmentWorkReceivingProvider(AppointmentWorkReceivingMapper mapper) { this.mapper=mapper; } + public boolean supports(Type type,Flow flow) { return type==Type.WAREHOUSE_RECEIPT && flow==Flow.TRANSFER; } + public List read(List requests) { + if(requests.isEmpty()) return List.of(); + var ids=requests.stream().map(FactRequest::containerBookingId).distinct().toList(); + var plans=mapper.plans(ids).stream().collect(Collectors.groupingBy(AppointmentWorkReceivingMapper.ContainerPlan::getSourceContainerBookingId)); + var sessions=mapper.sessions(ids).stream().collect(Collectors.toMap(s->s.getSourceContainerBookingId(),s->s)); + return requests.stream().map(r->read(r,plans.getOrDefault(r.containerBookingId(),List.of()),sessions.get(r.containerBookingId()))).toList(); + } + private Fact read(FactRequest request,List plans,com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession session) { + if(plans.size()>1000) throw unavailable("柜次接仓计划超过完整读取上限"); + var dates=plans.stream().map(p->p.getPlannedArrivalDate()).filter(Objects::nonNull).distinct().sorted().toList(); + var target=dates.isEmpty()?null:new Slot(dates.get(0),null,null,null,null); + boolean complete=session!=null && session.getCompletedAt()!=null && Set.of("COMPLETED","COMPLETED_WITH_DIFFERENCE").contains(session.getStatus()); + var local=complete?session.getCompletedAt():null; + var actual=local==null?null:new Actual(null,local,null); + var execution=complete?ExecutionStatus.COMPLETED:session!=null && session.getReceivingAt()!=null?ExecutionStatus.IN_PROGRESS:ExecutionStatus.PENDING; + var blocks=new ArrayList(); + if(dates.size()>1) blocks.add("同柜入仓计划日期不一致,请在原入仓计划核对"); + var refs=plans.stream().map(p->new BusinessReference("INBOUND_PLAN",p.getId().toString(),p.getPlanNo(),"入仓计划")).toList(); + var permission=plans.isEmpty()?OverseasPermissions.INBOUND_ORDER_CREATE:OverseasPermissions.INBOUND_ORDER_EDIT; + var single=plans.size()==1?plans.get(0):null; + var action=complete?null:new Action(ActionCode.SAVE_PLAN,plans.isEmpty()?"建立接仓计划":"查看并维护接仓计划",request.workItemKey(),new ReceiptTarget(single==null?null:single.getId().toString(),single==null?null:single.getPlanNo(),single==null?null:single.getCustomerId(),request.containerBookingId())); + String fingerprint=digest(plans.stream().map(p->p.getId()+":"+p.getVersion()+":"+p.getStatus()).toList()+":"+(session==null?"":session.getId()+":"+session.getVersion())); + // Arrival-date mutations are authoritative planned changes; first date is baselined by the work observer. + String impact=digest(dates.toString()); + return new Fact(request.workItemKey(),fingerprint,impact,!plans.isEmpty(),target==null?PlanStatus.UNPLANNED:PlanStatus.TARGET_SET, + execution,target,null,actual,null,plans.size()==1?plans.get(0).getPlanNo():null,null,List.of(OverseasPermissions.INBOUND_ORDER_LIST), + action==null?List.of():List.of(permission),action,List.copyOf(blocks),target!=null || actual!=null?List.of("SOURCE_TIMEZONE"):List.of(),refs,null,null); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java new file mode 100644 index 000000000..1bae1c9f4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java @@ -0,0 +1,136 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentplanning.*; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway; +import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import org.springframework.stereotype.Component; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +/** Full manifest from source quantities and original appointment allocations; owns no quantity ledger. */ +@Component +public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGateway.RequirementProvider { + private final AppointmentSourceService sources; + private final AppointmentCargoVerificationService cargo; + private final InboundFbaAppointmentService appointments; + private final AppointmentWorkSourceMapper sourceMapping; + public AppointmentWorkRequirementProvider(AppointmentSourceService sources,AppointmentCargoVerificationService cargo,InboundFbaAppointmentService appointments,AppointmentWorkSourceMapper sourceMapping) { + this.sources=sources; this.cargo=cargo; this.appointments=appointments; this.sourceMapping=sourceMapping; + } + /** 按当前实物分配生成客户需求,保留已撤销批次身份以退役旧事项。 */ + public List requirements(ContainerSource container,Flow effectiveFlow) { + var original=container.requirements().stream().map(r->"PRIVATE".equals(r.destinationKind())?new Requirement(r.requirementKey(),r.batchKey(),r.batchNo(),"CUSTOMER",r.originName(),r.destinationName(),r.timezone(),r.references(),r.replacesRequirementKeys(),r.sources()):r).toList(); + if(effectiveFlow==Flow.DIRECT) return original; + if(effectiveFlow!=Flow.TRANSFER) throw conflict("需求清单必须明确有效作业路线"); + var mapped=sourceMapping.containerSources(container.containerBookingId()); + if(mapped.size()>1000) throw unavailable("柜次尾程来源超过完整读取上限"); + var result=new LinkedHashMap(); + var privateDispatches=original.stream().anyMatch(r->"CUSTOMER".equals(r.destinationKind()))?sourceMapping.privateDispatches(container.containerBookingId()):List.of(); + if(privateDispatches.size()>1000) throw unavailable("客户仓排车来源超过完整读取上限"); + for(var requirement:original) { + var inbound=requirement.requirementKey().startsWith("INBOUND:")?requirement.requirementKey().substring(8):null; + var sourceOrders=mapped.stream().filter(s->Objects.equals(s.sourceInboundId(),inbound)).toList(); + if(sourceOrders.isEmpty()) { result.put(requirement.requirementKey(),requirement); continue; } + if("CUSTOMER".equals(requirement.destinationKind())) { customer(requirement,sourceOrders,privateDispatches,result);continue; } + if(!"FBA".equals(requirement.destinationKind())) { result.put(requirement.requirementKey(),requirement); continue; } + var refs=canonical(sourceOrders.stream().map(s->new SourceReference(s.customerId(),"ORDER:"+s.id(),null)).toList()); + List resolved; + try { resolved=sources.resolveBatch(refs); } + catch(org.springframework.web.server.ResponseStatusException error) { + if(!AppointmentWorkSourceAvailability.unresolved(error)) throw error; + // Keep the complete physical obligation and its canonical source identities. The fact + // provider reports the original prerequisite and exposes no booking action. + result.put(requirement.requirementKey(),new Requirement(requirement.requirementKey(),null,null, + requirement.destinationKind(),requirement.originName(),requirement.destinationName(),requirement.timezone(), + requirement.references(),List.of(),refs.stream().map(r->new SourceReference(r.customerId(),r.sourceKey(),r.destinationKey())).toList())); + continue; + } + var complete=resolved.stream().map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).toList(); + var history=appointments.relatedBatchSnapshots(complete); + var active=history.stream().filter(AppointmentWorkRequirementProvider::active).toList(); + // Unknown remaining quantity is still work, never a zero or an absent requirement. + boolean remainder=resolved.stream().map(cargo::inspect).anyMatch(c->c.availablePackages()==null || c.availablePackages()>0); + var replaced=history.stream().filter(a->!active(a)).map(FbaAppointmentResponse::batchKey).toList(); + if(remainder) { + var restored=new LinkedHashSet<>(replaced); + // 回到未分配需求时,同时承接原批次运输事项和按客户拆分的送仓事项。 + complete.forEach(ref->replaced.forEach(key->restored.add(customerRequirement(key,ref.customerId())))); + result.put(requirement.requirementKey(),new Requirement(requirement.requirementKey(),null,null,requirement.destinationKind(), + requirement.originName(),requirement.destinationName(),requirement.timezone(),requirement.references(),List.copyOf(restored), + complete.stream().map(r->new SourceReference(r.customerId(),r.sourceKey(),r.destinationKey())).toList())); + } + for(var batch:active) { + var replacementKeys=new LinkedHashSet<>(replaced); replacementKeys.add(requirement.requirementKey()); + history.stream().filter(a->a.replacementBatchKeys()!=null && a.replacementBatchKeys().contains(batch.batchKey())).map(FbaAppointmentResponse::batchKey).forEach(replacementKeys::add); + var previous=result.get(batch.batchKey()); + if(previous!=null) replacementKeys.addAll(previous.replacesRequirementKeys()); + var references=new ArrayList<>(requirement.references()); + if(previous!=null) references.addAll(previous.references()); + references.add(new BusinessReference("FBA_APPOINTMENT",batch.batchKey(),batch.appointmentNo(),"送仓预约")); + var batchSources=batch.items().stream().map(i->new SourceReference(i.customerId(),i.sourceKey(),i.destinationKey())).distinct().toList(); + result.put(batch.batchKey(),new Requirement(batch.batchKey(),batch.batchKey(),batch.appointmentNo(),"FBA",requirement.originName(), + batch.fbaWarehouseCode(),batch.timezone(),references.stream().distinct().toList(),List.copyOf(replacementKeys),batchSources)); + } + } + var scoped=new ArrayList(); + for(var requirement:result.values()) { + if(requirement.batchKey()==null || !requirement.batchKey().startsWith("FBA_APPOINTMENT:")) { scoped.add(requirement);continue; } + var customers=new TreeMap>(); + requirement.sources().forEach(s->customers.computeIfAbsent(s.customerId(),ignored->new ArrayList<>()).add(s)); + if(customers.isEmpty()) throw unavailable("实物批次缺少客户来源,不能生成送仓事项"); + customers.forEach((customer,refs)->{ + var replacements=new LinkedHashSet<>(requirement.replacesRequirementKeys()); + requirement.replacesRequirementKeys().stream().filter(k->k.startsWith("FBA_APPOINTMENT:")).map(k->customerRequirement(k,customer)).forEach(replacements::add); + // 客户事项承接同一实物批次的整体需求;批次身份未变化也须有明确拆分去向。 + replacements.add(requirement.batchKey()); + scoped.add(new Requirement(customerRequirement(requirement.batchKey(),customer),requirement.batchKey(),requirement.batchNo(),requirement.destinationKind(), + requirement.originName(),requirement.destinationName(),requirement.timezone(),requirement.references(),List.copyOf(replacements),List.copyOf(refs))); + }); + } + return List.copyOf(scoped); + } + private void customer(Requirement requirement,List orders,List dispatches,Map result) { + if(orders.stream().anyMatch(s->!"PRIVATE".equals(s.destinationType()))) throw conflict("客户仓需求与原订单目的地不一致"); + var related=dispatches.stream().filter(d->orders.stream().anyMatch(o->o.id()==d.orderId() && o.customerId().equals(d.customerId()))).toList(); + var active=related.stream().filter(d->!"CANCELLED".equals(d.status())).toList(); + var cancelled=related.stream().filter(d->"CANCELLED".equals(d.status())).map(d->"DISPATCH:"+d.dispatchId()).distinct().toList(); + boolean remaining=false; + for(var order:orders) { + long allocated=active.stream().filter(d->d.orderId()==order.id() && d.customerId().equals(order.customerId())).mapToLong(AppointmentWorkSourceMapper.DispatchSource::packages).sum(); + if(order.packageCount()!=null && allocated>order.packageCount()) throw conflict("客户仓排车逐票分配超过原货物数量"); + if(order.packageCount()==null || allocatednew SourceReference(o.customerId(),"ORDER:"+o.id(),"CUSTOMER:"+o.id())).toList())); + for(var batch:active.stream().map(d->"DISPATCH:"+d.dispatchId()).distinct().toList()) { + var all=sourceMapping.privateBatch(batch); + if(all.isEmpty() || all.size()>1000 || all.stream().anyMatch(d->"CANCELLED".equals(d.status()))) throw unavailable("客户仓排车批次来源不完整"); + var previous=result.get(batch); var replacements=new LinkedHashSet<>(cancelled);replacements.add(requirement.requirementKey()); + var refs=new ArrayList<>(requirement.references()); + if(previous!=null) { replacements.addAll(previous.replacesRequirementKeys());refs.addAll(previous.references()); } + refs.add(new BusinessReference("DISPATCH",batch,all.get(0).dispatchNo(),"客户仓排车")); + result.put(batch,new Requirement(batch,batch,all.get(0).dispatchNo(),"CUSTOMER",requirement.originName(),requirement.destinationName(),requirement.timezone(),refs.stream().distinct().toList(),List.copyOf(replacements),all.stream().map(d->new SourceReference(d.customerId(),d.sourceKey(),d.destinationKey())).distinct().toList())); + } + } + private List canonical(List input) { + if(input==null || input.isEmpty() || input.size()>100) throw unavailable("Firstleg需求缺少完整客户货源关系"); + var orders=input.stream().filter(s->s.sourceKey()!=null && s.sourceKey().startsWith("ORDER:")).map(s->{ + try { return new AppointmentExternalSourceGateway.OrderRef(s.customerId(),Long.parseLong(s.sourceKey().substring(6))); } + catch(RuntimeException error) { throw unavailable("Firstleg原订单关联无效",error); } + }).toList(); + var result=new ArrayList(); + if(!orders.isEmpty()) result.addAll(sources.canonicalOrders(orders)); + input.stream().filter(s->s.sourceKey()!=null && s.sourceKey().startsWith("BOOKING:")).map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).forEach(result::add); + if(result.size()!=input.size()) throw unavailable("来源键必须明确为ORDER或BOOKING"); + return result.stream().distinct().toList(); + } + /** 已取消且没有当前实物分配的记录只属历史;取消但仍有货物的批次继续产生需求。 */ + private static boolean active(FbaAppointmentResponse batch) { + if(batch.replacementBatchKeys()!=null && !batch.replacementBatchKeys().isEmpty()) return false; + // 客户来源取当前活动分配;空取消记录不能制造无货送仓事项,也不能阻断整份清单。 + // 剩余货量仍由 cargo.inspect 核算,有货的 CANCELLED/FAILED 批次不因预约状态退役。 + return !("CANCELLED".equals(batch.appointmentStatus()) && batch.items().isEmpty()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkSourceAvailability.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkSourceAvailability.java new file mode 100644 index 000000000..b6f21c826 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkSourceAvailability.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.inboundplan.work; + +import java.util.Set; +import org.springframework.web.server.ResponseStatusException; + +/** An unbookable physical source remains visible work; transport and integrity errors still fail the scan. */ +final class AppointmentWorkSourceAvailability { + private AppointmentWorkSourceAvailability() { } + private static final Set PREREQUISITES=Set.of( + "当前货源类型不支持FBA预约", "货物尚无已开船或后续实际船节点", "当前货源不是FBA送仓货物", + "接仓点不在启用的美国仓目录中", "目的仓不在启用的美国FBA目录中", "目的地已变化,请返回原业务重新核对"); + static boolean unresolved(ResponseStatusException error) { + return error.getStatusCode().value()==400 && error.getReason()!=null && PREREQUISITES.contains(error.getReason()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkSourceMapper.java new file mode 100644 index 000000000..9a3c03a5f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkSourceMapper.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.inboundplan.work; + +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface AppointmentWorkSourceMapper { + record Source(Long id,String customerId,String sourceOrderId,String sourceInboundId,String destinationType,String fbaWarehouseCode,Integer packageCount) { } + @Select("SELECT id,customer_id,source_order_id,source_inbound_id,destination_type,fba_warehouse_code,package_count FROM ll_order WHERE source_container_booking_id=#{container} ORDER BY id LIMIT 1001") + List containerSources(String container); + record ContainerSource(String containerBookingId,Long id,String customerId,String sourceOrderId,String sourceInboundId,String destinationType,String fbaWarehouseCode,Integer packageCount) { + public Source source() { return new Source(id,customerId,sourceOrderId,sourceInboundId,destinationType,fbaWarehouseCode,packageCount); } + } + @Select(""" + + """) + List containerSourcesBatch(@Param("containers") List containers); + record DispatchSource(long dispatchId,String dispatchNo,String status,long orderId,String customerId,String sourceKey,String destinationKey,long packages) { } + @Select("SELECT b.dispatch_id,d.dispatch_no,d.status,o.id order_id,b.customer_id,b.source_key,b.destination_key,SUM(b.package_count) packages FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id JOIN ll_order o ON b.destination_key=CONCAT('CUSTOMER:',o.id) AND b.customer_id=o.customer_id WHERE b.batch_key=CONCAT('DISPATCH:',d.id) AND o.source_container_booking_id=#{container} GROUP BY b.dispatch_id,d.dispatch_no,d.status,o.id,b.customer_id,b.source_key,b.destination_key ORDER BY b.dispatch_id,o.id LIMIT 1001") + List privateDispatches(String container); + @Select("SELECT b.dispatch_id,d.dispatch_no,d.status,o.id order_id,b.customer_id,b.source_key,b.destination_key,SUM(b.package_count) packages FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id JOIN ll_order o ON b.destination_key=CONCAT('CUSTOMER:',o.id) AND b.customer_id=o.customer_id WHERE b.batch_key=#{batch} AND b.batch_key=CONCAT('DISPATCH:',d.id) GROUP BY b.dispatch_id,d.dispatch_no,d.status,o.id,b.customer_id,b.source_key,b.destination_key ORDER BY o.id LIMIT 1001") + List privateBatch(String batch); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapter.java new file mode 100644 index 000000000..2eb4d45f6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapter.java @@ -0,0 +1,116 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentExecutionPort; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +/** Original allocations and signed/returned delivery evidence; never reads work projections. */ +@Component +public class FbaAppointmentExecutionAdapter implements FbaAppointmentExecutionPort { + private final AppointmentWorkDispatchMapper allocations; + private final FleetTripMapper trips; + private final FleetDispatchGateway dispatches; + private record Facts(List batches,List sources, + List rows, + Map outcomes, + Map evidence,Map invalid) { } + public FbaAppointmentExecutionAdapter(AppointmentWorkDispatchMapper allocations,FleetTripMapper trips,FleetDispatchGateway dispatches) { + this.allocations=allocations;this.trips=trips;this.dispatches=dispatches; + } + @Transactional(readOnly=true) + public Scope inspect(List input) { + var members=members(input);return scope(members,read(batches(members))); + } + @Transactional(propagation=Propagation.MANDATORY) + public Scope lockAndInspect(List input,String expectedVersion) { + var members=members(input);if(members.isEmpty()) throw bad("请选择需要重新预约的客户"); + var before=read(batches(members));var selected=selected(before,members); + var tripNos=tripNos(selected);var numbers=dispatchNos(selected); + for(var trip:tripNos) if(trips.lockByNo(trip)==null) throw conflict("原车次关系已变化,请重新核对"); + for(var number:numbers) { + dispatches.completion(number,true); + dispatches.lockForPlanning(number); + } + var current=read(before.batches());var scope=scope(members,current); + if(!numbers.equals(dispatchNos(selected(current,members))) || !tripNos.equals(tripNos(selected(current,members))) + || expectedVersion==null || !expectedVersion.equals(scope.version())) throw conflict("客户执行范围已变化,请重新读取预约候选"); + for(var member:scope.members()) if(!member.canRebook()) throw conflict(member.reason()); + return scope; + } + private Facts read(List batches) { + if(batches.isEmpty()) return new Facts(batches,List.of(),List.of(),Map.of(),Map.of(),Map.of()); + var sources=allocations.receivingSources(batches);var rows=allocations.transportRows(batches,List.of()); + if(sources.size()>20000 || rows.size()>1000) throw unavailable("客户执行事实超过完整读取上限"); + var outcomes=new TreeMap();var evidence=new TreeMap();var invalid=new TreeMap(); + for(var number:dispatchNos(rows)) { + var completion=dispatches.completion(number,false); + if(completion==null || !number.equals(completion.dispatchNo()) || completion.outcomes()==null) throw unavailable("原派送执行事实读取不完整"); + outcomes.put(number,completion); + if(rows.stream().anyMatch(r->number.equals(r.dispatchNo()) && "DEPARTED".equals(r.dispatchStatus()))) { + try { + var fact=dispatches.terminalEvidence(number,false); + if(fact==null || !number.equals(fact.dispatchNo()) || fact.deliveries()==null) throw unavailable("原发运签收退回事实缺失"); + evidence.put(number,fact); + } catch(ResponseStatusException error) { + if(error.getStatusCode().value()!=409) throw error; + invalid.put(number,Objects.toString(error.getReason(),"原发运证据不完整")); + } + } + } + return new Facts(batches,List.copyOf(sources),List.copyOf(rows),outcomes,evidence,invalid); + } + private Scope scope(List members,Facts facts) { + // The selected customer subset is deliberately absent from the version. All facts for the + // same batch set participate, including siblings omitted from the candidate selection. + return new Scope(digest(facts.toString()),members.stream().map(m->eligibility(m,facts)).toList()); + } + private Eligibility eligibility(Member member,Facts facts) { + var expected=facts.sources().stream().filter(s->member.batchKey().equals(s.batchKey()) && member.customerId().equals(s.customerId())).toList(); + if(expected.isEmpty() || expected.stream().anyMatch(s->s.packages()==null || s.packages()<=0)) return denied(member,false,"客户已移出本批或来源箱数尚未核实"); + var rows=selected(facts,List.of(member)); + boolean complete=!rows.isEmpty(); + var expectedAmounts=new HashMap,Long>(); + for(var source:expected) if(source.sourceKey()==null || source.destinationKey()==null || expectedAmounts.put(List.of(source.sourceKey(),source.destinationKey()),source.packages().longValue())!=null) + return denied(member,false,"原客户逐票分配存在重复或缺失"); + var allocated=rows.stream().collect(Collectors.groupingBy(r->List.of(r.sourceKey(),r.destinationKey()),Collectors.summingLong(AppointmentWorkDispatchMapper.TransportRow::packages))); + if(rows.stream().anyMatch(r->r.packages()<=0) || allocated.entrySet().stream().anyMatch(e->!expectedAmounts.containsKey(e.getKey()) || e.getValue()>expectedAmounts.get(e.getKey()))) + return denied(member,false,"排车逐票数量或来源不属于当前客户批次,请先核对原实物关联"); + if(!expectedAmounts.equals(allocated)) complete=false; + for(var row:rows) { + var proof=facts.evidence().get(row.dispatchNo()); + if(proof==null || !member.customerId().equals(proof.customerId())) { complete=false;continue; } + var delivery=proof.deliveries().stream().filter(d->member.customerId().equals(d.customerId()) && row.destinationKey().equals(d.destinationKey())).toList(); + long dispatched=facts.rows().stream().filter(r->row.dispatchNo().equals(r.dispatchNo()) && row.customerId().equals(r.customerId()) && row.destinationKey().equals(r.destinationKey())).mapToLong(AppointmentWorkDispatchMapper.TransportRow::packages).sum(); + if(delivery.size()!=1 || delivery.get(0).packages()!=dispatched || delivery.get(0).terminalAt()==null || delivery.get(0).terminalAt().isAfter(Instant.now())) complete=false; + } + if(complete) return denied(member,true,"本客户货物已实际签收或完成退回入仓,不再重新预约"); + for(var row:rows) { + if(facts.invalid().containsKey(row.dispatchNo())) return denied(member,false,facts.invalid().get(row.dispatchNo())); + var outcomes=facts.outcomes().get(row.dispatchNo()).outcomes(); + if(outcomes.stream().anyMatch(o->o.terminalAt()!=null || o.signedPackages()>0 || o.returnedPackages()>0 || Set.of("DELIVERING","DELIVERED","REJECTED","RETURN_PENDING","RETURNED").contains(Objects.toString(o.status(),"")))) + return denied(member,false,"本客户已有实际派送执行,请从原运输异常处理"); + if(!"PENDING_DEPARTURE".equals(row.dispatchStatus()) || positive(row.pickedPackages()) || positive(row.shippedPackages()) + || row.taskStatus()!=null && !"PDA_PENDING".equals(row.taskStatus()) || row.outboundStatus()!=null && !"WAIT_OUTBOUND".equals(row.outboundStatus()) + || row.tripNo()!=null && !"PLANNED".equals(row.tripStatus())) return denied(member,false,"本客户运输已开始执行,不能普通重新预约"); + } + return new Eligibility(member,false,true,null); + } + private static Eligibility denied(Member member,boolean completed,String reason) { return new Eligibility(member,completed,false,reason); } + private static boolean positive(Integer value) { return value!=null && value>0; } + private static List selected(Facts facts,List members) { + var scopes=new HashSet<>(members);return facts.rows().stream().filter(r->scopes.contains(new Member(r.batchKey(),r.customerId()))).toList(); + } + private static List dispatchNos(List rows) { return rows.stream().map(AppointmentWorkDispatchMapper.TransportRow::dispatchNo).distinct().sorted().toList(); } + private static List tripNos(List rows) { return rows.stream().map(AppointmentWorkDispatchMapper.TransportRow::tripNo).filter(Objects::nonNull).distinct().sorted().toList(); } + private static List batches(List members) { return members.stream().map(Member::batchKey).distinct().sorted().toList(); } + private static List members(List members) { + if(members==null || members.size()>200 || new HashSet<>(members).size()!=members.size() || members.stream().anyMatch(m->m==null || m.batchKey()==null || !m.batchKey().matches("FBA_APPOINTMENT:[1-9][0-9]*") || m.customerId()==null || m.customerId().isBlank())) throw bad("客户执行范围须为最多200个不重复真实客户批次"); + return members.stream().sorted(Comparator.comparing(Member::batchKey).thenComparing(Member::customerId)).toList(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentTransportAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentTransportAdapter.java new file mode 100644 index 000000000..10530665b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentTransportAdapter.java @@ -0,0 +1,159 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentTransportPort; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.*; +import org.springframework.transaction.support.*; +import java.util.*; +import java.util.stream.Collectors; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +/** Mutates original dispatch rows and fleet bindings only; owns no cargo quantity ledger. */ +@Component +public class FbaAppointmentTransportAdapter implements FbaAppointmentTransportPort { + private final AppointmentWorkDispatchMapper allocations; + private final FleetTripMapper trips; + private final FleetDispatchGateway dispatches; + private final FleetTripService fleet; + private final FleetBookingReservations reservations; + private final com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges changes; + private final Object lockContextKey=new Object(); + private record Locked(List batches,List tripNos,List rows,Scope scope) { } + private record Source(String batch,String customer,String source,String destination) { } + public FbaAppointmentTransportAdapter(AppointmentWorkDispatchMapper allocations,FleetTripMapper trips, + FleetDispatchGateway dispatches,@Lazy FleetTripService fleet,FleetBookingReservations reservations, + com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges changes) { + this.allocations=allocations;this.trips=trips;this.dispatches=dispatches;this.fleet=fleet;this.reservations=reservations;this.changes=changes; + } + @Transactional(readOnly=true) + public Scope inspect(List batchKeys) { + var batches=keys(batchKeys);var first=read(batches,List.of()); + return scope(batches,read(batches,tripNos(first))); + } + @Transactional(propagation=Propagation.MANDATORY) + public Scope lockAndInspect(List batchKeys,String expectedVersion) { + var batches=keys(batchKeys);var initial=read(batches,List.of());var tripNos=tripNos(initial); + for(var trip:tripNos) if(trips.lockByNo(trip)==null) throw conflict("原车次已变化,请重新核对"); + var all=read(batches,tripNos); + var targets=all.stream().filter(r->batches.contains(r.batchKey())).map(AppointmentWorkDispatchMapper.TransportRow::dispatchNo).distinct().sorted().toList(); + for(var number:targets) dispatches.lockForPlanning(number); + var lockedRows=read(batches,tripNos); + if(!tripNos(lockedRows).equals(tripNos)) throw conflict("排车车次关联已变化,请重新核对"); + var scope=scope(batches,lockedRows); + if(expectedVersion==null || !expectedVersion.equals(scope.version())) throw conflict("运输范围或版本已变化,请重新读取调整范围"); + if(!scope.blockReasons().isEmpty()) throw conflict(String.join(";",scope.blockReasons())); + if(TransactionSynchronizationManager.hasResource(lockContextKey)) throw conflict("同一事务不能重复打开运输调整范围"); + TransactionSynchronizationManager.bindResource(lockContextKey,new Locked(batches,tripNos,lockedRows,scope)); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + public void afterCompletion(int status) { TransactionSynchronizationManager.unbindResourceIfPossible(lockContextKey); } + }); + return scope; + } + @Transactional(propagation=Propagation.MANDATORY) + public Result apply(Scope locked,List moves,String operator,String reason) { + var context=(Locked)TransactionSynchronizationManager.getResource(lockContextKey); + if(context==null || !context.scope().equals(locked)) throw conflict("运输调整必须使用本事务锁定的权威范围"); + if(operator==null || operator.isBlank() || reason==null || reason.isBlank()) throw bad("运输调整须提供操作人和原因"); + var current=read(context.batches(),context.tripNos()); + if(!scope(context.batches(),current).version().equals(locked.version())) throw conflict("锁定后的排车事实已变化,请重新核对"); + var intents=new LinkedHashMap(); + if(moves==null || moves.size()>1000) throw bad("运输调整来源清单无效"); + for(var move:moves) { + if(move==null || !context.batches().contains(move.batchKey()) || move.originalPackages()<=0 || move.retainedPackages()<0 || move.retainedPackages()>move.originalPackages() + || move.customerId()==null || move.sourceKey()==null || move.destinationKey()==null) throw bad("运输调整必须对应原批次及0至当前量的真实逐票分配"); + if(move.retainedPackages()intents.containsKey(source(r))).toList(); + var counts=targetRows.stream().collect(Collectors.groupingBy(this::source,Collectors.summingLong(AppointmentWorkDispatchMapper.TransportRow::packages))); + var remove=new LinkedHashMap(); + for(var entry:intents.entrySet()) { + long bound=counts.getOrDefault(entry.getKey(),0L); + if(bound>entry.getValue().originalPackages()) throw conflict("原排车实物超过核心批次当前量,不能调整"); + remove.put(entry.getKey(),Math.toIntExact(Math.max(0,bound-entry.getValue().retainedPackages()))); + } + var byItem=current.stream().collect(Collectors.groupingBy(AppointmentWorkDispatchMapper.TransportRow::itemId,TreeMap::new,Collectors.toList())); + var selected=selectWholeItems(byItem,remove); + var affected=targetRows.stream().filter(r->selected.contains(r.itemId())).map(AppointmentWorkDispatchMapper.TransportRow::dispatchNo).distinct().sorted().toList(); + // Validate every partial edit before invoking any original command. Transaction rollback is still mandatory. + for(var number:affected) { + var rows=current.stream().filter(r->number.equals(r.dispatchNo())).toList(); + boolean whole=rows.stream().allMatch(r->selected.contains(r.itemId())); + var physical=allocations.dispatchPhysical(number); + if(physical.stream().anyMatch(p->!byItem.containsKey(p.itemId()))) throw conflict("原排车含未纳入逐票批次的实物,请先核对完整关联"); + for(var item:physical) if(item.packages()==null || byItem.get(item.itemId()).stream().mapToLong(AppointmentWorkDispatchMapper.TransportRow::packages).sum()!=item.packages()) + throw conflict("逐票批次分配未完整核平原排车实物,不能调整"); + if(!whole && rows.stream().anyMatch(r->r.outboundId()!=null)) throw conflict("部分移出前须先在原业务处理未执行出库单"); + } + var next=new LinkedHashSet(); + for(var number:affected) { + var rows=current.stream().filter(r->number.equals(r.dispatchNo())).toList();var first=rows.get(0); + boolean whole=rows.stream().allMatch(r->selected.contains(r.itemId())); + if(first.tripNo()!=null) fleet.unbindDispatch(first.tripNo(),operator,new FleetTripModels.BindingCommand(trips.findByNo(first.tripNo()).getRecordVersion(),number,reason)); + if(whole) { + allocations.remove(first.dispatchId()); + for(var row:rows) allocations.insert(new AppointmentWorkDispatchMapper.Bound(row.dispatchId(),row.itemId(),row.palletId(),intents.get(source(row)).laterBatchKey(),row.customerId(),row.sourceKey(),row.destinationKey(),row.packages())); + next.add("沿原排车重新安排后送车辆"); + } else { + for(var item:rows.stream().filter(r->selected.contains(r.itemId())).map(AppointmentWorkDispatchMapper.TransportRow::itemId).distinct().sorted().toList()) dispatches.removeItem(number,item); + if(first.tripNo()!=null) fleet.bindDispatch(first.tripNo(),operator,new FleetTripModels.BindingCommand(trips.findByNo(first.tripNo()).getRecordVersion(),number,reason)); + next.add("按后送批次重新选择已移出的实物"); + } + } + for(var number:context.tripNos()) { + var detail=fleet.detail(number); + if(detail.dispatches().isEmpty() && !reservations.hasConfirmed(trips.findByNo(number).getId())) { + // The original cancellation command also checks external reservations and releases vehicle/driver resources. + fleet.cancel(number,operator,new FleetTripModels.Cancel(detail.trip().recordVersion(),reason)); + next.add("原车无有效货物,已按车辆业务取消"); + } else next.add("核对原车剩余货物及发车安排"); + } + if(!affected.isEmpty()) changes.requirementsChanged(); + var follow=new LinkedHashSet<>(context.batches());intents.values().stream().map(Move::laterBatchKey).filter(Objects::nonNull).forEach(follow::add); + return new Result(vehicles(read(List.copyOf(follow),context.tripNos())),List.copyOf(next)); + } + private Set selectWholeItems(Map> byItem,Map wanted) { + var candidates=byItem.entrySet().stream().filter(e->e.getValue().stream().allMatch(r->wanted.getOrDefault(source(r),0)>0)).toList(); + var selected=new LinkedHashSet();var budget=new int[]{10000}; + if(!select(candidates,0,new HashMap<>(wanted),selected,budget)) throw conflict("移出数量不能精确匹配完整实物明细,请先在原库存业务按实际货物拆托后重试"); + return selected; + } + private boolean select(List>> items,int index,Map remaining,Set chosen,int[] budget) { + if(remaining.values().stream().allMatch(n->n==0)) return true; + if(index>=items.size() || --budget[0]<0) return false; + var item=items.get(index);var amounts=item.getValue().stream().collect(Collectors.groupingBy(this::source,Collectors.summingInt(AppointmentWorkDispatchMapper.TransportRow::packages))); + if(amounts.entrySet().stream().allMatch(e->e.getValue()<=remaining.getOrDefault(e.getKey(),0))) { + amounts.forEach((k,v)->remaining.compute(k,(ignored,n)->n-v));chosen.add(item.getKey()); + if(select(items,index+1,remaining,chosen,budget)) return true; + chosen.remove(item.getKey());amounts.forEach((k,v)->remaining.compute(k,(ignored,n)->n+v)); + } + return select(items,index+1,remaining,chosen,budget); + } + private Source source(AppointmentWorkDispatchMapper.TransportRow row) { return new Source(row.batchKey(),row.customerId(),row.sourceKey(),row.destinationKey()); } + private List keys(List keys) { + if(keys==null || keys.isEmpty() || keys.size()>100 || keys.stream().anyMatch(k->k==null || !k.matches("FBA_APPOINTMENT:[1-9][0-9]*"))) throw bad("请选择1至100个真实预约批次"); + return keys.stream().distinct().sorted().toList(); + } + private List read(List batches,List tripNos) { + var rows=allocations.transportRows(batches,tripNos);if(rows.size()>1000) throw conflict("运输实物范围超过完整核对上限,请缩小范围");return rows; + } + private List tripNos(List rows) { return rows.stream().map(AppointmentWorkDispatchMapper.TransportRow::tripNo).filter(Objects::nonNull).distinct().sorted().toList(); } + private Scope scope(List batches,List rows) { + var reasons=new LinkedHashSet(); + for(var row:rows) if(batches.contains(row.batchKey())) { + if(!"PENDING_DEPARTURE".equals(row.dispatchStatus()) || row.pickedPackages()!=null && row.pickedPackages()>0 || row.shippedPackages()!=null && row.shippedPackages()>0 + || row.taskStatus()!=null && !"PDA_PENDING".equals(row.taskStatus()) || row.outboundStatus()!=null && !"WAIT_OUTBOUND".equals(row.outboundStatus())) reasons.add("排车已开始执行,不能普通移出;请进入原运输异常处理"); + if(row.tripNo()!=null && !"PLANNED".equals(row.tripStatus())) reasons.add("原车次已开始执行,不能普通移出"); + if(row.itemPackages()==null || row.packages()<=0) reasons.add("原排车实物数量不完整"); + } + return new Scope(digest(batches+":"+rows),vehicles(rows),List.copyOf(reasons)); + } + private List vehicles(List rows) { + return rows.stream().collect(Collectors.groupingBy(r->List.of(r.dispatchNo(),r.batchKey()),LinkedHashMap::new,Collectors.toList())).values().stream().map(group->{ + var r=group.get(0);return new Vehicle(r.tripNo(),r.tripVersion()==null?null:Math.toIntExact(r.tripVersion()),r.dispatchNo(),r.customerId(),r.batchKey(),r.dispatchStatus(),group.stream().mapToInt(AppointmentWorkDispatchMapper.TransportRow::packages).sum()); + }).toList(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentWorkAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentWorkAdapter.java new file mode 100644 index 000000000..e8c543393 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentWorkAdapter.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentwork.AppointmentWorkFollowupInheritance; +import com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort; +import org.springframework.stereotype.Component; +import java.time.LocalDate; +import java.util.List; + +@Component +public class FbaAppointmentWorkAdapter implements FbaAppointmentWorkPort { + private final AppointmentWorkFollowupInheritance work; + public FbaAppointmentWorkAdapter(AppointmentWorkFollowupInheritance work) { this.work=work; } + public Followup resolveFollowup(String ownerId,LocalDate date) { + var resolved=work.resolve(ownerId,date); + return new Followup(resolved.owner().id(),resolved.owner().name(),resolved.nextFollowupDate()); + } + public void seedFollowup(Member original,Member later,Followup followup,String operatorId) { + work.seed(member(original),member(later),new com.baiyue.lastleg.appointmentwork.AppointmentWorkStore.Followup( + new com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.Owner(followup.ownerId(),followup.ownerName()),followup.nextFollowupDate()),operatorId); + } + public void inheritFollowup(List originals,List successors,String operatorId) { + work.inherit(originals.stream().map(FbaAppointmentWorkAdapter::member).toList(),successors.stream().map(FbaAppointmentWorkAdapter::member).toList(),operatorId); + } + private static AppointmentWorkFollowupInheritance.Member member(Member member) { return new AppointmentWorkFollowupInheritance.Member(member.batchKey(),member.customerId()); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index d6c4cb571..ba07fe491 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -294,6 +294,40 @@ public class InventoryCommandService { } } + /** Internal discard workflow: remove only stock held by the same discard item. */ + @Transactional + public InventoryCommandResponse discardHeld(String customerId, String operatorId, InventoryCommandRequests.Move request) { + if (request.packages() <= 0) throw badRequest("废弃数量必须为正数"); + return execute(customerId, operatorId, "DISCARD", request.idempotencyKey(), request, operation -> { + Locked locked = locked(customerId, request.positionId()); + InventoryPosition position = locked.position(); + InventoryBalance balance = locked.balance(); + requireReservation(customerId, request.positionId(), null, request.packages(), "DISCARD"); + long owned = ledgers.orderHoldCycle(customerId, balance.getInboundOrderId(), + required(request.sourceEventId(), "废弃库存预留标识不能为空")).stream() + .filter(l -> Objects.equals(l.getPositionId(), position.getId()) + && Objects.equals(l.getBusinessOrderNo(), request.businessOrderNo())) + .mapToLong(l -> value(l.getDeltaPackages())).sum(); + requireAtLeast(owned, request.packages(), "discard reservation"); + long before = value(position.getHoldPackages()); + requireAtLeast(before, request.packages(), "held"); + requireAtLeast(value(balance.getHoldPackages()), request.packages(), "balance held"); + position.setHoldPackages(before - request.packages()); + position.setStatus(positionPackages(position) == 0 ? "EMPTY" : "OCCUPIED"); + position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(LocalDateTime.now()); + positions.updateById(position); + balance.setHoldPackages(value(balance.getHoldPackages()) - request.packages()); touch(balance); + balances.updateById(balance); + boolean empty = position.getPalletId() != null && commands.palletPackages(position.getPalletId()) == 0; + if (empty) warehouseGateway.scrapPallet(customerId, position.getPalletId()); + ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), "DISCARD", + "HOLD", "DISCARDED", before, -request.packages(), before - request.packages(), + request.businessOrderNo(), balance.getInboundOrderNo(), request.outboundOrderNo(), request.sourceEventId(), + request.idempotencyKey(), request.remark()); + return response(operation, balance, position, empty); + }); + } + private InventoryCommandResponse ship(String customerId, String operatorId, InventoryCommandRequests.Move request, String skuSnapshot) { return ship(customerId, operatorId, request, skuSnapshot, null); 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/inventory/mapper/InventoryPositionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java index 5aa78e426..dc1e70a5d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java @@ -9,6 +9,7 @@ import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; import java.util.List; +import java.util.Collection; @Mapper public interface InventoryPositionMapper extends BaseMapper { @@ -17,6 +18,14 @@ public interface InventoryPositionMapper extends BaseMapper { List listBalance(@Param("customerId") String customerId, @Param("balanceId") Long balanceId); + @Select({""}) + List listBalances(@Param("customerId") String customerId, + @Param("balanceIds") Collection balanceIds); + @Select("SELECT * FROM ll_inventory_position WHERE id=#{positionId} AND customer_id=#{customerId} LIMIT 1") InventoryPosition findTenantPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapper.java index d602ab804..13ea3d7f8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapper.java @@ -8,12 +8,17 @@ import java.util.List; @Mapper public interface CustomerOrderProgressMapper { @Select(""" + """) - List sources(@Param("customerId") String customerId); + List sources(@Param("customerId") String customerId, @Param("orderIds") List orderIds); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index 103c4d6c9..519aee690 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -56,7 +56,8 @@ public interface LastlegOrderMapper extends BaseMapper { "WHERE g.container_booking_id=ll_order.source_container_booking_id " + "AND g.node_sequence>=40 AND g.actual_at IS NOT NULL) THEN 1 ELSE 0 END END)={0})"; - @Select("") @@ -83,7 +84,7 @@ public interface LastlegOrderMapper extends BaseMapper { "ORDER BY r.signed_at DESC,r.id DESC) AS measurementNo " + "FROM ll_return_receipt r JOIN ll_return_order o ON o.id=r.return_order_id AND o.customer_id=r.customer_id " + "WHERE o.lastleg_order_id IN #{id} " + - "AND r.unit_type IN ('PALLET','BOX') AND r.length IS NOT NULL AND r.width IS NOT NULL AND r.height IS NOT NULL" + + "AND r.unit_type IN ('PALLET','BOX','PARCEL') AND r.length IS NOT NULL AND r.width IS NOT NULL AND r.height IS NOT NULL" + ") m WHERE m.measurementNo=1 GROUP BY m.orderId") List findReturnVolumes(@Param("orderIds") List orderIds); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java index 74886139e..f5ce3216c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java @@ -20,6 +20,20 @@ import org.springframework.web.bind.annotation.RestController; @PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") public class PdaPalletOperationController { @Resource private PdaPalletOperationService service; + @Resource private com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService measurements; + + @PostMapping("/relabel-pallet-measurements") + public ApiResponse> measurements( + @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody PalletOperationRequests.RelabelSources request) { + return ApiResponse.success(measurements.list(PdaWarehouseIdentity.require(user), request.relabelNo())); + } + + @PostMapping("/relabel-pallet-measure") + public ApiResponse> measure( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService.Measure request) { + return ApiResponse.success(measurements.save(PdaWarehouseIdentity.require(user), request)); + } @PostMapping("/pallets/resolve") public ApiResponse resolve( 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 3194e89d6..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,17 +34,24 @@ public final class PalletOperationRequests { @NotEmpty @Size(max = 20) List<@Valid TargetMove> targets, @Size(max = 500) String reason) { } - /** actualSource为现场确认的原入仓库存行;cartonMarks确认本批目标绑定,不推断历史箱唛位置。 */ + 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, @Valid PalletExpectation source, @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/dto/PalletOperationResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java index 0eb4cf9ec..bc437ad42 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java @@ -25,7 +25,14 @@ public final class PalletOperationResponses { String palletCode, String status, int palletVersion, @JsonSerialize(using = ToStringSerializer.class) Long locationId, String locationCode, String destinationWarehouseCode, - long totalPackages, List inventory) { } + long totalPackages, List inventory, + java.math.BigDecimal grossWeightKg, java.math.BigDecimal volumeCbm) { + public PalletContent(long palletId, String palletCode, String status, int palletVersion, Long locationId, + String locationCode, String destinationWarehouseCode, long totalPackages, List inventory) { + this(palletId, palletCode, status, palletVersion, locationId, locationCode, destinationWarehouseCode, + totalPackages, inventory, null, null); + } + } public record PrintablePallet(@JsonSerialize(using = ToStringSerializer.class) long palletId, String palletCode, int palletVersion, String relabelNo, String printableHtml) { } 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 2330c0b53..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; @@ -105,13 +106,16 @@ public class PdaPalletOperationService { || !Objects.equals(carton.getOverseasWarehouseId(), app.getOverseasWarehouseId()) || !Objects.equals(carton.getStatus(), "RELABEL_PENDING")) throw conflict("退货换标来源托盘不存在或已被占用"); - PalletPhysicalGateway.PalletReference pallet = physical.resolve(carton.getPalletCode()); - if (pallet == null || pallet.warehouseId() != identity.overseasWarehouseId()) + String palletCode = text(carton.getPalletCode()); + PalletPhysicalGateway.PalletReference pallet = palletCode.isBlank() || "UNBOUND".equalsIgnoreCase(palletCode) + ? null : physical.resolve(palletCode); + if (palletCode.isBlank() || "UNBOUND".equalsIgnoreCase(palletCode)) palletCode = "UNBOUND"; + if (pallet != null && pallet.warehouseId() != identity.overseasWarehouseId()) throw conflict("退货换标来源托盘不属于当前仓库"); long quantity = entry.getValue().stream().anyMatch(item -> "RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) ? value(carton.getTotalQuantity()) : 1; result.add(new PalletOperationResponses.RelabelSource(carton.getId(), 0L, - pallet.palletId(), pallet.palletCode(), null, carton.getSourceBatchNo(), quantity, quantity, + pallet == null ? null : pallet.palletId(), palletCode, null, carton.getSourceBatchNo(), quantity, quantity, entry.getValue().stream().map(RelabelItem::getCartonMark).filter(Objects::nonNull).toList())); } return result; @@ -225,11 +229,14 @@ public class PdaPalletOperationService { if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) { throw conflict("换标单不属于 Token 冻结的海外仓"); } - Map pallets = resolvePallets(identity.overseasWarehouseId(), - request.source(), List.of(request.target())); - PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode())); - PalletPhysicalGateway.PalletReference target = pallets.get(code(request.target().palletCode())); - if (source.palletId() == target.palletId()) throw conflict("原托盘和目标托盘不能相同"); + if (request.target() == null) throw badRequest("目标托盘不能为空"); + PalletPhysicalGateway.PalletReference target = resolvePallets(identity.overseasWarehouseId(), + List.of(request.target())).get(code(request.target().palletCode())); + PalletPhysicalGateway.PalletReference source = request.source() == null ? null + : resolvePallets(identity.overseasWarehouseId(), List.of(request.source())).get(code(request.source().palletCode())); + Map pallets = new LinkedHashMap<>(); + if (source != null) pallets.put(source.palletCode(), source); + pallets.put(target.palletCode(), target); List marks = request.cartonMarks().stream().map(String::trim).filter(value -> !value.isEmpty()).toList(); if (marks.size() != request.cartonMarks().size() || new HashSet<>(marks).size() != marks.size()) { throw badRequest("箱唛不能为空且不能重复"); @@ -245,6 +252,13 @@ public class PdaPalletOperationService { Map> itemsByPosition = new LinkedHashMap<>(); Map anchors = new LinkedHashMap<>(); boolean returnRelabel = selected.stream().allMatch(item -> item.getReturnCartonId() != null); + boolean unboundReturnRelabel = returnRelabel && selected.stream().allMatch(item -> { + ReturnInventoryCarton carton = returnCartons.selectById(item.getReturnCartonId()); + return carton != null && (!org.springframework.util.StringUtils.hasText(carton.getPalletCode()) + || "UNBOUND".equalsIgnoreCase(carton.getPalletCode())); + }); + if (unboundReturnRelabel && source != null) throw conflict("无来源托盘的退货箱不能提交来源托盘"); + if (!unboundReturnRelabel && source == null) throw conflict("请先确认来源托盘"); InventoryPosition actual = returnRelabel || request.actualSource() == null ? null : positions.selectById(request.actualSource().positionId()); if (!returnRelabel && request.actualSource() != null && (actual == null @@ -255,7 +269,7 @@ public class PdaPalletOperationService { throw conflict("实际来源托盘库存已变化,请刷新后确认"); if (actual != null && application.getOutboundOrderNo() != null && !application.getOutboundOrderNo().isBlank()) throw conflict("已生成出库单,请先处理出库单,不能重新分配冻结来源"); - if (actual != null && marks.stream().anyMatch(boundRelabelMarks(application)::contains)) + if (actual != null && source.palletId() != target.palletId() && marks.stream().anyMatch(boundRelabelMarks(application)::contains)) throw conflict("本批包含本轮已组托的换标箱,请刷新后选择未组托的箱"); if ("PROCESSING".equals(application.getStatus())) { var workTask = relabelWorkTasks.find(application.getCustomerId(), application.getId()); @@ -269,7 +283,42 @@ public class PdaPalletOperationService { } } if (returnRelabel) { - return bindReturnRelabelPallet(identity, application, selected, source, target, request); + if (source != null && source.palletId() == target.palletId()) throw conflict("退货换标请使用独立目标托盘"); + return bindReturnRelabelPallet(identity, application, selected, source, target, request, unboundReturnRelabel); + } + if (source.palletId() == target.palletId()) { + return transaction.execute(status -> { + var lockedApp = relabelApplications.lockAnyByRelabelNo(application.getRelabelNo()); + requireRelabelWork(identity, lockedApp); + long total = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), source.palletId()); + long selectedPackages = selected.stream().mapToLong(i -> value(i.getNotifiedPackageCount())).sum(); + if (total <= 0 || total != selectedPackages) throw conflict("部分贴标必须拆到新托盘;只有整托全部贴标可保留原托盘"); + Map> byPosition = selected.stream().collect(Collectors.groupingBy(RelabelItem::getSourceInventoryPositionId)); + Map retained = new LinkedHashMap<>(); + for (var entry : byPosition.entrySet()) { + var row = mapper.lockPosition(application.getOverseasWarehouseId(), entry.getKey()); + long count = entry.getValue().stream().mapToLong(i -> value(i.getNotifiedPackageCount())).sum(); + if (row == null || !application.getCustomerId().equals(row.getCustomerId()) + || !Objects.equals(row.getPalletId(), source.palletId()) || value(row.getHoldPackages()) < count) + throw conflict("原托盘换标库存已变化,请刷新后核对"); + requirePalletReservation(application.getCustomerId(), entry.getKey(), inventoryCommands.relabelTransfer(application.getCustomerId(), application.getId())); + retained.put(entry.getKey(), positions.selectById(entry.getKey())); + } + requireRelabelDestination(identity.overseasWarehouseId(), selected, target); + 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(), 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))); + var operation = new PdaPalletOperation(); operation.setId(result.operationId()); + operation.setOverseasWarehouseId(identity.overseasWarehouseId()); operation.setOperatorKey(identity.operatorKey()); + operation.setOperationType(result.operationType()); operation.setIdempotencyKey(request.idempotencyKey()); + operation.setRequestHash(hash(request)); operation.setStatus("COMPLETED"); operation.setResultJson(write(result)); + operation.setCreatedAt(result.completedAt()); operation.setUpdatedAt(result.completedAt()); mapper.insert(operation); + return result; + }); } for (RelabelItem item : selected) { if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null @@ -303,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, @@ -313,25 +362,27 @@ public class PdaPalletOperationService { private PalletOperationResponses.Operation bindReturnRelabelPallet(PdaWarehouseIdentity identity, RelabelApplication application, List selected, PalletPhysicalGateway.PalletReference source, PalletPhysicalGateway.PalletReference target, - PalletOperationRequests.RelabelDisassemble request) { + PalletOperationRequests.RelabelDisassemble request, boolean unboundSource) { requireRelabelDestination(identity.overseasWarehouseId(), selected, target); Set alreadyBound = boundRelabelMarks(application); - if (selected.stream().map(RelabelItem::getCartonMark).anyMatch(alreadyBound::contains)) + boolean rebind = request.actualSource() == null && selected.stream().allMatch(item -> alreadyBound.contains(item.getCartonMark())); + if (!rebind && selected.stream().map(RelabelItem::getCartonMark).anyMatch(alreadyBound::contains)) throw conflict("退货换标箱已在本轮完成组托,请刷新后重试"); long quantity = 0; List cartons = new ArrayList<>(); for (RelabelItem item : selected) { ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId()); if (carton == null || !Objects.equals(carton.getCustomerId(), application.getCustomerId()) - || !Objects.equals(carton.getPalletCode(), source.palletCode()) + || (unboundSource ? (org.springframework.util.StringUtils.hasText(carton.getPalletCode()) && !"UNBOUND".equalsIgnoreCase(carton.getPalletCode())) + : !Objects.equals(carton.getPalletCode(), source.palletCode())) || !Objects.equals(carton.getRelabelApplicationId(), application.getId()) || !Objects.equals(carton.getStatus(), "RELABEL_PENDING")) throw conflict("退货换标来源托盘或库存状态已变化,请刷新后重试"); quantity += value(item.getNotifiedPackageCount()); cartons.add(carton); } - if (request.actualSource() == null || request.actualSource().positionId() != cartons.get(0).getId() - || request.actualSource().packages() != quantity) + if (!rebind && !unboundSource && (request.actualSource() == null || request.actualSource().positionId() != cartons.get(0).getId() + || request.actualSource().packages() != quantity)) throw conflict("退货换标拆出数量必须等于本批已完成贴标数量"); String key = required(request.idempotencyKey(), "幂等键不能为空"); String requestHash = hash(request); @@ -344,7 +395,8 @@ public class PdaPalletOperationService { RelabelItem item = selected.get(index); ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId()); if (carton == null || !Objects.equals(carton.getCustomerId(), application.getCustomerId()) - || !Objects.equals(carton.getPalletCode(), source.palletCode()) + || (unboundSource ? (org.springframework.util.StringUtils.hasText(carton.getPalletCode()) && !"UNBOUND".equalsIgnoreCase(carton.getPalletCode())) + : !Objects.equals(carton.getPalletCode(), source.palletCode())) || !Objects.equals(carton.getRelabelApplicationId(), application.getId()) || !"RELABEL_PENDING".equals(carton.getStatus())) throw conflict("退货换标来源库存已变化,请刷新后重试"); @@ -362,9 +414,10 @@ public class PdaPalletOperationService { } } recordReturnRelabelBinding(identity, application, selected, target.palletCode()); - List contents = List.of( - content(identity.overseasWarehouseId(), physical.resolve(source.palletCode())), - content(identity.overseasWarehouseId(), physical.resolve(target.palletCode()))); + List contents = source == null + ? List.of(content(identity.overseasWarehouseId(), physical.resolve(target.palletCode()))) + : List.of(content(identity.overseasWarehouseId(), physical.resolve(source.palletCode())), + content(identity.overseasWarehouseId(), physical.resolve(target.palletCode()))); PalletOperationResponses.Operation result = new PalletOperationResponses.Operation(operation.getId(), operation.getOperationType(), key, "COMPLETED", false, LocalDateTime.now(), contents); operation.setStatus("COMPLETED"); operation.setResultJson(write(result)); @@ -788,9 +841,16 @@ 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()))); + "targetPallets", targets.values().stream().map(InventoryPosition::getPalletNo).distinct().toList(), + "sourcePallets", mutation.itemsByPosition().keySet().stream().map(positions::selectById) + .filter(Objects::nonNull).map(InventoryPosition::getPalletNo).distinct().toList()))); relabelWorkHistory.insert(record); } @@ -836,6 +896,8 @@ public class PdaPalletOperationService { Set destinations = new HashSet<>(); selected.forEach(item -> destinations.add(text(item.getDestinationFbaWarehouseCode()))); if (destinations.size() != 1 || destinations.contains("")) throw conflict("请按已维护的新目的仓分批组托"); + returnCartons.palletCartons(String.valueOf(warehouseId), target.palletCode()) + .forEach(carton -> destinations.add(text(carton.getDestinationWarehouseCode()))); for (PalletInventoryRow row : mapper.content(String.valueOf(warehouseId), target.palletId())) { var bound = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() .eq("customer_id", row.getCustomerId()).eq("source_inventory_position_id", row.getPositionId())); @@ -898,10 +960,22 @@ public class PdaPalletOperationService { value(row.getReceivingPackages()), value(row.getVersion())); }).toList(); long total = rows.stream().mapToLong(PdaPalletOperationService::rowPackages).sum(); + var boxes = returnCartons.palletCartons(String.valueOf(warehouseId), pallet.palletCode()); + boxes.forEach(box -> destinations.add(text(box.getDestinationWarehouseCode()))); + total += boxes.size(); + // Derive totals from current physical bindings, so retries and rebinds cannot double count. + java.math.BigDecimal weight = rows.isEmpty() ? java.math.BigDecimal.ZERO : null; + java.math.BigDecimal volume = rows.isEmpty() ? java.math.BigDecimal.ZERO : null; + for (var box : boxes) { + if (weight != null) weight = box.getWeight() == null || box.getWeight().signum() <= 0 ? null : weight.add(box.getWeight()); + if (volume != null) volume = box.getLength() == null || box.getWidth() == null || box.getHeight() == null + || box.getLength().signum() <= 0 || box.getWidth().signum() <= 0 || box.getHeight().signum() <= 0 ? null + : volume.add(box.getLength().multiply(box.getWidth()).multiply(box.getHeight()).movePointLeft(6)); + } String locationCode = locationCode(warehouseId, pallet.locationId()); return new PalletOperationResponses.PalletContent(pallet.palletId(), pallet.palletCode(), pallet.status(), pallet.version(), pallet.locationId(), locationCode, destinations.size() == 1 ? destinations.iterator().next() : null, - total, inventory); + total, inventory, weight, volume); } private PdaPalletOperation begin(PdaWarehouseIdentity identity, String type, String key, String requestHash) { @@ -1054,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/controller/MerchantRelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java index 419d2e3f4..fdc6b2fcc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java @@ -74,6 +74,15 @@ public class MerchantRelabelApplicationController { if ("ITEM".equalsIgnoreCase(scope)) return ApiResponse.success(service.uploadItemFile(customer(user), relabelNo, itemId == null ? 0L : itemId, operatorLong(user), file)); return ApiResponse.success(service.uploadTotalFile(customer(user), relabelNo, operatorLong(user), file, kind)); } + /** 按登录客户及申请绑定关系读取贴标资料,禁止跨客户或跨明细下载。 */ + @GetMapping("/{relabelNo}/files/{fileId}/download") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") + public ResponseEntity downloadFile(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @PathVariable long fileId, @RequestParam String scope, + @RequestParam(required = false) Long itemId) { + var file = service.downloadFile(customer(user), relabelNo, scope, itemId, fileId); + return download(file.content(), file.fileName(), file.contentType()); + } @GetMapping("/{relabelNo}/shipping-label/{fileId}/download") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") public ResponseEntity downloadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java index c3cab46c9..9fa946db2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -134,6 +134,17 @@ public class RelabelApplicationController { return ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()).header("Content-Disposition", ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString()) .contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).contentLength(file.content().length).body(file.content()); } + /** 平台按申请绑定关系读取客户上传的面单。 */ + @GetMapping("/{relabelNo}/shipping-label/{fileId}/download") + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") + public ResponseEntity downloadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @PathVariable long fileId) { + requirePlatform(user); + var file = service.downloadShippingLabel(service.customerId(relabelNo), relabelNo, fileId); + return ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()) + .header("Content-Disposition", ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).contentLength(file.content().length).body(file.content()); + } @GetMapping("/{relabelNo}/completion-photos/{fileId}/download") @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") public ResponseEntity downloadCompletionPhoto(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java index 9e8d44564..1ba2d252d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java @@ -18,7 +18,12 @@ public final class RelabelResponses { String walletErrorMessage, String deliveryMethod, boolean printShippingLabel, String shippingLabelFileId, String shippingLabelFileName, String shippingLabelFileUrl, String fulfillmentStatus, String outboundOrderNo, LocalDateTime customerConfirmedAt, - String reviewChannel, String reviewRemark, LocalDateTime createdAt) {} + String reviewChannel, String reviewRemark, LocalDateTime createdAt, + LocalDateTime reviewedAt, LocalDateTime relabelCompletedAt, LocalDateTime outboundAt, + String productNameZh, String labelRequirement, List operationTypes, + List relatedBusinessOrderNos) {} + public record Milestones(LocalDateTime reviewedAt, LocalDateTime relabelCompletedAt, + LocalDateTime outboundAt) {} public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName, String productImageUrl, String productNameZh, int totalPackageCount, int notifiedPackageCount, String shipmentNo, String trackingCode, @@ -38,7 +43,21 @@ public final class RelabelResponses { String destinationFbaWarehouseName, String skuCode, String productNameZh, long availablePackages, long version, List positions, String sourceType, Long returnCartonId, String cartonMark, - List skuItems, long relabelledPackages) {} + List skuItems, long relabelledPackages, String relabelStatus, + long relabelledQuantity, long relabelReservedQuantity, Long stockQuantity, String inventoryStatus, String occupiedRelabelNo, List mergedIntoCartonMarks, boolean hasOutbound, + List skuConversions, + List cartonSkuItems, long cartonAvailablePackages) { + public InventoryCandidate(Long inventoryBalanceId, Long inboundOrderId, String inboundOrderNo, String inboundCode, + String overseasWarehouseId, String overseasWarehouseCode, String overseasWarehouseName, + String destinationFbaWarehouseCode, String destinationFbaWarehouseName, String skuCode, String productNameZh, + long availablePackages, long version, List positions, String sourceType, + Long returnCartonId, String cartonMark, List skuItems, long relabelledPackages) { + this(inventoryBalanceId, inboundOrderId, inboundOrderNo, inboundCode, overseasWarehouseId, + overseasWarehouseCode, overseasWarehouseName, destinationFbaWarehouseCode, destinationFbaWarehouseName, + skuCode, productNameZh, availablePackages, version, positions, sourceType, returnCartonId, cartonMark, + skuItems, relabelledPackages, null, relabelledPackages, 0, null, null, null, List.of(), false, List.of(), List.of(), 0); + } + } public record HistoricalOrder(Long id, String orderNo, String sourceOrderNo, String outboundOrderNo, String relabelOrderNo, String orderType, String status, Integer packageCount, LocalDateTime createdAt) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java index 7184b7048..c0cc1f19e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.relabel.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.dto.RelabelResponses; import com.baiyue.lastleg.relabel.entity.RelabelApplication; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @@ -9,6 +10,34 @@ import org.apache.ibatis.annotations.Update; @Mapper public interface RelabelApplicationMapper extends BaseMapper { + // Read existing facts so historical applications receive the same timestamps as new ones. + // COMPLETED alone is insufficient: SKU-only work can complete without leaving the warehouse. + @Select(""" + SELECT + (SELECT MAX(h.created_at) FROM ll_relabel_status_history h + WHERE h.customer_id=a.customer_id AND h.application_id=a.id + AND h.from_status='PENDING_REVIEW' AND h.to_status IN ('PENDING_PROCESSING','REJECTED')) AS reviewed_at, + CASE WHEN a.status IN ('CUSTOMER_CONFIRM_PENDING','READY_FOR_OUTBOUND','COMPLETED') THEN + COALESCE((SELECT MAX(t.submitted_at) FROM ll_relabel_pda_task t + WHERE t.customer_id=a.customer_id AND t.application_id=a.id), + (SELECT MAX(h.created_at) FROM ll_relabel_status_history h + WHERE h.customer_id=a.customer_id AND h.application_id=a.id + AND h.to_status='CUSTOMER_CONFIRM_PENDING')) + END AS relabel_completed_at, + COALESCE((SELECT MAX(t.completed_at) FROM ll_outbound_pda_task t + JOIN ll_outbound_order o ON o.id=t.outbound_order_id AND o.customer_id=t.customer_id + WHERE o.customer_id=a.customer_id AND o.outbound_no=a.outbound_order_no + AND o.status='OUTBOUNDED' AND t.status='COMPLETED'), + (SELECT MAX(w.outbound_at) FROM ll_warehouse_transfer_order w + WHERE w.customer_id=a.customer_id AND w.relabel_application_id=a.id AND w.relabel_no=a.relabel_no), + (SELECT MAX(h.created_at) FROM ll_relabel_status_history h + WHERE h.customer_id=a.customer_id AND h.application_id=a.id + AND h.from_status='READY_FOR_OUTBOUND' AND h.to_status='COMPLETED')) AS outbound_at + FROM ll_relabel_application a WHERE a.customer_id=#{customerId} AND a.id=#{applicationId} + """) + RelabelResponses.Milestones findMilestones( + @Param("customerId") String customerId, @Param("applicationId") Long applicationId); + @Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1") RelabelApplication findNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java index 871f82830..3ef755bed 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java @@ -6,6 +6,25 @@ import org.apache.ibatis.annotations.Mapper; @Mapper public interface RelabelItemMapper extends BaseMapper { + @org.apache.ibatis.annotations.Select("SELECT ri.* FROM ll_relabel_item ri JOIN ll_relabel_application ra ON ra.id=ri.application_id " + + "WHERE ri.customer_id=#{customerId} AND ri.return_carton_id=#{cartonId} AND ri.application_id<>#{currentApplicationId} " + + "AND ra.status NOT IN ('CANCELLED','REJECTED') AND ra.customer_confirmed_at IS NOT NULL " + + "AND EXISTS(SELECT 1 FROM ll_relabel_pda_unit_fact f WHERE f.item_id=ri.id AND f.result='RELABELLED') " + + "AND NOT EXISTS(SELECT 1 FROM ll_relabel_item newer JOIN ll_relabel_application na ON na.id=newer.application_id " + + "WHERE newer.return_carton_id=ri.return_carton_id AND newer.application_id<>#{currentApplicationId} AND na.status NOT IN ('CANCELLED','REJECTED') " + + "AND (COALESCE(newer.source_carton_mark,newer.carton_mark)=COALESCE(ri.source_carton_mark,ri.carton_mark) " + + "OR COALESCE(newer.source_carton_mark,newer.carton_mark)=(SELECT c.carton_mark FROM ll_return_inventory_carton c WHERE c.id=#{cartonId})) " + + "AND (na.created_at>ra.created_at OR (na.created_at=ra.created_at AND na.id>ra.id))) ORDER BY ri.id") + java.util.List priorCompletedReturnItems(@org.apache.ibatis.annotations.Param("customerId") String customerId, + @org.apache.ibatis.annotations.Param("cartonId") long cartonId, + @org.apache.ibatis.annotations.Param("currentApplicationId") long currentApplicationId); + @org.apache.ibatis.annotations.Update("UPDATE ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id " + + "SET i.return_carton_id=#{targetId},i.carton_mark=#{targetMark} WHERE i.customer_id=#{customerId} " + + "AND i.return_carton_id=#{sourceId} AND a.status NOT IN ('CANCELLED','REJECTED') AND a.customer_confirmed_at IS NOT NULL") + int moveCompletedReturnCarton(@org.apache.ibatis.annotations.Param("customerId") String customerId, + @org.apache.ibatis.annotations.Param("sourceId") long sourceId, + @org.apache.ibatis.annotations.Param("targetId") long targetId, + @org.apache.ibatis.annotations.Param("targetMark") String targetMark); @org.apache.ibatis.annotations.Select("SELECT id FROM ll_inventory_balance WHERE id=#{balanceId} AND customer_id=#{customerId} FOR UPDATE") Long lockSourceBalance(@org.apache.ibatis.annotations.Param("customerId") String customerId, @org.apache.ibatis.annotations.Param("balanceId") Long balanceId); 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 new file mode 100644 index 000000000..f2af956b3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelPalletMeasurementMapper.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.relabel.mapper; + +import org.apache.ibatis.annotations.*; +import java.math.BigDecimal; +import java.util.List; + +@Mapper +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," + + "COALESCE(available_packages,0)+COALESCE(hold_packages,0)+COALESCE(allocated_packages,0)+COALESCE(picked_packages,0)+COALESCE(receiving_packages,0) packages,version " + + "FROM ll_inventory_position WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} ORDER BY id") + List stock(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId); + @Select("SELECT id FROM ll_inventory_position WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND pallet_no=#{palletCode} ORDER BY id FOR UPDATE") + List lockStock(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode); + @Select("SELECT warehouse_pallet_id palletId,pallet_no palletNo,carton_count packages,length_cm lengthCm,width_cm widthCm,height_cm heightCm,gross_weight_kg grossWeightKg,volume_cbm volumeCbm " + + "FROM ll_warehouse_transfer_pallet WHERE customer_id=#{customerId} AND transfer_order_id=#{transferId} AND status='COMPLETED'") + List originals(@Param("customerId") String customerId, @Param("transferId") Long transferId); + @Select("SELECT COUNT(*) FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND pallet_id=#{palletId} " + + "AND business_type IN ('RELABEL_PALLET_DISASSEMBLE','PALLET_ASSEMBLE','PALLET_DISASSEMBLE','PALLET_SWAP','PALLET_UNBIND')") + int movements(@Param("customerId") String customerId, @Param("palletId") Long palletId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java index 0e4405b67..a15335082 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java @@ -86,6 +86,13 @@ public class PdaRelabelController { identity.overseasWarehouseId(), relabelNo, request)); } + @PostMapping("/{relabelNo}/return-cartons/pack") + public ApiResponse packReturn(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests.BoxMerge request) { + Identity identity = identity(user); + return ApiResponse.success(service.packReturn(identity.operatorKey(), identity.operatorName(), identity.overseasWarehouseId(), relabelNo, request)); + } + @PostMapping("/{relabelNo}/return-skus/complete-total") public ApiResponse completeReturnSkuTotal(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.BulkSkuTotal request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java index a44f212f0..b38d7d556 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java @@ -17,7 +17,13 @@ public final class PdaRelabelRequests { public record SkuScan(@NotNull @Positive Long itemId, @Min(0) int skuIndex, @NotBlank @Size(max = 255) String oldSku, @NotNull @Min(0) Integer expectedVersion, - @NotBlank @Size(max = 255) String idempotencyKey) { } + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 128) String cartonMark, + @Size(max = 128) String newSkuCode, @Positive Integer quantity) { + public SkuScan(Long itemId, int skuIndex, String oldSku, Integer expectedVersion, String idempotencyKey) { + this(itemId, skuIndex, oldSku, expectedVersion, idempotencyKey, null, null, null); + } + } public record CartonScan(@NotBlank @Size(max = 255) String cartonMark, @NotNull @Min(0) Integer expectedVersion, @@ -40,7 +46,13 @@ public final class PdaRelabelRequests { public record BulkSku(@NotNull @Positive Long itemId, @Positive int quantity, @NotNull @Min(0) Integer expectedVersion, - @NotBlank @Size(max = 255) String idempotencyKey) { } + @NotBlank @Size(max = 255) String idempotencyKey, + @NotBlank @Size(max = 128) String cartonMark, + @NotBlank @Size(max = 128) String skuCode) { + public BulkSku(Long itemId, int quantity, Integer expectedVersion, String idempotencyKey) { + this(itemId, quantity, expectedVersion, idempotencyKey, null, null); + } + } public record BulkSkuTotal(@NotBlank @Size(max = 255) String skuCode, @Positive int quantity, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java index 02afc5ff0..133064335 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java @@ -20,7 +20,20 @@ public final class PdaRelabelResponses { String operatorName, String reviewerName, List completionPhotoFileIds, String completionRemark, String reviewDecision, String reviewRemark, LocalDateTime claimedAt, LocalDateTime submittedAt, LocalDateTime reviewedAt, - List items, List unitFacts, List history) { } + List items, List unitFacts, List history, String deliveryMethod, + boolean requiresPalletBinding) { + public Detail(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline, String contactName, + String contactPhone, String applicationStatus, String operationType, String taskStatus, int expectedPackages, + int relabelledPackages, int skippedPackages, int exceptionPackages, int version, String operatorName, + String reviewerName, List completionPhotoFileIds, String completionRemark, String reviewDecision, + String reviewRemark, LocalDateTime claimedAt, LocalDateTime submittedAt, LocalDateTime reviewedAt, + List items, List unitFacts, List history) { + this(relabelNo, customerOrderNo, labelDeadline, contactName, contactPhone, applicationStatus, operationType, + taskStatus, expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, version, + operatorName, reviewerName, completionPhotoFileIds, completionRemark, reviewDecision, reviewRemark, + claimedAt, submittedAt, reviewedAt, items, unitFacts, history, null, false); + } + } public record Item(Long itemId, String skuCode, String productNameZh, String shipmentNo, String trackingCode, String newSkuCode, String labelRequirement, @@ -29,7 +42,29 @@ public final class PdaRelabelResponses { List skuItems, int expectedPackages, int relabelledPackages, int skippedPackages, int exceptionPackages, int cartonProcessedPackages, int skuProcessedPackages, - String destinationFbaWarehouseCode) { } + String destinationFbaWarehouseCode, Long returnCartonId, long returnQuantity, + String sourcePalletCode, String sourcePalletBasis) { + public Item(Long itemId, String skuCode, String productNameZh, String shipmentNo, String trackingCode, + String newSkuCode, String labelRequirement, String relabelFileId, String relabelFileName, String relabelFileUrl, + String cartonMark, String fbaCartonMark, String operationType, List skuItems, int expectedPackages, + int relabelledPackages, int skippedPackages, int exceptionPackages, int cartonProcessedPackages, + int skuProcessedPackages, String destinationFbaWarehouseCode, Long returnCartonId, long returnQuantity) { + this(itemId, skuCode, productNameZh, shipmentNo, trackingCode, newSkuCode, labelRequirement, + relabelFileId, relabelFileName, relabelFileUrl, cartonMark, fbaCartonMark, operationType, skuItems, + expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, cartonProcessedPackages, + skuProcessedPackages, destinationFbaWarehouseCode, returnCartonId, returnQuantity, null, null); + } + public Item(Long itemId, String skuCode, String productNameZh, String shipmentNo, String trackingCode, + String newSkuCode, String labelRequirement, String relabelFileId, String relabelFileName, String relabelFileUrl, + String cartonMark, String fbaCartonMark, String operationType, List skuItems, int expectedPackages, + int relabelledPackages, int skippedPackages, int exceptionPackages, int cartonProcessedPackages, + int skuProcessedPackages, String destinationFbaWarehouseCode) { + this(itemId, skuCode, productNameZh, shipmentNo, trackingCode, newSkuCode, labelRequirement, + relabelFileId, relabelFileName, relabelFileUrl, cartonMark, fbaCartonMark, operationType, skuItems, + expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, cartonProcessedPackages, + skuProcessedPackages, destinationFbaWarehouseCode, null, 0); + } + } public record SkuItem(String skuCode, String newSkuCode, int quantity, String remark) { } public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java index 6e18f077a..272ac6222 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java @@ -52,7 +52,35 @@ import java.util.stream.Collectors; @Service public class PdaRelabelService { + @Resource private com.baiyue.lastleg.relabel.service.ReturnSkuConversionService skuConversions; + @Resource private com.baiyue.lastleg.relabel.service.ReturnRelabelPackingService returnPacking; + + @Transactional + public PdaRelabelResponses.Detail packReturn(String operatorKey, String operatorName, long warehouseId, + String relabelNo, com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests.BoxMerge request) { + UnitCommand command = new UnitCommand(relabelNo, "RETURN_PACK", request); + var replay = replay(operatorKey, warehouseId, relabelNo, "RETURN_PACK", request.idempotencyKey(), command); + if (replay != null) return replay; + var app = requireApplication(relabelNo, warehouseId, true); + var task = requireOwnedTask(app, operatorKey, true); + var selected = itemList(app).stream().filter(i -> i.getReturnCartonId() != null + && Set.of("RETURN_BULK_SKU_LABEL", "SKU_LABEL").contains(operationType(i))).toList(); + if (selected.isEmpty() || selected.size() != itemList(app).size()) throw conflict("仅退货按件换标任务可使用此合箱操作"); + var facts = factList(task); + for (var item : selected) if (quantity(facts.stream().filter(f -> item.getId().equals(f.getItemId())).toList(), "RELABELLED") + != value(item.getNotifiedPackageCount())) throw conflict("请先完成每个来源包裹/箱的换标数量登记,再合箱"); + var sourceDeductions = returnPacking.deductions(selected); + var packed = returnPacking.pack(new com.baiyue.lastleg.pda.security.PdaWarehouseIdentity(operatorKey, operatorName, warehouseId), app, selected, request); + updateTask(task, task.getVersion()); + insertHistory(task, "RETURN_PACK", PROCESSING, PROCESSING, operatorKey, operatorName, + Map.of("cartonMark", packed.cartonMark(), "quantity", packed.totalQuantity(), "label", packed.label(), + "round", mixedRound(task), "sourceCartonMarks", request.sourceCartonMarks(), "sourceDeductions", sourceDeductions)); + var result = detail(app, task); + saveOperation(task, operatorKey, "RETURN_PACK", request.idempotencyKey(), command, result); + return result; + } @Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard bindingGuard; + @Resource private com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService palletMeasurements; public static final String AVAILABLE = "AVAILABLE"; public static final String PROCESSING = "PROCESSING"; public static final String WAIT_REVIEW = "WAIT_REVIEW"; @@ -66,6 +94,7 @@ public class PdaRelabelService { @Resource private PdaRelabelTaskMapper tasks; @Resource private PdaRelabelUnitFactMapper unitFacts; @Resource private ReturnInventoryCartonMapper returnCartons; + @Resource private com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper sourcePositions; @Resource private PdaRelabelOperationMapper operations; @Resource private PdaRelabelHistoryMapper histories; @Resource private RelabelApplicationService applicationService; @@ -230,11 +259,16 @@ public class PdaRelabelService { var task = requireOwnedTask(app, operatorKey, true); requireVersion(task, request.expectedVersion()); var item = requireItem(app, request.itemId()); + if (item.getReturnCartonId() != null && !Objects.equals(item.getCartonMark(), trim(request.cartonMark()))) + throw conflict("请扫描当前来源包裹/箱号后再扫描 SKU"); if (!Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(operationType(item))) throw conflict("该箱没有SKU换标任务"); if (value(item.getNotifiedPackageCount()) != 1) throw conflict("SKU逐条作业要求每个仓库唛头独立一箱"); var skus = readSkuItems(item.getSkuRelabelItemsJson()); if (request.skuIndex() < 0 || request.skuIndex() >= skus.size()) throw conflict("SKU明细不存在"); var sku = skus.get(request.skuIndex()); + if (item.getReturnCartonId() != null && (!Objects.equals(sku.newSkuCode(), trim(request.newSkuCode())) + || !Objects.equals(sku.quantity(), request.quantity()))) + throw conflict("新 SKU 和件数必须与已批准的来源箱明细一致"); if (!Objects.equals(sku.skuCode(), request.oldSku().trim()) || !StringUtils.hasText(sku.newSkuCode()) || sku.quantity() <= 0) throw conflict("旧SKU或绑定的新标签、数量不正确"); if (factList(task).stream().anyMatch(fact -> item.getId().equals(fact.getItemId())) @@ -275,6 +309,9 @@ public class PdaRelabelService { requireVersion(task, request.expectedVersion()); RelabelItem item = requireItem(application, request.itemId()); if (!"RETURN_BULK_SKU_LABEL".equals(operationType(item))) throw conflict("当前明细不是退货按件贴标任务"); + if (!Objects.equals(item.getCartonMark(), trim(request.cartonMark())) + || !Objects.toString(item.getSkuCode(), "").equalsIgnoreCase(Objects.toString(trim(request.skuCode()), ""))) + throw conflict("请扫描当前来源包裹/箱号和对应 SKU,不能登记其他来源"); int completed = quantity(factList(task).stream().filter(fact -> item.getId().equals(fact.getItemId())).toList(), "RELABELLED"); int remaining = value(item.getNotifiedPackageCount()) - completed; if (request.quantity() > remaining) throw conflict("本次完成数量超过该 SKU 剩余贴标数量"); @@ -309,6 +346,8 @@ public class PdaRelabelService { .filter(item -> skuCode.equalsIgnoreCase(Objects.toString(item.getSkuCode(), "").trim())) .toList(); if (skuItems.isEmpty()) throw notFound("当前换标单没有该退货 SKU"); + if (skuItems.stream().map(RelabelItem::getCartonMark).distinct().count() != 1) + throw conflict("同 SKU 来自多个包裹/箱,请逐来源扫码登记,不能自动分配"); List existingFacts = factList(task); Map completedByItem = new HashMap<>(); for (RelabelItem item : skuItems) { @@ -416,6 +455,7 @@ public class PdaRelabelService { PdaRelabelTask task = requireOwnedTask(application, operatorKey, true); requireVersion(task, request.expectedVersion()); if (!request.manualCompletion()) bindingGuard.requireBound(application); + palletMeasurements.requireMeasured(application); List detailItems = itemList(application); List facts = factList(task); boolean incomplete = detailItems.stream().anyMatch(item -> processedQuantity(facts, item.getId()) < value(item.getNotifiedPackageCount())); @@ -463,13 +503,16 @@ public class PdaRelabelService { StringUtils.hasText(order.getWarehouseRemark()) ? order.getWarehouseRemark() + "\n" + note : note)); } } + skuConversions.apply(application, detailItems, factList(task), mixedRound(task), histories.selectList(new LambdaQueryWrapper() + .eq(PdaRelabelHistory::getTaskId, task.getId()).eq(PdaRelabelHistory::getCustomerId, application.getCustomerId()) + .eq(PdaRelabelHistory::getAction, "SKU_SCAN"))); String before = task.getStatus(); task.setStatus(WAIT_REVIEW); task.setCompletionPhotoFileIds(write(photoIds)); task.setCompletionRemark(trim(request.remark())); task.setSubmittedAt(LocalDateTime.now()); updateTask(task, request.expectedVersion()); insertHistory(task, "COMPLETE", before, WAIT_REVIEW, operatorKey, operatorName, Map.of("photoFileIds", photoIds, "remark", Objects.toString(trim(request.remark()), ""), - "manualCompletion", request.manualCompletion())); + "manualCompletion", request.manualCompletion(), "roundKey", mixedRound(task))); applicationService.changeStatus(application.getCustomerId(), relabelNo, operatorKey, new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, request.remark())); applicationService.settlePdaCompletion(application.getCustomerId(), relabelNo, operatorKey); @@ -512,6 +555,7 @@ public class PdaRelabelService { if (!WAIT_REVIEW.equals(task.getStatus())) throw conflict("Relabel task is not awaiting review"); requireVersion(task, request.expectedVersion()); // Preserve the previous round before freeing the active-label uniqueness constraint. + skuConversions.reverse(application); List previousFacts = factList(task); insertHistory(task, "REWORK_ARCHIVE", WAIT_REVIEW, AVAILABLE, operatorKey, operatorName, Map.of("facts", previousFacts, "photoFileIds", Objects.toString(task.getCompletionPhotoFileIds(), "[]"), @@ -574,6 +618,9 @@ public class PdaRelabelService { throw conflict("Relabel item already has all required package facts"); } String normalizedOldLabel = trim(oldLabel); + // A merged return carton can carry several independent SKU task items. + // Product-label facts are unique by item; old_label's global key is for physical carton-label scans. + if (item.getReturnCartonId() != null && "SKU_LABEL".equals(operationType(item))) normalizedOldLabel = null; if (normalizedOldLabel != null && unitFacts.findOldLabel(normalizedOldLabel) != null) { throw conflict("Old label was already processed by another relabel unit"); } @@ -746,7 +793,8 @@ public class PdaRelabelService { quantity(itemFacts, "EXCEPTION"), count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "CARTON") ? 1 : 0), count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "SKU") ? 1 : 0), - item.getDestinationFbaWarehouseCode()); + item.getDestinationFbaWarehouseCode(), item.getReturnCartonId(), + returnQuantity(item), sourcePalletCode(item), item.getReturnCartonId() != null ? "RETURN_CARTON" : "INVENTORY_REFERENCE"); }).toList(); List factResponses = facts.stream().map(fact -> new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(), @@ -768,7 +816,15 @@ public class PdaRelabelService { task == null ? null : task.getCompletionRemark(), task == null ? null : task.getReviewDecision(), task == null ? null : task.getReviewRemark(), task == null ? null : task.getClaimedAt(), task == null ? null : task.getSubmittedAt(), task == null ? null : task.getReviewedAt(), - itemResponses, factResponses, historyResponses); + itemResponses, factResponses, historyResponses, application.getDeliveryMethod(), + detailItems.stream().anyMatch(item -> { + String operation = operationType(item); + if (item.getReturnCartonId() == null) + return Set.of("CARTON_LABEL", "CARTON_AND_SKU", "RETURN_BULK_SKU_LABEL").contains(operation); + if ("SKU_LABEL".equals(operation) && application.getDeliveryMethod() == null) return false; + return !"EXPRESS".equals(application.getDeliveryMethod()) && !"PICKUP".equals(application.getDeliveryMethod()) + && !"AMAZON_PICKUP".equals(application.getDeliveryMethod()) && !"CUSTOMER_PICKUP".equals(application.getDeliveryMethod()); + })); } private String effectiveTaskStatus(RelabelApplication application, PdaRelabelTask task) { @@ -841,6 +897,20 @@ public class PdaRelabelService { List.class, PdaRelabelResponses.SkuItem.class)); } catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read relabel SKU details", exception); } } + private String sourcePalletCode(RelabelItem item) { + if (item.getReturnCartonId() != null) { + var carton = returnCartons == null ? null : returnCartons.selectById(item.getReturnCartonId()); + return carton != null && Objects.equals(item.getCustomerId(), carton.getCustomerId()) ? carton.getPalletCode() : null; + } + var position = sourcePositions == null || item.getSourceInventoryPositionId() == null ? null + : sourcePositions.findTenantPosition(item.getCustomerId(), item.getSourceInventoryPositionId()); + return position != null ? position.getPalletNo() : null; + } + + private long returnQuantity(RelabelItem item) { + var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId()); + return carton == null || carton.getTotalQuantity() == null ? 0L : carton.getTotalQuantity(); + } private static String operationType(RelabelItem item) { return StringUtils.hasText(item.getOperationType()) ? item.getOperationType() : "CARTON_LABEL"; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelStatusRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelStatusRepository.java new file mode 100644 index 000000000..61587d82f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelStatusRepository.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.relabel.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; +import java.util.List; + +/** 封装换标状态命令的客户隔离查询与持久化。 */ +@Repository +public class RelabelStatusRepository { + @Resource private RelabelApplicationMapper applications; + @Resource private LastlegOrderMapper orders; + @Resource private ReturnOrderMapper returnOrders; + @Resource private ReturnInventoryCartonMapper returnCartons; + + /** 锁定申请,平台入口从单据解析客户。 */ + public RelabelApplication lockApplication(String customer, String no) { + return StringUtils.hasText(customer) ? applications.lockNo(customer, no) : applications.lockAnyByRelabelNo(no); + } + /** 读取申请主订单。 */ + public LastlegOrder findPrimaryOrder(Long id) { return orders.selectById(id); } + /** 保存申请状态及资金状态。 */ + public void saveApplication(RelabelApplication application) { applications.updateById(application); } + /** 保存关联订单状态。 */ + public void saveOrder(LastlegOrder order) { orders.updateById(order); } + /** 读取同一客户下关联的退货单。 */ + public List linkedReturnOrders(String customer, Long id) { + return returnOrders.selectList(new LambdaQueryWrapper() + .eq(ReturnOrder::getRelabelApplicationId, id).eq(ReturnOrder::getCustomerId, customer)); + } + /** 保存退货单换标状态。 */ + public void saveReturnOrder(ReturnOrder order) { returnOrders.updateById(order); } + /** 核对退货箱实际已换标数量。 */ + public long relabelledQuantity(Long id) { return returnCartons.totalRelabelledQuantity(id); } +} 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 4ad45ee05..e0cab9da6 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 @@ -78,9 +78,11 @@ public class RelabelApplicationService implements WalletResultHandler { public static final String READY_FOR_OUTBOUND = "READY_FOR_OUTBOUND"; private static final Set STATUSES = Set.of(PENDING_REVIEW, PENDING_PROCESSING, "PROCESSING", CUSTOMER_CONFIRM_PENDING, READY_FOR_OUTBOUND, "COMPLETED", "REJECTED", "CANCELLED"); - private static final Set DELIVERY_METHODS = Set.of("TRUCK", "EXPRESS", "TRUCK_PICKUP", "EXPRESS_PICKUP", "PICKUP"); + 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 com.baiyue.lastleg.relabel.repository.RelabelStatusRepository statusRepository; @Resource private RelabelItemMapper items; @Resource private RelabelStatusHistoryMapper histories; @Resource private LastlegOrderMapper orders; @@ -93,7 +95,11 @@ public class RelabelApplicationService implements WalletResultHandler { @Resource private InventoryPositionMapper inventoryPositions; @Resource private ReturnInventoryCartonMapper returnCartons; @Resource private ReturnInventoryCartonItemMapper returnCartonItems; + @Resource private com.baiyue.lastleg.relabel.service.ReturnSkuConversionService skuConversions; + @Resource private RelabelBindingGuard bindingGuard; + @Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnSkuConversionMapper skuConversionRows; @Resource private ReturnOrderMapper returnOrders; + @Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper returnPackingTraces; @Resource private RelabelDocumentFileGateway documentFiles; @Resource private PdaRelabelTaskMapper pdaTasks; @Resource private RelabelPhotoFileGateway photoFiles; @@ -101,6 +107,8 @@ public class RelabelApplicationService implements WalletResultHandler { @Resource private OutboundPalletItemMapper outboundItems; @Resource private OutboundPdaTaskMapper outboundTasks; @Resource private com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper deliveryOrders; + @Resource private com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory; + @Resource private com.baiyue.lastleg.fleet.driver.FleetWarehouseDirectory warehouseDirectory; @Resource private OverseasFeeSourceService feeSources; @Resource private WarehouseTransferOrderMapper warehouseTransfers; @Resource private com.baiyue.lastleg.transfer.service.WarehouseTransferRelabelCompletionService transferRelabelCompletion; @@ -250,10 +258,13 @@ public class RelabelApplicationService implements WalletResultHandler { .gt(InventoryBalance::getAvailablePackages, 0) .like(StringUtils.hasText(inboundCode), InventoryBalance::getInboundCode, trim(inboundCode)) .orderByDesc(InventoryBalance::getUpdatedAt)); - List result = new java.util.ArrayList<>(balances.stream().map(balance -> { - List positions = inventoryPositions.listBalance(customerId, balance.getId()) + Map> positionsByBalance = balances.isEmpty() ? Map.of() + : inventoryPositions.listBalances(customerId, balances.stream().map(InventoryBalance::getId).toList()) .stream().filter(position -> value(position.getAvailablePackages()) > 0) - .map(position -> new RelabelResponses.InventoryPosition(position.getId(), position.getPalletNo(), + .collect(Collectors.groupingBy(InventoryPosition::getBalanceId, LinkedHashMap::new, Collectors.toList())); + List result = new java.util.ArrayList<>(balances.stream().map(balance -> { + List positions = positionsByBalance.getOrDefault(balance.getId(), List.of()) + .stream().map(position -> new RelabelResponses.InventoryPosition(position.getId(), position.getPalletNo(), position.getBatchNo(), position.getWarehouseLocationCode(), value(position.getAvailablePackages()))) .toList(); long available = positions.stream().mapToLong(RelabelResponses.InventoryPosition::availablePackages).sum(); @@ -264,30 +275,49 @@ public class RelabelApplicationService implements WalletResultHandler { balance.getProductNameZh(), available, value(balance.getVersion()), positions, "INBOUND", null, null, List.of(), 0); }).filter(candidate -> candidate.availablePackages() > 0).toList()); - for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, operationWarehouseId.trim())) { + for (ReturnInventoryCarton carton : returnCartons.findRelabelCandidates(customerId, operationWarehouseId.trim())) { String keyword = trim(inboundCode); if (StringUtils.hasText(keyword) && !containsIgnoreCase(carton.getCartonMark(), keyword) && !containsIgnoreCase(carton.getSourceReturnNo(), keyword)) continue; - List cartonSkus = returnCartonItems.listCarton(carton.getId()).stream() + var cartonLines = returnCartonItems.listCarton(carton.getId()); + String productNames = cartonLines.stream().filter(item -> value(item.getQuantity()) > 0) + .map(item -> trim(item.getProductNameZh())).filter(StringUtils::hasText).distinct() + .collect(Collectors.joining("、")); + List cartonSkus = cartonLines.stream() .filter(item -> value(item.getQuantity()) > 0) .map(item -> { - long used = returnCartons.relabelledQuantity(carton.getId(), item.getSkuCode()); - long remaining = value(item.getQuantity()) - used - value(item.getPackingReservedQuantity()); + long remaining = "AVAILABLE".equals(carton.getStatus()) + ? remainingRelabelQuantity(carton.getId(), item) : 0; return new RelabelResponses.SkuItem(item.getSkuCode(), null, Math.toIntExact(Math.max(remaining, 0)), null); - }).filter(item -> item.quantity() > 0).toList(); - if (cartonSkus.isEmpty()) continue; + }).filter(item -> !"AVAILABLE".equals(carton.getStatus()) || item.quantity() > 0).toList(); + List physicalSkus = cartonLines.stream() + .filter(line -> value(line.getQuantity()) > 0) + .map(line -> new RelabelResponses.SkuItem(line.getSkuCode(), null, Math.toIntExact(value(line.getQuantity())), null)).toList(); + long cartonAvailable = "AVAILABLE".equals(carton.getStatus()) && carton.getRelabelApplicationId() == null + && cartonLines.stream().noneMatch(line -> value(line.getPackingReservedQuantity()) > 0) + && !physicalSkus.isEmpty() ? 1 : 0; + if (cartonSkus.isEmpty() && cartonAvailable == 0 && !java.util.Set.of("MERGED", "OUTBOUNDED").contains(carton.getStatus())) continue; String skuCodes = cartonSkus.stream().map(RelabelResponses.SkuItem::skuCode) .collect(Collectors.joining(",")); long availablePackages = cartonSkus.stream().mapToLong(RelabelResponses.SkuItem::quantity).sum(); - if (availablePackages <= 0) continue; - long relabelledPackages = carton.getTotalQuantity() == null ? 0 - : Math.max(0, carton.getTotalQuantity() - availablePackages); + long relabelledPackages = Math.max(returnCartons.totalRelabelledQuantity(carton.getId()), + cartonLines.stream().mapToLong(line -> value(line.getRelabelledQuantity()) + + returnCartons.completedSkuQuantity(carton.getId(), line.getSkuCode())).sum()); + long reserved = returnCartons.relabelReservedQuantity(carton.getId()); + List mergedCartonMarks = StringUtils.hasText(carton.getSourceReturnNo()) ? List.of() + : returnPackingTraces.listCartonHistory(customerId, carton.getCartonMark()).stream() + .filter(history -> carton.getCartonMark().equals(history.sourceCartonMark())) + .map(history -> history.targetCartonMark()).distinct().toList(); result.add(new RelabelResponses.InventoryCandidate(null, null, carton.getSourceReturnNo(), null, carton.getOverseasWarehouseId(), null, null, carton.getDestinationWarehouseCode(), - carton.getDestinationWarehouseName(), skuCodes, "退货入库库存", availablePackages, 0, List.of(), - "PARCEL".equals(carton.getSourceUnitType()) ? "RETURN_PARCEL" : "RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus, relabelledPackages)); + carton.getDestinationWarehouseName(), skuCodes, productNames, availablePackages, 0, List.of(), + "PARCEL".equals(carton.getSourceUnitType()) ? "RETURN_PARCEL" : "RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus, relabelledPackages, + com.baiyue.lastleg.returnreceipt.service.ReturnRelabelProgress.status(value(carton.getTotalQuantity()), relabelledPackages, reserved), relabelledPackages, reserved, java.util.Set.of("MERGED", "OUTBOUNDED").contains(carton.getStatus()) ? 0L : value(carton.getTotalQuantity()), carton.getStatus(), carton.getRelabelNo(), + mergedCartonMarks, returnCartons.hasOutbound(carton.getId()), + skuConversionRows.listCarton(customerId, carton.getId()).stream() + .map(com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses.SkuConversion::from).toList(), physicalSkus, cartonAvailable)); } return result; } @@ -343,12 +373,12 @@ public class RelabelApplicationService implements WalletResultHandler { || request.items().stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item))); String deliveryMethod = trim(request.deliveryMethod()); if (requiresOutbound && (!StringUtils.hasText(deliveryMethod) || !DELIVERY_METHODS.contains(deliveryMethod))) { - throw badRequest("Delivery method must be TRUCK, PICKUP or EXPRESS when carton labels are changed"); + throw badRequest("箱唛换标请选择平台卡车、快递、亚马逊自提、客户自提或自提"); } if (!requiresOutbound && StringUtils.hasText(deliveryMethod)) { throw badRequest("SKU-only relabel does not create an outbound fulfillment"); } - if (Boolean.TRUE.equals(request.printShippingLabel()) && !Set.of("EXPRESS", "EXPRESS_PICKUP").contains(deliveryMethod)) { + if (Boolean.TRUE.equals(request.printShippingLabel()) && !Set.of("EXPRESS").contains(deliveryMethod)) { throw badRequest("Shipping-label printing is only available for express delivery"); } String requestHash = hash(request); @@ -370,6 +400,7 @@ public class RelabelApplicationService implements WalletResultHandler { Set selectedCartonMarks = new HashSet<>(); Map selectedReturnCartons = new LinkedHashMap<>(); Map selectedReturnPackages = new LinkedHashMap<>(); + Map> selectedReturnSkus = new LinkedHashMap<>(); for (RelabelRequests.Item item : request.items()) { validateOperation(item); if (item.notifiedPackageCount() > item.totalPackageCount()) { @@ -388,36 +419,44 @@ 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); - if ("PARCEL".equals(carton.getSourceUnitType()) && !"SKU_LABEL".equals(item.operationType())) - throw conflict("散件退货库存只能贴 SKU,请先合箱后申请箱唛或混贴"); final long sourceReturnCartonId = carton.getId(); Map availableSku = returnCartonItems.lockCarton(sourceReturnCartonId).stream() .collect(Collectors.toMap(line -> line.getSkuCode().toUpperCase(java.util.Locale.ROOT), - line -> Math.max(0, value(line.getQuantity()) - value(line.getPackingReservedQuantity()) - - returnCartons.relabelledQuantity(sourceReturnCartonId, line.getSkuCode())), Long::sum)); - Map requestedSku = new java.util.HashMap<>(); + line -> "CARTON_LABEL".equals(operationType(item)) ? value(line.getQuantity()) + : remainingRelabelQuantity(sourceReturnCartonId, line), Long::sum)); + if (availableSku.values().stream().noneMatch(quantity -> quantity > 0)) + throw conflict("来源箱 SKU 已全部换标,没有剩余可换标数量"); + Map requestedSku = selectedReturnSkus.computeIfAbsent(carton.getId(), ignored -> new java.util.HashMap<>()); if (item.skuItems() != null) for (var skuItem : item.skuItems()) { long quantity = requestedSku.merge(skuItem.skuCode().toUpperCase(java.util.Locale.ROOT), (long) skuItem.quantity(), Long::sum); if (quantity > availableSku.getOrDefault(skuItem.skuCode().toUpperCase(java.util.Locale.ROOT), 0L)) throw conflict("SKU " + skuItem.skuCode() + " 的换标数量超过来源箱可用数量"); + var physical = returnCartonItems.lockCarton(sourceReturnCartonId).stream() + .filter(line -> line.getSkuCode().equalsIgnoreCase(skuItem.skuCode())).findFirst().orElse(null); + if (physical != null && quantity < value(physical.getQuantity()) + && returnPackingTraces.ownership(customerId, carton.getCartonMark(), physical.getSkuCode()).size() > 1) + throw conflict("跨来源单同 SKU 部分贴标须先现场分拣确认,不能按单号猜测实物来源"); } + if ("CARTON_AND_SKU".equals(operationType(item)) && (!requestedSku.equals(availableSku) + || returnCartonItems.lockCarton(sourceReturnCartonId).stream() + .anyMatch(line -> remainingRelabelQuantity(sourceReturnCartonId, line) < value(line.getQuantity())))) + throw conflict("包裹/箱混贴必须包含实物全部 SKU 和件数;部分数量请先隔离为独立新箱"); if (returnCartonItems.lockCarton(carton.getId()).stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0)) throw conflict("退货箱SKU已被合箱计划锁定"); if (Set.of("CARTON_LABEL", "CARTON_AND_SKU").contains(item.operationType()) && (item.totalPackageCount() != 1 || item.notifiedPackageCount() != 1)) throw conflict("退货箱唛换标按真实箱计数,每个来源箱只能填写一箱"); - long previouslyRelabelled = returnCartons.relabelledQuantity(carton.getId(), item.skuCode()); long requestedPackages = selectedReturnPackages.merge(carton.getId(), (long) item.notifiedPackageCount(), Math::addExact); - if (requestedPackages + previouslyRelabelled > value(carton.getTotalQuantity())) { + if (requestedPackages > value(carton.getTotalQuantity())) { throw conflict("退货托盘 " + carton.getCartonMark() + " 可用箱数不足"); } sources.add(new SourceInventory(item, null, carton)); @@ -437,6 +476,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()); } @@ -445,7 +488,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; @@ -495,7 +538,8 @@ public class RelabelApplicationService implements WalletResultHandler { } } for (String returnNo : selectedReturnCartons.values().stream().map(ReturnInventoryCarton::getSourceReturnNo) - .filter(StringUtils::hasText).distinct().sorted().toList()) { + .filter(StringUtils::hasText).flatMap(value -> java.util.Arrays.stream(value.split(","))) + .map(String::trim).filter(StringUtils::hasText).distinct().sorted().toList()) { ReturnOrder returnOrder = returnOrders.lockCustomerNo(customerId, returnNo); if (returnOrder != null) { returnOrder.setRelabelApplicationId(applicationId); @@ -578,6 +622,7 @@ public class RelabelApplicationService implements WalletResultHandler { Objects.requireNonNullElse(value.getPackageCount(), 0), value.getCurrentStatus())).toList(), evidence); } + /** 统一执行换标单审核、作业状态流转和作废。 */ @Transactional public RelabelResponses.Detail changeStatus(String customerId, String relabelNo, String operatorId, RelabelRequests.ChangeStatus request) { @@ -596,12 +641,11 @@ public class RelabelApplicationService implements WalletResultHandler { return changeStatus(customerId, order.getRelabelOrderNo(), operatorId, request, true); } + /** 在申请行锁内执行审核及作废,保留实际作业与状态历史。 */ private RelabelResponses.Detail changeStatus(String customerId, String relabelNo, String operatorId, RelabelRequests.ChangeStatus request, boolean directTailOrderReview) { - RelabelApplication application = StringUtils.hasText(customerId) - ? applications.lockNo(customerId, relabelNo) - : applications.lockAnyByRelabelNo(relabelNo); + RelabelApplication application = statusRepository.lockApplication(customerId, relabelNo); if (application == null) throw notFound("Relabel application not found"); customerId = application.getCustomerId(); String next = request.status().trim().toUpperCase(); @@ -609,20 +653,25 @@ public class RelabelApplicationService implements WalletResultHandler { if ("REJECTED".equals(next) && !StringUtils.hasText(request.remark())) { throw badRequest("审核驳回必须填写原因"); } - LastlegOrder primaryOrder = orders.selectById(application.getLastlegOrderId()); + LastlegOrder primaryOrder = statusRepository.findPrimaryOrder(application.getLastlegOrderId()); boolean directApplication = primaryOrder != null && "RELABEL_APPLICATION".equals(primaryOrder.getOrderType()); - if (PENDING_REVIEW.equals(application.getStatus()) && directApplication != directTailOrderReview) { + if (!"CANCELLED".equals(next) && PENDING_REVIEW.equals(application.getStatus()) && directApplication != directTailOrderReview) { throw conflict(directApplication ? "直接创建的贴标单请在尾程订单审核" : "关联业务产生的换标单请在换标单审核"); } if (next.equals(application.getStatus())) return detail(customerId, relabelNo); validateTransition(application.getStatus(), next); + // 作废仅释放申请占用,已经发生的换标事实继续保留;驳回仍禁止回退实际作业。 + if ("REJECTED".equals(next) && itemList(application.getId()).stream() + .filter(item -> item.getReturnCartonId() != null) + .anyMatch(item -> statusRepository.relabelledQuantity(item.getReturnCartonId()) > 0)) + throw conflict("该退货任务已有实际商品换标事实,不能驳回并恢复为未换标库存;请按异常完工或退回重做处理"); if (PENDING_REVIEW.equals(application.getStatus()) && PENDING_PROCESSING.equals(next) && itemList(application.getId()).stream().anyMatch(item -> "RETURN_BULK_SKU_LABEL".equals(operationType(item)))) { boolean hasCartonFile = StringUtils.hasText(application.getCartonTotalFileId()); boolean hasSkuFile = StringUtils.hasText(application.getSkuTotalFileId()); - if (hasCartonFile == hasSkuFile) throw conflict("退货贴标审核前必须且只能上传一个箱唛总文件或 SKU 总文件"); + if (!hasCartonFile && !hasSkuFile) throw conflict("退货贴标审核前必须上传箱唛总文件或 SKU 总文件,两类资料可分别上传"); } if ("PROCESSING".equals(next) && !Set.of("FROZEN", "NOT_REQUIRED").contains(application.getWalletStatus())) { @@ -632,20 +681,20 @@ public class RelabelApplicationService implements WalletResultHandler { LocalDateTime now = LocalDateTime.now(); application.setStatus(next); application.setUpdatedAt(now); - applications.updateById(application); + statusRepository.saveApplication(application); List linkedOrders = tailOrders(application); if (linkedOrders.isEmpty()) throw conflict("Linked tail-leg order is missing"); if (PENDING_PROCESSING.equals(next)) { application.setFulfillmentStatus("WAIT_RELABEL"); - applications.updateById(application); + statusRepository.saveApplication(application); } if (CUSTOMER_CONFIRM_PENDING.equals(next)) { application.setFulfillmentStatus("WAIT_CUSTOMER_CONFIRMATION"); - applications.updateById(application); + statusRepository.saveApplication(application); } else if ("COMPLETED".equals(next)) { boolean preDispatched = "PRE_DISPATCH_CREATED".equals(application.getFulfillmentStatus()); application.setFulfillmentStatus("OUTBOUNDED"); - applications.updateById(application); + statusRepository.saveApplication(application); if (!StringUtils.hasText(application.getOutboundOrderNo()) && !preDispatched) { releaseInventory(customerId, operatorId, application); @@ -656,7 +705,7 @@ public class RelabelApplicationService implements WalletResultHandler { if ("FROZEN".equals(application.getWalletStatus())) { walletCommands.publish("RELEASE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null, "换标取消,释放冻结", 2L); - application.setWalletStatus("RELEASE_PENDING"); applications.updateById(application); + application.setWalletStatus("RELEASE_PENDING"); statusRepository.saveApplication(application); } } for (LastlegOrder order : linkedOrders) { @@ -672,28 +721,25 @@ public class RelabelApplicationService implements WalletResultHandler { case "CANCELLED" -> "CANCELLED"; default -> mainStatus; }; - // Reviewing/processing a return relabel must not move the already - // completed inbound order back to assignment or relabel waiting. - if ("RETURN_RELABEL".equals(order.getOrderType())) { - orderStatus = "INBOUND_COMPLETED"; + // 退货不回退已完成入库;转运作废只终止换标,不取消原转运主单。 + if ("RETURN_RELABEL".equals(order.getOrderType()) + || "WAREHOUSE_TRANSFER".equals(order.getOrderType()) && "CANCELLED".equals(next)) { + orderStatus = orderBefore; } order.setCurrentStatus(orderStatus); - order.setSubOrderStatus("RELABEL:" + next); + if (!"RETURN_RELABEL".equals(order.getOrderType())) order.setSubOrderStatus("RELABEL:" + next); order.setRelabelStatus(businessRelabelStatus(next)); order.setUpdatedAt(now); - orders.updateById(order); + statusRepository.saveOrder(order); if (!Objects.equals(orderBefore, orderStatus)) { insertOrderHistory(customerId, order.getId(), orderBefore, orderStatus, request.remark(), now); } } - ReturnOrder returnOrder = returnOrders.selectOne(new LambdaQueryWrapper() - .eq(ReturnOrder::getRelabelApplicationId, application.getId()) - .eq(ReturnOrder::getCustomerId, customerId) - .last("LIMIT 1")); - if (returnOrder != null) { + List linkedReturnOrders = statusRepository.linkedReturnOrders(customerId, application.getId()); + for (ReturnOrder returnOrder : linkedReturnOrders) { returnOrder.setRelabelStatus(next); returnOrder.setUpdatedAt(now); - returnOrders.updateById(returnOrder); + statusRepository.saveReturnOrder(returnOrder); } insertHistory(customerId, application.getId(), before, next, operatorId, request.remark(), now); return detail(customerId, relabelNo); @@ -791,7 +837,7 @@ public class RelabelApplicationService implements WalletResultHandler { applications.updateById(application); for (LastlegOrder order : tailOrders(application)) { order.setRelabelStatus(PENDING_REVIEW); - order.setSubOrderStatus("RELABEL:" + PENDING_REVIEW); + if (!"RETURN_RELABEL".equals(order.getOrderType())) order.setSubOrderStatus("RELABEL:" + PENDING_REVIEW); order.setUpdatedAt(now); orders.updateById(order); } @@ -810,7 +856,8 @@ public class RelabelApplicationService implements WalletResultHandler { application.setUpdatedAt(LocalDateTime.now()); applications.updateById(application); for (LastlegOrder order : tailOrders(application)) { if ("RETURN_RELABEL".equals(order.getOrderType()) && !Objects.equals(order.getRelabelOrderNo(), application.getRelabelNo())) continue; - order.setRelabelStatus("PROCESSING"); order.setSubOrderStatus("RELABEL:PROCESSING"); + order.setRelabelStatus("PROCESSING"); + if (!"RETURN_RELABEL".equals(order.getOrderType())) order.setSubOrderStatus("RELABEL:PROCESSING"); order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order); } insertHistory(customerId, application.getId(), before, "PROCESSING", operatorId, remark, LocalDateTime.now()); @@ -894,12 +941,19 @@ public class RelabelApplicationService implements WalletResultHandler { throw conflict("Upload the shipping label before confirming relabel completion"); } List relabelItems = itemList(application.getId()); + // Manual warehouse completion does not waive physical binding at customer confirmation. + bindingGuard.requireBound(application); + skuConversions.confirm(application); WarehouseTransferOrder linkedTransfer = warehouseTransfers == null ? null : warehouseTransfers.lockByRelabelNo(customerId, relabelNo); boolean existingStockTransfer = linkedTransfer != null && StringUtils.hasText(linkedTransfer.getAllocationIdempotencyKey()); boolean requiresOutbound = relabelItems.stream() .anyMatch(item -> Set.of("CARTON_LABEL", "CARTON_AND_SKU").contains(operationType(item))); + boolean directOutbound = "EXPRESS".equals(application.getDeliveryMethod()) + || "PICKUP".equals(application.getDeliveryMethod()) + || "AMAZON_PICKUP".equals(application.getDeliveryMethod()) + || "CUSTOMER_PICKUP".equals(application.getDeliveryMethod()); reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CONFIRMED"); reviewTask.setReviewerKey(operatorId); reviewTask.setReviewerName("客户确认"); reviewTask.setReviewRemark(request.remark()); reviewTask.setReviewedAt(LocalDateTime.now()); @@ -911,9 +965,9 @@ public class RelabelApplicationService implements WalletResultHandler { application.setCustomerConfirmationKey(key); application.setCustomerConfirmedAt(now); application.setFulfillmentStatus(requiresOutbound - ? ("TRUCK".equals(application.getDeliveryMethod()) ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION") + ? (directOutbound ? "WAIT_OUTBOUND_CREATION" : "WAIT_PRE_DISPATCH") : "NOT_REQUIRED"); - if (requiresOutbound && !existingStockTransfer && !"TRUCK".equals(application.getDeliveryMethod())) { + if (requiresOutbound && !existingStockTransfer && directOutbound) { createDirectOutbound(application, operatorId, now); application.setFulfillmentStatus("OUTBOUND_CREATED"); } @@ -927,9 +981,9 @@ public class RelabelApplicationService implements WalletResultHandler { String orderBefore = order.getCurrentStatus(); // 换标确认时货物已经完成入库,不得退回最初建单的“待分配”。 // 独立换标先进入待出库;仓储转运会在下方按目的仓进一步收敛为待排车或待确认目的仓。 - order.setCurrentStatus("RETURN_RELABEL".equals(order.getOrderType()) ? "INBOUND_COMPLETED" + order.setCurrentStatus("RETURN_RELABEL".equals(order.getOrderType()) ? orderBefore : requiresOutbound ? "WAIT_OUTBOUND" : "COMPLETED"); - order.setSubOrderStatus("RELABEL:" + application.getFulfillmentStatus()); + if (!"RETURN_RELABEL".equals(order.getOrderType())) order.setSubOrderStatus("RELABEL:" + application.getFulfillmentStatus()); order.setRelabelStatus("COMPLETED"); order.setUpdatedAt(now); orders.updateById(order); @@ -938,11 +992,10 @@ public class RelabelApplicationService implements WalletResultHandler { request.remark(), now); } } - ReturnOrder returnOrder = returnOrders.selectOne(new LambdaQueryWrapper() + List completedReturnOrders = returnOrders.selectList(new LambdaQueryWrapper() .eq(ReturnOrder::getRelabelApplicationId, application.getId()) - .eq(ReturnOrder::getCustomerId, customerId) - .last("LIMIT 1")); - if (returnOrder != null) { + .eq(ReturnOrder::getCustomerId, customerId)); + for (ReturnOrder returnOrder : completedReturnOrders) { returnOrder.setRelabelStatus("COMPLETED"); returnOrder.setUpdatedAt(now); returnOrders.updateById(returnOrder); @@ -954,13 +1007,14 @@ public class RelabelApplicationService implements WalletResultHandler { carton.setRelabelStatus("COMPLETED"); carton.setRelabelCompletedAt(now); // 换标已完成后释放“当前申请占用”关系,但保留 COMPLETED 作为历史状态。 // 否则退货箱虽显示 AVAILABLE,下一次贴标申请仍会被旧 relabel_application_id 拦截。 - boolean awaitingTruck = requiresOutbound && "TRUCK".equals(application.getDeliveryMethod()); + boolean awaitingTruck = requiresOutbound && !directOutbound; if (!StringUtils.hasText(application.getOutboundOrderNo()) && !awaitingTruck) { carton.setRelabelApplicationId(null); carton.setRelabelNo(null); } carton.setStatus(StringUtils.hasText(application.getOutboundOrderNo()) ? "OUTBOUND_PENDING" : awaitingTruck ? "RELABEL_PENDING" : "AVAILABLE"); carton.setUpdatedAt(now); returnCartons.updateById(carton); + if (carton.getRelabelApplicationId() == null) returnCartons.clearRelabel(carton.getId()); } insertHistory(customerId, application.getId(), before, application.getStatus(), operatorId, request.remark(), now); @@ -1010,6 +1064,7 @@ public class RelabelApplicationService implements WalletResultHandler { boolean relabelHasDestination = !relabelItems.isEmpty() && relabelItems.stream() .allMatch(item -> StringUtils.hasText(item.getDestinationFbaWarehouseCode())); application.setFulfillmentStatus(existingStockTransfer ? "WAIT_TRANSFER_OUTBOUND" + : StringUtils.hasText(application.getOutboundOrderNo()) ? "OUTBOUND_CREATED" : "READY_FOR_DISPATCH".equals(transferStatus) || relabelHasDestination ? "WAIT_PRE_DISPATCH" : transferStatus); applications.updateById(application); @@ -1041,15 +1096,33 @@ public class RelabelApplicationService implements WalletResultHandler { return; } List relabelItems = itemList(application.getId()); + Map confirmedReturnCartons = new LinkedHashMap<>(); for (RelabelItem source : relabelItems) { if (source.getReturnCartonId() == null) continue; ReturnInventoryCarton carton = returnCartons.lockId(source.getReturnCartonId()); if (carton == null || !application.getCustomerId().equals(carton.getCustomerId()) + || !Objects.equals(application.getOverseasWarehouseId(), carton.getOverseasWarehouseId()) || !application.getId().equals(carton.getRelabelApplicationId()) || !"RELABEL_PENDING".equals(carton.getStatus()) || intValue(source.getNotifiedPackageCount()) != 1) throw conflict("退货整箱换标出库必须覆盖该箱全部 SKU 数量:" + source.getCartonMark()); com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountGuard.requireConfirmed(carton); + confirmedReturnCartons.put(carton.getId(), carton); + } + boolean returnOnly = relabelItems.stream().allMatch(item -> item.getReturnCartonId() != null); + String customerName = null; + String warehouseName = null; + if (returnOnly) { + // Resolve once per new outbound, before any insert; every destination shares these snapshots. + var customer = customerDirectory.find(application.getCustomerId()); + var warehouse = warehouseDirectory.required(Long.parseLong(application.getOverseasWarehouseId()), "OVERSEAS"); + if (customer == null || !application.getCustomerId().equals(customer.id()) || !StringUtils.hasText(customer.name()) + || warehouse == null || !application.getOverseasWarehouseId().equals(warehouse.id()) + || !"OVERSEAS".equals(warehouse.region()) || !StringUtils.hasText(warehouse.name())) { + throw conflict("客户或作业仓库名称未完整返回,请核实主档后重试"); + } + customerName = customer.name().trim(); + warehouseName = warehouse.name().trim(); } OutboundOrder outbound = new OutboundOrder(); outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId()); @@ -1058,12 +1131,17 @@ public class RelabelApplicationService implements WalletResultHandler { outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(relabelItems.stream().mapToInt(item -> item.getReturnCartonId() == null ? intValue(item.getNotifiedPackageCount()) : 1).sum()); outbound.setPalletCount((int) relabelItems.stream().map(this::outboundSourceKey).distinct().count()); outbound.setRecipientInfo(application.getDeliveryMethod()); outbound.setIdempotencyKey(key); + if (returnOnly) { + var measurement = returnMeasurement(relabelItems, confirmedReturnCartons); + outbound.setGrossWeightKg(measurement.weightKg()); outbound.setVolumeCbm(measurement.volumeCbm()); + } outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound); Map> byPosition = relabelItems.stream().collect(Collectors.groupingBy( this::outboundSourceKey, LinkedHashMap::new, Collectors.toList())); for (Map.Entry> entry : byPosition.entrySet()) { InventoryPosition position = entry.getKey() > 0 ? inventoryPositions.selectById(entry.getKey()) : null; - if (entry.getKey() > 0 && (position == null || !application.getCustomerId().equals(position.getCustomerId()))) { + if (entry.getKey() > 0 && (position == null || !application.getCustomerId().equals(position.getCustomerId()) + || !Objects.equals(application.getOverseasWarehouseId(), position.getOverseasWarehouseId()))) { throw conflict("Relabel source inventory position no longer exists"); } List group = entry.getValue(); @@ -1082,12 +1160,16 @@ public class RelabelApplicationService implements WalletResultHandler { .filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); item.setInboundCode(group.stream().map(RelabelItem::getSourceInboundCode).filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); item.setOrderRefs(writeOutboundRefs(application.getRelabelNo(), position == null ? null : entry.getKey(), group)); + if (position == null) { + var measurement = returnMeasurement(group, confirmedReturnCartons); + item.setGrossWeightKg(measurement.weightKg()); item.setVolumeCbm(measurement.volumeCbm()); + } item.setCreatedAt(now); outboundItems.insert(item); } OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(application.getCustomerId()); task.setOutboundOrderId(outbound.getId()); task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); outboundTasks.insert(task); - if (relabelItems.stream().allMatch(item -> item.getReturnCartonId() != null)) { + if (returnOnly) { Map> destinations = relabelItems.stream().collect(Collectors.groupingBy( item -> Objects.toString(item.getDestinationFbaWarehouseCode(), ""), LinkedHashMap::new, Collectors.toList())); int index = 0; @@ -1097,12 +1179,15 @@ public class RelabelApplicationService implements WalletResultHandler { delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", application.getOverseasWarehouseId(), delivery.getId())); delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); delivery.setOverseasWarehouseId(application.getOverseasWarehouseId()); + delivery.setCustomerName(customerName); delivery.setOverseasWarehouseName(warehouseName); delivery.setDeliveryGroupKey("RETURN_RELABEL:" + destination.getKey()); delivery.setDestinationWarehouseCode(destination.getKey()); delivery.setDestinationType(StringUtils.hasText(destination.getKey()) ? "FBA" : null); delivery.setContactName(application.getContactName()); delivery.setContactPhone(application.getContactPhone()); delivery.setDeliveryMethod(application.getDeliveryMethod()); delivery.setStatus("WAIT_DELIVERY"); delivery.setPackageCount(destination.getValue().size()); delivery.setPalletCount(0); + var measurement = returnMeasurement(destination.getValue(), confirmedReturnCartons); + delivery.setTotalWeightKg(measurement.weightKg()); delivery.setTotalVolumeCbm(measurement.volumeCbm()); delivery.setIdempotencyKey(key + ":DELIVERY:" + index++); delivery.setVersion(0L); delivery.setCreatedBy(operatorId); delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery); @@ -1111,6 +1196,24 @@ public class RelabelApplicationService implements WalletResultHandler { application.setOutboundOrderNo(outbound.getOutboundNo()); } + private record ReturnMeasurement(BigDecimal weightKg, BigDecimal volumeCbm) { } + + private ReturnMeasurement returnMeasurement(List sources, Map cartons) { + BigDecimal weight = BigDecimal.ZERO; + BigDecimal volume = BigDecimal.ZERO; + // The selected physical carton contributes once, regardless of its SKU count or piece quantities. + for (Long cartonId : sources.stream().map(RelabelItem::getReturnCartonId).distinct().toList()) { + ReturnInventoryCarton carton = cartons.get(cartonId); + if (carton == null) return new ReturnMeasurement(null, null); + weight = weight == null || !positive(carton.getWeight()) ? null : weight.add(carton.getWeight()); + volume = volume == null || !positive(carton.getLength()) || !positive(carton.getWidth()) || !positive(carton.getHeight()) + ? null : volume.add(carton.getLength().multiply(carton.getWidth()).multiply(carton.getHeight()).movePointLeft(6)); + } + return new ReturnMeasurement(weight, volume); + } + + private static boolean positive(BigDecimal value) { return value != null && value.signum() > 0; } + private String writeOutboundRefs(String relabelNo, Long positionId, List values) { try { Map refs = new LinkedHashMap<>(); @@ -1163,7 +1266,12 @@ public class RelabelApplicationService implements WalletResultHandler { private RelabelResponses.Summary summary(RelabelApplication value, List detailItems) { LastlegOrder order = orders.selectById(value.getLastlegOrderId()); - String skuCodes = detailItems.stream().map(RelabelItem::getSkuCode).filter(Objects::nonNull).distinct().collect(Collectors.joining(",")); + RelabelResponses.Milestones milestones = applications.findMilestones(value.getCustomerId(), value.getId()); + String skuCodes = detailItems.stream().flatMap(item -> { + List skuItems = readSkuItems(item.getSkuRelabelItemsJson()); + return skuItems.isEmpty() ? java.util.Arrays.stream(Objects.requireNonNullElse(item.getSkuCode(), "").split(",")) + : skuItems.stream().map(RelabelResponses.SkuItem::skuCode); + }).filter(StringUtils::hasText).map(String::trim).distinct().collect(Collectors.joining(",")); return new RelabelResponses.Summary(value.getId(), value.getRelabelNo(), value.getLastlegOrderId(), order == null ? null : order.getOrderNo(), value.getCustomerOrderNo(), value.getOverseasWarehouseId(), @@ -1180,7 +1288,30 @@ public class RelabelApplicationService implements WalletResultHandler { value.getFulfillmentStatus(), value.getOutboundOrderNo(), value.getCustomerConfirmedAt(), order != null && "RELABEL_APPLICATION".equals(order.getOrderType()) ? "TAIL_ORDER" : "RELABEL_ORDER", null, - value.getCreatedAt()); + value.getCreatedAt(), milestones == null ? null : milestones.reviewedAt(), + milestones == null ? null : milestones.relabelCompletedAt(), + milestones == null ? null : milestones.outboundAt(), + detailItems.stream().map(RelabelItem::getProductNameZh).filter(StringUtils::hasText) + .map(String::trim).distinct().collect(Collectors.joining("、")), + detailItems.stream().map(RelabelItem::getLabelRequirement).filter(StringUtils::hasText) + .map(String::trim).distinct().collect(Collectors.joining(";")), + detailItems.stream().map(RelabelItem::getOperationType).filter(StringUtils::hasText) + .map(String::trim).distinct().toList(), relatedBusinessOrderNos(value, order, detailItems)); + } + + private List relatedBusinessOrderNos(RelabelApplication application, LastlegOrder order, + List detailItems) { + if (order != null && Objects.equals(application.getCustomerId(), order.getCustomerId()) + && !"RELABEL_APPLICATION".equals(order.getOrderType())) { + String businessNo = StringUtils.hasText(order.getSourceOrderNo()) ? order.getSourceOrderNo() : order.getOrderNo(); + if (StringUtils.hasText(businessNo)) return List.of(businessNo.trim()); + } + // Standalone relabel orders keep the actual inventory source on their item snapshots. + // Their tail sourceOrderNo is a customer reference, not a business order number. + return detailItems.stream().map(item -> StringUtils.hasText(item.getSourceInboundOrderNo()) + ? item.getSourceInboundOrderNo() + : "RETURN_BULK_SKU_LABEL".equals(item.getOperationType()) ? item.getShipmentNo() : null) + .filter(StringUtils::hasText).map(String::trim).distinct().toList(); } @Override public boolean supports(String businessType) { return "RELABEL_ORDER".equals(businessType); } @@ -1232,12 +1363,11 @@ public class RelabelApplicationService implements WalletResultHandler { .last("LIMIT 1")); if (existingReturn != null) { existingReturn.setRelabelOrderNo(relabelNo); existingReturn.setRelabelStatus(PENDING_REVIEW); - existingReturn.setSubOrderStatus("RETURN_RELABEL:" + PENDING_REVIEW); existingReturn.setPackageCount(packageCount); existingReturn.setUpdatedAt(now); orders.updateById(existingReturn); return existingReturn; } 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); @@ -1262,7 +1392,7 @@ public class RelabelApplicationService implements WalletResultHandler { // A linked warehouse-transfer relabel can cover only part of the // transfer. Keep the authoritative whole-order package count so later // dispatches can attribute the remaining cartons correctly. - if (!"WAREHOUSE_TRANSFER".equals(order.getOrderType())) order.setPackageCount(packageCount); + if (!"WAREHOUSE_TRANSFER".equals(order.getOrderType()) && !"RETURN_RELABEL".equals(order.getOrderType())) order.setPackageCount(packageCount); order.setUpdatedAt(now); orders.updateById(order); return order; } @@ -1279,6 +1409,11 @@ public class RelabelApplicationService implements WalletResultHandler { private record SourceInventory(RelabelRequests.Item request, InventoryBalance balance, ReturnInventoryCarton returnCarton) {} + private long remainingRelabelQuantity(long cartonId, ReturnInventoryCartonItem line) { + return Math.max(0, value(line.getQuantity()) - value(line.getPackingReservedQuantity()) + - value(line.getRelabelledQuantity()) + - returnCartons.completedSkuQuantity(cartonId, line.getSkuCode())); + } private static long value(Long value) { return value == null ? 0L : value; } private static int intValue(Integer value) { return value == null ? 0 : value; } private static boolean containsIgnoreCase(String value, String fragment) { @@ -1295,6 +1430,7 @@ public class RelabelApplicationService implements WalletResultHandler { && "RELABEL_PENDING".equals(carton.getStatus())) { carton.setRelabelApplicationId(null); carton.setRelabelNo(null); carton.setRelabelStatus(null); carton.setStatus("AVAILABLE"); carton.setUpdatedAt(LocalDateTime.now()); returnCartons.updateById(carton); + returnCartons.clearRelabel(carton.getId()); } continue; } @@ -1352,6 +1488,9 @@ public class RelabelApplicationService implements WalletResultHandler { && (item.skuItems() == null || item.skuItems().isEmpty())) { throw badRequest("SKU label changes require at least one SKU detail"); } + if (item.returnCartonId() != null && item.skuItems() != null && item.skuItems().stream() + .anyMatch(sku -> sku.skuCode().trim().equalsIgnoreCase(sku.newSkuCode().trim()))) + throw badRequest("退货 SKU 转换的新旧编码必须不同"); } private static String operationType(RelabelRequests.Item item) { return StringUtils.hasText(item.operationType()) ? item.operationType().trim().toUpperCase() : "CARTON_LABEL"; @@ -1396,7 +1535,12 @@ public class RelabelApplicationService implements WalletResultHandler { value.setToStatus(to); value.setEventId("RELABEL_APPLICATION"); value.setRemark(trim(remark)); value.setCreatedAt(now); orderHistories.insert(value); } + /** 客户确认换标完成后禁止作废,其余状态允许终止申请。 */ private static void validateTransition(String from, String to) { + if ("CANCELLED".equals(to)) { + if (Set.of(READY_FOR_OUTBOUND, "COMPLETED").contains(from)) throw conflict("换标完成的单据不能作废"); + return; + } boolean allowed = PENDING_REVIEW.equals(from) && Set.of(PENDING_PROCESSING, "REJECTED", "CANCELLED").contains(to) || PENDING_PROCESSING.equals(from) && Set.of("PROCESSING", "REJECTED", "CANCELLED").contains(to) || "PROCESSING".equals(from) && Set.of(CUSTOMER_CONFIRM_PENDING, "REJECTED", "CANCELLED").contains(to) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java index 88cb5602f..0ceef07f3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java @@ -22,19 +22,26 @@ public class RelabelBindingGuard { @Resource private PdaRelabelHistoryMapper histories; @Resource private InventoryPositionMapper positions; @Resource private ReturnInventoryCartonMapper returnCartons; + @Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnSkuConversionMapper conversions; + @Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper returnLines; @Resource private ObjectMapper objectMapper; public void requireBound(RelabelApplication app) { List cartons = items.selectList(new QueryWrapper() .eq("application_id", app.getId()).eq("customer_id", app.getCustomerId())) - .stream().filter(item -> Set.of("CARTON_LABEL", "CARTON_AND_SKU", "RETURN_BULK_SKU_LABEL") + .stream().filter(item -> (item.getReturnCartonId() != null && "SKU_LABEL".equals(item.getOperationType())) || Set.of("CARTON_LABEL", "CARTON_AND_SKU", "RETURN_BULK_SKU_LABEL") .contains(item.getOperationType())).toList(); if (cartons.isEmpty()) return; Map> bindings = new HashMap<>(); + String currentRound = null; for (PdaRelabelHistory history : histories.selectList(new QueryWrapper() .eq("application_id", app.getId()).eq("customer_id", app.getCustomerId()) .orderByAsc("created_at", "id"))) { - if ("REWORK_ARCHIVE".equals(history.getAction())) bindings.clear(); + if ("REWORK_ARCHIVE".equals(history.getAction())) { bindings.clear(); currentRound = null; } + if ("COMPLETE".equals(history.getAction())) { + try { currentRound = objectMapper.readTree(history.getDetailJson()).path("roundKey").asText(null); } + catch (Exception failure) { throw conflict("换标完工轮次记录无法读取"); } + } if (!"PALLET_BIND".equals(history.getAction())) continue; try { var data = objectMapper.readTree(history.getDetailJson()); @@ -44,27 +51,90 @@ public class RelabelBindingGuard { } catch (Exception e) { throw conflict("组托记录无法读取,请核对后重试"); } } for (RelabelItem item : cartons) { - if (item.getReturnCartonId() != null && !"RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) { - var carton = returnCartons.selectById(item.getReturnCartonId()); - if (carton == null || !app.getCustomerId().equals(carton.getCustomerId())) throw conflict("退货箱来源缺失"); - if (!"TRUCK".equals(app.getDeliveryMethod())) continue; - if (!bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode())) + var sourceCarton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId()); + if (item.getReturnCartonId() != null && (sourceCarton == null + || !Objects.equals(app.getCustomerId(), sourceCarton.getCustomerId()) + || !Objects.equals(app.getOverseasWarehouseId(), sourceCarton.getOverseasWarehouseId()))) + throw conflict("退货箱来源缺失或仓库不一致"); + if (item.getReturnCartonId() != null && !Set.of("RETURN_BULK_SKU_LABEL", "SKU_LABEL").contains(item.getOperationType())) { + var carton = sourceCarton; + if ("CARTON_AND_SKU".equals(item.getOperationType())) { + var expected = new HashMap(); + try { + for (var sku : objectMapper.readTree(item.getSkuRelabelItemsJson())) + expected.merge(sku.path("skuCode").asText().toUpperCase(Locale.ROOT), sku.path("quantity").asLong(), Long::sum); + } catch (Exception failure) { throw conflict("混贴 SKU 明细无法读取"); } + projectCurrentContents(app, List.of(item), expected, currentRound); + var actual = new HashMap(); + returnLines.lockCarton(item.getReturnCartonId()).forEach(line -> actual.merge(line.getSkuCode().toUpperCase(Locale.ROOT), line.getQuantity(), Long::sum)); + if (!expected.equals(actual)) throw conflict("包裹/箱混贴必须包含实物全部 SKU 和件数;部分数量请先隔离为独立新箱"); + } + if ("EXPRESS".equals(app.getDeliveryMethod()) || "PICKUP".equals(app.getDeliveryMethod()) + || "AMAZON_PICKUP".equals(app.getDeliveryMethod()) || "CUSTOMER_PICKUP".equals(app.getDeliveryMethod())) continue; + if (carton.getPalletCode() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode())) throw conflict("退货箱尚未完成排车托盘绑定"); continue; } - if ("RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) { - var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId()); - if (carton == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode()) + if (Set.of("RETURN_BULK_SKU_LABEL", "SKU_LABEL").contains(item.getOperationType()) && item.getReturnCartonId() != null) { + var sameCarton = cartons.stream().filter(i -> Objects.equals(i.getReturnCartonId(), item.getReturnCartonId())).toList(); + var stock = item.getReturnCartonId() == null ? List.of() : returnLines.lockCarton(item.getReturnCartonId()); + var selected = new HashMap(); + for (var i : sameCarton) { + if ("RETURN_BULK_SKU_LABEL".equals(i.getOperationType())) selected.merge(i.getSkuCode().toUpperCase(Locale.ROOT), (long)i.getNotifiedPackageCount(), Long::sum); + else try { + for (var sku : objectMapper.readTree(i.getSkuRelabelItemsJson())) selected.merge(sku.path("skuCode").asText().toUpperCase(Locale.ROOT), sku.path("quantity").asLong(), Long::sum); + } catch (Exception failure) { throw conflict("换标明细无法读取"); } + } + var actual = new HashMap(); + stock.forEach(i -> actual.merge(i.getSkuCode().toUpperCase(Locale.ROOT), i.getQuantity(), Long::sum)); + projectCurrentContents(app, sameCarton, selected, currentRound); + if (!selected.equals(actual)) throw conflict("部分换标货物必须先合箱生成独立新箱,再提交完工"); + // SKU-only work has no outbound fulfillment, but must still cover the isolated carton. + if ("SKU_LABEL".equals(item.getOperationType()) && app.getDeliveryMethod() == null) continue; + if ("EXPRESS".equals(app.getDeliveryMethod()) || "PICKUP".equals(app.getDeliveryMethod()) + || "AMAZON_PICKUP".equals(app.getDeliveryMethod()) || "CUSTOMER_PICKUP".equals(app.getDeliveryMethod())) continue; + var carton = sourceCarton; + if (carton.getPalletCode() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode()) || !"PROCESSING".equals(carton.getRelabelStatus())) throw conflict("退货箱 " + item.getCartonMark() + " 尚未绑定带目的仓的新托盘,不能完成换标"); continue; } var position = item.getSourceInventoryPositionId() == null ? null : positions.findTenantPosition(app.getCustomerId(), item.getSourceInventoryPositionId()); - if (position == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(position.getPalletNo()) + if (position == null || !Objects.equals(app.getOverseasWarehouseId(), position.getOverseasWarehouseId()) + || position.getPalletNo() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(position.getPalletNo()) || position.getHoldPackages() == null || position.getHoldPackages() < item.getNotifiedPackageCount()) throw conflict("箱唛 " + item.getCartonMark() + " 尚未完成托盘绑定,请先在 PDA 确认组托,不能完成或排车出库"); } } + + private void projectCurrentContents(RelabelApplication app, List selectedItems, + Map expected, String currentRound) { + if (!"CUSTOMER_CONFIRM_PENDING".equals(app.getStatus()) && !"READY_FOR_OUTBOUND".equals(app.getStatus()) + && !"COMPLETED".equals(app.getStatus())) return; + if (currentRound == null || currentRound.isBlank()) throw conflict("换标完工轮次缺失,不能核对当前 SKU 库存"); + // Warehouse completion has already applied SKU conversions. Confirmation and dispatch + // compare against that round's persisted changes, never assume that every new SKU exists. + Map outgoing = new HashMap<>(), incoming = new HashMap<>(); + for (var row : conversions.lockApplication(app.getCustomerId(), app.getId())) { + if (!Objects.equals(row.getCustomerId(), app.getCustomerId()) || !Objects.equals(row.getApplicationId(), app.getId()) + || !Objects.equals(row.getRoundKey(), currentRound) + || !("APPLIED".equals(row.getStatus()) || "CONFIRMED".equals(row.getStatus())) + || selectedItems.stream().noneMatch(item -> Objects.equals(item.getId(), row.getItemId()) + && Objects.equals(item.getReturnCartonId(), row.getCartonId()))) continue; + String oldSku = row.getOldSkuCode().toUpperCase(Locale.ROOT); + String newSku = row.getNewSkuCode().toUpperCase(Locale.ROOT); + long quantity = Objects.requireNonNullElse(row.getQuantity(), 0L); + if (quantity <= 0) throw conflict("换标转换数量与批准明细不一致"); + outgoing.merge(oldSku, quantity, Math::addExact); + incoming.merge(newSku, quantity, Math::addExact); + } + for (var source : outgoing.entrySet()) { + long remaining = expected.getOrDefault(source.getKey(), 0L) - source.getValue(); + if (remaining < 0) throw conflict("换标转换数量与批准明细不一致"); + if (remaining == 0) expected.remove(source.getKey()); else expected.put(source.getKey(), remaining); + } + incoming.forEach((sku, quantity) -> expected.merge(sku, quantity, Math::addExact)); + } private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } } 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 new file mode 100644 index 000000000..4646fa63e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelPalletMeasurementService.java @@ -0,0 +1,181 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.mapper.*; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; +import com.baiyue.lastleg.relabel.pda.mapper.*; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.math.*; +import java.time.LocalDateTime; +import java.util.*; + +/** Measurements are audited relabel facts, valid only for the measured inventory composition/version. */ +@Service +public class RelabelPalletMeasurementService { + @Resource private RelabelPalletMeasurementMapper mapper; + @Resource private RelabelApplicationMapper applications; + @Resource private RelabelItemMapper items; + @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, + @NotNull @DecimalMin("0.01") @Digits(integer=6,fraction=2) BigDecimal widthCm, + @NotNull @DecimalMin("0.01") @Digits(integer=6,fraction=2) BigDecimal heightCm, + @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, 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)); + } + private RelabelApplication scoped(PdaWarehouseIdentity identity, String no, boolean lock) { + var app = lock ? applications.lockAnyByRelabelNo(no) : applications.findAnyByRelabelNo(no); + if (app == null || !Objects.equals(app.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) + throw conflict("换标单不属于当前海外仓"); + return app; + } + @Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED) + public List save(PdaWarehouseIdentity identity, Measure request) { + var app = scoped(identity, request.relabelNo(), true); + if (!Set.of("PROCESSING", "READY_FOR_OUTBOUND").contains(app.getStatus()) + || app.getOutboundOrderNo() != null && !app.getOutboundOrderNo().isBlank() + || app.getFulfillmentStatus() != null && !Set.of("WAIT_RELABEL", "WAIT_PRE_DISPATCH").contains(app.getFulfillmentStatus())) + throw conflict("已排车或出库的换标单不能修改测量"); + var task = tasks.find(app.getCustomerId(), app.getId()); + if (task == null || "PROCESSING".equals(app.getStatus()) && (!"PROCESSING".equals(task.getStatus()) || !identity.operatorKey().equals(task.getOperatorKey()))) + throw conflict("请先认领当前换标任务"); + var records = histories(app); + String payload = write(request); + for (var history : records) if ("PALLET_MEASURE".equals(history.getAction())) { + var data = tree(history); + if (request.idempotencyKey().equals(data.path("request").path("idempotencyKey").asText())) { + if (!payload.equals(write(json.convertValue(data.path("request"), Measure.class)))) + throw conflict("测量幂等键不能用于不同数据"); + return snapshots(app); + } + } + mapper.lockStock(app.getCustomerId(), app.getOverseasWarehouseId(), request.palletCode()); + 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); + var history = new PdaRelabelHistory(); history.setId(IdWorker.getId()); history.setTaskId(task.getId()); + history.setApplicationId(app.getId()); history.setCustomerId(app.getCustomerId()); history.setAction("PALLET_MEASURE"); + history.setOperatorKey(identity.operatorKey()); history.setOperatorName(identity.operatorName()); + history.setFromStatus(task.getStatus()); history.setToStatus(task.getStatus()); history.setCreatedAt(LocalDateTime.now()); + history.setDetailJson(write(Map.of("request", request, "measurement", measured))); histories.insert(history); + return snapshots(app); + } + public List snapshots(RelabelApplication app) { + Long transferId = mapper.transfer(app.getCustomerId(), app.getRelabelNo()); + 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); + if (pallet.equals(measured.palletId()) && version.equals(measured.stockVersion())) value = measured; + } + 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, + 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); + } + return result; + } + public void requireMeasured(RelabelApplication app) { + for (var p : snapshots(app)) if (p.grossWeightKg() == null || p.volumeCbm() == null + || !(positive(p.lengthCm()) && positive(p.widthCm()) && positive(p.heightCm())) && !completeSourceCartons(p) + || p.grossWeightKg().signum() <= 0 || p.volumeCbm().signum() <= 0) + 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")); + } + private com.fasterxml.jackson.databind.JsonNode tree(PdaRelabelHistory h) { + try { return json.readTree(h.getDetailJson()); } catch (Exception e) { throw conflict("托盘测量记录无法读取"); } + } + private String write(Object value) { try { return json.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException(e); } } + 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/ReturnRelabelPackingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/ReturnRelabelPackingService.java new file mode 100644 index 000000000..0d3d23925 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/ReturnRelabelPackingService.java @@ -0,0 +1,136 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests; +import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import java.util.*; + +/** Called inside the owned PDA task transaction; materializes only its completed pieces. */ +@Service +public class ReturnRelabelPackingService { + @Resource private ReturnInventoryService inventory; + @Resource private ReturnInventoryCartonMapper cartons; + @Resource private RelabelItemMapper items; + @Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper cartonItems; + @Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnRelabelCompletionLedgerMapper completionLedger; + @Resource private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper facts; + @Resource private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper histories; + @Resource private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper tasks; + @Resource private com.fasterxml.jackson.databind.ObjectMapper json; + + @org.springframework.transaction.annotation.Transactional + public ReturnInventoryResponses.MergeResult pack(PdaWarehouseIdentity identity, RelabelApplication app, + List selected, ReturnInventoryRequests.BoxMerge request) { + if (request.targetCartonMark() != null || selected.isEmpty()) throw conflict("换标合箱必须生成独立新箱"); + var marks = selected.stream().map(RelabelItem::getCartonMark).distinct().sorted().toList(); + if (!new HashSet<>(marks).equals(new HashSet<>(request.sourceCartonMarks()))) throw conflict("请扫描本次换标全部来源包裹/箱"); + if (selected.stream().map(RelabelItem::getDestinationFbaWarehouseCode).distinct().count() != 1) + throw conflict("不同目的仓不能合箱"); + var deductions = deductions(selected); + if (request.sourceDeductions() != null && !totals(deductions).equals(totals(request.sourceDeductions()))) + throw conflict("合箱数量必须等于本次已完成换标的明细数量"); + var inheritedTransfers = new ArrayList>(); + for (String mark : marks) { + var source = cartons.lockMark(mark); + if (source == null || !app.getCustomerId().equals(source.getCustomerId()) + || !app.getId().equals(source.getRelabelApplicationId()) || !"RELABEL_PENDING".equals(source.getStatus())) + throw conflict("来源包裹/箱已变化,请刷新"); + var prior = items.priorCompletedReturnItems(app.getCustomerId(), source.getId(), app.getId()); + if (!prior.isEmpty()) { + var ids = prior.stream().map(i -> i.getId().toString()).toList(); + var raw = inheritedQuantities(prior); + var physical = new HashMap(); + cartonItems.lockCarton(source.getId()).forEach(i -> physical.merge(i.getSkuCode().toUpperCase(Locale.ROOT), i.getQuantity(), Math::addExact)); + var moved = new ArrayList>(); + for (var entry : totals(deductions).entrySet()) { + if (!mark.equals(entry.getKey().get(0))) continue; + String sku = entry.getKey().get(1).toUpperCase(Locale.ROOT); + long inherited = raw.getOrDefault(sku, 0L) - completionLedger.deductedQuantity(ids, sku); + long stock = physical.getOrDefault(sku, 0L); + if (inherited < 0 || inherited > stock) throw conflict("历史换标实物归属与来源库存不一致,请核对来源"); + if (inherited > 0 && inherited < stock && entry.getValue() < stock) + throw conflict("已换标与未换标货物来源不能自动猜测,请整箱隔离后重新换标"); + long transfer = Math.min(inherited, entry.getValue()); + if (transfer > 0) moved.add(Map.of("skuCode", sku, "quantity", transfer)); + } + if (!moved.isEmpty()) inheritedTransfers.add(Map.of("sourceItemIds", ids, "sourceCartonMark", mark, "items", moved)); + } + source.setStatus("AVAILABLE"); cartons.updateById(source); + } + var result = inventory.mergeBoxes(identity, new ReturnInventoryRequests.BoxMerge(marks, null, + "RELABEL-PACK:" + app.getId() + ":" + request.idempotencyKey(), deductions, + request.length(), request.width(), request.height(), request.weight())); + for (String mark : marks) { + var source = cartons.lockMark(mark); + cartons.clearRelabel(source.getId()); + } + var target = cartons.lockMark(result.cartonMark()); + target.setStatus("RELABEL_PENDING"); target.setRelabelApplicationId(app.getId()); + target.setRelabelNo(app.getRelabelNo()); target.setRelabelStatus("PROCESSING"); + target.setDestinationWarehouseCode(selected.get(0).getDestinationFbaWarehouseCode()); + cartons.updateById(target); + for (var item : selected) { + item.setReturnCartonId(target.getId()); item.setCartonMark(target.getCartonMark()); + items.updateById(item); + } + if (!inheritedTransfers.isEmpty()) { + var task = tasks.find(app.getCustomerId(), app.getId()); + for (var transfer : inheritedTransfers) { + var detail = new LinkedHashMap(transfer); + detail.put("targetCartonMark", result.cartonMark()); detail.put("targetApplicationId", app.getId().toString()); + var history = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory(); + history.setId(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId()); history.setTaskId(task.getId()); + history.setApplicationId(app.getId()); history.setCustomerId(app.getCustomerId()); + history.setAction("RETURN_COMPLETION_TRANSFER"); history.setFromStatus("PROCESSING"); history.setToStatus("PROCESSING"); + history.setOperatorKey(identity.operatorKey()); history.setOperatorName(identity.operatorName()); + try { history.setDetailJson(json.writeValueAsString(detail)); } + catch (java.io.IOException failure) { throw new IllegalStateException(failure); } + history.setCreatedAt(java.time.LocalDateTime.now()); histories.insert(history); + } + } + return result; + } + + private Map inheritedQuantities(List prior) { + var completed = facts.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .in("item_id", prior.stream().map(RelabelItem::getId).toList()).eq("result", "RELABELLED")); + var result = new HashMap(); + for (var item : prior) { + long count = completed.stream().filter(f -> item.getId().equals(f.getItemId())).mapToLong(f -> f.getQuantity() == null ? 1 : f.getQuantity()).sum(); + if ("RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) result.merge(item.getSkuCode().toUpperCase(Locale.ROOT), count, Math::addExact); + else for (var deduction : deductions(List.of(item))) + result.merge(deduction.skuCode().toUpperCase(Locale.ROOT), Math.multiplyExact(count, deduction.quantity()), Math::addExact); + } + return result; + } + + public List deductions(List selected) { + var deductions = new ArrayList(); + for (var i : selected) { + if ("RETURN_BULK_SKU_LABEL".equals(i.getOperationType())) deductions.add(new ReturnInventoryRequests.SourceDeduction(i.getCartonMark(), i.getSkuCode(), i.getNotifiedPackageCount())); + else try { + for (var sku : json.readTree(i.getSkuRelabelItemsJson())) deductions.add(new ReturnInventoryRequests.SourceDeduction(i.getCartonMark(), sku.path("skuCode").asText(), sku.path("quantity").asLong())); + } catch (java.io.IOException failure) { throw conflict("换标 SKU 明细无法读取"); } + } + return deductions; + } + private Map, Long> totals(List lines) { + var result = new LinkedHashMap, Long>(); + for (var line : lines) { + if (line.quantity() <= 0) throw conflict("合箱扣减数量必须为正数"); + result.merge(List.of(line.cartonMark(), line.skuCode()), line.quantity(), Math::addExact); + } + return result; + } + + private RuntimeException conflict(String reason) { + return new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, reason); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/ReturnSkuConversionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/ReturnSkuConversionService.java new file mode 100644 index 000000000..b73f5a853 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/ReturnSkuConversionService.java @@ -0,0 +1,216 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; +import com.baiyue.lastleg.returnreceipt.entity.*; +import com.baiyue.lastleg.returnreceipt.mapper.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.util.*; + +/** Converts physical contents and SKU balances together; the carton itself never gains or loses pieces. */ +@Service +public class ReturnSkuConversionService { + @Resource private ReturnSkuConversionMapper conversions; + @Resource private ReturnInventoryCartonMapper cartons; + @Resource private ReturnInventoryCartonItemMapper contents; + @Resource private ReturnInventoryBalanceMapper balances; + @Resource private ReturnPackingTraceMapper traces; + @Resource private ReturnInventoryOperationMapper operations; + @Resource private ObjectMapper json; + + @Transactional + public void apply(RelabelApplication app, List items, List facts, String round) { + apply(app, items, facts, round, List.of()); + } + + @Transactional + public void apply(RelabelApplication app, List items, List facts, String round, + List scans) { + var existing = new ArrayList<>(conversions.lockApplication(app.getCustomerId(), app.getId())); + for (var item : items.stream().sorted(Comparator.comparing(RelabelItem::getReturnCartonId, + Comparator.nullsLast(Comparator.naturalOrder())).thenComparing(RelabelItem::getId)).toList()) { + if (item.getReturnCartonId() == null || !Set.of("SKU_LABEL", "RETURN_BULK_SKU_LABEL", "CARTON_AND_SKU").contains(item.getOperationType())) continue; + long completed = facts.stream().filter(f -> item.getId().equals(f.getItemId()) && "RELABELLED".equals(f.getResult())) + .mapToLong(f -> f.getQuantity() == null ? 1 : f.getQuantity()).sum(); + if (completed == 0 && scans.isEmpty()) continue; + var carton = ownedCarton(app, item.getReturnCartonId()); + try { + if ("RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) { + // 按件贴标未声明 SKU 编码变更时只保留作业事实,不生成不存在的新 SKU。 + if (completed == 0 || item.getNewSkuCode() == null || item.getNewSkuCode().isBlank()) continue; + convert(app, item, carton, item.getSkuCode(), item.getNewSkuCode(), completed, round, existing); + } else { + var approved = json.readTree(item.getSkuRelabelItemsJson()); + var doneIndices = new HashSet(); + if (completed == 0) for (var scan : scans) { + if (!"SKU_SCAN".equals(scan.getAction())) continue; + var detail = json.readTree(scan.getDetailJson()); + if (!round.equals(detail.path("round").asText()) || !item.getId().toString().equals(detail.path("itemId").asText())) continue; + int index = detail.path("skuIndex").asInt(-1); + if (index < 0 || index >= approved.size()) throw conflict("SKU 扫描明细索引无效"); + var sku = approved.get(index); + if (!sku.path("skuCode").asText().equals(detail.path("oldSku").asText()) + || !sku.path("newSkuCode").asText().equals(detail.path("newSku").asText()) + || sku.path("quantity").asLong() != detail.path("quantity").asLong()) throw conflict("SKU 扫描记录与批准明细不一致"); + doneIndices.add(index); + } + for (int index = 0; index < approved.size(); index++) { + if (completed == 0 && !doneIndices.contains(index)) continue; + var sku = approved.get(index); + convert(app, item, carton, sku.path("skuCode").asText(), sku.path("newSkuCode").asText(), + Math.multiplyExact(completed == 0 ? 1 : completed, sku.path("quantity").asLong()), round, existing); + } + } + } catch (java.io.IOException failure) { throw conflict("换标 SKU 明细无法读取"); } + refreshTypes(carton); + } + } + + private void convert(RelabelApplication app, RelabelItem item, ReturnInventoryCarton carton, + String oldSku, String newSku, long quantity, String round, List existing) { + oldSku = code(oldSku); newSku = code(newSku); + if (quantity <= 0 || oldSku.equalsIgnoreCase(newSku)) throw conflict("新旧 SKU 必须不同且换标数量必须大于零"); + for (var row : existing) if (item.getId().equals(row.getItemId()) && round.equals(row.getRoundKey()) + && oldSku.equalsIgnoreCase(row.getOldSkuCode()) && newSku.equalsIgnoreCase(row.getNewSkuCode())) { + if (row.getQuantity() != quantity || "REVERSED".equals(row.getStatus())) throw conflict("本轮换标库存转换已变化"); + return; + } + var source = contents.lockSku(carton.getId(), oldSku); + if (source == null || n(source.getQuantity()) - n(source.getPackingReservedQuantity()) - n(source.getRelabelledQuantity()) < quantity) + throw conflict("旧 SKU 的未换标库存不足:" + oldSku); + var target = contents.lockSku(carton.getId(), newSku); + boolean removed = source.getQuantity() == quantity, added = target == null; + var ownership = ownership(carton, oldSku, source.getQuantity()); + String product = source.getProductNameZh(); + source.setQuantity(source.getQuantity() - quantity); + if (removed) contents.deleteById(source.getId()); else contents.updateById(source); + if (target == null) { + target = new ReturnInventoryCartonItem(); target.setId(IdWorker.getId()); target.setCartonId(carton.getId()); + target.setCustomerId(app.getCustomerId()); target.setSkuCode(newSku); target.setProductNameZh(product); + target.setQuantity(quantity); target.setPackingReservedQuantity(0L); target.setRelabelledQuantity(quantity); + contents.insert(target); + } else { + target.setQuantity(Math.addExact(n(target.getQuantity()), quantity)); + target.setRelabelledQuantity(Math.addExact(n(target.getRelabelledQuantity()), quantity)); contents.updateById(target); + } + adjustBalances(carton, oldSku, newSku, product, quantity, removed, added); + var row = new ReturnSkuConversion(); row.setId(IdWorker.getId()); row.setCustomerId(app.getCustomerId()); + row.setOverseasWarehouseId(carton.getOverseasWarehouseId()); row.setApplicationId(app.getId()); row.setRelabelNo(app.getRelabelNo()); + row.setItemId(item.getId()); row.setRoundKey(round); row.setCartonId(carton.getId()); row.setCartonMark(carton.getCartonMark()); + row.setOldSkuCode(oldSku); row.setNewSkuCode(newSku); row.setProductNameZh(product); row.setQuantity(quantity); + row.setStatus("APPLIED"); row.setCompletedAt(LocalDateTime.now()); + var movedOwners = new ArrayList(); + long remaining = quantity; + for (var owner : ownership) { + long moved = Math.min(remaining, owner.quantity()); + if (moved > 0) movedOwners.add(new ReturnPackingTraceMapper.Ownership(owner.returnNo(), moved)); + remaining -= moved; + } + if (remaining != 0) throw conflict("SKU 换标来源追溯数量不足"); + try { row.setOwnershipJson(json.writeValueAsString(movedOwners)); } + catch (java.io.IOException failure) { throw new IllegalStateException("Cannot save SKU conversion ownership", failure); } + conversions.insert(row); existing.add(row); traceConversion(row, movedOwners, false); + } + + @Transactional + public void confirm(RelabelApplication app) { + for (var row : conversions.lockApplication(app.getCustomerId(), app.getId())) if ("APPLIED".equals(row.getStatus())) { + row.setStatus("CONFIRMED"); row.setConfirmedAt(LocalDateTime.now()); conversions.updateById(row); + } + } + + @Transactional + public void reverse(RelabelApplication app) { + var rows = new ArrayList<>(conversions.lockApplication(app.getCustomerId(), app.getId())); Collections.reverse(rows); + for (var row : rows) { + if ("CONFIRMED".equals(row.getStatus())) throw conflict("已确认的 SKU 库存转换不能退回"); + if (!"APPLIED".equals(row.getStatus())) continue; + var carton = ownedCarton(app, row.getCartonId()); + var source = contents.lockSku(carton.getId(), row.getNewSkuCode()); + long quantity = row.getQuantity(); + if (source == null || n(source.getQuantity()) < quantity || n(source.getRelabelledQuantity()) < quantity + || n(source.getPackingReservedQuantity()) > 0) throw conflict("已换标 SKU 库存已变化,不能退回"); + List ownership; + try { ownership = json.readValue(row.getOwnershipJson(), new com.fasterxml.jackson.core.type.TypeReference<>() {}); } + catch (java.io.IOException failure) { throw conflict("换标来源追溯记录无法读取"); } + var target = contents.lockSku(carton.getId(), row.getOldSkuCode()); + boolean removed = source.getQuantity() == quantity, added = target == null; + source.setQuantity(source.getQuantity() - quantity); source.setRelabelledQuantity(source.getRelabelledQuantity() - quantity); + if (removed) contents.deleteById(source.getId()); else contents.updateById(source); + if (target == null) { + target = new ReturnInventoryCartonItem(); target.setId(IdWorker.getId()); target.setCartonId(carton.getId()); + target.setCustomerId(app.getCustomerId()); target.setSkuCode(row.getOldSkuCode()); target.setProductNameZh(row.getProductNameZh()); + target.setQuantity(quantity); target.setPackingReservedQuantity(0L); target.setRelabelledQuantity(0L); contents.insert(target); + } else { target.setQuantity(Math.addExact(n(target.getQuantity()), quantity)); contents.updateById(target); } + adjustBalances(carton, row.getNewSkuCode(), row.getOldSkuCode(), row.getProductNameZh(), quantity, removed, added); + traceConversion(row, ownership, true); + row.setStatus("REVERSED"); row.setReversedAt(LocalDateTime.now()); conversions.updateById(row); refreshTypes(carton); + } + } + + private void adjustBalances(ReturnInventoryCarton carton, String oldSku, String newSku, String product, + long quantity, boolean removed, boolean added) { + boolean box = !"PARCEL".equals(carton.getSourceUnitType()); + if (balances.deductConversion(carton.getCustomerId(), carton.getOverseasWarehouseId(), oldSku, quantity, box && removed ? 1 : 0) != 1) + throw conflict("退货 SKU 余额不足:" + oldSku); + if (balances.adjust(IdWorker.getId(), carton.getCustomerId(), carton.getOverseasWarehouseId(), newSku, + product, quantity, quantity, box && added ? 1 : 0) <= 0) throw conflict("新 SKU 库存入账失败"); + } + + private ReturnInventoryCarton ownedCarton(RelabelApplication app, long id) { + var carton = cartons.lockId(id); + if (carton == null || !app.getCustomerId().equals(carton.getCustomerId()) + || !app.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId()) + || !app.getId().equals(carton.getRelabelApplicationId()) || !"RELABEL_PENDING".equals(carton.getStatus())) + throw conflict("换标来源箱或包裹已变化"); + return carton; + } + + private void refreshTypes(ReturnInventoryCarton carton) { + var lines = contents.lockCarton(carton.getId()); + if (lines.stream().mapToLong(i -> n(i.getQuantity())).sum() != n(carton.getTotalQuantity())) throw conflict("SKU 转换后箱内件数不守恒"); + carton.setSkuTypes(lines.size()); carton.setUpdatedAt(LocalDateTime.now()); cartons.updateById(carton); + } + + private List ownership(ReturnInventoryCarton carton, String sku, long quantity) { + var result = new ArrayList<>(traces.ownership(carton.getCustomerId(), carton.getCartonMark(), sku)); + long traced = result.stream().mapToLong(ReturnPackingTraceMapper.Ownership::quantity).sum(); + if (traced < quantity) { + String[] sources = code(carton.getSourceReturnNo()).split(","); + if (sources.length != 1) throw conflict("混箱 SKU 缺少来源追溯,不能转换库存"); + result.add(new ReturnPackingTraceMapper.Ownership(sources[0], quantity - traced)); + } + return result; + } + + private void traceConversion(ReturnSkuConversion row, List ownership, boolean reverse) { + var op = new ReturnInventoryOperation(); op.setId(IdWorker.getId()); op.setOperatorId("SKU_CONVERSION:" + row.getCustomerId()); + op.setIdempotencyKey(row.getId() + (reverse ? ":REVERSE" : ":APPLY")); op.setRequestHash(op.getIdempotencyKey()); + op.setOperationType(reverse ? "SKU_CONVERSION_REVERSE" : "SKU_CONVERSION"); op.setResultCartonId(row.getCartonId()); + op.setResultJson("{}"); op.setCreatedAt(LocalDateTime.now()); op.setUpdatedAt(op.getCreatedAt()); operations.insert(op); + long remaining = row.getQuantity(); + for (var owner : ownership) { + long moved = Math.min(remaining, owner.quantity()); if (moved == 0) continue; + traces.insert(IdWorker.getId(), row.getCustomerId(), op.getId(), row.getCartonMark(), "", owner.returnNo(), + reverse ? row.getNewSkuCode() : row.getOldSkuCode(), moved, op.getCreatedAt()); + traces.insert(IdWorker.getId(), row.getCustomerId(), op.getId(), "", row.getCartonMark(), owner.returnNo(), + reverse ? row.getOldSkuCode() : row.getNewSkuCode(), moved, op.getCreatedAt()); + remaining -= moved; + } + if (remaining != 0) throw conflict("SKU 换标来源追溯数量不足"); + } + private static long n(Long value) { return value == null ? 0 : value; } + private static String code(String value) { + if (value == null || value.isBlank()) throw conflict("SKU 或来源单号缺失"); return value.trim(); + } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnInventoryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnInventoryController.java index 689ad53da..80cde6628 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnInventoryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnInventoryController.java @@ -24,6 +24,11 @@ public class PdaReturnInventoryController { @Resource private ReturnInventoryService service; @Resource private com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountService counting; + @PostMapping("/cartons/{cartonMark}/confirm-label") + public ApiResponse confirmLabel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String cartonMark) { + return ApiResponse.success(service.confirmLabel(PdaWarehouseIdentity.require(user), cartonMark)); + } + @GetMapping("/counting/tasks") public ApiResponse> countingTasks( @AuthenticationPrincipal AuthenticatedUser user, @Valid com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Query query) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptRequests.java index 2d16026cd..04cef7f7e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptRequests.java @@ -25,11 +25,18 @@ public final class PdaReturnReceiptRequests { @Size(max = 16) String unitType, @Size(max = 128) String unitCode, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight, - Long totalBoxCount) { + Long totalBoxCount, Boolean originalPackaging) { public Confirm(String returnNo, String trackingNo, String startedBy, String palletCode, List<@Valid Item> items, String idempotencyKey) { this(returnNo, trackingNo, startedBy, palletCode, items, idempotencyKey, - null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null); + } + public Confirm(String returnNo, String trackingNo, String startedBy, String palletCode, + List<@Valid Item> items, String idempotencyKey, String unitType, String unitCode, + BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight, + Long totalBoxCount) { + this(returnNo, trackingNo, startedBy, palletCode, items, idempotencyKey, unitType, unitCode, + length, width, height, weight, totalBoxCount, null); } } public record Item(@NotBlank @Size(max = 128) String skuCode, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnCartonCountDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnCartonCountDtos.java index ffe6b622e..33475af58 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnCartonCountDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnCartonCountDtos.java @@ -3,14 +3,20 @@ package com.baiyue.lastleg.returnreceipt.dto; import jakarta.validation.Valid; import jakarta.validation.constraints.*; import java.util.List; +import java.math.BigDecimal; public final class ReturnCartonCountDtos { private ReturnCartonCountDtos() { } public record Query(@Min(1) long page, @Min(1) @Max(50) long size, @Size(max=128) String keyword) { } public record Sku(String skuCode, String productNameZh, long quantity) { } - public record Box(String cartonMark, List items) { } + public record Box(String cartonMark, List items, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight) { + public Box(String cartonMark, List items) { this(cartonMark, items, null, null, null, null); } + } public record Task(String receiptNo, String returnNo, String palletCode, String status, List expectedItems, List boxes) { } public record CountItem(@NotBlank @Size(max=128) String skuCode, @Min(1) long quantity) { } - public record CountBox(@NotBlank @Size(max=128) String cartonMark, @NotEmpty @Size(max=100) List<@NotNull @Valid CountItem> items) { } + public record CountBox(@NotBlank @Size(max=128) String cartonMark, @NotEmpty @Size(max=100) List<@NotNull @Valid CountItem> items, + @Positive BigDecimal length, @Positive BigDecimal width, @Positive BigDecimal height, @Positive BigDecimal weight) { + public CountBox(String cartonMark, List<@NotNull @Valid CountItem> items) { this(cartonMark, items, null, null, null, null); } + } public record Confirm(@NotBlank @Size(max=128) String idempotencyKey, @NotEmpty @Size(max=500) List<@NotNull @Valid CountBox> boxes) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryRequests.java index e3f4d37d5..8a3158e39 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryRequests.java @@ -45,9 +45,13 @@ public final class ReturnInventoryRequests { @NotEmpty @Size(max = 100) List<@Valid PlanBox> boxes, @Size(max = 1000) String remark, @NotBlank @Size(max = 255) String idempotencyKey, - @Size(max = 500) List<@NotBlank String> sourceCartonMarks) { + @Size(max = 500) List<@NotBlank String> sourceCartonMarks, + @Size(max = 1000) List<@Valid SourceDeduction> sourceDeductions) { + public PackingPlanCreate(List returnNos, String mode, List boxes, String remark, String idempotencyKey, List sourceCartonMarks) { + this(returnNos, mode, boxes, remark, idempotencyKey, sourceCartonMarks, null); + } public PackingPlanCreate(List returnNos, String mode, List boxes, String remark, String idempotencyKey) { - this(returnNos, mode, boxes, remark, idempotencyKey, List.of()); + this(returnNos, mode, boxes, remark, idempotencyKey, List.of(), null); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java index 9da92ed40..1589e311a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java @@ -12,27 +12,42 @@ public final class ReturnInventoryResponses { public record CartonSummary(String cartonMark, String overseasWarehouseId, String sourceBatchNo, String sourceReturnNo, String status, int skuTypes, long totalQuantity, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight, - LocalDateTime createdAt) { } + LocalDateTime createdAt, String relabelStatus, long relabelledQuantity, + long relabelReservedQuantity) { } public record InventoryItem(String overseasWarehouseId, String cartonMark, String sourceBatchNo, String status, String skuCode, String productNameZh, long quantity, int cartonSkuTypes, long cartonTotalQuantity, - LocalDateTime receivedAt) { } + LocalDateTime receivedAt, long relabelledQuantity, long remainingRelabelQuantity) { } public record SummaryItem(String overseasWarehouseId, String skuCode, String productNameZh, - long totalQuantity, long availableQuantity, long cartonQuantity, LocalDateTime updatedAt) { } + long totalQuantity, long availableQuantity, long cartonQuantity, LocalDateTime updatedAt, + long relabelLockedQuantity) { } public record CartonDetail(String cartonMark, String customerId, String overseasWarehouseId, String sourceBatchNo, String sourceReturnNo, String status, String fbaCartonMark, String destinationWarehouseCode, String destinationWarehouseName, String relabelNo, String relabelStatus, int skuTypes, long totalQuantity, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight, LocalDateTime receivedAt, List items, - String locationCode, String palletCode, LocalDateTime putawayAt, List mergeHistory) { } + String locationCode, String palletCode, LocalDateTime putawayAt, List mergeHistory, + long relabelledQuantity, long relabelReservedQuantity, List skuConversions) { } + public record SkuConversion(String relabelNo, String cartonMark, String oldSkuCode, String newSkuCode, + long quantity, String status, LocalDateTime completedAt) { + public static SkuConversion from(com.baiyue.lastleg.returnreceipt.entity.ReturnSkuConversion row) { + return new SkuConversion(row.getRelabelNo(), row.getCartonMark(), row.getOldSkuCode(), row.getNewSkuCode(), + row.getQuantity(), row.getStatus(), row.getCompletedAt()); + } + } public record MergeHistory(String planNo, String sourceCartonMark, String targetCartonMark, String returnNo, String skuCode, long quantity, LocalDateTime createdAt) { } - public record CartonItem(String skuCode, String productNameZh, long quantity, long availableQuantity) { + public record CartonItem(String skuCode, String productNameZh, long quantity, long availableQuantity, + long relabelledQuantity, long remainingRelabelQuantity) { + public CartonItem(String skuCode, String productNameZh, long quantity, long availableQuantity) { + this(skuCode, productNameZh, quantity, availableQuantity, 0, availableQuantity); + } public CartonItem(String skuCode, String productNameZh, long quantity) { this(skuCode, productNameZh, quantity, quantity); } } public record Label(String cartonMark, String overseasWarehouseId, String customerId, String sourceBatchNo, String sourceReturnNo, long totalQuantity, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight, List items, LocalDateTime generatedAt, String printableHtml) { } - public record MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label, String planNo) { + public record MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label, String planNo, String operatorName) { + public MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label, String planNo) { this(cartonMark, skuCode, totalQuantity, label, planNo, null); } public MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label) { this(cartonMark, skuCode, totalQuantity, label, null); } } public record PutawayResult(String cartonMark, String locationCode, String palletCode, LocalDateTime putawayAt) { } @@ -45,19 +60,25 @@ public final class ReturnInventoryResponses { public record PackingPlan(String planNo, String customerId, String overseasWarehouseId, List returnNos, String mode, String status, List boxes, String remark, LocalDateTime pushedAt, List sourceCartonMarks, List sources, - LocalDateTime createdAt, List resultCartons, LocalDateTime completedAt) { } - public record PlanSource(String cartonMark, List items) { } + LocalDateTime createdAt, List resultCartons, LocalDateTime completedAt, + List