优化
This commit is contained in:
@@ -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 隐式提交,不能宣称整体可回滚;失败保留错误并只读复查,已完成项不删除重建。两步是已核缺项的补齐方案,不是任意版本升级工具;重新执行前仍需核对目标实例与结构。
|
||||
|
||||
## 海外表和字段
|
||||
|
||||
|
||||
@@ -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 或部署。
|
||||
|
||||
@@ -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,不能据此宣称完整功能上线。
|
||||
@@ -1,4 +1,4 @@
|
||||
-- 固定目标本机 baiyue_lastleg,只修复自动免费仓租结构,不更新业务数据。
|
||||
-- 固定目标经单独授权的 TEST 或本机开发 baiyue_lastleg,只修复自动免费仓租结构,不更新业务数据。
|
||||
-- 使用独立连接、遇错停止;先核对既有表结构,不能重放空库初始化。
|
||||
USE baiyue_lastleg;
|
||||
SELECT COLUMN_NAME,COLUMN_TYPE,IS_NULLABLE FROM information_schema.COLUMNS
|
||||
|
||||
@@ -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 本地验证记录(使用最终受影响文件;复用已通过且输入未变的结果):
|
||||
|
||||
|
||||
+4
-2
@@ -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 " +
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
/** 退回来源仍使用本票实际实收,不借普通入仓别名。 */
|
||||
|
||||
+17
@@ -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<com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses.Task> 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
|
||||
|
||||
+15
@@ -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
|
||||
) { }
|
||||
+6
@@ -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<com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt> skuReceipts(String customerId, Long planId) {
|
||||
return skuReceipts.listPlan(customerId, planId);
|
||||
}
|
||||
|
||||
/** 收货完成前只读计划身份,运输父锁取得后再加来源行锁。 */
|
||||
public InboundPlan findPlan(String customerId, String planNo) {
|
||||
|
||||
+65
-1
@@ -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<String, Object> 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<InboundPlanLine> lines = repository.lines(plan.getId());
|
||||
List<InboundPlanPalletAllocation> 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<InboundPlanPallet> pallets = new ArrayList<>(repository.pallets(plan.getId()));
|
||||
Set<Long> 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);
|
||||
|
||||
+13
-4
@@ -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<InboundPlanLine> lines, List<InboundPlanPalletAllocation> 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<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
|
||||
@@ -105,7 +107,7 @@ public class InboundStockReceiptService {
|
||||
List<ReceivingSkuReceipt> 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<SkuRemainder> skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType())
|
||||
@@ -145,14 +147,17 @@ public class InboundStockReceiptService {
|
||||
completeOrders(customerId, plan, orderById, receivedAt, allowPartial);
|
||||
}
|
||||
|
||||
/** 逐 SKU 校验实收归属;短少可以少于冻结箱数,但不允许其他 SKU 超收抵扣。 */
|
||||
private List<InboundStockReceipt> persistExactSkuReceipts(String customerId, InboundPlan plan,
|
||||
InboundPdaTask task, InboundPlanLine line, List<InboundPlanPalletAllocation> lineAllocations,
|
||||
Map<Long, InboundPlanPallet> palletById, LastlegOrder order,
|
||||
List<ReceivingSkuReceipt> exactReceipts, long pickedPackages, LocalDateTime receivedAt) {
|
||||
List<ReceivingSkuReceipt> 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<String, Long> expectedBySku = skuRemainders(order.getId(), pickedPackages).stream()
|
||||
Map<String, Long> expectedBySku = skuRemainders(order.getId(), value(line.getRequiredPackageCount())).stream()
|
||||
.collect(Collectors.toMap(value -> value.skuCode, value -> value.remaining, Long::sum,
|
||||
java.util.LinkedHashMap::new));
|
||||
Map<String, Long> actualBySku = exactReceipts.stream().collect(Collectors.groupingBy(
|
||||
@@ -161,7 +166,11 @@ public class InboundStockReceiptService {
|
||||
Map<String, Long> 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");
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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");
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 <foreach collection='sources' item='s' open='(' separator=' OR ' close=')'>
|
||||
(o.customer_id=#{s.tenantKey} AND o.source_inbound_id=#{s.inboundKey})
|
||||
|
||||
@@ -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: 平台按单票手工生成入库计划
|
||||
|
||||
+30
-20
@@ -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<Long>();
|
||||
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<Long>();
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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();
|
||||
}
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
+13
@@ -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
|
||||
|
||||
+106
@@ -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箱");
|
||||
}
|
||||
}
|
||||
+28
@@ -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");
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user