From ac085e0f53c720e40f7a09d9570d29f5e2ce0dde Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 10 Aug 2026 13:30:59 +0800 Subject: [PATCH] chore(deploy): synchronize phase3 unified database migration --- deploy/sql/头程数据库统一增量-MySQL8.sql | 738 +++++++++++------- .../db/phase3-inbound-parent-child-schema.sql | 8 + .../Phase3FinalUnifiedSqlContractTest.java | 70 ++ .../WarehouseRoleSqlContractTest.java | 11 +- .../db/phase3-order-parent-child-schema.sql | 24 +- .../OrderParentChildSqlContractTest.java | 63 ++ 6 files changed, 618 insertions(+), 296 deletions(-) create mode 100644 order/src/test/java/com/baiyue/order/repository/OrderParentChildSqlContractTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 17e93978d..15664e8b0 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -6820,8 +6820,191 @@ WHERE TRUE ON DUPLICATE KEY UPDATE id = id; -- ============================================================================ +-- 来源:order/src/main/resources/db/phase3-order-parent-child-schema.sql +SET NAMES utf8mb4; +USE baiyue_order; + +DROP PROCEDURE IF EXISTS phase3_order_add_column; +DELIMITER $$ +CREATE PROCEDURE phase3_order_add_column(IN p_table VARCHAR(64), IN p_column VARCHAR(64), IN p_definition TEXT) +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name=p_table AND column_name=p_column) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `', p_column, '` ', p_definition); + PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; +CALL phase3_order_add_column('ord_order','order_role',"VARCHAR(12) NOT NULL DEFAULT 'MASTER' COMMENT '订单层级:MASTER、CHILD'"); +CALL phase3_order_add_column('ord_order','parent_order_id','BIGINT UNSIGNED NULL COMMENT ''直属父订单ID;禁止子订单嵌套'''); +CALL phase3_order_add_column('ord_order','root_order_id','BIGINT UNSIGNED NULL COMMENT ''根主订单ID'''); +CALL phase3_order_add_column('ord_order','split_sequence','INT UNSIGNED NULL COMMENT ''同一父订单下的同级顺序'''); + +DROP PROCEDURE IF EXISTS phase3_order_add_index; +DELIMITER $$ +CREATE PROCEDURE phase3_order_add_index(IN p_table VARCHAR(64), IN p_name VARCHAR(64), IN p_definition TEXT) +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name=p_table AND index_name=p_name) THEN + SET @ddl = CONCAT('ALTER TABLE `',p_table,'` ADD ',p_definition); + PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; +CALL phase3_order_add_index('ord_order','uk_order_parent_sequence', + 'UNIQUE KEY `uk_order_parent_sequence` (`tenant_id`,`parent_order_id`,`split_sequence`)'); +CALL phase3_order_add_index('ord_order','idx_order_root_role', + 'KEY `idx_order_root_role` (`tenant_id`,`root_order_id`,`order_role`,`created_at`,`id`)'); + +DROP PROCEDURE IF EXISTS phase3_order_parent_fk; +DELIMITER $$ +CREATE PROCEDURE phase3_order_parent_fk() +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() + AND table_name='ord_order' AND constraint_name='fk_order_parent') THEN + ALTER TABLE ord_order DROP FOREIGN KEY fk_order_parent; + END IF; + ALTER TABLE ord_order ADD CONSTRAINT fk_order_parent FOREIGN KEY (parent_order_id) REFERENCES ord_order(id); +END$$ +DELIMITER ; +CALL phase3_order_parent_fk(); +DROP PROCEDURE IF EXISTS phase3_order_parent_fk; + +CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split ( + id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + parent_order_id BIGINT UNSIGNED NOT NULL, + source_order_id BIGINT UNSIGNED NOT NULL, + child_order_id BIGINT UNSIGNED NOT NULL, + split_role VARCHAR(12) NOT NULL COMMENT 'LOADED或REMAINDER', + loading_plan_id BIGINT UNSIGNED NULL, + container_booking_id BIGINT UNSIGNED NULL, + source_package_count INT UNSIGNED NOT NULL, + loaded_package_count INT UNSIGNED NOT NULL, + remaining_package_count INT UNSIGNED NOT NULL, + source_detail_json JSON NULL, + operation_id VARCHAR(64) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + request_hash CHAR(64) NOT NULL, + expected_parent_version INT NOT NULL, + created_by BIGINT UNSIGNED NOT NULL, + created_by_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + version INT NOT NULL DEFAULT 0, + PRIMARY KEY(id), + UNIQUE KEY uk_order_split_operation (tenant_id,operation_id,split_role), + UNIQUE KEY uk_order_split_idempotency (tenant_id,parent_order_id,idempotency_key,split_role), + UNIQUE KEY uk_order_split_child (tenant_id,child_order_id), + KEY idx_order_split_parent (tenant_id,parent_order_id,created_at,id), + KEY idx_order_split_source (tenant_id,source_order_id,split_role), + KEY idx_order_split_container (tenant_id,container_booking_id,created_at,id), + CONSTRAINT fk_order_split_parent FOREIGN KEY(parent_order_id) REFERENCES ord_order(id), + CONSTRAINT fk_order_split_child FOREIGN KEY(child_order_id) REFERENCES ord_order(id), + CONSTRAINT chk_order_split_quantity CHECK (source_package_count > 0 AND loaded_package_count > 0 + AND loaded_package_count + remaining_package_count = source_package_count) +) ENGINE=InnoDB COMMENT='父订单→子订单→柜次数量台账'; + +DROP TRIGGER IF EXISTS trg_ord_order_parent_insert; +DROP TRIGGER IF EXISTS trg_ord_order_parent_update; +DELIMITER $$ +CREATE TRIGGER trg_ord_order_parent_insert BEFORE INSERT ON ord_order FOR EACH ROW +BEGIN + IF NEW.order_role='MASTER' THEN + SET NEW.parent_order_id=NULL; + IF NEW.root_order_id IS NULL THEN SET NEW.root_order_id=NEW.id; END IF; + ELSEIF NEW.order_role='CHILD' THEN + IF NEW.parent_order_id IS NULL OR NEW.root_order_id IS NULL OR NEW.split_sequence IS NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='子订单必须有父订单、根订单和顺序号'; + END IF; + IF NEW.root_order_id<>NEW.parent_order_id OR EXISTS( + SELECT 1 FROM ord_order p WHERE p.id=NEW.parent_order_id AND p.order_role<>'MASTER') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='子订单只允许直接挂在主订单下'; + END IF; + IF NOT EXISTS(SELECT 1 FROM ord_order p WHERE p.id=NEW.parent_order_id AND p.tenant_id=NEW.tenant_id AND p.deleted=0) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='父订单不存在或客户归属不一致'; + END IF; + ELSE + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单层级非法'; + END IF; +END$$ +CREATE TRIGGER trg_ord_order_parent_update BEFORE UPDATE ON ord_order FOR EACH ROW +BEGIN + IF NEW.order_role<>OLD.order_role OR NOT (NEW.parent_order_id<=>OLD.parent_order_id) + OR NOT (NEW.root_order_id<=>OLD.root_order_id) OR NOT (NEW.split_sequence<=>OLD.split_sequence) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单父子关系不可修改'; + END IF; +END$$ +DELIMITER ; + +CALL phase3_order_add_column('ord_order_fulfillment_split','split_role',"VARCHAR(12) NOT NULL DEFAULT 'LOADED' COMMENT 'LOADED或REMAINDER'"); +CALL phase3_order_add_column('ord_order_fulfillment_split','source_order_id','BIGINT UNSIGNED NULL COMMENT ''实际被消费的来源订单ID'''); +CALL phase3_order_add_column('ord_order_fulfillment_split','request_hash',"CHAR(64) NULL COMMENT '请求内容SHA-256;存量记录允许为空'"); +-- 旧版台账没有来源字段;当时只允许从根主订单拆分,因此可无歧义回填为父订单。 +UPDATE ord_order_fulfillment_split + SET source_order_id = parent_order_id + WHERE source_order_id IS NULL; +-- 旧记录没有请求哈希。生成稳定哨兵哈希可保持非空约束,并让旧幂等键重放明确冲突。 +UPDATE ord_order_fulfillment_split + SET request_hash = SHA2(CONCAT('LEGACY:', tenant_id, ':', id), 256) + WHERE request_hash IS NULL OR request_hash = ''; +ALTER TABLE ord_order_fulfillment_split + MODIFY source_order_id BIGINT UNSIGNED NOT NULL COMMENT '实际被消费的来源订单ID', + MODIFY request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256'; +CALL phase3_order_add_index('ord_order_fulfillment_split','idx_order_split_source', + 'KEY `idx_order_split_source` (`tenant_id`,`source_order_id`,`split_role`)'); + +DROP PROCEDURE IF EXISTS phase3_order_drop_split_index; +DELIMITER $$ +CREATE PROCEDURE phase3_order_drop_split_index() +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ord_order_fulfillment_split' AND index_name='uk_order_split_operation') THEN + ALTER TABLE ord_order_fulfillment_split DROP INDEX uk_order_split_operation; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ord_order_fulfillment_split' AND index_name='uk_order_split_idempotency') THEN + ALTER TABLE ord_order_fulfillment_split DROP INDEX uk_order_split_idempotency; + END IF; +END$$ +DELIMITER ; +CALL phase3_order_drop_split_index(); +DROP PROCEDURE IF EXISTS phase3_order_drop_split_index; +CALL phase3_order_add_index('ord_order_fulfillment_split','uk_order_split_operation', + 'UNIQUE KEY `uk_order_split_operation` (`tenant_id`,`operation_id`,`split_role`)'); +CALL phase3_order_add_index('ord_order_fulfillment_split','uk_order_split_idempotency', + 'UNIQUE KEY `uk_order_split_idempotency` (`tenant_id`,`parent_order_id`,`idempotency_key`,`split_role`)'); +DROP PROCEDURE IF EXISTS phase3_order_add_column; +DROP PROCEDURE IF EXISTS phase3_order_add_index; + +DROP PROCEDURE IF EXISTS phase3_order_assert; +DELIMITER $$ +CREATE PROCEDURE phase3_order_assert() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ord_order' AND column_name IN ('order_role','parent_order_id','root_order_id','split_sequence'))<>4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单父子字段未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() + AND table_name='ord_order_fulfillment_split')<>1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单拆分台账未创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ord_order_fulfillment_split' + AND column_name IN ('source_order_id','request_hash') AND is_nullable='NO')<>2 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='ord_order_fulfillment_split' + AND index_name IN ('idx_order_split_source','uk_order_split_operation', + 'uk_order_split_idempotency'))<>3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单拆分来源或幂等索引未完整创建'; + END IF; +END$$ +DELIMITER ; +CALL phase3_order_assert(); +DROP PROCEDURE IF EXISTS phase3_order_assert; + -- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql --- ============================================================================ SET NAMES utf8mb4; USE baiyue_firstleg; @@ -6871,10 +7054,19 @@ CALL phase3_inbound_add_column( CALL phase3_inbound_add_column( 'fl_inbound_order', 'package_count', "INT UNSIGNED NULL COMMENT '入仓单箱数;三期唯一数量口径' AFTER total_package_count"); +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'split_purpose', + "VARCHAR(16) NOT NULL DEFAULT 'MASTER' COMMENT '履约分片用途:MASTER、LOADED、REMAINDER' AFTER package_count"); CALL phase3_inbound_add_column( 'fl_inbound_order', 'master_order_id', "BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER' AND deleted = 0 THEN order_id ELSE NULL END) STORED COMMENT '仅用于约束有效主单订单唯一性'"); +-- 旧实现只生成未装余量子单,可无歧义映射为 REMAINDER。 +UPDATE fl_inbound_order + SET split_purpose = 'REMAINDER' + WHERE inbound_role = 'CHILD' + AND split_purpose = 'MASTER'; + DROP PROCEDURE IF EXISTS phase3_inbound_add_column; DROP PROCEDURE IF EXISTS phase3_inbound_add_index; @@ -6902,9 +7094,6 @@ DELIMITER ; CALL phase3_inbound_add_index( 'fl_inbound_order', 'uk_inbound_master_order', 'UNIQUE KEY `uk_inbound_master_order` (`tenant_id`, `master_order_id`)'); -CALL phase3_inbound_add_index( - 'fl_inbound_order', 'uk_inbound_one_child', - 'UNIQUE KEY `uk_inbound_one_child` (`tenant_id`, `parent_inbound_id`)'); 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`)'); @@ -6929,10 +7118,29 @@ BEGIN ) THEN ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_parent_sequence; END IF; + IF EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_one_child' + ) THEN + ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_one_child; + END IF; + IF EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_one_secondary' + ) THEN + ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_one_secondary; + END IF; END$$ DELIMITER ; CALL phase3_inbound_drop_legacy_index(); DROP PROCEDURE IF EXISTS phase3_inbound_drop_legacy_index; +CALL phase3_inbound_add_index( + 'fl_inbound_order', 'uk_inbound_parent_split_sequence', + 'UNIQUE KEY `uk_inbound_parent_split_sequence` (`tenant_id`, `parent_inbound_id`, `split_sequence`)'); DROP PROCEDURE IF EXISTS phase3_inbound_add_index; CREATE TABLE IF NOT EXISTS fl_inbound_split_batch ( @@ -6967,13 +7175,13 @@ CREATE TABLE IF NOT EXISTS fl_inbound_split_item ( child_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '子入仓单ID', line_no INT UNSIGNED NOT NULL COMMENT '同一拆分批次内的行号', source_inbound_detail_id BIGINT UNSIGNED NULL COMMENT '订单货物明细逻辑ID', - source_measurement_id BIGINT UNSIGNED NULL COMMENT '来源测量批次ID', - source_measurement_item_id BIGINT UNSIGNED NULL COMMENT '来源测量箱规明细ID', + source_measurement_id BIGINT UNSIGNED NOT NULL COMMENT '来源测量批次ID', + source_measurement_item_id BIGINT UNSIGNED NOT NULL COMMENT '来源测量箱规明细ID', package_count INT UNSIGNED NOT NULL COMMENT '分配给子单的箱数', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (id), UNIQUE KEY uk_inbound_split_batch_line (tenant_id, split_batch_id, line_no), - UNIQUE KEY uk_inbound_split_batch_source_item (tenant_id, split_batch_id, source_measurement_item_id), + UNIQUE KEY uk_inbound_split_batch_source_child (tenant_id, split_batch_id, source_measurement_item_id, child_inbound_id), KEY idx_inbound_split_detail (tenant_id, source_inbound_detail_id, created_at), CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0), CONSTRAINT fk_inbound_split_item_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id), @@ -7039,6 +7247,30 @@ CALL phase3_inbound_add_column( CALL phase3_inbound_add_column( 'fl_inbound_split_item', 'line_no', "INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一拆分批次内的行号' AFTER child_inbound_id"); +ALTER TABLE fl_inbound_split_item + MODIFY source_measurement_id BIGINT UNSIGNED NOT NULL COMMENT '来源测量批次ID', + MODIFY source_measurement_item_id BIGINT UNSIGNED NOT NULL COMMENT '来源测量箱规明细ID'; +CALL phase3_inbound_add_column( + 'fl_inbound_split_batch', 'loaded_child_inbound_id', + "BIGINT UNSIGNED NULL COMMENT '本批次实装子单ID' AFTER package_count"); +CALL phase3_inbound_add_column( + 'fl_inbound_split_batch', 'source_inbound_id', + "BIGINT UNSIGNED NULL COMMENT '本批次实际消耗的MASTER或REMAINDER分片ID' AFTER parent_inbound_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_split_batch', 'remainder_child_inbound_id', + "BIGINT UNSIGNED NULL COMMENT '本批次余量子单ID' AFTER loaded_child_inbound_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_split_batch', 'loaded_package_count', + "INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '本批次实装箱数' AFTER remainder_child_inbound_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_split_batch', 'remaining_package_count', + "INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '本批次未装箱数' AFTER loaded_package_count"); +CALL phase3_inbound_add_column( + 'fl_inbound_split_batch', 'loading_plan_id', + "BIGINT UNSIGNED NULL COMMENT '关联装柜计划/柜次' AFTER remaining_package_count"); +CALL phase3_inbound_add_column( + 'fl_inbound_split_batch', 'container_booking_id', + "BIGINT UNSIGNED NULL COMMENT '关联柜次标识(兼容查询)' AFTER loading_plan_id"); DROP PROCEDURE IF EXISTS phase3_inbound_add_column; DROP PROCEDURE IF EXISTS phase3_inbound_add_relation; @@ -7069,6 +7301,15 @@ CALL phase3_inbound_add_relation( CALL phase3_inbound_add_relation( 'fl_inbound_order', 'fk_inbound_root_order', 'FOREIGN KEY (`root_inbound_id`) REFERENCES `fl_inbound_order` (`id`)'); +CALL phase3_inbound_add_relation( + 'fl_inbound_split_batch', 'fk_inbound_split_loaded_child', + 'FOREIGN KEY (`loaded_child_inbound_id`) REFERENCES `fl_inbound_order` (`id`)'); +CALL phase3_inbound_add_relation( + 'fl_inbound_split_batch', 'fk_inbound_split_source', + 'FOREIGN KEY (`source_inbound_id`) REFERENCES `fl_inbound_order` (`id`)'); +CALL phase3_inbound_add_relation( + 'fl_inbound_split_batch', 'fk_inbound_split_remainder_child', + 'FOREIGN KEY (`remainder_child_inbound_id`) REFERENCES `fl_inbound_order` (`id`)'); CALL phase3_inbound_add_relation( 'fl_inbound_measurement', 'fk_measure_split_batch', 'FOREIGN KEY (`split_batch_id`) REFERENCES `fl_inbound_split_batch` (`id`)'); @@ -7114,9 +7355,11 @@ CALL phase3_inbound_add_index( 'fl_inbound_split_item', 'uk_inbound_split_batch_line', 'UNIQUE KEY `uk_inbound_split_batch_line` (`tenant_id`, `split_batch_id`, `line_no`)'); CALL phase3_inbound_add_index( - 'fl_inbound_split_item', 'uk_inbound_split_batch_source_item', - 'UNIQUE KEY `uk_inbound_split_batch_source_item` (`tenant_id`, `split_batch_id`, `source_measurement_item_id`)'); -DROP PROCEDURE IF EXISTS phase3_inbound_add_index; + 'fl_inbound_split_batch', 'idx_inbound_split_loading_plan', + 'KEY `idx_inbound_split_loading_plan` (`tenant_id`, `loading_plan_id`, `container_booking_id`, `status`, `id`)'); +CALL phase3_inbound_add_index( + 'fl_inbound_split_batch', 'uk_inbound_split_source_once', + 'UNIQUE KEY `uk_inbound_split_source_once` (`tenant_id`, `source_inbound_id`)'); DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index; DELIMITER $$ @@ -7130,10 +7373,18 @@ BEGIN AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_batch_child') THEN ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_batch_child; END IF; + IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_batch_source_item') THEN + ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_batch_source_item; + END IF; END$$ DELIMITER ; CALL phase3_inbound_drop_split_legacy_index(); DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index; +CALL phase3_inbound_add_index( + 'fl_inbound_split_item', 'uk_inbound_split_batch_source_child', + 'UNIQUE KEY `uk_inbound_split_batch_source_child` (`tenant_id`, `split_batch_id`, `source_measurement_item_id`, `child_inbound_id`)'); +DROP PROCEDURE IF EXISTS phase3_inbound_add_index; DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_insert; DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_update; @@ -7156,19 +7407,25 @@ BEGIN IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法'; END IF; + IF NEW.split_purpose NOT IN ('MASTER', 'LOADED', 'REMAINDER') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单分片用途非法'; + END IF; IF NEW.package_count IS NOT NULL AND NEW.package_count = 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单箱数必须大于0'; END IF; IF NEW.inbound_role = 'MASTER' AND NEW.parent_inbound_id IS NOT NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主入仓单不能绑定父单'; END IF; + IF NEW.inbound_role = 'MASTER' AND NEW.split_purpose <> 'MASTER' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主入仓单用途必须为MASTER'; + END IF; IF NEW.inbound_role = 'CHILD' THEN IF NEW.parent_inbound_id IS NULL OR NEW.root_inbound_id IS NULL OR NEW.split_sequence IS NULL OR NEW.package_count IS NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单缺少主单、顺序号或箱数'; END IF; - IF NEW.split_status <> 'NOT_SPLIT' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子仅两级,子入仓单不可继续拆分'; + IF NEW.split_purpose = 'MASTER' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单必须标记LOADED或REMAINDER'; END IF; IF NOT EXISTS ( SELECT 1 FROM fl_inbound_order parent_order @@ -7186,12 +7443,17 @@ CREATE TRIGGER trg_inbound_order_parent_before_update BEFORE UPDATE ON fl_inbound_order FOR EACH ROW BEGIN - IF NOT (OLD.inbound_role <=> NEW.inbound_role) + IF NOT (OLD.tenant_id <=> NEW.tenant_id) + OR NOT (OLD.inbound_role <=> NEW.inbound_role) OR NOT (OLD.parent_inbound_id <=> NEW.parent_inbound_id) OR NOT (OLD.root_inbound_id <=> NEW.root_inbound_id) - OR NOT (OLD.split_sequence <=> NEW.split_sequence) THEN + OR NOT (OLD.split_sequence <=> NEW.split_sequence) + OR NOT (OLD.split_purpose <=> NEW.split_purpose) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单主子归属创建后不可修改'; END IF; + IF NEW.split_purpose NOT IN ('MASTER', 'LOADED', 'REMAINDER') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单分片用途非法'; + END IF; IF NEW.inbound_role = 'CHILD' AND (NEW.package_count IS NULL OR NEW.package_count = 0) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单箱数必须大于0'; @@ -7199,8 +7461,9 @@ BEGIN IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法'; END IF; - IF NEW.inbound_role = 'CHILD' AND NEW.split_status <> 'NOT_SPLIT' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子仅两级,子入仓单不可继续拆分'; + IF (NEW.inbound_role = 'CHILD' AND NEW.split_purpose = 'MASTER') + 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') @@ -7213,6 +7476,9 @@ CREATE TRIGGER trg_inbound_split_item_before_insert BEFORE INSERT ON fl_inbound_split_item FOR EACH ROW BEGIN + IF NEW.source_measurement_id IS NULL OR NEW.source_measurement_item_id IS NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分明细必须绑定真实测量来源'; + END IF; IF NOT EXISTS ( SELECT 1 FROM fl_inbound_split_batch batch @@ -7226,18 +7492,19 @@ BEGIN 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.parent_inbound_id = NEW.parent_inbound_id AND child_order.package_count >= NEW.package_count AND child_order.deleted = 0 - AND (NEW.source_measurement_item_id IS NULL OR EXISTS ( + AND EXISTS ( SELECT 1 FROM fl_inbound_measurement_item source_item JOIN fl_inbound_measurement source_batch ON source_batch.id = source_item.measurement_id WHERE source_item.id = NEW.source_measurement_item_id AND source_item.tenant_id = NEW.tenant_id AND source_item.measurement_id = NEW.source_measurement_id - AND source_batch.inbound_id = NEW.parent_inbound_id - )) + AND source_batch.inbound_id = COALESCE(batch.source_inbound_id, batch.parent_inbound_id) + ) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分明细与批次、主单或子单不一致'; END IF; @@ -7259,6 +7526,36 @@ BEGIN ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次主单不存在或客户归属不一致'; END IF; + IF NEW.source_inbound_id IS NULL OR NOT EXISTS ( + SELECT 1 FROM fl_inbound_order source_order + WHERE source_order.id = NEW.source_inbound_id + AND source_order.tenant_id = NEW.tenant_id + AND source_order.deleted = 0 + AND source_order.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 + (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')) + ) THEN + 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 ( + SELECT 1 FROM fl_loading_plan plan + WHERE plan.id = NEW.loading_plan_id + AND plan.container_booking_id = NEW.container_booking_id + AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item + WHERE plan_item.loading_plan_id = plan.id + AND plan_item.account_id = NEW.tenant_id + AND plan_item.inbound_id = NEW.source_inbound_id + AND plan_item.deleted = 0) + ) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '实装拆分批次必须绑定匹配的装柜计划和柜次'; + END IF; END$$ CREATE TRIGGER trg_inbound_split_batch_before_update @@ -7268,16 +7565,40 @@ BEGIN IF OLD.status IN ('APPLIED', 'CANCELLED') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已结束拆分批次不可修改'; END IF; + IF NOT (OLD.parent_inbound_id <=> NEW.parent_inbound_id) + OR NOT (OLD.source_inbound_id <=> NEW.source_inbound_id) + OR NOT (OLD.loaded_child_inbound_id <=> NEW.loaded_child_inbound_id) + OR NOT (OLD.remainder_child_inbound_id <=> NEW.remainder_child_inbound_id) + OR NOT (OLD.package_count <=> NEW.package_count) + OR NOT (OLD.loaded_package_count <=> NEW.loaded_package_count) + OR NOT (OLD.remaining_package_count <=> NEW.remaining_package_count) + OR NOT (OLD.loading_plan_id <=> NEW.loading_plan_id) + OR NOT (OLD.container_booking_id <=> NEW.container_booking_id) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次来源、子单、柜次与数量创建后不可修改'; + END IF; IF NEW.status = 'APPLIED' THEN IF NEW.applied_at IS NULL + OR (NEW.loaded_package_count > 0 AND ( + NEW.loading_plan_id IS NULL OR NEW.container_booking_id IS NULL OR NOT EXISTS ( + SELECT 1 FROM fl_loading_plan plan + WHERE plan.id = NEW.loading_plan_id + AND plan.container_booking_id = NEW.container_booking_id + AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item + WHERE plan_item.loading_plan_id = plan.id + AND plan_item.account_id = NEW.tenant_id + AND plan_item.inbound_id = NEW.source_inbound_id + AND plan_item.deleted = 0))) OR NOT EXISTS ( SELECT 1 FROM fl_inbound_order parent_order WHERE parent_order.id = NEW.parent_inbound_id AND parent_order.tenant_id = NEW.tenant_id AND parent_order.inbound_role = 'MASTER' - AND parent_order.package_count > 0 - AND parent_order.split_status = 'PARTIALLY_SPLIT' AND parent_order.version > NEW.expected_parent_version + ) OR NOT EXISTS ( + SELECT 1 FROM fl_inbound_order source_order + WHERE source_order.id = NEW.source_inbound_id + AND source_order.tenant_id = NEW.tenant_id + AND source_order.split_status = 'FULLY_SPLIT' ) OR (SELECT COALESCE(SUM(item.package_count), 0) FROM fl_inbound_split_item item @@ -7286,7 +7607,26 @@ BEGIN (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) <> 1 OR + 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 + 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 EXISTS (SELECT 1 FROM fl_inbound_split_item item JOIN fl_inbound_order child_order ON child_order.id = item.child_inbound_id @@ -7365,21 +7705,45 @@ 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')) <> 7 THEN + 'master_order_id', 'split_purpose')) <> 8 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单关系字段未完整创建'; END IF; IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics - WHERE table_schema = DATABASE() - AND index_name IN ('uk_inbound_master_order', 'uk_inbound_one_child', - 'uk_inbound_split_operation', 'uk_inbound_split_idempotency', - 'uk_inbound_split_history_operation')) <> 5 THEN + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND index_name IN ('uk_inbound_master_order', 'uk_inbound_parent_split_sequence')) <> 2 + 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', + 'uk_inbound_split_source_once')) <> 3 + OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_split_item' + AND index_name IN ('uk_inbound_split_batch_line', + 'uk_inbound_split_batch_source_child')) <> 2 + OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_split_history' + AND index_name = 'uk_inbound_split_history_operation') <> 1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单唯一约束未完整创建'; END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_split_batch' + AND column_name IN ('source_inbound_id', 'loaded_child_inbound_id', + 'remainder_child_inbound_id', 'loaded_package_count', + 'remaining_package_count', 'loading_plan_id', + 'container_booking_id')) <> 7 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次来源、子单、数量或柜次字段未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_split_item' + AND column_name IN ('source_measurement_id', 'source_measurement_item_id') + AND is_nullable = 'NO') <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分数量台账必须绑定非空测量来源'; + END IF; IF (SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_schema = DATABASE() AND constraint_name IN ('fk_inbound_parent_order', 'fk_inbound_root_order', + 'fk_inbound_split_source', 'fk_inbound_split_loaded_child', 'fk_inbound_split_remainder_child', 'fk_measure_split_batch', 'fk_measure_source_batch', - 'fk_measure_item_split_item', 'fk_measure_item_source_item')) <> 6 THEN + 'fk_measure_item_split_item', 'fk_measure_item_source_item')) <> 9 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单与测量关联约束未完整创建'; END IF; IF (SELECT COUNT(*) FROM information_schema.triggers @@ -7399,248 +7763,25 @@ BEGIN SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' - AND index_name = 'uk_inbound_tenant_order' + AND index_name IN ('uk_inbound_tenant_order', 'uk_inbound_one_child', + 'uk_inbound_one_secondary', 'uk_inbound_parent_sequence') ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '旧订单唯一索引仍阻止创建子入仓单'; END IF; - IF (SELECT COUNT(*) FROM information_schema.tables - WHERE table_schema = 'baiyue_firstleg' - AND table_name = 'fl_inbound_carton_label') <> 1 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:稳定箱号映射表未创建'; - END IF; - IF (SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' - AND table_name = 'fl_inbound_measurement_item' - AND column_name IN ('box_code', 'machine_no', 'device_measured_at', 'updated_at')) <> 4 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:单箱当前测量字段未完整创建'; - END IF; - IF (SELECT COUNT(DISTINCT CONCAT(table_name, '.', index_name)) - FROM information_schema.statistics - WHERE table_schema = 'baiyue_firstleg' - AND ((table_name = 'fl_inbound_carton_label' - AND index_name IN ('uk_carton_label_box_code', 'uk_carton_label_index')) - OR (table_name = 'fl_inbound_measurement_item' - AND index_name = 'uk_measure_box_code')) - AND non_unique = 0) <> 3 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:稳定箱号或单箱测量唯一约束未完整创建'; - END IF; - IF (SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' - AND table_name = 'fl_mojia_measurement_receipt' - AND column_name IN ('id', 'tenant_id', 'subject_id', 'box_code', 'length_cm', 'width_cm', - 'scanned_shipment_id', 'height_cm', 'weight_kg', 'machine_no', 'measured_at', 'image_file_id', - 'status', 'match_failure_code', 'match_failure_reason', 'request_id', - 'trace_id', 'created_at', 'updated_at')) <> 19 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:墨家未匹配测量收件箱字段未完整创建'; - END IF; - IF (SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' - AND table_name = 'fl_inbound_measurement_item' - AND column_name = 'scanned_shipment_id' - AND character_maximum_length = 64) <> 1 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:箱级测量实扫Shipment ID字段未创建'; - END IF; - IF (SELECT character_maximum_length FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' - AND table_name = 'fl_inbound_measurement_item' - AND column_name = 'calculation_rule_version') <> 64 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:测量计算规则版本字段长度不是64'; - END IF; - IF (SELECT COUNT(*) FROM information_schema.tables - WHERE table_schema = 'baiyue_firstleg' - AND table_name = 'fl_customer_dimension_config') <> 1 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:客户尺寸计算配置表未创建'; - END IF; - IF (SELECT COUNT(*) FROM information_schema.statistics - WHERE table_schema = 'baiyue_firstleg' - AND table_name = 'fl_customer_dimension_config' - AND index_name = 'uk_customer_dimension_config_tenant_box' - AND non_unique = 0 - AND ((seq_in_index = 1 AND column_name = 'tenant_id') - OR (seq_in_index = 2 AND column_name = 'box_type'))) <> 2 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:客户四箱规尺寸配置唯一约束未创建'; - END IF; - IF (SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_customer_dimension_config' - AND column_name IN ('id', 'tenant_id', 'box_type', 'inner_length_cm', 'inner_width_cm', - 'inner_height_cm', 'edge_addition_cm', 'remark', 'initialization_key', - 'created_by', 'created_at', 'updated_by', 'updated_at', 'deleted', 'version')) <> 15 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:客户四箱规尺寸配置字段不完整'; - END IF; IF EXISTS ( - SELECT tenant.id - FROM baiyue_iam.iam_tenant tenant - LEFT JOIN baiyue_firstleg.fl_customer_dimension_config config - ON config.tenant_id = tenant.id AND config.deleted = 0 - WHERE tenant.tenant_type = 'MERCHANT' AND tenant.deleted = 0 AND tenant.id > 0 - GROUP BY tenant.id - HAVING COUNT(config.id) <> 4 - OR COUNT(DISTINCT CASE - WHEN config.box_type IN ('SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE') - THEN config.box_type END) <> 4 + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'fl_inbound_split_item' + AND index_name IN ('uk_inbound_split_child', 'uk_inbound_split_batch_child', + 'uk_inbound_split_batch_source_item') ) THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:现有企业客户未完整持有固定四箱规尺寸配置'; - END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission - WHERE id = 5100010 AND tenant_id = 0 AND app_code = 'PLATFORM' - AND permission_code = 'platform:customer:list' AND permission_name = '企业客户' - AND status = 'ACTIVE' AND deleted = 0) <> 1 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:客户尺寸计算配置权限父节点企业客户不存在'; - END IF; - IF (SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_auth' AND table_name = 'auth_account' - AND column_name = 'password_change_required') <> 1 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:客户管理员强制改密字段未创建'; - END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission - WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND permission_type = 'BUTTON' - AND parent_id = 5100010 AND status = 'ACTIVE' AND deleted = 0 - AND ((id = 5100016 AND permission_code = 'platform:customer:reset-password') - OR (id = 5100017 AND permission_code = 'platform:customer:delete') - OR (id = 5100018 AND permission_code = 'platform:customer:impersonate'))) <> 3 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:平台客户操作权限未完整创建'; - END IF; - IF (SELECT COUNT(DISTINCT p.permission_code) - FROM baiyue_iam.iam_role_permission rp - JOIN baiyue_iam.iam_role r ON r.id = rp.role_id AND r.tenant_id = rp.tenant_id - JOIN baiyue_iam.iam_permission p ON p.id = rp.permission_id AND p.tenant_id = rp.tenant_id - WHERE r.tenant_id = 0 AND r.app_code = 'PLATFORM' AND r.role_code = 'PLATFORM_ADMIN' - AND p.permission_code IN ('platform:customer:reset-password', - 'platform:customer:delete', - 'platform:customer:impersonate')) <> 3 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:平台管理员未获得客户操作权限'; - END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission - WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND permission_type = 'BUTTON' - AND parent_id = 5100010 AND status = 'ACTIVE' AND deleted = 0 - AND ((id = 5102111 AND permission_code = 'firstleg:dimension-config:view') - OR (id = 5102112 AND permission_code = 'firstleg:dimension-config:edit'))) <> 2 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:客户尺寸计算配置权限码、固定ID或父节点不正确'; - END IF; - IF (SELECT COUNT(DISTINCT p.permission_code) - FROM baiyue_iam.iam_role_permission rp - JOIN baiyue_iam.iam_role r ON r.id = rp.role_id AND r.tenant_id = rp.tenant_id - JOIN baiyue_iam.iam_permission p ON p.id = rp.permission_id AND p.tenant_id = rp.tenant_id - WHERE r.tenant_id = 0 AND r.app_code = 'PLATFORM' AND r.role_code = 'PLATFORM_ADMIN' - AND p.permission_code IN ('firstleg:dimension-config:view', 'firstleg:dimension-config:edit')) <> 2 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:平台管理员未获得客户尺寸计算配置读写权限'; - END IF; - IF (SELECT COUNT(DISTINCT p.permission_code) - FROM baiyue_iam.iam_role_permission rp - JOIN baiyue_iam.iam_role r ON r.id = rp.role_id AND r.tenant_id = rp.tenant_id - JOIN baiyue_iam.iam_permission p ON p.id = rp.permission_id AND p.tenant_id = rp.tenant_id - WHERE r.tenant_id = 0 AND r.app_code = 'PLATFORM' AND r.role_code = 'ORDER_OPERATOR' - AND p.permission_code = 'firstleg:dimension-config:view') <> 1 - OR (SELECT COUNT(*) - FROM baiyue_iam.iam_role_permission rp - JOIN baiyue_iam.iam_role r ON r.id = rp.role_id AND r.tenant_id = rp.tenant_id - JOIN baiyue_iam.iam_permission p ON p.id = rp.permission_id AND p.tenant_id = rp.tenant_id - WHERE r.tenant_id = 0 AND r.app_code = 'PLATFORM' AND r.role_code = 'ORDER_OPERATOR' - AND p.permission_code = 'firstleg:dimension-config:edit') <> 0 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:订单运营角色的客户尺寸计算配置权限不是只读'; - END IF; - IF (SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' - AND column_name IN ('loading_plan_id', 'container_no', 'task_status')) <> 3 - OR (SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' - AND column_name IN ('tenant_id', 'inbound_id', 'status', 'expected_count', 'picked_count')) <> 0 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:智能装柜拣货任务表型不正确'; - END IF; - IF (SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task' - AND column_name IN ('tenant_id', 'inbound_id', 'status', 'expected_count', 'picked_count')) <> 5 - OR (SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task' - AND column_name IN ('loading_plan_id', 'container_no', 'task_status')) <> 0 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:入库汇总拣货任务表型不正确'; - END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission - WHERE tenant_id = 0 AND app_code = 'PLATFORM' - AND ((id = 5103040 AND permission_code = 'platform:download:list' - AND permission_name = '下载中心' AND route_path = '/downloads') - OR (id = 5103041 AND permission_code = 'platform:download:all' AND parent_id = 5103040))) <> 2 - OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission - WHERE tenant_id = 0 AND app_code = 'PLATFORM' - AND ((id = 5103060 AND permission_code = 'platform:location:list' AND parent_id = 5103000) - OR (id = 5103061 AND permission_code = 'platform:location:create' AND parent_id = 5103060) - OR (id = 5103062 AND permission_code = 'platform:location:edit' AND parent_id = 5103060) - OR (id = 5103063 AND permission_code = 'platform:location:status' AND parent_id = 5103060) - OR (id = 5103064 AND permission_code = 'platform:location:delete' AND parent_id = 5103060))) <> 5 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:下载中心或库位权限语义未修复'; - END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission - WHERE tenant_id = 0 AND app_code = 'PLATFORM' - AND id BETWEEN 5102121 AND 5102129 - AND permission_code IN ('firstleg:inbound:split', 'firstleg:inbound:preload', - 'firstleg:inbound:preload-view', 'platform:firstleg:inbound:loading:edit', - 'platform:firstleg:inbound:loading:view', 'platform:firstleg:inbound:loading:complete', - 'platform:firstleg:inbound:delivery:complete', 'platform:firstleg:inbound:picking:edit', - 'platform:firstleg:inbound:picking:view')) <> 9 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:三期入库装柜权限 ID 未完成迁移'; - END IF; - IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, '|', permission.permission_code)) - FROM baiyue_iam.iam_role_permission role_permission - JOIN baiyue_iam.iam_role role - ON role.id = role_permission.role_id AND role.tenant_id = role_permission.tenant_id - JOIN baiyue_iam.iam_permission permission - ON permission.id = role_permission.permission_id - AND permission.tenant_id = role_permission.tenant_id - WHERE role.tenant_id = 0 - AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR') - AND permission.permission_code IN ( - 'firstleg:inbound:split', 'firstleg:inbound:preload', - 'firstleg:inbound:preload-view', 'platform:firstleg:inbound:loading:edit', - 'platform:firstleg:inbound:loading:view', 'platform:firstleg:inbound:loading:complete', - 'platform:firstleg:inbound:delivery:complete', 'platform:firstleg:inbound:picking:edit', - 'platform:firstleg:inbound:picking:view')) <> 18 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:三期入库装柜角色授权未完整写入'; - END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission - WHERE tenant_id = 0 AND app_code = 'PLATFORM' - AND ((id = 5102091 AND permission_code = 'platform:firstleg:container:view' AND parent_id = 5102090) - OR (id = 5102092 AND permission_code = 'platform:firstleg:container:create' AND parent_id = 5102090) - OR (id = 5102093 AND permission_code = 'platform:firstleg:container:edit' AND parent_id = 5102090) - OR (id = 5102094 AND permission_code = 'platform:firstleg:smart-loading:list' AND parent_id = 5102000) - OR (id = 5102096 AND permission_code = 'platform:firstleg:smart-loading:create' AND parent_id = 5102094) - OR (id = 5102097 AND permission_code = 'platform:firstleg:smart-loading:assign' AND parent_id = 5102094) - OR (id = 5102098 AND permission_code = 'platform:firstleg:smart-loading:delete' AND parent_id = 5102094) - OR (id = 5102099 AND permission_code = 'platform:firstleg:smart-loading:push' AND parent_id = 5102094))) <> 8 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:智能装柜权限 ID 或父子关系不正确'; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '旧拆分明细唯一索引仍阻止数量守恒分配'; END IF; END$$ DELIMITER ; CALL phase3_assert_inbound_parent_child_schema(); DROP PROCEDURE IF EXISTS phase3_assert_inbound_parent_child_schema; -CALL assert_loading_pick_task_shapes(1); -DROP PROCEDURE IF EXISTS assert_loading_pick_task_shapes; - --- ============================================================ --- 三期服务中心订舱与拖车预约 -- 来源:firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql -- 来源:firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql -- ============================================================ @@ -8585,7 +8726,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_operation ( UNIQUE KEY uk_loading_pick_operation_id (operation_id), KEY idx_loading_pick_operation_task (pick_task_id, created_at), CONSTRAINT fk_loading_pick_operation_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), - CONSTRAINT chk_loading_pick_operation_action CHECK (action_type IN ('CLAIM', 'ASSIGN', 'REVIEW')) + CONSTRAINT chk_loading_pick_operation_action CHECK (action_type IN ('CLAIM', 'ASSIGN', 'ITEM_CLAIM', 'ITEM_ASSIGN', 'REVIEW')) ) ENGINE=InnoDB COMMENT='柜次PDA任务级幂等审计'; DROP TRIGGER IF EXISTS trg_loading_pick_review_before_update; @@ -8715,43 +8856,57 @@ DROP PROCEDURE IF EXISTS assert_phase3_final_schema; -- PDA 部分拣货释放策略:NULL/AUTO 按比例释放,MANUAL + [] 表示人工明确不释放。 USE baiyue_firstleg; -DROP PROCEDURE IF EXISTS phase3_pda_add_column; +ALTER TABLE fl_loading_pick_item + ADD COLUMN IF NOT EXISTS release_mode VARCHAR(10) NULL COMMENT 'AUTO按比例释放,MANUAL按人工选择释放' AFTER item_status, + ADD COLUMN IF NOT EXISTS release_location_ids JSON NULL COMMENT '人工选择释放库位ID;空数组表示明确释放0个' AFTER release_mode; +-- PDA 多人协作:领取粒度由整柜任务下沉为单条拣货明细。 +CREATE TABLE IF NOT EXISTS fl_loading_pick_item_assignment ( + pick_item_id BIGINT UNSIGNED NOT NULL, + assignee_id BIGINT UNSIGNED NOT NULL, + assignee_name VARCHAR(64) NOT NULL, + assigned_by BIGINT UNSIGNED NOT NULL, + assigned_by_name VARCHAR(64) NULL, + assignment_type VARCHAR(16) NOT NULL COMMENT 'CLAIMED/ASSIGNED', + version INT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (pick_item_id), + KEY idx_loading_pick_item_assignee (assignee_id, updated_at), + CONSTRAINT fk_loading_pick_item_assignment_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id), + CONSTRAINT chk_loading_pick_item_assignment_type CHECK (assignment_type IN ('CLAIMED', 'ASSIGNED')) +) ENGINE=InnoDB COMMENT='PDA拣货明细领取与作业员分配'; + +ALTER TABLE fl_loading_pick_operation + DROP CHECK IF EXISTS chk_loading_pick_operation_action, + ADD CONSTRAINT chk_loading_pick_operation_action + CHECK (action_type IN ('CLAIM', 'ASSIGN', 'ITEM_CLAIM', 'ITEM_ASSIGN', 'REVIEW')); + +DROP PROCEDURE IF EXISTS assert_phase3_pda_item_assignment_schema; DELIMITER $$ -CREATE PROCEDURE phase3_pda_add_column(IN p_table VARCHAR(64), IN p_column VARCHAR(64), IN p_ddl TEXT) +CREATE PROCEDURE assert_phase3_pda_item_assignment_schema() BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = DATABASE() AND table_name = p_table AND column_name = p_column - ) THEN - SET @phase3_pda_ddl = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN ', p_ddl); - PREPARE phase3_pda_stmt FROM @phase3_pda_ddl; - EXECUTE phase3_pda_stmt; - DEALLOCATE PREPARE phase3_pda_stmt; + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_pick_item_assignment') <> 1 + OR (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_pick_item_assignment' + AND index_name='idx_loading_pick_item_assignee') <> 2 + OR (SELECT COUNT(*) FROM information_schema.referential_constraints + WHERE constraint_schema='baiyue_firstleg' + AND constraint_name='fk_loading_pick_item_assignment_item') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA明细级多人分配表创建失败'; END IF; END$$ DELIMITER ; +CALL assert_phase3_pda_item_assignment_schema(); +DROP PROCEDURE IF EXISTS assert_phase3_pda_item_assignment_schema; -CALL phase3_pda_add_column('fl_loading_pick_item', 'release_mode', - '`release_mode` VARCHAR(10) NULL COMMENT ''AUTO按比例释放,MANUAL按人工选择释放'' AFTER `item_status`'); -CALL phase3_pda_add_column('fl_loading_pick_item', 'release_location_ids', - '`release_location_ids` JSON NULL COMMENT ''人工选择释放库位ID;空数组表示明确释放0个'' AFTER `release_mode`'); -CALL phase3_pda_add_column('fl_inbound_pallet_allocation', 'reservation_id', - '`reservation_id` BIGINT UNSIGNED NULL COMMENT ''仓库预占资源唯一ID,A0共享场景用于区分托盘'' AFTER `location_code`'); +ALTER TABLE fl_inbound_pallet_allocation + ADD COLUMN IF NOT EXISTS reservation_id BIGINT UNSIGNED NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分托盘' AFTER location_code; -SET @phase3_pda_index_ddl = IF( - EXISTS (SELECT 1 FROM information_schema.statistics - WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_pallet_allocation' - AND index_name = 'idx_allocation_reservation'), - 'SELECT 1', - 'ALTER TABLE fl_inbound_pallet_allocation ADD INDEX idx_allocation_reservation (reservation_id)'); -PREPARE phase3_pda_index_stmt FROM @phase3_pda_index_ddl; -EXECUTE phase3_pda_index_stmt; -DEALLOCATE PREPARE phase3_pda_index_stmt; - -DROP PROCEDURE IF EXISTS phase3_pda_add_column; - -/* Phase 3: six-carrier schedule query tasks, truthful manual fallback and three-day rate limit. */ +-- 来源:firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql +SET NAMES utf8mb4; USE baiyue_firstleg; + CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( id BIGINT UNSIGNED NOT NULL, query_no VARCHAR(40) NOT NULL, @@ -8976,7 +9131,7 @@ DELIMITER ; CALL assert_carrier_schedule_query_schema(); DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_schema; --- 三期仓库常用角色初始化。模块来源:iam/src/main/resources/db/phase3-warehouse-roles.sql。 +-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql -- 三期仓库常用系统角色:仓库操作员、仓库拣货员、仓库理货员。 -- 本脚本只组合已经存在的权限资源,需在三期 Firstleg/IAM 权限脚本之后执行,可重复部署。 USE baiyue_iam; @@ -9195,6 +9350,5 @@ DROP PROCEDURE assert_phase3_warehouse_roles_ready; DROP TEMPORARY TABLE IF EXISTS tmp_phase3_warehouse_role_permission; -- 二、三期对外统一使用 INBOUNDED;历史 WAIT_SPLIT 仅由查询适配为 INBOUNDED,统一迁移不改写主表或历史状态。 --- 必须保持为整份脚本最后一个结果,避免后续增量失败却被部署端误判为成功。 SELECT 'PASS' AS migration_status, - '统一增量全部执行完成;PDA多人协作、船司查询和仓库角色最小权限等全部增量及断言通过' AS message; + '统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、公共资源及可靠审计结构校验通过' AS message; 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 c6fbf775a..584e2b12c 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 @@ -119,6 +119,14 @@ BEGIN ) THEN ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_one_child; END IF; + IF EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_one_secondary' + ) THEN + ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_one_secondary; + END IF; END$$ DELIMITER ; CALL phase3_inbound_drop_legacy_index(); 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 84cc84700..a8615d21d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -50,4 +50,74 @@ class Phase3FinalUnifiedSqlContractTest { .contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')") .contains("角色授权未完整写入"); } + + @Test + void phase3ModuleSqlBlocksAreContiguousAndSynchronised() throws Exception { + String unified = normalise(Files.readString(resolveUnified())); + assertBlock(unified, "-- 来源:order/src/main/resources/db/phase3-order-parent-child-schema.sql", + "-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql", + "order/src/main/resources/db/phase3-order-parent-child-schema.sql"); + assertBlock(unified, "-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql", + "-- 来源:firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql", + "firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql"); + assertBlock(unified, "-- 来源:firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql", + "-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql", + "firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql"); + assertBlock(unified, "-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql", + "-- 二、三期对外统一使用 INBOUNDED;历史 WAIT_SPLIT 仅由查询适配为 INBOUNDED,统一迁移不改写主表或历史状态。", + "iam/src/main/resources/db/phase3-warehouse-roles.sql"); + } + + @Test + void passIsUniqueAndOnlyTheFinalExecutableStatement() throws Exception { + String sql = normalise(Files.readString(resolveUnified())); + String pass = "SELECT 'PASS' AS migration_status"; + assertThat(count(sql, pass)).isEqualTo(1); + int passIndex = sql.lastIndexOf(pass); + assertThat(passIndex).isGreaterThan(sql.lastIndexOf("USE ")) + .isGreaterThan(sql.lastIndexOf("ALTER TABLE ")) + .isGreaterThan(sql.lastIndexOf("CREATE TABLE ")) + .isGreaterThan(sql.lastIndexOf("CALL ")); + } + + @Test + void legacySplitIndexesAreOnlyDroppedAndNeverRecreated() throws Exception { + String sql = normalise(Files.readString(resolveUnified())); + assertThat(sql) + .contains("DROP INDEX uk_inbound_one_child") + .contains("DROP INDEX uk_inbound_one_secondary") + .doesNotContain("UNIQUE KEY `uk_inbound_one_child`") + .doesNotContain("UNIQUE KEY `uk_inbound_one_secondary`") + .doesNotContain("UNIQUE KEY `uk_inbound_split_batch_source_item`"); + } + + private static void assertBlock(String unified, String marker, String nextMarker, String modulePath) + throws Exception { + int start = unified.indexOf(marker); + int next = unified.indexOf(nextMarker, start + marker.length()); + assertThat(start).as("missing marker %s", marker).isGreaterThanOrEqualTo(0); + assertThat(next).as("missing next marker %s", nextMarker).isGreaterThan(start); + String module = normalise(Files.readString(resolve(modulePath))).trim(); + String block = unified.substring(start + marker.length(), next).trim(); + assertThat(block).as("module block drift: %s", modulePath) + .startsWith(module); + } + + private static Path resolveUnified() { + return resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"); + } + + private static Path resolve(String path) { + Path direct = Path.of(path); + if (Files.exists(direct)) return direct; + return Path.of("..", path).normalize(); + } + + private static String normalise(String value) { + return value.replace("\r\n", "\n").replace("\uFEFF", ""); + } + + private static int count(String value, String token) { + return value.split(java.util.regex.Pattern.quote(token), -1).length - 1; + } } diff --git a/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java index 5e514b241..c89e5c9ec 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java @@ -19,7 +19,7 @@ class WarehouseRoleSqlContractTest { void unifiedDeploymentContainsTheSameWarehouseRoleContract() throws Exception { String sql = unifiedSql(); assertWarehouseRoleContract(sql); - assertThat(sql) + assertThat(roleMigrationBlock(sql)) .doesNotContain("\n ADD COLUMN IF NOT EXISTS") .doesNotContain("DROP CHECK IF EXISTS"); assertThat(sql.lastIndexOf("SELECT 'PASS' AS migration_status")) @@ -97,6 +97,15 @@ class WarehouseRoleSqlContractTest { return sql.substring(start, end > start ? end : sql.length()); } + private static String roleMigrationBlock(String sql) { + String marker = "-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql"; + String endMarker = "-- 二、三期对外统一使用 INBOUNDED;历史 WAIT_SPLIT 仅由查询适配为 INBOUNDED,统一迁移不改写主表或历史状态。"; + int start = sql.indexOf(marker); + int end = sql.indexOf(endMarker, start + marker.length()); + if (start < 0 || end < 0) throw new IllegalStateException("统一入口缺少仓库角色连续区块"); + return sql.substring(start, end); + } + private static String moduleSql() throws Exception { return new ClassPathResource("db/phase3-warehouse-roles.sql") .getContentAsString(StandardCharsets.UTF_8); diff --git a/order/src/main/resources/db/phase3-order-parent-child-schema.sql b/order/src/main/resources/db/phase3-order-parent-child-schema.sql index 80fa6c31b..9610a9449 100644 --- a/order/src/main/resources/db/phase3-order-parent-child-schema.sql +++ b/order/src/main/resources/db/phase3-order-parent-child-schema.sql @@ -16,7 +16,6 @@ CALL phase3_order_add_column('ord_order','order_role',"VARCHAR(12) NOT NULL DEFA CALL phase3_order_add_column('ord_order','parent_order_id','BIGINT UNSIGNED NULL COMMENT ''直属父订单ID;禁止子订单嵌套'''); CALL phase3_order_add_column('ord_order','root_order_id','BIGINT UNSIGNED NULL COMMENT ''根主订单ID'''); CALL phase3_order_add_column('ord_order','split_sequence','INT UNSIGNED NULL COMMENT ''同一父订单下的同级顺序'''); -DROP PROCEDURE IF EXISTS phase3_order_add_column; DROP PROCEDURE IF EXISTS phase3_order_add_index; DELIMITER $$ @@ -33,7 +32,6 @@ CALL phase3_order_add_index('ord_order','uk_order_parent_sequence', 'UNIQUE KEY `uk_order_parent_sequence` (`tenant_id`,`parent_order_id`,`split_sequence`)'); CALL phase3_order_add_index('ord_order','idx_order_root_role', 'KEY `idx_order_root_role` (`tenant_id`,`root_order_id`,`order_role`,`created_at`,`id`)'); -DROP PROCEDURE IF EXISTS phase3_order_add_index; DROP PROCEDURE IF EXISTS phase3_order_parent_fk; DELIMITER $$ @@ -118,7 +116,14 @@ DELIMITER ; CALL phase3_order_add_column('ord_order_fulfillment_split','split_role',"VARCHAR(12) NOT NULL DEFAULT 'LOADED' COMMENT 'LOADED或REMAINDER'"); CALL phase3_order_add_column('ord_order_fulfillment_split','source_order_id','BIGINT UNSIGNED NULL COMMENT ''实际被消费的来源订单ID'''); CALL phase3_order_add_column('ord_order_fulfillment_split','request_hash',"CHAR(64) NULL COMMENT '请求内容SHA-256;存量记录允许为空'"); --- 存量空来源必须先由发布前数据核对补齐;P0 契约不允许继续写无来源台账。 +-- 旧版台账没有来源字段;当时只允许从根主订单拆分,因此可无歧义回填为父订单。 +UPDATE ord_order_fulfillment_split + SET source_order_id = parent_order_id + WHERE source_order_id IS NULL; +-- 旧记录没有请求哈希。生成稳定哨兵哈希可保持非空约束,并让旧幂等键重放明确冲突。 +UPDATE ord_order_fulfillment_split + SET request_hash = SHA2(CONCAT('LEGACY:', tenant_id, ':', id), 256) + WHERE request_hash IS NULL OR request_hash = ''; ALTER TABLE ord_order_fulfillment_split MODIFY source_order_id BIGINT UNSIGNED NOT NULL COMMENT '实际被消费的来源订单ID', MODIFY request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256'; @@ -145,6 +150,8 @@ CALL phase3_order_add_index('ord_order_fulfillment_split','uk_order_split_operat 'UNIQUE KEY `uk_order_split_operation` (`tenant_id`,`operation_id`,`split_role`)'); CALL phase3_order_add_index('ord_order_fulfillment_split','uk_order_split_idempotency', 'UNIQUE KEY `uk_order_split_idempotency` (`tenant_id`,`parent_order_id`,`idempotency_key`,`split_role`)'); +DROP PROCEDURE IF EXISTS phase3_order_add_column; +DROP PROCEDURE IF EXISTS phase3_order_add_index; DROP PROCEDURE IF EXISTS phase3_order_assert; DELIMITER $$ @@ -158,6 +165,17 @@ BEGIN AND table_name='ord_order_fulfillment_split')<>1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单拆分台账未创建'; END IF; + IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ord_order_fulfillment_split' + AND column_name IN ('source_order_id','request_hash') AND is_nullable='NO')<>2 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='ord_order_fulfillment_split' + AND index_name IN ('idx_order_split_source','uk_order_split_operation', + 'uk_order_split_idempotency'))<>3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单拆分来源或幂等索引未完整创建'; + END IF; END$$ DELIMITER ; CALL phase3_order_assert(); diff --git a/order/src/test/java/com/baiyue/order/repository/OrderParentChildSqlContractTest.java b/order/src/test/java/com/baiyue/order/repository/OrderParentChildSqlContractTest.java new file mode 100644 index 000000000..e922fb3b2 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/repository/OrderParentChildSqlContractTest.java @@ -0,0 +1,63 @@ +package com.baiyue.order.repository; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class OrderParentChildSqlContractTest { + + @Test + void sourceAndRequestHashAreRequiredAndHelpersLiveUntilFinalCall() throws Exception { + String sql = read(resolveModule()); + + assertThat(sql) + .contains("source_order_id BIGINT UNSIGNED NOT NULL") + .contains("KEY idx_order_split_source (tenant_id,source_order_id,split_role)") + .contains("MODIFY source_order_id BIGINT UNSIGNED NOT NULL") + .contains("MODIFY request_hash CHAR(64) NOT NULL") + .contains("UPDATE ord_order_fulfillment_split") + .contains("SHA2(CONCAT('LEGACY:', tenant_id, ':', id), 256)"); + assertThat(sql.lastIndexOf("DROP PROCEDURE IF EXISTS phase3_order_add_column")) + .isGreaterThan(sql.lastIndexOf("CALL phase3_order_add_column")); + assertThat(sql.lastIndexOf("DROP PROCEDURE IF EXISTS phase3_order_add_index")) + .isGreaterThan(sql.lastIndexOf("CALL phase3_order_add_index")); + } + + @Test + void unifiedEntryContainsTheExactOrderBlockBeforeFirstleg() throws Exception { + String unified = read(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql")); + String module = read(resolveModule()).trim(); + String marker = "-- 来源:order/src/main/resources/db/phase3-order-parent-child-schema.sql"; + String next = "-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql"; + int start = unified.indexOf(marker); + int end = unified.indexOf(next, start + marker.length()); + + assertThat(start).isGreaterThanOrEqualTo(0); + assertThat(end).isGreaterThan(start); + assertThat(unified.substring(start + marker.length(), end).trim()).startsWith(module); + assertThat(unified.lastIndexOf("SELECT 'PASS' AS migration_status")) + .isGreaterThan(end); + } + + private static Path resolve(String path) { + Path direct = Path.of(path); + return Files.exists(direct) ? direct : Path.of("..", path).normalize(); + } + + private static Path resolveModule() { + Path direct = Path.of("src/main/resources/db/phase3-order-parent-child-schema.sql"); + return Files.exists(direct) + ? direct + : Path.of("order/src/main/resources/db/phase3-order-parent-child-schema.sql"); + } + + private static String read(Path path) throws Exception { + return Files.readString(path, StandardCharsets.UTF_8) + .replace("\r\n", "\n") + .replace("\uFEFF", ""); + } +}