From 2927fbee06c8286abb850aa73ea82bd092eb4eed Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 26 Oct 2026 17:27:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20261022-海外费用仓租数据库变更汇总.md | 9 +- deploy/sql/README.md | 2 + ...erseas-fees-storage-rent-structure-add.sql | 97 ++++++++++++++++ .../overseas-storage-rent-zero-fee-repair.sql | 2 +- .../overseas-fees-storage-rent-release.md | 23 +++- .../mapper/DispatchCandidateMapper.java | 6 +- .../dispatch/mapper/DispatchSourceMapper.java | 4 +- .../controller/InboundPlanController.java | 17 +++ .../dto/ConfirmInboundShortageRequest.java | 15 +++ .../pda/repository/PdaWorkflowRepository.java | 6 + .../service/InboundPdaWorkflowService.java | 66 ++++++++++- .../service/InboundStockReceiptService.java | 17 ++- .../service/ReceivingContainerService.java | 4 +- .../workflow/OrderWorkflowFactMapper.java | 2 +- .../openapi/overseas-business-v1.yaml | 26 +++++ .../DispatchCandidateMapperContractTest.java | 50 +++++---- ...DispatchInboundReceiptPersistenceTest.java | 2 + .../DispatchPlanPdaReadPersistenceTest.java | 2 +- .../controller/InboundPlanControllerTest.java | 13 +++ .../InboundShortageConfirmationTest.java | 106 ++++++++++++++++++ .../InboundStockReceiptServiceTest.java | 28 +++++ ...ema-alignment-migrations-contract.test.mjs | 53 +++++++++ 22 files changed, 512 insertions(+), 38 deletions(-) create mode 100644 deploy/sql/overseas-fees-storage-rent-structure-add.sql create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConfirmInboundShortageRequest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundShortageConfirmationTest.java diff --git a/deploy/sql/20261022-海外费用仓租数据库变更汇总.md b/deploy/sql/20261022-海外费用仓租数据库变更汇总.md index 74645a113..357741d93 100644 --- a/deploy/sql/20261022-海外费用仓租数据库变更汇总.md +++ b/deploy/sql/20261022-海外费用仓租数据库变更汇总.md @@ -11,7 +11,14 @@ 两份文件虽然名称包含“增量”,仍只用于对应区域的空库初始化。新增列、索引和约束直接维护在最终 `CREATE TABLE` 中,菜单及授权使用初始化种子;不追加存量改造、权限版本回填或动态迁移段。目标 schema 必须为空,按文件原有初始化流程执行;不能向已有业务库重放。 -本次不再提供这项功能的独立升级脚本。已有库升级必须按实际结构和明确授权另行准备、审阅受控增量,不能把删除重复文件理解为已有库已完成升级。 +已有库只按实际缺项执行定向结构补齐,不能把删除重复文件理解为已有库已完成升级。指定 TEST 的最小执行集合如下;文件准备不代表获得执行授权或已部署。 + +| 顺序 | 文件 | 允许的变更与核对结果 | +|---|---|---| +| 1 | [表与审核人字段补齐](overseas-fees-storage-rent-structure-add.sql) | 只补 `ll_storage_rent_inbound`、`ll_finance_fee_source.reviewer_name`;已存在且匹配时跳过,同名异构则停止;成功回查表结构、字段并输出 `TABLE_COLUMN_READY` | +| 2 | [免费仓租定向修复](overseas-storage-rent-zero-fee-repair.sql) | 仅自动应收仓租允许原币/结算双零与空汇率;保留其他费用正额及有效汇率约束,保留已有汇率精度与数值默认值;最终核对 `SHOW CREATE TABLE` 中金额、汇率两项 CHECK 和汇率可空性 | + +两步均固定 `baiyue_lastleg`,使用独立连接、支持 `DELIMITER` 的客户端,禁止遇错继续;不执行业务 DML、不补历史计租记录、不变更余额、汇率配置或权限。DDL 隐式提交,不能宣称整体可回滚;失败保留错误并只读复查,已完成项不删除重建。两步是已核缺项的补齐方案,不是任意版本升级工具;重新执行前仍需核对目标实例与结构。 ## 海外表和字段 diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 5674b6160..64eecf602 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,7 @@ # 单测试环境数据库空库初始化 +`overseas-fees-storage-rent-structure-add.sql` 为指定 TEST 海外费用/仓租的定向结构补齐,只增加缺失的 `ll_storage_rent_inbound` 和 `ll_finance_fee_source.reviewer_name`。全部前置检查在首个业务 DDL 前完成;已有结构匹配时跳过,不匹配则停止,不覆盖或回填。随后按单独授权执行已有 `overseas-storage-rent-zero-fee-repair.sql`,补齐自动免费仓租的金额与汇率约束。使用独立连接、遇错停止,不重放空库初始化;DDL 隐式提交,失败先回查,不删除已完成项。两步范围与发布清单见[海外费用数据库定义汇总](20261022-海外费用仓租数据库变更汇总.md)及[发布配套](../../lastleg-service/docs/overseas-fees-storage-rent-release.md#发布配套)。文件准备不代表已执行或部署。 + 放舱后预计到港窄维护需要配套更新 `trg_ocean_booking_before_update`:仅从冻结比较中移除 `estimated_arrival_at`,其余运输、预报、关系方及柜型冻结不变。定义已同步 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一基线;已有 TEST 需另行审阅并授权替换该触发器,不重放空库文件,不回填数据。本轮只准备定义,未执行 SQL 或部署。 货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。 diff --git a/deploy/sql/overseas-fees-storage-rent-structure-add.sql b/deploy/sql/overseas-fees-storage-rent-structure-add.sql new file mode 100644 index 000000000..1db557726 --- /dev/null +++ b/deploy/sql/overseas-fees-storage-rent-structure-add.sql @@ -0,0 +1,97 @@ +-- 指定 TEST 的海外费用/仓租结构补齐;实例必须由执行者另行核对并获准。 +-- 仅新增 ll_storage_rent_inbound 和 ll_finance_fee_source.reviewer_name。 +-- 使用独立连接,支持 DELIMITER,遇错停止;禁止 mysql --force。 +-- 不更新费用、库存、钱包或历史计租数据,不重放空库初始化。 +-- DDL 隐式提交;失败先回查结构,禁止删除已建表/字段来重跑。 +USE baiyue_lastleg; + +-- 仅清理本脚本可能遗留的专用校验过程,不删除业务对象。 +DROP PROCEDURE IF EXISTS overseas_fee_rent_structure_add_20261026; +DELIMITER $$ +CREATE PROCEDURE overseas_fee_rent_structure_add_20261026() +BEGIN + DECLARE inbound_exists INT DEFAULT 0; + DECLARE reviewer_exists INT DEFAULT 0; + + IF DATABASE()<>'baiyue_lastleg' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='目标必须为 baiyue_lastleg'; + END IF; + -- 在首个业务 DDL 前核对两张依赖表及新增字段的定位列。 + IF (SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('ll_finance_fee_source','ll_storage_rent_lot') + AND TABLE_TYPE='BASE TABLE' AND ENGINE='InnoDB')<>2 + OR (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' + AND COLUMN_NAME='reviewer_id' AND DATA_TYPE='varchar' AND CHARACTER_MAXIMUM_LENGTH=128)<>1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='费用来源或计租批次基础结构不匹配,停止补齐'; + END IF; + + SELECT COUNT(*) INTO reviewer_exists FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' AND COLUMN_NAME='reviewer_name'; + IF reviewer_exists>0 AND (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' AND COLUMN_NAME='reviewer_name' + AND DATA_TYPE='varchar' AND CHARACTER_MAXIMUM_LENGTH=255 AND IS_NULLABLE='YES' + AND COLUMN_DEFAULT IS NULL AND EXTRA='')<>1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已存在的审核人名称字段与正式定义不同,停止且不覆盖'; + END IF; + + SELECT COUNT(*) INTO inbound_exists FROM information_schema.TABLES + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound'; + -- 已存在的同名表必须完整匹配;不以 IF NOT EXISTS 掩盖半成品或异构表。 + IF inbound_exists>0 THEN + IF (SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound' + AND TABLE_TYPE='BASE TABLE' AND ENGINE='InnoDB' AND TABLE_COLLATION='utf8mb4_unicode_ci')<>1 + OR (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound')<>4 + OR (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound' + AND IS_NULLABLE='NO' AND COLUMN_DEFAULT IS NULL AND EXTRA='' + AND ((COLUMN_NAME IN ('receipt_id','lot_id') AND COLUMN_TYPE='bigint') + OR (COLUMN_NAME='quantity' AND COLUMN_TYPE='decimal(24,12)') + OR (COLUMN_NAME='created_at' AND COLUMN_TYPE='datetime(3)')))<>4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已存在的计租实收表字段与正式定义不同,停止且不覆盖'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound' AND INDEX_NAME='PRIMARY')<>1 + OR (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound' AND INDEX_NAME='PRIMARY' + AND COLUMN_NAME='receipt_id' AND SEQ_IN_INDEX=1 AND NON_UNIQUE=0 AND SUB_PART IS NULL)<>1 + OR (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound' + AND INDEX_NAME='idx_storage_rent_inbound_lot')<>1 + OR (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound' + AND INDEX_NAME='idx_storage_rent_inbound_lot' AND COLUMN_NAME='lot_id' + AND SEQ_IN_INDEX=1 AND NON_UNIQUE=1 AND SUB_PART IS NULL)<>1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='计租实收表主键或批次索引不匹配,停止且不覆盖'; + END IF; + END IF; + + -- 全部前置检查通过后才补缺项;重跑时保持已存在的业务结构和数据。 + IF reviewer_exists=0 THEN + ALTER TABLE ll_finance_fee_source + ADD COLUMN reviewer_name VARCHAR(255) NULL COMMENT '审核人名称快照' AFTER reviewer_id; + END IF; + IF inbound_exists=0 THEN + CREATE TABLE ll_storage_rent_inbound ( + receipt_id bigint NOT NULL COMMENT '实际入库凭证主键,防止重复累计计租数量', + lot_id bigint NOT NULL COMMENT '头程同单同仓计租批次主键', + quantity decimal(24,12) NOT NULL COMMENT '本次实收追加计租箱数', + created_at datetime(3) NOT NULL COMMENT '计租登记时间,采用协调世界时', + PRIMARY KEY (receipt_id), + KEY idx_storage_rent_inbound_lot (lot_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='头程实收计租幂等登记'; + END IF; +END$$ +DELIMITER ; +CALL overseas_fee_rent_structure_add_20261026(); +DROP PROCEDURE overseas_fee_rent_structure_add_20261026; + +SHOW CREATE TABLE ll_storage_rent_inbound; +SELECT COLUMN_NAME,COLUMN_TYPE,IS_NULLABLE,COLUMN_DEFAULT +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' AND COLUMN_NAME='reviewer_name'; +SELECT 'TABLE_COLUMN_READY' AS structure_status; +-- TABLE_COLUMN_READY 只表示上述表/字段补齐;免费仓租另执行已审阅的 +-- overseas-storage-rent-zero-fee-repair.sql,不能据此宣称完整功能上线。 diff --git a/deploy/sql/overseas-storage-rent-zero-fee-repair.sql b/deploy/sql/overseas-storage-rent-zero-fee-repair.sql index 6eadb4cd1..904fe09bc 100644 --- a/deploy/sql/overseas-storage-rent-zero-fee-repair.sql +++ b/deploy/sql/overseas-storage-rent-zero-fee-repair.sql @@ -1,4 +1,4 @@ --- 固定目标本机 baiyue_lastleg,只修复自动免费仓租结构,不更新业务数据。 +-- 固定目标经单独授权的 TEST 或本机开发 baiyue_lastleg,只修复自动免费仓租结构,不更新业务数据。 -- 使用独立连接、遇错停止;先核对既有表结构,不能重放空库初始化。 USE baiyue_lastleg; SELECT COLUMN_NAME,COLUMN_TYPE,IS_NULLABLE FROM information_schema.COLUMNS diff --git a/lastleg-service/docs/overseas-fees-storage-rent-release.md b/lastleg-service/docs/overseas-fees-storage-rent-release.md index 32c1a4759..94e232e1f 100644 --- a/lastleg-service/docs/overseas-fees-storage-rent-release.md +++ b/lastleg-service/docs/overseas-fees-storage-rent-release.md @@ -38,10 +38,27 @@ - 后端:Firstleg 公共目录内部接口、Warehouse 客户仓租内部报价接口、Lastleg 费用/仓租及真实收发货挂钩。Finance 继续作为余额、授信、冻结和实扣的唯一权威;不得绕过 Outbox/Inbox 链路。 - 前端:Admin 海外费用管理与海外 PDA 出库预校验同时发布,不能只更新一端。 - 数据库文件:本次五份版本脚本及两份专项汇总 SQL 已收敛到 [国内统一空库基线](../../deploy/sql/头程数据库统一增量-MySQL8.sql) 和 [海外统一空库基线](../../deploy/sql/海外区域统一增量-MySQL8.sql)。海外基线完整包含资金与仓租四表、拆柜成本可空费用引用、退货 SKU 换标仓租份额快照、审核人名称及采购退款请求幂等字段和唯一索引,国内基线包含菜单和默认管理员授权。内容清单见 [数据库变更汇总](../../deploy/sql/20261022-海外费用仓租数据库变更汇总.md)。 -- 已有库:不能重放统一空库初始化;须按实际结构和明确授权审阅增量。[免费仓租定向结构修复](../../deploy/sql/overseas-storage-rent-zero-fee-repair.sql) 仅解决自动仓租双零金额和空汇率入费用源表,保留人工正额与正常费用有效汇率约束,不更新业务数据。独立的 [既有费用写权限修复](../../deploy/sql/overseas-fee-canonical-write-permissions-repair.sql) 仍只处理已有管理员两项 canonical 写权限,不能代替结构升级或菜单初始化。 +- 已有库:不能重放统一空库初始化;须按实际结构和明确授权审阅增量。指定 TEST 先使用[表与审核人字段补齐](../../deploy/sql/overseas-fees-storage-rent-structure-add.sql),再使用[免费仓租定向结构修复](../../deploy/sql/overseas-storage-rent-zero-fee-repair.sql);精确范围与失败语义只维护在[数据库变更汇总](../../deploy/sql/20261022-海外费用仓租数据库变更汇总.md)。独立的 [既有费用写权限修复](../../deploy/sql/overseas-fee-canonical-write-permissions-repair.sql) 仍只处理已有管理员两项 canonical 写权限,不能代替结构升级或菜单初始化。 - 新空库:Lastleg 源基线与海外统一初始化脚本同步新表和字段;国内统一基线同步菜单。菜单查询权限 `platform:overseas-fee:list`,编辑与审核分别沿用 `platform:finance:fee-source:edit/review`,仅有查询权限不能写费用。 - 配置:核对 Firstleg、Warehouse 内部服务地址及内部令牌、客户仓租阶梯、有效 USD/CNY 月度汇率、Finance 资金与费用事件直投及回执配置。当前结算支持 USD、CNY,CNY 按1:1结算,其他币种必须先调整为受支持币种。不得在材料中记录密钥。 -- 发布顺序:先发布 Warehouse 客户计租接口与 Finance,再发布 Lastleg,配套更新 Admin 与海外 PDA。成功扣款引用新增可选的 `walletBusinessType/walletBusinessNo`,分别指向人工费用冻结编号或仓租出库预占编号;外层业务编号仍是实际服务单。Finance 校验客户、金额、成功命令、冻结与流水一致且不可重复关联,接收后标记已结清。旧版 Finance 不识别这两个字段,不得先发生产者。 +- 发布顺序:先发布 Firstleg 公共费用目录、Warehouse 客户计租接口与 Finance,再发布 Lastleg,配套更新 Admin 与海外 PDA。成功扣款引用新增可选的 `walletBusinessType/walletBusinessNo`,分别指向人工费用冻结编号或仓租出库预占编号;外层业务编号仍是实际服务单。Finance 校验客户、金额、成功命令、冻结与流水一致且不可重复关联,接收后标记已结清。旧版 Finance 不识别这两个字段,不得先发生产者。 + +### TEST 配套发布清单 + +本清单用于准备和审阅,不表示环境已完成执行。实际版本、部署结果及未关闭验收项仍只维护于[测试环境主契约](../../../docs/测试环境单机部署与配置契约.md)。发布前冻结后端、Admin、海外 PDA 的精确提交和产物摘要;执行期间任一版本变化都需重新核对受影响项,不使用浮动分支头替代审阅版本。 + +| 顺序 | 对象 | 执行前提与通过标准 | +|---|---|---| +| 1 | 规则与执行窗口 | 确认人工应收是否开放“推送结算/余额扣费”选择:当前前后端已支持显式选择,未选择才按业务默认规则;本文件首版固定分流描述不能作为该选择功能已获确认的依据。明确本批 TEST SQL、服务发布及测试客户写入授权,切换期间暂停相关收费与出库操作 | +| 2 | Lastleg 结构 | 按数据库变更汇总审阅并执行两个定向文件,核对新增表/字段及免费仓租约束;不重新初始化已有资金、计租表,不重放已有权限种子,不改现行汇率 | +| 3 | Firstleg、Warehouse、Finance | 用匹配后端提交分别发布,核对容器镜像与健康;Firstleg 具备 `/internal/firstleg/v1/fee-items`,Warehouse 具备客户计租 `/pricing`,Finance 接受应收结算方式及独立扣款引用;内部服务地址、统一服务令牌及事件回传配置匹配 | +| 4 | Lastleg | 依赖就绪后发布费用/仓租编排及收发货挂钩;统一 Gateway 下费用分页、项目目录、仓租列表与仓库选项均返回业务成功,不能只检查 HTTP 200 | +| 5 | Admin、海外 PDA | Admin 包含海外费用管理和单据费用操作;海外 PDA 包含 `/pda/v1/last-leg/storage-rent/preparations` 出库预校验。H5 按宿主机静态站点规则发布并核验资源摘要;APK/WGT 按实际设备另行核对,H5 通过不等于设备已更新 | +| 6 | 最小业务验收 | 使用明确授权的新测试样本完成下表;逐笔核对业务单、费用、冻结/消费/退款流水及事件回执。查询成功、表为空或历史事件已投递不能替代新功能闭环 | + +第一轮最小样本按以下顺序执行:人工余额收费的登记冻结、审核实扣、原请求重放与驳回释放;账单收费审核入池且钱包不变;免费仓租记录无冻结/扣款;非零仓租出库预占、审核前调价及审核实扣;头程同单同仓补入库不重置库龄、重复实收不增加计租数量。供应商账期/直付与采购专用资金链继续按下表分别验收,不由人工收费样本推定通过。 + +若结构、服务、页面或设备任一项不匹配,保持相关收费/出库操作暂停并记录失败,不通过补零、伪造回执或重建费用继续;DDL 不做自动回滚,服务回滚仅限明确核对过的同协议同 Schema 版本。页面布局由人工按 1920×1080、100% 缩放提供截图核对。 ## 实际环境验收 @@ -65,7 +82,7 @@ ## 验证记录 -本地验证结果在本次交付说明中列出;真实环境 SQL、部署、HTTP 联调与 PDA 设备验收尚未执行。上线前逐项填写环境、版本、执行时间与证据,未通过项不得标成通过。 +本地验证结果在本次交付说明中列出;真实环境 SQL、部署、HTTP 联调及 PDA 验收状态统一维护在[测试环境单机部署与配置契约](../../../docs/测试环境单机部署与配置契约.md)。该主契约逐项记录环境、版本、执行时间与证据,未通过项不得标成通过。 2026-10-22 本地验证记录(使用最终受影响文件;复用已通过且输入未变的结果): diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index 23968c094..161483e0c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -11,6 +11,8 @@ import java.util.List; public interface DispatchCandidateMapper { /** 自动预留托盘不是实收;尚未开始收货时仍以计划来源预排,不生成库存或实托装载事实。 */ String PLAN_ONLY = "(p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0))"; + /** 短少确认后没有实收的预留托盘不再作为在途货候选,避免继续排出不存在的箱数。 */ + String OPEN_PLAN = "COALESCE(ip.status,'') NOT IN ('COMPLETED','PARTIAL_INBOUND')"; /** 在途计划的全部真实来源必须已离港;人工节点覆盖后不再借用原柜节点。 */ String DEPARTED_PLAN = "EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " + @@ -178,7 +180,7 @@ public interface DispatchCandidateMapper { "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + "WHERE ha.pallet_id=p.id AND ho.detained=1) " + - "AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " + + "AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " + "AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " + "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " + @@ -283,7 +285,7 @@ public interface DispatchCandidateMapper { "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + "WHERE ha.pallet_id=p.id AND ho.detained=1) " + - "AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " + + "AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " + "AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " + "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java index e2a2baf4b..f035d1da9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java @@ -33,7 +33,7 @@ public interface DispatchSourceMapper { FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id - WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status='COMPLETED' AND p.warehouse_pallet_id IS NOT NULL + WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND') AND p.warehouse_pallet_id IS NOT NULL GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,p.delivery_address HAVING COUNT(*)=COUNT(a.counted_package_count) AND COUNT(DISTINCT l.destination_type)=1 AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1 @@ -115,7 +115,7 @@ public interface DispatchSourceMapper { /** 原计划完成实收后才能把预排估算替换为实际整托数量,避免收货中提前锁定半托。 */ @Select("SELECT COUNT(*) FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id " - + "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PENDING_RECONCILIATION')") + + "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND','PENDING_RECONCILIATION')") int receivedPlanCompleted(@Param("customer") String customer,@Param("id") Long id); /** 退回来源仍使用本票实际实收,不借普通入仓别名。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index ef92711ee..88f56a6ed 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -38,6 +38,23 @@ public class InboundPlanController { @Resource private InboundPlanService service; @Resource private ContainerInboundPlanConversionCoordinator containerConversion; @Resource private InboundFbaAppointmentService appointmentService; + @Resource private com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow; + + /** 平台核对现场短少后按实收入库,不修改原应收数量。 */ + @PostMapping("/{planNo}/confirm-shortage") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')") + @Operation(summary = "确认入库短少", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT) + public ApiResponse confirmShortage( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest request) { + if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可确认入库短少"); + } + String customerId = convertScope(user, request.customerId()); + String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(inboundWorkflow.confirmShortage(customerId, planNo, operatorId, idempotencyKey, request)); + } /** 按入仓计划明细分页查询可预约的入库订单行。 */ @GetMapping diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConfirmInboundShortageRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConfirmInboundShortageRequest.java new file mode 100644 index 000000000..50bd4ff5a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConfirmInboundShortageRequest.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** 平台确认现场短少,数量仅用于核对当前收货事实,不能补写实收。 */ +public record ConfirmInboundShortageRequest( + @NotBlank String customerId, + @NotNull @Min(0) Integer version, + @NotNull @Min(1) Integer expectedPackageCount, + @NotNull @Min(1) Integer receivedPackageCount, + @NotBlank @Size(max = 200) String reason +) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java index dbd9b4615..2a5ede8dc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java @@ -36,6 +36,12 @@ public class PdaWorkflowRepository { @Resource private InboundPalletMergeMapper merges; @Resource private InboundPlanCartonMapper cartons; @Resource private LastlegOrderMapper orders; + @Resource private com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper skuReceipts; + + /** 短少确认只能采用本客户本计划已登记的逐 SKU 实收。 */ + public List skuReceipts(String customerId, Long planId) { + return skuReceipts.listPlan(customerId, planId); + } /** 收货完成前只读计划身份,运输父锁取得后再加来源行锁。 */ public InboundPlan findPlan(String customerId, String planNo) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index bcd469f2a..7bbac0829 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -716,7 +716,71 @@ public class InboundPdaWorkflowService { repository.update(plan); } - /** Explicit operator decision to receive only the cartons actually picked. */ + /** 平台确认 FBA 短少后将真实实收入库;原应收、现场结束时间和短少证据保留。 */ + @Transactional + public PdaResponses.Task confirmShortage(String customerId, String planNo, String operatorId, + String idempotencyKey, com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest request) { + if (idempotencyKey == null || idempotencyKey.isBlank() || idempotencyKey.length() > 128 + || operatorId == null || operatorId.isBlank() || request.reason() == null || request.reason().isBlank()) { + throw conflict("短少确认必须提供操作人、请求键和确认原因"); + } + InboundPlan original = repository.findPlan(customerId, planNo); + if (original == null) throw notFound("入库单不存在或不属于当前客户"); + dispatchReceipts.lock(List.of(original.getId())); + InboundPlan plan = requirePlan(customerId, planNo); + // 将单号和操作人纳入幂等摘要,避免跨单或跨操作人复用请求键。 + Map command = Map.of("planNo", planNo, "operatorId", operatorId, "request", request); + InboundPdaOperation replay = replay(customerId, "CONFIRM_SHORTAGE", idempotencyKey, command); + if (replay != null) return read(replay, PdaResponses.Task.class); + InboundPdaTask task = repository.lockTask(customerId, plan.getId()); + if (!"PENDING_RECONCILIATION".equals(plan.getStatus()) || task == null + || !"PENDING_RECONCILIATION".equals(task.getStatus())) { + throw conflict("仅待差异核对的入库单可确认短少,请刷新后重试"); + } + if (!Objects.equals(plan.getVersion(), request.version())) throw conflict("入库单已变化,请刷新后重新确认短少"); + if (mode(plan) != InboundMode.FBA) throw conflict("当前短少确认仅适用于按 SKU 登记实收的 FBA 入库单"); + if (unloadPhotoFileIds(task).isEmpty() || task.getUnloadPhotoUploadedAt() == null || task.getCompletedAt() == null) { + throw conflict("现场收货尚未结束或缺少卸柜凭证,不能确认短少"); + } + List lines = repository.lines(plan.getId()); + List allocations = repository.allocations(plan.getId()); + int expected = lines.stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum(); + int received = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum(); + if (!Objects.equals(expected, request.expectedPackageCount()) || !Objects.equals(received, request.receivedPackageCount())) { + throw conflict("应收或实收数量已变化,请刷新后重新确认"); + } + if (received <= 0 || received >= expected) throw conflict("仅有实际收货且实收少于应收时可确认短少"); + var skuReceipts = repository.skuReceipts(customerId, plan.getId()); + for (InboundPlanLine line : lines) { + int picked = allocations.stream().filter(a -> Objects.equals(a.getLineId(), line.getId())) + .mapToInt(a -> value(a.getPickedPackageCount())).sum(); + int skuPackages = skuReceipts.stream().filter(r -> Objects.equals(r.getLineId(), line.getId())) + .mapToInt(r -> value(r.getReceivedPackages())).sum(); + if (picked < 0 || picked > value(line.getRequiredPackageCount()) || picked != skuPackages) { + throw conflict("逐票 SKU 实收与托盘数量不一致或存在超收,不能按短少确认"); + } + } + if (allocations.stream().anyMatch(a -> value(a.getPickedPackageCount()) < 0 + || value(a.getPickedPackageCount()) != value(a.getCountedPackageCount()))) { + throw conflict("清点与收货数量不一致,不能确认短少"); + } + List pallets = new ArrayList<>(repository.pallets(plan.getId())); + Set palletIds = pallets.stream().map(InboundPlanPallet::getId).collect(java.util.stream.Collectors.toSet()); + pallets.addAll(repository.palletsByIds(allocations.stream().map(InboundPlanPalletAllocation::getPalletId) + .filter(id -> id != null && !palletIds.contains(id)).collect(java.util.stream.Collectors.toSet()))); + // 库存和库龄采用原现场收货时间,不因后台核对延迟重置;不重复发布柜级卸货事件。 + LocalDateTime now = LocalDateTime.now(); + stockReceipts.stockIn(customerId, plan, task, lines, allocations, pallets, task.getCompletedAt(), true); + task.setStatus("PARTIAL_INBOUND"); task.setUpdatedAt(now); task.setVersion(value(task.getVersion()) + 1); + repository.update(task); + plan.setStatus("PARTIAL_INBOUND"); plan.setUpdatedAt(now); repository.update(plan); + dispatchReceipts.complete(List.of(plan.getId()), operatorId, now); + PdaResponses.Task result = taskResponse(task, plan, planNo); + saveOperation(customerId, task.getId(), null, "CONFIRM_SHORTAGE", idempotencyKey, command, result); + return result; + } + + /** 操作员明确选择仅将当前已收箱数入库。 */ @Transactional public PdaResponses.Task partialComplete(String customerId, String planNo, PdaRequests.ForceComplete request) { InboundPlan plan = requirePlan(customerId, planNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java index 1c1106b18..dd77ddf57 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -50,6 +50,7 @@ public class InboundStockReceiptService { @Resource private DropshipOrderService dropshipOrders; @Resource private LastlegOrderStatusHistoryMapper orderHistories; + /** 按完整实收生成库存凭证。 */ @Transactional public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task, List lines, List allocations, @@ -57,6 +58,7 @@ public class InboundStockReceiptService { stockIn(customerId, plan, task, lines, allocations, pallets, receivedAt, false); } + /** 部分入库仍按逐 SKU 实收生成凭证,原申报箱数仅作为上限。 */ @Transactional public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task, List lines, List allocations, @@ -105,7 +107,7 @@ public class InboundStockReceiptService { List exactSkuReceipts = skuReceiptsByLine.getOrDefault(line.getId(), List.of()); if (!exactSkuReceipts.isEmpty()) { created.addAll(persistExactSkuReceipts(customerId, plan, task, line, lineAllocations, - palletById, order, exactSkuReceipts, pickedPackages, receivedAt)); + palletById, order, exactSkuReceipts, pickedPackages, receivedAt, allowPartial)); continue; } List skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType()) @@ -145,14 +147,17 @@ public class InboundStockReceiptService { completeOrders(customerId, plan, orderById, receivedAt, allowPartial); } + /** 逐 SKU 校验实收归属;短少可以少于冻结箱数,但不允许其他 SKU 超收抵扣。 */ private List persistExactSkuReceipts(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line, List lineAllocations, Map palletById, LastlegOrder order, - List exactReceipts, long pickedPackages, LocalDateTime receivedAt) { + List exactReceipts, long pickedPackages, LocalDateTime receivedAt, boolean allowPartial) { + if (exactReceipts.stream().anyMatch(r -> r.getSkuCode() == null || r.getSkuCode().isBlank() + || value(r.getReceivedPackages()) <= 0)) throw conflict("SKU 实收必须包含有效 SKU 和正数箱数"); long actualPackages = exactReceipts.stream().mapToLong(value -> value(value.getReceivedPackages())).sum(); if (actualPackages != pickedPackages) throw conflict("FBA SKU receipts must equal the picked package total"); - Map expectedBySku = skuRemainders(order.getId(), pickedPackages).stream() + Map expectedBySku = skuRemainders(order.getId(), value(line.getRequiredPackageCount())).stream() .collect(Collectors.toMap(value -> value.skuCode, value -> value.remaining, Long::sum, java.util.LinkedHashMap::new)); Map actualBySku = exactReceipts.stream().collect(Collectors.groupingBy( @@ -161,7 +166,11 @@ public class InboundStockReceiptService { Map normalizedExpected = expectedBySku.entrySet().stream().collect(Collectors.toMap( value -> value.getKey().toUpperCase(java.util.Locale.ROOT), Map.Entry::getValue, Long::sum, java.util.LinkedHashMap::new)); - if (!actualBySku.equals(normalizedExpected)) { + boolean invalid = allowPartial + ? actualBySku.entrySet().stream().anyMatch(entry -> !normalizedExpected.containsKey(entry.getKey()) + || entry.getValue() > normalizedExpected.get(entry.getKey())) + : !actualBySku.equals(normalizedExpected); + if (invalid) { throw conflict("FBA SKU receipts must exactly match the frozen SKU package snapshots"); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 949763110..71619b2f6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -738,7 +738,7 @@ public class ReceivingContainerService { boolean allPlansReady = totalOrders > 0 && inboundCodes == totalOrders && plannedOrders == totalOrders && planCount == totalOrders; boolean allPlansCompleted = !matchedPlans.isEmpty() - && matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION") + && matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PARTIAL_INBOUND", "PENDING_RECONCILIATION") .contains(plan.getStatus())); boolean shippingReady = facts == null ? shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1 @@ -812,7 +812,7 @@ public class ReceivingContainerService { if (values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) { throw conflict("Every inbound plan in the receiving container must have an owning customer"); } - if (values.stream().allMatch(value -> List.of("COMPLETED", "PENDING_RECONCILIATION") + if (values.stream().allMatch(value -> List.of("COMPLETED", "PARTIAL_INBOUND", "PENDING_RECONCILIATION") .contains(value.getStatus()))) { throw conflict("Receiving container is already completed"); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapper.java index dc8dc34e8..058efb950 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapper.java @@ -20,7 +20,7 @@ public interface OrderWorkflowFactMapper { WHERE di.id=i.dispatch_item_id AND di.customer_id=i.customer_id AND di.dispatch_order_id=b.dispatch_order_id AND di.destination_type='PRIVATE' AND TRIM(di.delivery_address)=TRIM(d.delivery_address) AND COALESCE(TRIM(di.postal_code),'')=COALESCE(TRIM(d.postal_code),''))))) delivering, - EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.customer_id=o.customer_id AND p.source_order_id=o.id AND p.status IN ('COMPLETED','PENDING_RECONCILIATION')) unloaded + EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.customer_id=o.customer_id AND p.source_order_id=o.id AND p.status IN ('COMPLETED','PARTIAL_INBOUND','PENDING_RECONCILIATION')) unloaded FROM ll_order o WHERE o.source_region='FIRSTLEG' AND o.order_type NOT IN ('RELABEL_APPLICATION','WAREHOUSE_TRANSFER') AND (o.customer_id=#{s.tenantKey} AND o.source_inbound_id=#{s.inboundKey}) diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index e23c85b8b..8e04a22ae 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -667,6 +667,32 @@ paths: responses: '200': { description: 平台入库订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanDetailResponse' } } } } '404': { description: 入库订单不存在或不属于指定客户 } + /api/platform/v1/last-leg/inbound-plans/{planNo}/confirm-shortage: + post: + summary: 确认FBA入库短少并按实收入库 + description: 权限 platform:overseas-warehouse:inbound-order:edit;仅平台指定客户操作。PENDING_RECONCILIATION且0<实收<应收时,核对版本及逐SKU实收,保留原应收和现场收货时间,按实收写入库存并进入PARTIAL_INBOUND,同事务交接原预排。操作原因与操作人写入收货操作记录;不重复发布卸柜事件。超收、SKU不一致、客户不匹配和陈旧版本拒绝;相同请求键重放不会重复入库。 + security: [{ bearerAuth: [] }] + parameters: + - { name: planNo, in: path, required: true, schema: { type: string } } + - { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [customerId, version, expectedPackageCount, receivedPackageCount, reason] + properties: + customerId: { type: string, pattern: '^[1-9][0-9]*$' } + version: { type: integer, minimum: 0 } + expectedPackageCount: { type: integer, minimum: 1 } + receivedPackageCount: { type: integer, minimum: 1 } + reason: { type: string, minLength: 1, maxLength: 200 } + responses: + '200': { description: ApiResponse包装的收货任务结果,status为PARTIAL_INBOUND } + '403': { description: 非平台身份或无入库编辑权限 } + '404': { description: 入库单不存在或客户不匹配 } + '409': { description: 版本、状态、实收数量、SKU或幂等键冲突 } /api/platform/v1/last-leg/inbound-plans/manual: post: summary: 平台按单票手工生成入库计划 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java index 5b0cdbba1..5a8e83e91 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java @@ -16,12 +16,12 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; class DispatchCandidateMapperContractTest { - /** 执行正式计划/实收判定与离港门禁,覆盖自动预留、人工覆盖、混合来源及库存耗尽。 */ + /** 执行正式候选和提交资格,覆盖在途预排、收货结束后的空预留托盘及真实库存。 */ @Test void reservedPalletsCanBePlannedAfterDepartureWithoutInventingReceivedStock() throws Exception { try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-prearrival;MODE=MySQL")) { var db = connection.createStatement(); - db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR)"); + db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,status VARCHAR DEFAULT 'PDA_PENDING')"); db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT,assignment_status VARCHAR,actual_package_count INT)"); db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,order_id BIGINT)"); db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)"); @@ -29,8 +29,8 @@ class DispatchCandidateMapperContractTest { db.execute("CREATE TABLE ll_order_manual_node(order_id BIGINT,customer_id VARCHAR,node_code VARCHAR,node_at TIMESTAMP)"); db.execute("CREATE TABLE ll_container_shipping_gate(container_booking_id BIGINT,node_sequence INT,actual_at TIMESTAMP)"); db.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR,pallet_id BIGINT,available_packages INT)"); - for (int id = 1; id <= 9; id++) { - db.execute("INSERT INTO ll_inbound_plan VALUES (" + id + ",'A')"); + for (int id = 1; id <= 14; id++) { + db.execute("INSERT INTO ll_inbound_plan(id,customer_id) VALUES (" + id + ",'A')"); db.execute("INSERT INTO ll_inbound_plan_pallet VALUES (" + id + "," + id + "," + (100 + id) + ",'RESERVED',0)"); db.execute("INSERT INTO ll_inbound_plan_line VALUES (" + id + ",'A'," + id + ")"); db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (" + id + "," + id + ")"); @@ -44,24 +44,34 @@ class DispatchCandidateMapperContractTest { db.execute("INSERT INTO ll_order_manual_node VALUES (7,'A','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP),(9,'B','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP)"); db.execute("INSERT INTO ll_inbound_plan_line VALUES (20,'A',2)"); db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (8,20)"); + // 同一计划原分两托,最终21箱全收在一托;另一预留托盘不能继续冒充可预排货物。 + db.execute("UPDATE ll_inbound_plan SET status='COMPLETED' WHERE id IN (10,13,14)"); + db.execute("UPDATE ll_inbound_plan_pallet SET plan_id=10,assignment_status='ASSIGNED',actual_package_count=21 WHERE id=11"); + db.execute("INSERT INTO ll_inventory_position VALUES ('A',111,21)"); + db.execute("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND' WHERE id=12"); + db.execute("UPDATE ll_inbound_plan_pallet SET warehouse_pallet_id=NULL WHERE id=13"); + db.execute("UPDATE ll_inbound_plan_pallet SET assignment_status='ASSIGNED' WHERE id=14"); String plan = DispatchCandidateMapper.PLAN_ONLY; - String eligible = "AND (NOT " + plan + " OR (" + DispatchCandidateMapper.DEPARTED_PLAN + ")) " - + "AND (" + plan + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " - + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0))"; - var ids = new ArrayList(); - try (var rows = db.executeQuery("SELECT p.id,CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS physical," - + "CASE WHEN " + plan + " THEN 'PLAN' ELSE 'RECEIVED' END AS stage " - + "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE 1=1 " + eligible + " ORDER BY p.id")) { - while (rows.next()) { - long id = rows.getLong("id"); ids.add(id); - assertEquals(id == 3 ? "RECEIVED" : "PLAN", rows.getString("stage")); - assertEquals(id == 3 ? 103L : null, rows.getObject("physical", Long.class)); - } - } - assertEquals(List.of(1L, 3L, 7L), ids); - // 查询和写入选中来源共用同一判定;不允许仅在列表展示在途但提交时又要求实收。 + // 直接执行两条生产SQL中的资格片段,避免仅验证测试自行拼出的规则。 for (String sql : List.of(DispatchCandidateMapper.AVAILABLE_PLAN_BODY, DispatchCandidateMapper.SELECTED_PLAN_SQL)) { - assertTrue(sql.contains(eligible.replace("> 0", "> 0").replace("pos.customer_id=ip.customer_id", sql.contains("WHERE pos.customer_id=#{customerId}") ? "pos.customer_id=#{customerId}" : "pos.customer_id=ip.customer_id"))); + int start = sql.indexOf("AND (NOT " + plan + " OR ("); + int stockGate = sql.indexOf("AND (" + plan + " OR EXISTS", start); + int end = sql.indexOf("AND NOT EXISTS (SELECT 1 FROM ll_", stockGate); + String eligible = sql.substring(start, end).replace(">", ">") + .replace("#{customerId}", "'A'"); + var ids = new ArrayList(); + try (var rows = db.executeQuery("SELECT p.id,p.actual_package_count,CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS physical," + + "CASE WHEN " + plan + " THEN 'PLAN' ELSE 'RECEIVED' END AS stage " + + "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE 1=1 " + eligible + " ORDER BY p.id")) { + while (rows.next()) { + long id = rows.getLong("id"); ids.add(id); + assertEquals(id == 3 || id == 11 ? "RECEIVED" : "PLAN", rows.getString("stage")); + assertEquals(id == 3 || id == 11 ? 100L + id : null, rows.getObject("physical", Long.class)); + if (id == 11) assertEquals(21, rows.getInt("actual_package_count")); + } + } + // 完成入库或已确认短少后,空预留托盘不可排车,真实实收托盘继续按库存校验。 + assertEquals(List.of(1L, 3L, 7L, 11L), ids); assertTrue(sql.contains("CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java index 412c304be..73a542ec7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java @@ -53,6 +53,8 @@ class DispatchInboundReceiptPersistenceTest { db.update("DELETE FROM ll_order_detail WHERE customer_id='101'"); assertThat(mapper.confirmedPlan("101",1).get(0).grossWeightKg()).isNull(); assertThat(mapper.confirmedPlan("other",1)).isEmpty(); + db.update("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND'"); + assertThat(mapper.confirmedPlan("101",1).get(0).packageCount()).isEqualTo(9); db.update("UPDATE ll_inbound_plan SET status='RECEIVING'"); assertThat(mapper.confirmedPlan("101",1)).isEmpty(); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java index d116164cb..0e44c61cd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java @@ -26,7 +26,7 @@ class DispatchPlanPdaReadPersistenceTest { f.f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(2,1,'101')"); var mapper=f.f.session.getMapper(com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper.class); assertThat(mapper.receivedPlanCompleted("101",2L)).isZero(); - for(var status:List.of("COMPLETED","PENDING_RECONCILIATION")) { + for(var status:List.of("COMPLETED","PARTIAL_INBOUND","PENDING_RECONCILIATION")) { f.f.db.update("UPDATE ll_inbound_plan SET status=?",status); assertThat(mapper.receivedPlanCompleted("101",2L)).isOne(); assertThat(mapper.receivedPlanCompleted("other",2L)).isZero(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java index c34060f95..3912dbdfd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java @@ -31,6 +31,7 @@ class InboundPlanControllerTest { @Mock InboundPlanService service; @Mock InboundFbaAppointmentService appointmentService; @Mock ContainerInboundPlanConversionCoordinator containerConversion; + @Mock com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow; private InboundPlanController controller; @BeforeEach @@ -39,6 +40,18 @@ class InboundPlanControllerTest { ReflectionTestUtils.setField(controller, "service", service); ReflectionTestUtils.setField(controller, "appointmentService", appointmentService); ReflectionTestUtils.setField(controller, "containerConversion", containerConversion); + ReflectionTestUtils.setField(controller, "inboundWorkflow", inboundWorkflow); + } + + /** 短少确认只允许平台指定具体客户,并保留真实操作人。 */ + @Test void platformConfirmsShortageWithExplicitCustomer() { + var request = new com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest("100",4,21,20,"实收短少"); + controller.confirmShortage(platform(), "IP-1", "key", request); + verify(inboundWorkflow).confirmShortage("100", "IP-1", "7", "key", request); + var merchant = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of()); + assertThatThrownBy(() -> controller.confirmShortage(merchant, "IP-1", "key", request)).isInstanceOf(ResponseStatusException.class); + var noCustomer = new com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest("0",4,21,20,"实收短少"); + assertThatThrownBy(() -> controller.confirmShortage(platform(), "IP-1", "key", noCustomer)).isInstanceOf(ResponseStatusException.class); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundShortageConfirmationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundShortageConfirmationTest.java new file mode 100644 index 000000000..4341dffa3 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundShortageConfirmationTest.java @@ -0,0 +1,106 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest; +import com.baiyue.lastleg.inboundplan.entity.*; +import com.baiyue.lastleg.inboundplan.pda.entity.*; +import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway; +import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class InboundShortageConfirmationTest { + @Mock PdaWorkflowRepository repository; + @Mock InboundStockReceiptService stock; + @Mock InboundDispatchReceiptGateway dispatch; + @Mock InboundUnloadCompletedOutboxService outbox; + InboundPdaWorkflowService service; + InboundPlan plan; + InboundPdaTask task; + InboundPlanLine line; + InboundPlanPalletAllocation allocation; + final LocalDateTime receivedAt = LocalDateTime.of(2026, 10, 20, 12, 0); + + /** 准备已结束现场收货、应收21实收20的冻结事实。 */ + @BeforeEach void setup() { + service = new InboundPdaWorkflowService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "stockReceipts", stock); + ReflectionTestUtils.setField(service, "dispatchReceipts", dispatch); + ReflectionTestUtils.setField(service, "unloadCompletedOutbox", outbox); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); + plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setCustomerId("100"); + plan.setStatus("PENDING_RECONCILIATION"); plan.setVersion(4); + task = new InboundPdaTask(); task.setId(2L); task.setPlanId(1L); task.setCustomerId("100"); + task.setStatus("PENDING_RECONCILIATION"); task.setVersion(2); task.setCompletedAt(receivedAt); + task.setUnloadPhotoFileIds("[10]"); task.setUnloadPhotoUploadedAt(receivedAt); + line = new InboundPlanLine(); line.setId(3L); line.setOrderId(9L); line.setFbaWarehouseCode("ONT8"); line.setRequiredPackageCount(21); + allocation = new InboundPlanPalletAllocation(); allocation.setId(4L); allocation.setLineId(3L); + allocation.setPalletId(5L); allocation.setPickedPackageCount(20); allocation.setCountedPackageCount(20); + var sku = new ReceivingSkuReceipt(); sku.setLineId(3L); sku.setPalletId(5L); sku.setSkuCode("SKU-1"); sku.setReceivedPackages(20); + when(repository.findPlan("100", "IP-1")).thenReturn(plan); + lenient().when(repository.lockPlan("100", "IP-1")).thenReturn(plan); + lenient().when(repository.lockTask("100", 1L)).thenReturn(task); + lenient().when(repository.lines(1L)).thenReturn(List.of(line)); + lenient().when(repository.allocations(1L)).thenReturn(List.of(allocation)); + lenient().when(repository.skuReceipts("100", 1L)).thenReturn(List.of(sku)); + } + + /** 确认后保留原应收,按原收货时点入库并交接运输,重放不会重复记库存。 */ + @Test void stocksActualTwentyOnceAndPreservesShortageAndReceiptTime() { + var request = request(4, 20); + var result = service.confirmShortage("100", "IP-1", "admin", "key", request); + assertEquals("PARTIAL_INBOUND", result.status()); assertEquals(21, line.getRequiredPackageCount()); + assertEquals(receivedAt, task.getCompletedAt()); + var sequence = inOrder(dispatch, repository, stock); + sequence.verify(dispatch).lock(List.of(1L)); sequence.verify(repository).lockPlan("100", "IP-1"); + sequence.verify(stock).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(line)), eq(List.of(allocation)), anyList(), eq(receivedAt), eq(true)); + sequence.verify(dispatch).complete(eq(List.of(1L)), eq("admin"), any()); + var audit = ArgumentCaptor.forClass(InboundPdaOperation.class); + verify(repository).insert(audit.capture()); + assertTrue(audit.getValue().getRequestJson().contains("现场确认短少1箱")); + when(repository.operation("100", "key")).thenReturn(audit.getValue()); + assertEquals(result, service.confirmShortage("100", "IP-1", "admin", "key", request)); + verify(stock, times(1)).stockIn(anyString(), any(), any(), anyList(), anyList(), anyList(), any(), eq(true)); + verifyNoInteractions(outbox); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "new-key", request)); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 19))); + } + + /** 页面旧版本或旧实收数不能确认库存。 */ + @Test void rejectsStaleVersionAndCounts() { + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(3, 20))); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 19))); + verifyNoInteractions(stock); + } + + /** 没有逐 SKU 实收时不能按计划猜测缺少哪个商品。 */ + @Test void rejectsMissingSkuReceipts() { + when(repository.skuReceipts("100", 1L)).thenReturn(List.of()); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20))); + verifyNoInteractions(stock); + } + + /** 客户归属不匹配时不触发任何库存或运输操作。 */ + @Test void rejectsWrongCustomer() { + when(repository.findPlan("100", "IP-1")).thenReturn(null); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20))); + verifyNoInteractions(stock, dispatch); + } + + /** 当前实收值随请求一起参与版本及幂等校验。 */ + private ConfirmInboundShortageRequest request(int version, int received) { + return new ConfirmInboundShortageRequest("100", version, 21, received, "现场确认短少1箱"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index cc62bf9de..461c1de86 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -325,6 +325,34 @@ class InboundStockReceiptServiceTest { List.of(new DropshipPalletSnapshot.Sku("SKU-1", "Product", packages, packages * 10))); } + /** FBA确认短少后只把20箱登记到库存,不修改21箱的冻结申报。 */ + @Test void exactSkuShortageStocksOnlyActualPackages() { + Fixture f = fixture(21, 21, 20, 210); + when(orders.selectBatchIds(any())).thenReturn(List.of(f.order)); + when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 20))); + when(inventory.stockIn(eq("100"), eq("operator"), any())) + .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 20, 0, 0, 0, false, false)); + service.stockIn("100", f.plan, f.task, List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now(), true); + var request = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class); + verify(inventory).stockIn(eq("100"), eq("operator"), request.capture()); + assertEquals(20, request.getValue().packages()); + assertEquals(21, f.detail.getPackageCount()); + assertEquals("INBOUND_COMPLETED", f.order.getCurrentStatus()); + assertEquals("INBOUND_PLAN:PARTIAL_INBOUND", f.order.getSubOrderStatus()); + } + + /** 一个SKU少收不能抵消另一个SKU超收,即使总箱数仍少于应收。 */ + @Test void partialSkuReceiptRejectsOverReceiptOfAnotherSku() { + Fixture f = fixture(21, 21, 20, 210); + f.detail.setPackageCount(10); + when(orderDetails.selectList(any())).thenReturn(List.of(f.detail, detail(202, "SKU-2", 11, 110))); + when(orders.selectBatchIds(any())).thenReturn(List.of(f.order)); + when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 11), skuReceipt(12, 21, "SKU-2", 9))); + assertThrows(ResponseStatusException.class, () -> service.stockIn("100", f.plan, f.task, + List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now(), true)); + verify(inventory, never()).stockIn(any(), any(), any()); + } + private Fixture fixture(int required, int planned, int picked, int quantity) { InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setOverseasWarehouseId(900L); InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator"); diff --git a/scripts/database-schema-alignment-migrations-contract.test.mjs b/scripts/database-schema-alignment-migrations-contract.test.mjs index b96f69e3a..99f4819f9 100644 --- a/scripts/database-schema-alignment-migrations-contract.test.mjs +++ b/scripts/database-schema-alignment-migrations-contract.test.mjs @@ -6,6 +6,59 @@ import { fileURLToPath } from 'node:url' import { buildPlan, loadScopes } from '../deploy/sql/scripts/db-migrations.mjs' const repositoryRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))) + +const overseasFeeStructurePath = 'deploy/sql/overseas-fees-storage-rent-structure-add.sql' +const readSql = relative => readFile(path.join(repositoryRoot, relative), 'utf8') +const withoutSqlComments = source => source.replace(/^\s*--.*$/gm, '') + +test('海外费用结构补齐只修改已核准的表字段,不包含业务数据写入', async () => { + const source = withoutSqlComments(await readSql(overseasFeeStructurePath)) + assert.match(source, /USE baiyue_lastleg;/) + assert.doesNotMatch(source, /\b(?:INSERT|UPDATE|DELETE|REPLACE|TRUNCATE)\b/i) + assert.doesNotMatch(source, /\bDROP\s+(?:TABLE|DATABASE|COLUMN|INDEX)\b/i) + assert.deepEqual([...source.matchAll(/^\s*ALTER TABLE\s+(\w+)/gim)].map(match => match[1]), ['ll_finance_fee_source']) + assert.deepEqual([...source.matchAll(/^\s*CREATE TABLE\s+(\w+)/gim)].map(match => match[1]), ['ll_storage_rent_inbound']) + assert.match(source, /ADD COLUMN reviewer_name VARCHAR\(255\) NULL COMMENT '审核人名称快照' AFTER reviewer_id;/) +}) + +test('海外费用计租实收表与两份正式基线完全一致', async () => { + const files = [overseasFeeStructurePath, 'lastleg-service/src/main/resources/db/lastleg.sql', 'deploy/sql/海外区域统一增量-MySQL8.sql'] + const definitions = await Promise.all(files.map(async file => { + const source = await readSql(file) + const definition = source.match(/CREATE TABLE(?: IF NOT EXISTS)? ll_storage_rent_inbound\s*\([\s\S]*?\) ENGINE=[^;]+;/)?.[0] + assert.ok(definition, file) + assert.ok(/reviewer_name\s+VARCHAR\(255\)\s+(?:COLLATE\s+\w+\s+)?(?:DEFAULT\s+)?NULL\s+COMMENT '审核人名称快照'/i.test(source), `${file} 审核人名称须为可空 VARCHAR(255)`) + return definition.replace(' IF NOT EXISTS', '').replace(/\s+/g, ' ').toLowerCase() + })) + assert.equal(definitions[0], definitions[1]) + assert.equal(definitions[0], definitions[2]) +}) + +test('海外费用结构补齐先检查同名结构再修改,支持匹配结构重跑', async () => { + const source = withoutSqlComments(await readSql(overseasFeeStructurePath)) + const firstDdl = source.indexOf('ALTER TABLE ll_finance_fee_source') + assert.ok(firstDdl > 0) + const guards = source.slice(0, firstDdl) + for (const marker of ["DATABASE()<>'baiyue_lastleg'", 'reviewer_exists>0', 'inbound_exists>0', "TABLE_COLLATION='utf8mb4_unicode_ci'", "COLUMN_TYPE='decimal(24,12)'", "COLUMN_TYPE='datetime(3)'", "INDEX_NAME='PRIMARY'", "INDEX_NAME='idx_storage_rent_inbound_lot'"]) { + assert.ok(guards.includes(marker), marker) + } + assert.match(guards, /SIGNAL SQLSTATE '45000'/) + assert.match(source, /IF reviewer_exists=0 THEN\s+ALTER TABLE/) + assert.match(source, /IF inbound_exists=0 THEN\s+CREATE TABLE/) + assert.match(source, /CALL overseas_fee_rent_structure_add_20261026\(\);[\s\S]+SHOW CREATE TABLE ll_storage_rent_inbound;[\s\S]+'TABLE_COLUMN_READY'/) +}) + +test('海外费用免费仓租修复只放行自动应收双零,保留其他费用及汇率边界', async () => { + const repair = await readSql('deploy/sql/overseas-storage-rent-zero-fee-repair.sql') + const normalized = repair.replaceAll("''", "'") + const autoZero = "amount=0 AND original_amount=0 AND created_by='STORAGE_RENT' AND fee_item_code='FEE_TYPE_077' AND direction='RECEIVABLE'" + assert.ok(normalized.includes(`CHECK(amount>0 OR (${autoZero}))`)) + assert.ok(normalized.includes(`CHECK((exchange_rate IS NOT NULL AND exchange_rate>0) OR (exchange_rate IS NULL AND ${autoZero}))`)) + assert.match(repair, /SELECT COLUMN_TYPE,COLUMN_COMMENT,COLUMN_DEFAULT INTO rate_type,rate_comment,rate_default/) + assert.match(repair, /rate_default IS NOT NULL AND rate_default NOT REGEXP/) + assert.doesNotMatch(withoutSqlComments(repair), /^\s*(?:INSERT|UPDATE|DELETE|REPLACE|TRUNCATE)\b/im) +}) + const migrations = { iam: 'deploy/sql/migrations/cn/baiyue_iam/V1__align_schema_baseline.sql', iamForwardFix: 'deploy/sql/migrations/cn/baiyue_iam/V3__align_domestic_pda_mojia_sailing_permissions.sql',