feat(firstleg): enforce single remainder split schema

This commit is contained in:
2026-08-11 09:18:03 +08:00
parent e915086133
commit fe60a20d95
5 changed files with 232 additions and 68 deletions
@@ -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',
@@ -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',
@@ -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'")
@@ -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);
@@ -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("入仓单主子归属创建后不可修改");
}