diff --git a/AGENTS.md b/AGENTS.md index 35f4819ff..3fddbd721 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/` | 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..09111ffc5 100644 --- a/deploy/env/.env.test.example +++ b/deploy/env/.env.test.example @@ -21,6 +21,17 @@ 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_EXECUTION_ENABLED=false +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/README.md b/deploy/sql/README.md index 6f815584c..2b76d2b9d 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,6 +26,8 @@ 内部拼车需求池的当前结构在 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。 + ## 唯一入口与区域边界 两个统一 SQL 文件仍只用于空库初始化,名称中的“增量”不表示可向已有业务库重放。已有生产库的受控结构补齐单独放在 `migrations/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。 @@ -29,7 +35,7 @@ | 区域 | 完整入口 | 服务 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` | 184 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 197 | - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 @@ -51,7 +57,7 @@ 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/184`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/197`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 @@ -67,7 +73,7 @@ | 所属库 | 保留内容 | 不包含 | |---|---|---| -| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、442 个权限、630 条角色权限、7 个数据范围、客户编号起点;无人工完成订单权限 | 测试客户、固定客户账号、额外扩权 | +| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、449 个权限、637 条角色权限、7 个数据范围、客户编号起点;无人工完成订单权限 | 测试客户、固定客户账号、额外扩权 | | Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 | | Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 | | File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 | @@ -139,3 +145,5 @@ 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 和三端照片功能共同发布;不回填历史废弃单,不修改库存。脚本存在不表示目标环境已执行。 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/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index cd4b64281..db5c6cbdd 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), @@ -5337,7 +5335,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) @@ -8315,4 +8313,24 @@ 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; +-- 自营业务菜单资源用于菜单管理;四个页面和所有只读接口统一由 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), + (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,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)); + SELECT 'PASS' AS initialization_status, 'CN' AS region, 226 AS initialized_tables; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 1ce5bf37b..b81e07adf 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -2373,13 +2373,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 +2395,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 +2414,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,9 +2461,10 @@ 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), @@ -2486,9 +2489,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 +2524,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 +2540,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 +2567,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 +2593,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), @@ -3907,8 +3912,328 @@ CREATE TABLE ll_procurement_closure_item ( CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运'; +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 'SCHEDULED、MANUAL_RETRY、BACKFILL、RECHECK、REBUILD、VALIDATION;验证不由自动计划创建', + 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 ll_appointment_impact ( container_booking_id VARCHAR(64) NOT NULL COMMENT '稳定柜次关联键,不作业务编号展示', @@ -3949,4 +4274,49 @@ CREATE TABLE ll_appointment_impact_history ( CONSTRAINT chk_appointment_impact_action CHECK (action IN ('OBSERVED','CLAIM','RECORD','RESOLVE')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='船期观察及人工处理追加历史'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 184 AS initialized_tables; +-- 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, + 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; + +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 197 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/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/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/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/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/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/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 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/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..fd0d1e492 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,7 +34,7 @@ public final class PalletOperationRequests { @NotEmpty @Size(max = 20) List<@Valid TargetMove> targets, @Size(max = 500) String reason) { } - /** actualSource为现场确认的原入仓库存行;cartonMarks确认本批目标绑定,不推断历史箱唛位置。 */ + /** source为空时表示退货箱没有来源托盘,现场仅扫描箱唛后绑定目标托盘;actualSource为现场确认的原入仓库存行。 */ public record RelabelDisassemble(@NotBlank @Size(max = 128) String idempotencyKey, @NotBlank @Size(max = 64) String relabelNo, @Valid PalletExpectation source, 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..6c2f9a61d 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 @@ -105,13 +105,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 +228,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 +251,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 +268,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 +282,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()), 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 @@ -313,25 +361,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 +394,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 +413,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)); @@ -790,7 +842,9 @@ public class PdaPalletOperationService { record.setCreatedAt(LocalDateTime.now()); record.setDetailJson(write(Map.of("round", Objects.toString(task.getReviewedAt(), "INITIAL"), "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 +890,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 +954,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) { 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..ccd4f5e71 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelPalletMeasurementMapper.java @@ -0,0 +1,26 @@ +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) {} + @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..76cc186ff 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,6 +52,33 @@ 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; public static final String AVAILABLE = "AVAILABLE"; public static final String PROCESSING = "PROCESSING"; @@ -66,6 +93,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 +258,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 +308,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 +345,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) { @@ -463,6 +501,9 @@ 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()); @@ -512,6 +553,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 +616,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 +791,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 +814,7 @@ 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(), "TRUCK".equals(application.getDeliveryMethod())); } private String effectiveTaskStatus(RelabelApplication application, PdaRelabelTask task) { @@ -841,6 +887,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/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index 4ad45ee05..665db4ed9 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 @@ -93,7 +93,10 @@ 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 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; @@ -250,10 +253,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 +270,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; } @@ -370,6 +395,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()) { @@ -396,28 +422,36 @@ public class RelabelApplicationService implements WalletResultHandler { if (!carton.getCartonMark().equals(item.cartonMark().trim())) 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)); @@ -495,7 +529,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); @@ -618,11 +653,15 @@ public class RelabelApplicationService implements WalletResultHandler { } if (next.equals(application.getStatus())) return detail(customerId, relabelNo); validateTransition(application.getStatus(), next); + if (Set.of("REJECTED", "CANCELLED").contains(next) && itemList(application.getId()).stream() + .filter(item -> item.getReturnCartonId() != null) + .anyMatch(item -> returnCartons.totalRelabelledQuantity(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())) { @@ -675,10 +714,10 @@ public class RelabelApplicationService implements WalletResultHandler { // 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"; + 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); @@ -686,11 +725,10 @@ public class RelabelApplicationService implements WalletResultHandler { insertOrderHistory(customerId, order.getId(), orderBefore, orderStatus, request.remark(), now); } } - ReturnOrder returnOrder = returnOrders.selectOne(new LambdaQueryWrapper() + List linkedReturnOrders = 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 : linkedReturnOrders) { returnOrder.setRelabelStatus(next); returnOrder.setUpdatedAt(now); returnOrders.updateById(returnOrder); @@ -791,7 +829,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 +848,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,6 +933,7 @@ public class RelabelApplicationService implements WalletResultHandler { throw conflict("Upload the shipping label before confirming relabel completion"); } List relabelItems = itemList(application.getId()); + skuConversions.confirm(application); WarehouseTransferOrder linkedTransfer = warehouseTransfers == null ? null : warehouseTransfers.lockByRelabelNo(customerId, relabelNo); boolean existingStockTransfer = linkedTransfer != null @@ -927,9 +967,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 +978,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); @@ -961,6 +1000,7 @@ public class RelabelApplicationService implements WalletResultHandler { 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); @@ -1163,7 +1203,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 +1225,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,7 +1300,6 @@ 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(); @@ -1262,7 +1329,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 +1346,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 +1367,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 +1425,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"; 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..027baff4a 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,12 +22,13 @@ public class RelabelBindingGuard { @Resource private PdaRelabelHistoryMapper histories; @Resource private InventoryPositionMapper positions; @Resource private ReturnInventoryCartonMapper returnCartons; + @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<>(); @@ -44,24 +45,47 @@ public class RelabelBindingGuard { } catch (Exception e) { throw conflict("组托记录无法读取,请核对后重试"); } } for (RelabelItem item : cartons) { - if (item.getReturnCartonId() != null && !"RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) { + if (item.getReturnCartonId() != null && !Set.of("RETURN_BULK_SKU_LABEL", "SKU_LABEL").contains(item.getOperationType())) { var carton = returnCartons.selectById(item.getReturnCartonId()); if (carton == null || !app.getCustomerId().equals(carton.getCustomerId())) throw conflict("退货箱来源缺失"); + 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 明细无法读取"); } + 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 (!"TRUCK".equals(app.getDeliveryMethod())) continue; - if (!bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode())) + if (carton.getPalletCode() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode())) throw conflict("退货箱尚未完成排车托盘绑定"); continue; } - if ("RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) { + 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)); + if (!selected.equals(actual)) throw conflict("部分换标货物必须先合箱生成独立新箱,再提交完工"); + if (!"TRUCK".equals(app.getDeliveryMethod())) continue; var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId()); - if (carton == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode()) + if (carton == null || 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 || position.getPalletNo() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(position.getPalletNo()) || position.getHoldPackages() == null || position.getHoldPackages() < item.getNotifiedPackageCount()) throw conflict("箱唛 " + item.getCartonMark() + " 尚未完成托盘绑定,请先在 PDA 确认组托,不能完成或排车出库"); } 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..1708beff3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelPalletMeasurementService.java @@ -0,0 +1,134 @@ +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; + 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) {} + + 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("托盘库存已变化,请刷新后重新测量"); + 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 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 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())); + 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(",")); + 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, + original.lengthCm(), original.widthCm(), original.heightCm(), original.grossWeightKg(), original.volumeCbm(), true); + } + 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 + || p.lengthCm() == null || p.widthCm() == null || p.heightCm() == null + || p.grossWeightKg().signum() <= 0 || p.volumeCbm().signum() <= 0) + throw conflict("托盘 " + p.palletCode() + " 拆合托后尚未确认实测尺寸和重量,请在 PDA 补测(含原托盘剩余货物)"); + } + 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