From fe60a20d9564728032cce5a7d69c5a38dba53242 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 11 Aug 2026 09:17:02 +0800 Subject: [PATCH] feat(firstleg): enforce single remainder split schema --- deploy/sql/头程数据库统一增量-MySQL8.sql | 130 +++++++++++++----- .../db/phase3-inbound-parent-child-schema.sql | 130 +++++++++++++----- .../InboundParentChildSqlContractTest.java | 19 ++- .../Phase3FinalUnifiedSqlContractTest.java | 16 +++ .../service/SplitPreloadSqlContractTest.java | 5 +- 5 files changed, 232 insertions(+), 68 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index c5487a1c7..96d7c4b82 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -7399,6 +7399,8 @@ USE baiyue_firstleg; /* * 三期主子入仓单结构迁移。 * 仅建立结构和数据库保护,不自动拆分或回填存量业务数据。 + * 新写入采用“来源单承接实装、仅创建余量子单”;LOADED 子单和 loaded_child_inbound_id + * 仅为历史双子单读取保留,不删除、不清洗存量记录。 * tenant_id 继续表示客户隔离键;数量统一使用 package_count。 */ DROP PROCEDURE IF EXISTS phase3_inbound_add_column; @@ -7455,6 +7457,10 @@ UPDATE fl_inbound_order WHERE inbound_role = 'CHILD' AND split_purpose = 'MASTER'; +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'active_remainder_root_id', + "BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN inbound_role = 'CHILD' AND split_purpose = 'REMAINDER' AND split_status = 'NOT_SPLIT' AND deleted = 0 THEN root_inbound_id ELSE NULL END) STORED COMMENT '有效余量唯一约束键;历史已完成分片为空'"); + DROP PROCEDURE IF EXISTS phase3_inbound_add_column; DROP PROCEDURE IF EXISTS phase3_inbound_add_index; @@ -7486,6 +7492,31 @@ CALL phase3_inbound_add_index( 'fl_inbound_order', 'idx_inbound_root_role', 'KEY `idx_inbound_root_role` (`tenant_id`, `root_inbound_id`, `inbound_role`, `created_at`, `id`)'); +DROP PROCEDURE IF EXISTS phase3_assert_active_remainder_unique_ready; +DELIMITER $$ +CREATE PROCEDURE phase3_assert_active_remainder_unique_ready() +BEGIN + IF EXISTS ( + SELECT 1 + FROM fl_inbound_order + WHERE inbound_role = 'CHILD' + AND split_purpose = 'REMAINDER' + AND split_status = 'NOT_SPLIT' + AND deleted = 0 + AND root_inbound_id IS NOT NULL + GROUP BY tenant_id, root_inbound_id + HAVING COUNT(*) > 1 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同一根入仓单存在多个有效余量,请先人工核对历史数据'; + END IF; +END$$ +DELIMITER ; +CALL phase3_assert_active_remainder_unique_ready(); +DROP PROCEDURE IF EXISTS phase3_assert_active_remainder_unique_ready; +CALL phase3_inbound_add_index( + 'fl_inbound_order', 'uk_inbound_one_active_remainder', + 'UNIQUE KEY `uk_inbound_one_active_remainder` (`tenant_id`, `active_remainder_root_id`)'); + DROP PROCEDURE IF EXISTS phase3_inbound_drop_legacy_index; DELIMITER $$ CREATE PROCEDURE phase3_inbound_drop_legacy_index() @@ -7812,8 +7843,8 @@ BEGIN OR NEW.split_sequence IS NULL OR NEW.package_count IS NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单缺少主单、顺序号或箱数'; END IF; - IF NEW.split_purpose = 'MASTER' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单必须标记LOADED或REMAINDER'; + IF NEW.split_purpose <> 'REMAINDER' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '新建子入仓单只能作为余量分片'; END IF; IF NOT EXISTS ( SELECT 1 FROM fl_inbound_order parent_order @@ -7853,11 +7884,6 @@ BEGIN OR (NEW.inbound_role = 'MASTER' AND NEW.split_purpose <> 'MASTER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单层级与用途不一致'; END IF; - IF NEW.inbound_role = 'MASTER' - AND ((NEW.package_count = 0 AND NEW.split_status <> 'FULLY_SPLIT') - OR (NEW.split_status = 'FULLY_SPLIT' AND COALESCE(NEW.package_count, 0) <> 0)) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单箱数归零必须与完全拆分状态同时提交'; - END IF; END$$ CREATE TRIGGER trg_inbound_split_item_before_insert @@ -7876,11 +7902,14 @@ BEGIN AND batch.tenant_id = NEW.tenant_id AND batch.parent_inbound_id = NEW.parent_inbound_id AND batch.status = 'PREPARED' + AND batch.loaded_child_inbound_id IS NULL + AND batch.remainder_child_inbound_id IS NOT NULL + AND NEW.child_inbound_id = batch.remainder_child_inbound_id AND parent_order.tenant_id = NEW.tenant_id AND parent_order.inbound_role = 'MASTER' AND child_order.tenant_id = NEW.tenant_id AND child_order.inbound_role = 'CHILD' - AND child_order.split_purpose IN ('LOADED', 'REMAINDER') + AND child_order.split_purpose = 'REMAINDER' AND child_order.parent_inbound_id = NEW.parent_inbound_id AND child_order.package_count >= NEW.package_count AND child_order.deleted = 0 @@ -7905,6 +7934,13 @@ BEGIN IF NEW.status <> 'PREPARED' OR NEW.applied_at IS NOT NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次必须从PREPARED创建'; END IF; + IF NEW.loaded_child_inbound_id IS NOT NULL + OR NEW.loaded_package_count = 0 + OR NEW.loaded_package_count + NEW.remaining_package_count <> NEW.package_count + OR (NEW.remaining_package_count = 0 AND NEW.remainder_child_inbound_id IS NOT NULL) + OR (NEW.remaining_package_count > 0 AND NEW.remainder_child_inbound_id IS NULL) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '新拆分批次必须由来源单承接实装且最多创建一个余量子单'; + END IF; IF NOT EXISTS ( SELECT 1 FROM fl_inbound_order parent_order WHERE parent_order.id = NEW.parent_inbound_id @@ -7919,16 +7955,21 @@ BEGIN WHERE source_order.id = NEW.source_inbound_id AND source_order.tenant_id = NEW.tenant_id AND source_order.deleted = 0 - AND COALESCE(source_order.package_count, source_order.total_package_count) = NEW.package_count AND ((source_order.id = NEW.parent_inbound_id AND source_order.inbound_role = 'MASTER' - AND source_order.split_status = 'NOT_SPLIT') OR + AND ((source_order.split_status = 'NOT_SPLIT' + AND COALESCE(source_order.package_count, source_order.total_package_count) = NEW.package_count) + OR (source_order.split_status = 'FULLY_SPLIT' + AND source_order.package_count = NEW.loaded_package_count))) OR (source_order.inbound_role = 'CHILD' AND source_order.parent_inbound_id = NEW.parent_inbound_id AND source_order.split_purpose = 'REMAINDER' - AND source_order.split_status = 'NOT_SPLIT')) + AND ((source_order.split_status = 'NOT_SPLIT' + AND source_order.package_count = NEW.package_count) + OR (source_order.split_status = 'FULLY_SPLIT' + AND source_order.package_count = NEW.loaded_package_count)))) ) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次来源必须是箱数匹配且未消费的根主单或余量子单'; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次来源必须是箱数守恒的根主单或余量分片'; END IF; IF NEW.loaded_package_count > 0 AND ( NEW.loading_plan_id IS NULL OR NEW.container_booking_id IS NULL OR NOT EXISTS ( @@ -7944,6 +7985,20 @@ BEGIN ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '实装拆分批次必须绑定匹配的装柜计划和柜次'; END IF; + IF NEW.remainder_child_inbound_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM fl_inbound_order remainder_order + WHERE remainder_order.id = NEW.remainder_child_inbound_id + AND remainder_order.tenant_id = NEW.tenant_id + AND remainder_order.parent_inbound_id = NEW.parent_inbound_id + AND remainder_order.root_inbound_id = NEW.parent_inbound_id + AND remainder_order.inbound_role = 'CHILD' + AND remainder_order.split_purpose = 'REMAINDER' + AND remainder_order.split_status = 'NOT_SPLIT' + AND remainder_order.package_count = NEW.remaining_package_count + AND remainder_order.deleted = 0 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '余量子入仓单与拆分批次数量或根归属不一致'; + END IF; END$$ CREATE TRIGGER trg_inbound_split_batch_before_update @@ -7987,34 +8042,28 @@ BEGIN WHERE source_order.id = NEW.source_inbound_id AND source_order.tenant_id = NEW.tenant_id AND source_order.split_status = 'FULLY_SPLIT' + AND source_order.package_count = NEW.loaded_package_count ) OR (SELECT COALESCE(SUM(item.package_count), 0) FROM fl_inbound_split_item item WHERE item.tenant_id = NEW.tenant_id - AND item.split_batch_id = NEW.id) <> NEW.package_count OR + AND item.split_batch_id = NEW.id) <> NEW.remaining_package_count OR (SELECT COUNT(DISTINCT item.child_inbound_id) FROM fl_inbound_split_item item WHERE item.tenant_id = NEW.tenant_id - AND item.split_batch_id = NEW.id) NOT BETWEEN 1 AND 2 OR - (SELECT COALESCE(SUM(item.package_count), 0) - FROM fl_inbound_split_item item - WHERE item.tenant_id = NEW.tenant_id - AND item.split_batch_id = NEW.id - AND item.child_inbound_id = NEW.loaded_child_inbound_id) <> NEW.loaded_package_count OR - (SELECT COALESCE(SUM(item.package_count), 0) - FROM fl_inbound_split_item item - WHERE item.tenant_id = NEW.tenant_id - AND item.split_batch_id = NEW.id - AND item.child_inbound_id = NEW.remainder_child_inbound_id) <> NEW.remaining_package_count OR + AND item.split_batch_id = NEW.id) <> CASE WHEN NEW.remaining_package_count > 0 THEN 1 ELSE 0 END OR + (NEW.loaded_child_inbound_id IS NOT NULL) OR NEW.loaded_package_count + NEW.remaining_package_count <> NEW.package_count OR - (NEW.loaded_package_count > 0 AND NEW.loaded_child_inbound_id IS NULL) OR (NEW.remaining_package_count > 0 AND NEW.remainder_child_inbound_id IS NULL) OR - (NEW.loaded_child_inbound_id IS NOT NULL AND NOT EXISTS ( - SELECT 1 FROM fl_inbound_order c WHERE c.id = NEW.loaded_child_inbound_id - AND c.parent_inbound_id = NEW.parent_inbound_id AND c.split_purpose = 'LOADED')) OR (NEW.remainder_child_inbound_id IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM fl_inbound_order c WHERE c.id = NEW.remainder_child_inbound_id - AND c.parent_inbound_id = NEW.parent_inbound_id AND c.split_purpose = 'REMAINDER')) OR + AND c.parent_inbound_id = NEW.parent_inbound_id AND c.root_inbound_id = NEW.parent_inbound_id + AND c.split_purpose = 'REMAINDER' AND c.split_status = 'NOT_SPLIT' + AND c.package_count = NEW.remaining_package_count AND c.deleted = 0)) OR + EXISTS (SELECT 1 FROM fl_inbound_split_item item + WHERE item.tenant_id = NEW.tenant_id + AND item.split_batch_id = NEW.id + AND item.child_inbound_id <> NEW.remainder_child_inbound_id) OR EXISTS (SELECT 1 FROM fl_inbound_split_item item JOIN fl_inbound_order child_order ON child_order.id = item.child_inbound_id @@ -8036,6 +8085,24 @@ BEGIN ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子单承接箱数超过来源测量箱规箱数'; END IF; + IF NOT EXISTS ( + SELECT 1 + FROM fl_inbound_order root_order + WHERE root_order.id = NEW.parent_inbound_id + AND root_order.tenant_id = NEW.tenant_id + AND root_order.inbound_role = 'MASTER' + AND root_order.deleted = 0 + AND root_order.total_package_count = + COALESCE(root_order.package_count, 0) + + COALESCE((SELECT SUM(child.package_count) + FROM fl_inbound_order child + WHERE child.tenant_id = NEW.tenant_id + AND child.root_inbound_id = root_order.id + AND child.inbound_role = 'CHILD' + AND child.deleted = 0), 0) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '根入仓单原始箱数与已完成分片及当前余量不守恒'; + END IF; ELSEIF NEW.status = 'CANCELLED' THEN IF EXISTS ( SELECT 1 FROM fl_inbound_split_item item @@ -8093,12 +8160,13 @@ BEGIN AND table_name = 'fl_inbound_order' AND column_name IN ('inbound_role', 'parent_inbound_id', 'root_inbound_id', 'split_sequence', 'split_status', 'package_count', - 'master_order_id', 'split_purpose')) <> 8 THEN + 'master_order_id', 'split_purpose', 'active_remainder_root_id')) <> 9 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单关系字段未完整创建'; END IF; IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' - AND index_name IN ('uk_inbound_master_order', 'uk_inbound_parent_split_sequence')) <> 2 + AND index_name IN ('uk_inbound_master_order', 'uk_inbound_parent_split_sequence', + 'uk_inbound_one_active_remainder')) <> 3 OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_split_batch' AND index_name IN ('uk_inbound_split_operation', 'uk_inbound_split_idempotency', diff --git a/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql b/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql index 6e46e6917..37bfa1803 100644 --- a/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql +++ b/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql @@ -4,6 +4,8 @@ USE baiyue_firstleg; /* * 三期主子入仓单结构迁移。 * 仅建立结构和数据库保护,不自动拆分或回填存量业务数据。 + * 新写入采用“来源单承接实装、仅创建余量子单”;LOADED 子单和 loaded_child_inbound_id + * 仅为历史双子单读取保留,不删除、不清洗存量记录。 * tenant_id 继续表示客户隔离键;数量统一使用 package_count。 */ DROP PROCEDURE IF EXISTS phase3_inbound_add_column; @@ -60,6 +62,10 @@ UPDATE fl_inbound_order WHERE inbound_role = 'CHILD' AND split_purpose = 'MASTER'; +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'active_remainder_root_id', + "BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN inbound_role = 'CHILD' AND split_purpose = 'REMAINDER' AND split_status = 'NOT_SPLIT' AND deleted = 0 THEN root_inbound_id ELSE NULL END) STORED COMMENT '有效余量唯一约束键;历史已完成分片为空'"); + DROP PROCEDURE IF EXISTS phase3_inbound_add_column; DROP PROCEDURE IF EXISTS phase3_inbound_add_index; @@ -91,6 +97,31 @@ CALL phase3_inbound_add_index( 'fl_inbound_order', 'idx_inbound_root_role', 'KEY `idx_inbound_root_role` (`tenant_id`, `root_inbound_id`, `inbound_role`, `created_at`, `id`)'); +DROP PROCEDURE IF EXISTS phase3_assert_active_remainder_unique_ready; +DELIMITER $$ +CREATE PROCEDURE phase3_assert_active_remainder_unique_ready() +BEGIN + IF EXISTS ( + SELECT 1 + FROM fl_inbound_order + WHERE inbound_role = 'CHILD' + AND split_purpose = 'REMAINDER' + AND split_status = 'NOT_SPLIT' + AND deleted = 0 + AND root_inbound_id IS NOT NULL + GROUP BY tenant_id, root_inbound_id + HAVING COUNT(*) > 1 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同一根入仓单存在多个有效余量,请先人工核对历史数据'; + END IF; +END$$ +DELIMITER ; +CALL phase3_assert_active_remainder_unique_ready(); +DROP PROCEDURE IF EXISTS phase3_assert_active_remainder_unique_ready; +CALL phase3_inbound_add_index( + 'fl_inbound_order', 'uk_inbound_one_active_remainder', + 'UNIQUE KEY `uk_inbound_one_active_remainder` (`tenant_id`, `active_remainder_root_id`)'); + DROP PROCEDURE IF EXISTS phase3_inbound_drop_legacy_index; DELIMITER $$ CREATE PROCEDURE phase3_inbound_drop_legacy_index() @@ -417,8 +448,8 @@ BEGIN OR NEW.split_sequence IS NULL OR NEW.package_count IS NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单缺少主单、顺序号或箱数'; END IF; - IF NEW.split_purpose = 'MASTER' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单必须标记LOADED或REMAINDER'; + IF NEW.split_purpose <> 'REMAINDER' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '新建子入仓单只能作为余量分片'; END IF; IF NOT EXISTS ( SELECT 1 FROM fl_inbound_order parent_order @@ -458,11 +489,6 @@ BEGIN OR (NEW.inbound_role = 'MASTER' AND NEW.split_purpose <> 'MASTER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单层级与用途不一致'; END IF; - IF NEW.inbound_role = 'MASTER' - AND ((NEW.package_count = 0 AND NEW.split_status <> 'FULLY_SPLIT') - OR (NEW.split_status = 'FULLY_SPLIT' AND COALESCE(NEW.package_count, 0) <> 0)) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单箱数归零必须与完全拆分状态同时提交'; - END IF; END$$ CREATE TRIGGER trg_inbound_split_item_before_insert @@ -481,11 +507,14 @@ BEGIN AND batch.tenant_id = NEW.tenant_id AND batch.parent_inbound_id = NEW.parent_inbound_id AND batch.status = 'PREPARED' + AND batch.loaded_child_inbound_id IS NULL + AND batch.remainder_child_inbound_id IS NOT NULL + AND NEW.child_inbound_id = batch.remainder_child_inbound_id AND parent_order.tenant_id = NEW.tenant_id AND parent_order.inbound_role = 'MASTER' AND child_order.tenant_id = NEW.tenant_id AND child_order.inbound_role = 'CHILD' - AND child_order.split_purpose IN ('LOADED', 'REMAINDER') + AND child_order.split_purpose = 'REMAINDER' AND child_order.parent_inbound_id = NEW.parent_inbound_id AND child_order.package_count >= NEW.package_count AND child_order.deleted = 0 @@ -510,6 +539,13 @@ BEGIN IF NEW.status <> 'PREPARED' OR NEW.applied_at IS NOT NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次必须从PREPARED创建'; END IF; + IF NEW.loaded_child_inbound_id IS NOT NULL + OR NEW.loaded_package_count = 0 + OR NEW.loaded_package_count + NEW.remaining_package_count <> NEW.package_count + OR (NEW.remaining_package_count = 0 AND NEW.remainder_child_inbound_id IS NOT NULL) + OR (NEW.remaining_package_count > 0 AND NEW.remainder_child_inbound_id IS NULL) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '新拆分批次必须由来源单承接实装且最多创建一个余量子单'; + END IF; IF NOT EXISTS ( SELECT 1 FROM fl_inbound_order parent_order WHERE parent_order.id = NEW.parent_inbound_id @@ -524,16 +560,21 @@ BEGIN WHERE source_order.id = NEW.source_inbound_id AND source_order.tenant_id = NEW.tenant_id AND source_order.deleted = 0 - AND COALESCE(source_order.package_count, source_order.total_package_count) = NEW.package_count AND ((source_order.id = NEW.parent_inbound_id AND source_order.inbound_role = 'MASTER' - AND source_order.split_status = 'NOT_SPLIT') OR + AND ((source_order.split_status = 'NOT_SPLIT' + AND COALESCE(source_order.package_count, source_order.total_package_count) = NEW.package_count) + OR (source_order.split_status = 'FULLY_SPLIT' + AND source_order.package_count = NEW.loaded_package_count))) OR (source_order.inbound_role = 'CHILD' AND source_order.parent_inbound_id = NEW.parent_inbound_id AND source_order.split_purpose = 'REMAINDER' - AND source_order.split_status = 'NOT_SPLIT')) + AND ((source_order.split_status = 'NOT_SPLIT' + AND source_order.package_count = NEW.package_count) + OR (source_order.split_status = 'FULLY_SPLIT' + AND source_order.package_count = NEW.loaded_package_count)))) ) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次来源必须是箱数匹配且未消费的根主单或余量子单'; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次来源必须是箱数守恒的根主单或余量分片'; END IF; IF NEW.loaded_package_count > 0 AND ( NEW.loading_plan_id IS NULL OR NEW.container_booking_id IS NULL OR NOT EXISTS ( @@ -549,6 +590,20 @@ BEGIN ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '实装拆分批次必须绑定匹配的装柜计划和柜次'; END IF; + IF NEW.remainder_child_inbound_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM fl_inbound_order remainder_order + WHERE remainder_order.id = NEW.remainder_child_inbound_id + AND remainder_order.tenant_id = NEW.tenant_id + AND remainder_order.parent_inbound_id = NEW.parent_inbound_id + AND remainder_order.root_inbound_id = NEW.parent_inbound_id + AND remainder_order.inbound_role = 'CHILD' + AND remainder_order.split_purpose = 'REMAINDER' + AND remainder_order.split_status = 'NOT_SPLIT' + AND remainder_order.package_count = NEW.remaining_package_count + AND remainder_order.deleted = 0 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '余量子入仓单与拆分批次数量或根归属不一致'; + END IF; END$$ CREATE TRIGGER trg_inbound_split_batch_before_update @@ -592,34 +647,28 @@ BEGIN WHERE source_order.id = NEW.source_inbound_id AND source_order.tenant_id = NEW.tenant_id AND source_order.split_status = 'FULLY_SPLIT' + AND source_order.package_count = NEW.loaded_package_count ) OR (SELECT COALESCE(SUM(item.package_count), 0) FROM fl_inbound_split_item item WHERE item.tenant_id = NEW.tenant_id - AND item.split_batch_id = NEW.id) <> NEW.package_count OR + AND item.split_batch_id = NEW.id) <> NEW.remaining_package_count OR (SELECT COUNT(DISTINCT item.child_inbound_id) FROM fl_inbound_split_item item WHERE item.tenant_id = NEW.tenant_id - AND item.split_batch_id = NEW.id) NOT BETWEEN 1 AND 2 OR - (SELECT COALESCE(SUM(item.package_count), 0) - FROM fl_inbound_split_item item - WHERE item.tenant_id = NEW.tenant_id - AND item.split_batch_id = NEW.id - AND item.child_inbound_id = NEW.loaded_child_inbound_id) <> NEW.loaded_package_count OR - (SELECT COALESCE(SUM(item.package_count), 0) - FROM fl_inbound_split_item item - WHERE item.tenant_id = NEW.tenant_id - AND item.split_batch_id = NEW.id - AND item.child_inbound_id = NEW.remainder_child_inbound_id) <> NEW.remaining_package_count OR + AND item.split_batch_id = NEW.id) <> CASE WHEN NEW.remaining_package_count > 0 THEN 1 ELSE 0 END OR + (NEW.loaded_child_inbound_id IS NOT NULL) OR NEW.loaded_package_count + NEW.remaining_package_count <> NEW.package_count OR - (NEW.loaded_package_count > 0 AND NEW.loaded_child_inbound_id IS NULL) OR (NEW.remaining_package_count > 0 AND NEW.remainder_child_inbound_id IS NULL) OR - (NEW.loaded_child_inbound_id IS NOT NULL AND NOT EXISTS ( - SELECT 1 FROM fl_inbound_order c WHERE c.id = NEW.loaded_child_inbound_id - AND c.parent_inbound_id = NEW.parent_inbound_id AND c.split_purpose = 'LOADED')) OR (NEW.remainder_child_inbound_id IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM fl_inbound_order c WHERE c.id = NEW.remainder_child_inbound_id - AND c.parent_inbound_id = NEW.parent_inbound_id AND c.split_purpose = 'REMAINDER')) OR + AND c.parent_inbound_id = NEW.parent_inbound_id AND c.root_inbound_id = NEW.parent_inbound_id + AND c.split_purpose = 'REMAINDER' AND c.split_status = 'NOT_SPLIT' + AND c.package_count = NEW.remaining_package_count AND c.deleted = 0)) OR + EXISTS (SELECT 1 FROM fl_inbound_split_item item + WHERE item.tenant_id = NEW.tenant_id + AND item.split_batch_id = NEW.id + AND item.child_inbound_id <> NEW.remainder_child_inbound_id) OR EXISTS (SELECT 1 FROM fl_inbound_split_item item JOIN fl_inbound_order child_order ON child_order.id = item.child_inbound_id @@ -641,6 +690,24 @@ BEGIN ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子单承接箱数超过来源测量箱规箱数'; END IF; + IF NOT EXISTS ( + SELECT 1 + FROM fl_inbound_order root_order + WHERE root_order.id = NEW.parent_inbound_id + AND root_order.tenant_id = NEW.tenant_id + AND root_order.inbound_role = 'MASTER' + AND root_order.deleted = 0 + AND root_order.total_package_count = + COALESCE(root_order.package_count, 0) + + COALESCE((SELECT SUM(child.package_count) + FROM fl_inbound_order child + WHERE child.tenant_id = NEW.tenant_id + AND child.root_inbound_id = root_order.id + AND child.inbound_role = 'CHILD' + AND child.deleted = 0), 0) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '根入仓单原始箱数与已完成分片及当前余量不守恒'; + END IF; ELSEIF NEW.status = 'CANCELLED' THEN IF EXISTS ( SELECT 1 FROM fl_inbound_split_item item @@ -698,12 +765,13 @@ BEGIN AND table_name = 'fl_inbound_order' AND column_name IN ('inbound_role', 'parent_inbound_id', 'root_inbound_id', 'split_sequence', 'split_status', 'package_count', - 'master_order_id', 'split_purpose')) <> 8 THEN + 'master_order_id', 'split_purpose', 'active_remainder_root_id')) <> 9 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单关系字段未完整创建'; END IF; IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' - AND index_name IN ('uk_inbound_master_order', 'uk_inbound_parent_split_sequence')) <> 2 + AND index_name IN ('uk_inbound_master_order', 'uk_inbound_parent_split_sequence', + 'uk_inbound_one_active_remainder')) <> 3 OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_split_batch' AND index_name IN ('uk_inbound_split_operation', 'uk_inbound_split_idempotency', diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java index a57c73212..ceb938c9c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java @@ -22,10 +22,13 @@ class InboundParentChildSqlContractTest { .contains("'fl_inbound_order', 'package_count'") .contains("'fl_inbound_order', 'split_purpose'") .contains("SET split_purpose = 'REMAINDER'") + .contains("active_remainder_root_id") .contains("GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER'") + .contains("GENERATED ALWAYS AS (CASE WHEN inbound_role = 'CHILD'") .contains("DROP INDEX uk_inbound_tenant_order") .contains("uk_inbound_master_order") .contains("uk_inbound_parent_split_sequence") + .contains("uk_inbound_one_active_remainder") .contains("DROP INDEX uk_inbound_parent_sequence"); } @@ -54,17 +57,23 @@ class InboundParentChildSqlContractTest { } @Test - void protectsOwnershipAndTotals() { + void protectsSingleRemainderWritesAndConservation() { assertThat(sql) .contains("子入仓单必须绑定同一客户的有效主单") .contains("parent_order.version > NEW.expected_parent_version") .contains("NEW.loaded_package_count") .contains("NEW.remaining_package_count") .contains("NEW.loaded_package_count + NEW.remaining_package_count <> NEW.package_count") + .contains("NEW.loaded_child_inbound_id IS NOT NULL") + .contains("NEW.child_inbound_id = batch.remainder_child_inbound_id") + .contains("source_order.package_count = NEW.loaded_package_count") + .contains("item.child_inbound_id <> NEW.remainder_child_inbound_id") + .contains("<> NEW.remaining_package_count") + .contains("根入仓单原始箱数与已完成分片及当前余量不守恒") .contains("COALESCE(source_order.package_count, source_order.total_package_count) = NEW.package_count") .contains("AND plan_item.deleted = 0))))\n OR NOT EXISTS (") .contains("source_batch.inbound_id = COALESCE(batch.source_inbound_id, batch.parent_inbound_id)") - .contains("拆分批次来源必须是箱数匹配且未消费的根主单或余量子单") + .contains("拆分批次来源必须是箱数守恒的根主单或余量分片") .contains("SUM(split_item.package_count) > source_item.package_count") .contains("子单承接箱数超过来源测量箱规箱数") .contains("child_order.package_count >= NEW.package_count") @@ -75,13 +84,14 @@ class InboundParentChildSqlContractTest { } @Test - void validatesMultiChildAndMultiSkuTotals() { + void validatesSingleRemainderAndMultiSkuTotals() { assertThat(sql) .contains("uk_inbound_parent_split_sequence") + .contains("uk_inbound_one_active_remainder") .contains("uk_inbound_split_batch_line") .contains("uk_inbound_split_batch_source_child") .contains("COUNT(DISTINCT item.child_inbound_id)") - .contains("NOT BETWEEN 1 AND 2") + .contains("<> CASE WHEN NEW.remaining_package_count > 0 THEN 1 ELSE 0 END") .contains("child_order.package_count <> SUM(item.package_count)"); } @@ -108,6 +118,7 @@ class InboundParentChildSqlContractTest { assertThat(sql) .contains("table_name = 'fl_inbound_split_batch'") .contains("uk_inbound_split_source_once") + .contains("uk_inbound_one_active_remainder") .contains("table_name = 'fl_inbound_split_item'") .contains("uk_inbound_split_batch_source_child") .contains("is_nullable = 'NO'") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index f08c26ecd..39547bfbf 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -107,6 +107,22 @@ class Phase3FinalUnifiedSqlContractTest { .doesNotContain("UNIQUE KEY `uk_inbound_split_batch_source_item`"); } + @Test + void unifiedEntryEnforcesSingleRemainderWritesAndKeepsLegacyColumns() throws Exception { + String sql = normalise(Files.readString(resolveUnified())); + + assertThat(sql) + .contains("'fl_inbound_order', 'active_remainder_root_id'") + .contains("uk_inbound_one_active_remainder") + .contains("uk_inbound_split_source_once") + .contains("NEW.loaded_child_inbound_id IS NOT NULL") + .contains("NEW.child_inbound_id = batch.remainder_child_inbound_id") + .contains("source_order.package_count = NEW.loaded_package_count") + .contains("根入仓单原始箱数与已完成分片及当前余量不守恒") + .contains("'fl_inbound_split_batch', 'loaded_child_inbound_id'") + .contains("fk_inbound_split_loaded_child"); + } + private static void assertBlock(String unified, String marker, String nextMarker, String modulePath) throws Exception { int start = unified.indexOf(marker); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java index d4ec93b8c..7c5354aa4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java @@ -16,7 +16,7 @@ class SplitPreloadSqlContractTest { } @Test - void limitsSecondaryInboundDepth() throws Exception { + void limitsNewChildrenToRootBasedRemainders() throws Exception { String sql = moduleMigration(); assertThat(sql) .contains("parent_inbound_id") @@ -25,7 +25,8 @@ class SplitPreloadSqlContractTest { .contains("split_status") .contains("uk_inbound_parent_split_sequence") .contains("fk_inbound_parent_order") - .contains("子入仓单必须标记LOADED或REMAINDER") + .contains("新建子入仓单只能作为余量分片") + .contains("uk_inbound_one_active_remainder") .contains("入仓单主子归属创建后不可修改"); }