diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 1db0b69ff..970b30b74 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -3939,7 +3939,8 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item ( COMMENT '更新时间', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), - UNIQUE KEY uk_loading_item_active_detail (account_id, active_inbound_detail_id), + UNIQUE KEY uk_loading_item_plan_detail (loading_plan_id, active_inbound_detail_id), + KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id), KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id), KEY idx_loading_item_account (account_id, created_at, id), KEY idx_loading_item_inbound (account_id, inbound_id, deleted), @@ -4505,8 +4506,8 @@ BEGIN END IF; IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item' - AND index_name = 'uk_loading_item_active_detail' AND non_unique = 0) <> 2 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:唯一活动明细锁缺失'; + AND index_name = 'uk_loading_item_plan_detail' AND non_unique = 0) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失'; END IF; IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = 'baiyue_firstleg' @@ -5481,6 +5482,586 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM' ) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); +-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql +SET NAMES utf8mb4; +USE baiyue_firstleg; + +/* + * 三期主子入仓单结构迁移。 + * 仅建立结构和数据库保护,不自动拆分或回填存量业务数据。 + * tenant_id 继续表示客户隔离键;数量统一使用 package_count。 + */ +DROP PROCEDURE IF EXISTS phase3_inbound_add_column; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_add_column( + IN p_table_name VARCHAR(64), + IN p_column_name 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_name + AND column_name = p_column_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `', + p_column_name, '` ', p_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; + +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'inbound_role', + "VARCHAR(16) NOT NULL DEFAULT 'MASTER' COMMENT '入仓单层级:MASTER主单、CHILD子单' AFTER inbound_code"); +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'parent_inbound_id', + "BIGINT UNSIGNED NULL COMMENT '直属主入仓单ID;主单为空' AFTER inbound_role"); +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'root_inbound_id', + "BIGINT UNSIGNED NULL COMMENT '根主入仓单ID;存量主单可为空,按自身ID解释' AFTER parent_inbound_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'split_sequence', + "INT UNSIGNED NULL COMMENT '同一主单下的子单顺序号' AFTER root_inbound_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'split_status', + "VARCHAR(24) NOT NULL DEFAULT 'NOT_SPLIT' COMMENT '拆分状态:NOT_SPLIT、PARTIALLY_SPLIT、FULLY_SPLIT' AFTER split_sequence"); +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', 'master_order_id', + "BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER' AND deleted = 0 THEN order_id ELSE NULL END) STORED COMMENT '仅用于约束有效主单订单唯一性'"); + +DROP PROCEDURE IF EXISTS phase3_inbound_add_column; + +DROP PROCEDURE IF EXISTS phase3_inbound_add_index; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_add_index( + IN p_table_name VARCHAR(64), + IN p_index_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_name + AND index_name = p_index_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD ', p_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +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_parent_sequence', + 'UNIQUE KEY `uk_inbound_parent_sequence` (`tenant_id`, `parent_inbound_id`, `split_sequence`)'); +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_inbound_drop_legacy_index; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_drop_legacy_index() +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_tenant_order' + ) THEN + ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_tenant_order; + END IF; +END$$ +DELIMITER ; +CALL phase3_inbound_drop_legacy_index(); +DROP PROCEDURE IF EXISTS phase3_inbound_drop_legacy_index; +DROP PROCEDURE IF EXISTS phase3_inbound_add_index; + +CREATE TABLE IF NOT EXISTS fl_inbound_split_batch ( + id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', + parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID', + operation_id VARCHAR(64) NOT NULL COMMENT '业务操作幂等号', + idempotency_key VARCHAR(128) NOT NULL COMMENT '请求幂等键', + expected_parent_version INT NOT NULL COMMENT '操作时主单乐观锁版本', + package_count INT UNSIGNED NOT NULL COMMENT '本批次拆分箱数', + status VARCHAR(16) NOT NULL DEFAULT 'PREPARED' COMMENT 'PREPARED、APPLIED、CANCELLED', + remark VARCHAR(500) NULL COMMENT '拆分原因或备注', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + applied_at DATETIME(3) NULL COMMENT '批次生效时间', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_split_operation (tenant_id, operation_id), + UNIQUE KEY uk_inbound_split_idempotency (tenant_id, parent_inbound_id, idempotency_key), + KEY idx_inbound_split_parent (tenant_id, parent_inbound_id, created_at, id), + CONSTRAINT chk_inbound_split_package CHECK (package_count > 0), + CONSTRAINT chk_inbound_split_status CHECK (status IN ('PREPARED', 'APPLIED', 'CANCELLED')), + CONSTRAINT fk_inbound_split_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='主入仓单拆分批次;数量口径仅为箱数'; + +CREATE TABLE IF NOT EXISTS fl_inbound_split_item ( + id BIGINT UNSIGNED NOT NULL COMMENT '拆分明细ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', + split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID', + parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID', + child_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '子入仓单ID', + source_inbound_detail_id BIGINT UNSIGNED NULL COMMENT '订单货物明细逻辑ID', + source_measurement_id BIGINT UNSIGNED 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_child (tenant_id, child_inbound_id), + UNIQUE KEY uk_inbound_split_batch_child (tenant_id, split_batch_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), + CONSTRAINT fk_inbound_split_item_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_inbound_split_item_child FOREIGN KEY (child_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_inbound_split_item_measurement FOREIGN KEY (source_measurement_id) REFERENCES fl_inbound_measurement (id) +) ENGINE=InnoDB COMMENT='主子入仓单拆分数量台账'; + +CREATE TABLE IF NOT EXISTS fl_inbound_split_history ( + id BIGINT UNSIGNED NOT NULL COMMENT '拆分历史ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', + split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID', + operation_id VARCHAR(64) NOT NULL COMMENT '历史操作幂等号', + action_type VARCHAR(24) NOT NULL COMMENT 'CREATE、APPLY、CANCEL', + before_snapshot JSON NULL COMMENT '操作前快照', + after_snapshot JSON NOT NULL COMMENT '操作后快照', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + operation_reason VARCHAR(500) NULL COMMENT '操作原因', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_split_history_operation (tenant_id, operation_id), + KEY idx_inbound_split_history (tenant_id, split_batch_id, created_at, id), + CONSTRAINT chk_inbound_split_action CHECK (action_type IN ('CREATE', 'APPLY', 'CANCEL')), + CONSTRAINT fk_inbound_split_history_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id) +) ENGINE=InnoDB COMMENT='不可变主子入仓单拆分审计历史'; + +DROP PROCEDURE IF EXISTS phase3_inbound_add_column; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_add_column( + IN p_table_name VARCHAR(64), + IN p_column_name 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_name + AND column_name = p_column_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `', + p_column_name, '` ', p_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; +CALL phase3_inbound_add_column( + 'fl_inbound_measurement', 'split_batch_id', + "BIGINT UNSIGNED NULL COMMENT '产生该测量批次的入仓拆分批次ID' AFTER inbound_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_measurement', 'source_measurement_id', + "BIGINT UNSIGNED NULL COMMENT '子单继承或复核时的来源测量批次ID' AFTER split_batch_id"); +DROP PROCEDURE IF EXISTS phase3_inbound_add_column; + +DROP PROCEDURE IF EXISTS phase3_inbound_add_relation; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_add_relation( + IN p_table_name VARCHAR(64), + IN p_constraint_name VARCHAR(64), + IN p_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema = DATABASE() + AND table_name = p_table_name + AND constraint_name = p_constraint_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD CONSTRAINT `', + p_constraint_name, '` ', p_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; +CALL phase3_inbound_add_relation( + 'fl_inbound_order', 'fk_inbound_parent_order', + 'FOREIGN KEY (`parent_inbound_id`) REFERENCES `fl_inbound_order` (`id`)'); +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_measurement', 'fk_measure_split_batch', + 'FOREIGN KEY (`split_batch_id`) REFERENCES `fl_inbound_split_batch` (`id`)'); +CALL phase3_inbound_add_relation( + 'fl_inbound_measurement', 'fk_measure_source_batch', + 'FOREIGN KEY (`source_measurement_id`) REFERENCES `fl_inbound_measurement` (`id`)'); +DROP PROCEDURE IF EXISTS phase3_inbound_add_relation; + +DROP PROCEDURE IF EXISTS phase3_inbound_add_index; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_add_index( + IN p_table_name VARCHAR(64), + IN p_index_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_name + AND index_name = p_index_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD ', p_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; +CALL phase3_inbound_add_index( + 'fl_inbound_measurement', 'idx_measure_split_source', + 'KEY `idx_measure_split_source` (`tenant_id`, `split_batch_id`, `source_measurement_id`, `created_at`)'); +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; +DROP TRIGGER IF EXISTS trg_inbound_split_item_before_insert; +DROP TRIGGER IF EXISTS trg_inbound_split_batch_before_insert; +DROP TRIGGER IF EXISTS trg_inbound_split_batch_before_update; +DROP TRIGGER IF EXISTS trg_inbound_split_item_before_update; +DROP TRIGGER IF EXISTS trg_inbound_split_item_before_delete; +DROP TRIGGER IF EXISTS trg_inbound_split_history_before_update; +DROP TRIGGER IF EXISTS trg_inbound_split_history_before_delete; + +DELIMITER $$ +CREATE TRIGGER trg_inbound_order_parent_before_insert +BEFORE INSERT ON fl_inbound_order +FOR EACH ROW +BEGIN + IF NEW.inbound_role NOT IN ('MASTER', 'CHILD') 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 = '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 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.deleted = 0 + ) OR NEW.root_inbound_id <> NEW.parent_inbound_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单必须绑定同一客户的有效主单'; + END IF; + END IF; +END$$ + +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) + 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 + 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'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_item_before_insert +BEFORE INSERT ON fl_inbound_split_item +FOR EACH ROW +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM fl_inbound_split_batch batch + JOIN fl_inbound_order parent_order ON parent_order.id = batch.parent_inbound_id + JOIN fl_inbound_order child_order ON child_order.id = NEW.child_inbound_id + WHERE batch.id = NEW.split_batch_id + AND batch.tenant_id = NEW.tenant_id + AND batch.parent_inbound_id = NEW.parent_inbound_id + AND batch.status = 'PREPARED' + 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.parent_inbound_id = NEW.parent_inbound_id + AND child_order.package_count = NEW.package_count + AND child_order.deleted = 0 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分明细与批次、主单或子单不一致'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_batch_before_insert +BEFORE INSERT ON fl_inbound_split_batch +FOR EACH ROW +BEGIN + IF NEW.status <> 'PREPARED' OR NEW.applied_at IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次必须从PREPARED创建'; + END IF; + IF 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.deleted = 0 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次主单不存在或客户归属不一致'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_batch_before_update +BEFORE UPDATE ON fl_inbound_split_batch +FOR EACH ROW +BEGIN + IF OLD.status IN ('APPLIED', 'CANCELLED') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已结束拆分批次不可修改'; + END IF; + IF NEW.status = 'APPLIED' THEN + IF NEW.applied_at IS NULL + 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.version > NEW.expected_parent_version + ) 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 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单可用箱数未归零或子单箱数合计不一致'; + END IF; + ELSEIF NEW.status = 'CANCELLED' THEN + IF EXISTS ( + SELECT 1 FROM fl_inbound_split_item item + WHERE item.tenant_id = NEW.tenant_id + AND item.split_batch_id = NEW.id + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有子单明细的拆分批次不可取消'; + END IF; + ELSEIF NEW.status <> 'PREPARED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次状态流转非法'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_item_before_update +BEFORE UPDATE ON fl_inbound_split_item +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分数量台账不可修改'; +END$$ + +CREATE TRIGGER trg_inbound_split_item_before_delete +BEFORE DELETE ON fl_inbound_split_item +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分数量台账不可删除'; +END$$ + +CREATE TRIGGER trg_inbound_split_history_before_update +BEFORE UPDATE ON fl_inbound_split_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分历史不可修改'; +END$$ + +CREATE TRIGGER trg_inbound_split_history_before_delete +BEFORE DELETE ON fl_inbound_split_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分历史不可删除'; +END$$ +DELIMITER ; + +DROP PROCEDURE IF EXISTS phase3_assert_inbound_parent_child_schema; +DELIMITER $$ +CREATE PROCEDURE phase3_assert_inbound_parent_child_schema() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name IN ('fl_inbound_split_batch', 'fl_inbound_split_item', + 'fl_inbound_split_history')) <> 3 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_order' + AND column_name IN ('inbound_role', 'parent_inbound_id', 'root_inbound_id', + 'split_sequence', 'split_status', 'package_count', + 'master_order_id')) <> 7 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_parent_sequence', + 'uk_inbound_split_operation', 'uk_inbound_split_idempotency', + 'uk_inbound_split_child', 'uk_inbound_split_history_operation')) <> 6 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_measure_split_batch', 'fk_measure_source_batch')) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单与测量关联约束未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.triggers + WHERE trigger_schema = DATABASE() + AND trigger_name IN ('trg_inbound_order_parent_before_insert', + 'trg_inbound_order_parent_before_update', + 'trg_inbound_split_item_before_insert', + 'trg_inbound_split_batch_before_insert', + 'trg_inbound_split_batch_before_update', + 'trg_inbound_split_item_before_update', + 'trg_inbound_split_item_before_delete', + 'trg_inbound_split_history_before_update', + 'trg_inbound_split_history_before_delete')) <> 9 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单数据库保护触发器未完整创建'; + 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_tenant_order' + ) THEN + 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; + +/* + * 部分装柜拆单契约: + * 1. parent_inbound_id 为空的是主单,非空的是唯一二级子单; + * 2. 锁定阶段只记录箱数,完成阶段绑定由剩余箱数生成的子单; + * 3. 子单不能再作为父单,锁定数量创建后不可修改。 + */ +DROP PROCEDURE IF EXISTS phase3_split_add_column; +DELIMITER $$ +CREATE PROCEDURE phase3_split_add_column( + IN p_table_name VARCHAR(64), IN p_column_name 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_name AND column_name = p_column_name + ) THEN + SET @phase3_split_ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `', + p_column_name, '` ', p_definition); + PREPARE phase3_split_stmt FROM @phase3_split_ddl; + EXECUTE phase3_split_stmt; + DEALLOCATE PREPARE phase3_split_stmt; + END IF; +END$$ +DELIMITER ; + +CALL phase3_split_add_column('fl_inbound_order', 'parent_inbound_id', + 'BIGINT UNSIGNED NULL COMMENT ''生成本单的主入库单ID;仅二级子单有值'' AFTER id'); +CALL phase3_split_add_column('fl_inbound_order', 'split_level', + 'TINYINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN parent_inbound_id IS NULL THEN 0 ELSE 1 END) STORED COMMENT ''拆单层级:0主单、1二级子单'' AFTER parent_inbound_id'); +CALL phase3_split_add_column('fl_inbound_order', 'split_status', + 'VARCHAR(24) NOT NULL DEFAULT ''NOT_SPLIT'' COMMENT ''NOT_SPLIT/PARTIALLY_SPLIT/FULLY_SPLIT'' AFTER split_level'); +DROP PROCEDURE IF EXISTS phase3_split_add_column; + +SET @phase3_split_index_exists = ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_one_secondary' +); +SET @phase3_split_ddl = IF(@phase3_split_index_exists = 0, + 'ALTER TABLE fl_inbound_order ADD UNIQUE KEY uk_inbound_one_secondary (tenant_id, parent_inbound_id)', + 'SELECT 1'); +PREPARE phase3_split_stmt FROM @phase3_split_ddl; +EXECUTE phase3_split_stmt; +DEALLOCATE PREPARE phase3_split_stmt; + +SET @phase3_split_fk_exists = ( + SELECT COUNT(*) FROM information_schema.table_constraints + WHERE constraint_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND constraint_name = 'fk_inbound_parent' AND constraint_type = 'FOREIGN KEY' +); +SET @phase3_split_ddl = IF(@phase3_split_fk_exists = 0, + 'ALTER TABLE fl_inbound_order ADD CONSTRAINT fk_inbound_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id)', + 'SELECT 1'); +PREPARE phase3_split_stmt FROM @phase3_split_ddl; +EXECUTE phase3_split_stmt; +DEALLOCATE PREPARE phase3_split_stmt; + +CREATE TABLE IF NOT EXISTS fl_inbound_split_detail ( + id BIGINT UNSIGNED NOT NULL COMMENT '部分锁定明细ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', + parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入库单ID', + child_inbound_id BIGINT UNSIGNED NULL COMMENT '完成拆单后生成的二级子入库单ID', + inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '原订单货物明细ID,不建立跨库外键', + source_package_count INT UNSIGNED NOT NULL COMMENT '拆分前该明细总箱数快照', + locked_package_count INT UNSIGNED NOT NULL COMMENT '本次装柜锁定箱数', + remaining_package_count INT UNSIGNED GENERATED ALWAYS AS ( + source_package_count - locked_package_count + ) STORED COMMENT '生成二级子单的剩余箱数', + status VARCHAR(16) NOT NULL DEFAULT 'LOCKED' COMMENT 'LOCKED/COMPLETED', + operation_id VARCHAR(128) NOT NULL COMMENT '部分锁定操作幂等号', + created_by BIGINT UNSIGNED NOT NULL, + created_by_name VARCHAR(64) NULL, + completed_by BIGINT UNSIGNED NULL, + completed_by_name VARCHAR(64) NULL, + completed_at DATETIME(3) NULL, + 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), + version INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_split_parent_detail (tenant_id, parent_inbound_id, inbound_detail_id), + UNIQUE KEY uk_inbound_split_child_detail (tenant_id, child_inbound_id, inbound_detail_id), + UNIQUE KEY uk_inbound_split_operation (operation_id), + KEY idx_inbound_split_status (tenant_id, parent_inbound_id, status), + CONSTRAINT fk_inbound_split_detail_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_inbound_split_detail_child FOREIGN KEY (child_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT chk_inbound_split_partial CHECK ( + source_package_count > 1 AND locked_package_count > 0 + AND locked_package_count < source_package_count + ), + CONSTRAINT chk_inbound_split_status CHECK (status IN ('LOCKED', 'COMPLETED')), + CONSTRAINT chk_inbound_split_completion CHECK ( + (status = 'LOCKED' AND child_inbound_id IS NULL AND completed_at IS NULL) + OR (status = 'COMPLETED' AND child_inbound_id IS NOT NULL AND completed_at IS NOT NULL) + ) +) ENGINE=InnoDB COMMENT='按箱数部分锁定及剩余箱数生成二级子入库单的不可变契约'; + DROP PROCEDURE IF EXISTS unified_assert_final_extensions; DELIMITER $$ CREATE PROCEDURE unified_assert_final_extensions() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java index fca7a552b..574ffd8b4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -3,7 +3,6 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedDetail; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary; @@ -24,7 +23,6 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; @@ -70,19 +68,6 @@ public class PlatformLoadingListController { planId, user.userId(), user.username(), idempotencyKey, request)); } - @Operation(summary = "完成装柜", description = "状态仅允许 LOADING 推进到 COMPLETED,完成后主单和明细冻结。") - @PostMapping("/loading-plans/{planId}/complete") - @PreAuthorize("hasAuthority('platform:firstleg:preload:complete')") - public ApiResponse complete( - @AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long planId, - @RequestHeader("Idempotency-Key") String idempotencyKey, - @Valid @RequestBody AdvanceRequest request) { - FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.complete( - planId, user.userId(), user.username(), idempotencyKey, request)); - } - @Operation(summary = "分页查询完成装柜清单", description = "只读取 COMPLETED 状态的装柜计划。") @GetMapping("/loading-lists") @PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java index cf35b5333..eaa9d8eac 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPickItemEntity; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; @Mapper @@ -18,4 +19,24 @@ public interface LoadingPickItemMapper extends BaseMapper AND picked_package_count + #{count} <= planned_package_count """) int pick(@Param("id") long id, @Param("count") int count, @Param("version") int version); + + /** + * 以装柜计划的全部有效明细为完成口径,不能只检查当前 PDA 任务中的明细。 + */ + @Select(""" + SELECT COUNT(*) + FROM fl_loading_plan_item plan_item + WHERE plan_item.loading_plan_id = #{planId} + AND plan_item.deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM fl_loading_pick_item pick_item + JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id + WHERE pick_item.loading_plan_item_id = plan_item.id + AND pick_task.loading_plan_id = #{planId} + AND pick_task.task_status = 'LOADING' + AND pick_item.picked_package_count = plan_item.package_count + ) + """) + long unfinishedCount(@Param("planId") long planId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index a1c10da33..1713c4fe6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -7,7 +7,8 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba o.warehouse_id warehouseId,o.destination_warehouse_id destinationWarehouseId, o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName, o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName, - i.package_count availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg, + i.package_count-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li + WHERE li.account_id=o.tenant_id AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg, COALESCE(i.unit_volume_cbm,0) unitVolumeCbm FROM fl_inbound_order o JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id @@ -15,10 +16,10 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2 WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED') JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id - LEFT JOIN fl_loading_plan_item l - ON l.account_id=o.tenant_id AND l.active_inbound_detail_id=i.inbound_detail_id WHERE o.tenant_id=#{accountId} AND o.process_status='INBOUNDED' - AND i.inbound_detail_id IS NOT NULL AND l.id IS NULL + AND i.inbound_detail_id IS NOT NULL + AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li + WHERE li.account_id=o.tenant_id AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0) ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC """) List candidates(@Param("accountId") long accountId); @@ -29,7 +30,8 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba o.warehouse_id warehouseId,o.destination_warehouse_id destinationWarehouseId, o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName, o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName, - i.package_count availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg, + i.package_count-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li + WHERE li.account_id=o.tenant_id AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg, COALESCE(i.unit_volume_cbm,0) unitVolumeCbm FROM fl_inbound_order o JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id @@ -39,7 +41,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id WHERE o.tenant_id=#{accountId} AND o.id=#{inboundId} AND o.process_status='INBOUNDED' AND i.inbound_detail_id=#{detailId} - ORDER BY m.id DESC LIMIT 1 + ORDER BY m.id DESC LIMIT 1 FOR UPDATE """) Candidate candidate(@Param("accountId") long accountId,@Param("inboundId") long inboundId, @Param("detailId") long detailId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java index 7051e39f6..e30e13c91 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java @@ -67,6 +67,10 @@ public class LoadingPickRepository { return items.pick(itemId, count, version); } + public boolean allPlanItemsPicked(long planId) { + return items.unfinishedCount(planId) == 0; + } + public LoadingPickHistoryEntity history(String operationId) { return histories.selectOne(new LambdaQueryWrapper() .eq(LoadingPickHistoryEntity::getOperationId, operationId)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java index b3e9de55d..e774deb0d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -48,8 +48,9 @@ public class SmartLoadingRepository { .eq(LoadingPlanItemEntity::getLoadingPlanId, planId) .eq(LoadingPlanItemEntity::getId, itemId).eq(LoadingPlanItemEntity::getDeleted, false)); } - public LoadingPlanItemEntity activeItem(long accountId, long detailId) { + public LoadingPlanItemEntity activeItem(long planId, long accountId, long detailId) { return items.selectOne(new LambdaQueryWrapper() + .eq(LoadingPlanItemEntity::getLoadingPlanId, planId) .eq(LoadingPlanItemEntity::getAccountId, accountId) .eq(LoadingPlanItemEntity::getInboundDetailId, detailId) .eq(LoadingPlanItemEntity::getDeleted, false)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 2f363d130..ab1f4813c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -4,7 +4,6 @@ import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedDetail; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary; @@ -15,6 +14,7 @@ import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow; import com.baiyue.firstleg.repository.LoadingListRepository; +import com.baiyue.firstleg.repository.LoadingPickRepository; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -36,6 +36,8 @@ public class LoadingListService { @Resource private LoadingListRepository repository; @Resource + private LoadingPickRepository pickRepository; + @Resource private ObjectMapper objectMapper; public PageResult preloadPage(PreloadQuery query) { @@ -101,40 +103,40 @@ public class LoadingListService { return repository.preloadDetail(requirePlan(planId)); } + /** + * 最后一箱 PDA 拣货完成后自动调用。服务与数据库触发器均以计划全部有效明细为口径复核。 + */ @Transactional - @BusinessAudit(event = "PRELOAD_COMPLETE", resourceType = "LOADING_PLAN", - resourceId = "#planId", description = "完成预装") - public ConfirmedDetail complete(long planId, long operatorId, String operatorName, - String idempotencyKey, AdvanceRequest request) { - advance(planId, operatorId, operatorName, idempotencyKey, request, - "LOADING", "COMPLETED"); - return repository.confirmedDetail(requirePlan(planId)); - } - - private void advance(long planId, long operatorId, String operatorName, String idempotencyKey, - AdvanceRequest request, String fromStatus, String toStatus) { - String requestHash = sha256(toStatus + '|' + planId + '|' + request.expectedVersion() - + '|' + trim(request.operationReason())); - String operationId = operationId(toStatus, planId, idempotencyKey); - HistoryRow replay = repository.history(operationId); - if (replay != null) { - requireSameRequest(replay, requestHash); + @BusinessAudit(event = "PDA_COMPLETE_LOADING", resourceType = "LOADING_PLAN", + resourceId = "#planId", description = "PDA拣货完成后自动生成装柜清单") + public void finishFromPda(long planId, long pickTaskId, long operatorId, String operatorName) { + String operationId = "PDA-COMPLETE-" + planId + '-' + pickTaskId; + if (repository.history(operationId) != null) { return; } - + if (!pickRepository.allPlanItemsPicked(planId)) { + throw conflict("计划仍存在未完成PDA拣货的箱数,不能生成装柜清单"); + } AggregateRow before = requirePlan(planId); - if (!fromStatus.equals(before.getPlanStatus())) { - throw conflict("当前状态为 " + before.getPlanStatus() + ",不能推进到 " + toStatus); + if ("COMPLETED".equals(before.getPlanStatus())) { + return; } - if (repository.advance(planId, fromStatus, toStatus, request.expectedVersion(), + if (!"LOADING".equals(before.getPlanStatus())) { + throw conflict("当前状态为 " + before.getPlanStatus() + ",不能由PDA完成装柜"); + } + if (repository.advance(planId, "LOADING", "COMPLETED", before.getVersion(), operatorId, trim(operatorName)) != 1) { - throw conflict("预装计划已被其他操作修改,请刷新后重试"); + AggregateRow current = requirePlan(planId); + if ("COMPLETED".equals(current.getPlanStatus())) { + return; + } + throw conflict("装柜计划已被其他操作修改,请刷新后重试"); } - repository.appendHistory(planId, operationId, "ADVANCE_PRELOAD_STATUS", - fromStatus, toStatus, json(snapshot(before)), - json(values("requestHash", requestHash, "status", toStatus, - "version", request.expectedVersion() + 1)), operatorId, trim(operatorName), - trim(request.operationReason())); + repository.appendHistory(planId, operationId, "PDA_COMPLETE_LOADING", + "LOADING", "COMPLETED", json(snapshot(before)), + json(values("source", "PDA", "pickTaskId", pickTaskId, "status", "COMPLETED", + "version", before.getVersion() + 1)), operatorId, trim(operatorName), + "全部箱数已完成PDA拣货"); } private AggregateRow requirePlan(long planId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index bf75bce82..125b629bd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -26,6 +26,8 @@ import java.util.List; public class LoadingPickService { @Resource private LoadingPickRepository repository; + @Resource + private LoadingListService loadingListService; @Transactional public void begin(long planId, String containerNo, long operatorId, String operatorName, @@ -104,6 +106,9 @@ public class LoadingPickService { history.setOperatorId(operatorId); history.setOperatorName(operatorName); repository.insert(history); + if (repository.allPlanItemsPicked(task.getLoadingPlanId())) { + loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName); + } return view(requiredTask(taskId)); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 2dac22253..e18f273c8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -91,10 +91,11 @@ public class SmartLoadingService { if (candidate == null) throw conflict("入库货物不存在或尚未完成入库"); if (!Objects.equals(candidate.accountId(), request.accountId())) throw forbidden(); OrderCargoClient.CargoBinding cargo = loadCargo(candidate); - LoadingPlanItemEntity existing = repository.activeItem(request.accountId(), request.inboundDetailId()); - if (existing != null && !Objects.equals(existing.getLoadingPlanId(), planId)) throw conflict("货物明细已被有效计划锁定"); + LoadingPlanItemEntity existing = repository.activeItem(planId, request.accountId(), request.inboundDetailId()); int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount()); - if (total > candidate.availablePackageCount()) throw validation("累计分配箱数超过可用箱数"); + int availableIncludingCurrent = candidate.availablePackageCount() + + (existing == null ? 0 : existing.getPackageCount()); + if (total > availableIncludingCurrent) throw validation("累计分配箱数超过可用箱数"); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); List lines = new ArrayList<>(repository.items(planId)); if (existing != null) lines.removeIf(x -> Objects.equals(x.getId(), existing.getId())); @@ -167,7 +168,7 @@ public class SmartLoadingService { if (!Objects.equals(item.getAccountId(), request.accountId())) throw forbidden(); if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突"); Candidate candidate = repository.candidate(item.getAccountId(), item.getInboundId(), item.getInboundDetailId()); - if (candidate == null || request.packageCount() > candidate.availablePackageCount()) throw validation("调整箱数超过可用箱数"); + if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数"); OrderCargoClient.CargoBinding cargo = loadCargo(candidate); List others = new ArrayList<>(repository.items(planId)); others.removeIf(x -> Objects.equals(x.getId(), itemId)); 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 new file mode 100644 index 000000000..59e495435 --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql @@ -0,0 +1,539 @@ +SET NAMES utf8mb4; +USE baiyue_firstleg; + +/* + * 三期主子入仓单结构迁移。 + * 仅建立结构和数据库保护,不自动拆分或回填存量业务数据。 + * tenant_id 继续表示客户隔离键;数量统一使用 package_count。 + */ +DROP PROCEDURE IF EXISTS phase3_inbound_add_column; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_add_column( + IN p_table_name VARCHAR(64), + IN p_column_name 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_name + AND column_name = p_column_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `', + p_column_name, '` ', p_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; + +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'inbound_role', + "VARCHAR(16) NOT NULL DEFAULT 'MASTER' COMMENT '入仓单层级:MASTER主单、CHILD子单' AFTER inbound_code"); +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'parent_inbound_id', + "BIGINT UNSIGNED NULL COMMENT '直属主入仓单ID;主单为空' AFTER inbound_role"); +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'root_inbound_id', + "BIGINT UNSIGNED NULL COMMENT '根主入仓单ID;存量主单可为空,按自身ID解释' AFTER parent_inbound_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'split_sequence', + "INT UNSIGNED NULL COMMENT '同一主单下的子单顺序号' AFTER root_inbound_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'split_status', + "VARCHAR(24) NOT NULL DEFAULT 'NOT_SPLIT' COMMENT '拆分状态:NOT_SPLIT、PARTIALLY_SPLIT、FULLY_SPLIT' AFTER split_sequence"); +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', 'master_order_id', + "BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER' AND deleted = 0 THEN order_id ELSE NULL END) STORED COMMENT '仅用于约束有效主单订单唯一性'"); + +DROP PROCEDURE IF EXISTS phase3_inbound_add_column; + +DROP PROCEDURE IF EXISTS phase3_inbound_add_index; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_add_index( + IN p_table_name VARCHAR(64), + IN p_index_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_name + AND index_name = p_index_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD ', p_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +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_parent_sequence', + 'UNIQUE KEY `uk_inbound_parent_sequence` (`tenant_id`, `parent_inbound_id`, `split_sequence`)'); +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_inbound_drop_legacy_index; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_drop_legacy_index() +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_tenant_order' + ) THEN + ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_tenant_order; + END IF; +END$$ +DELIMITER ; +CALL phase3_inbound_drop_legacy_index(); +DROP PROCEDURE IF EXISTS phase3_inbound_drop_legacy_index; +DROP PROCEDURE IF EXISTS phase3_inbound_add_index; + +CREATE TABLE IF NOT EXISTS fl_inbound_split_batch ( + id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', + parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID', + operation_id VARCHAR(64) NOT NULL COMMENT '业务操作幂等号', + idempotency_key VARCHAR(128) NOT NULL COMMENT '请求幂等键', + expected_parent_version INT NOT NULL COMMENT '操作时主单乐观锁版本', + package_count INT UNSIGNED NOT NULL COMMENT '本批次拆分箱数', + status VARCHAR(16) NOT NULL DEFAULT 'PREPARED' COMMENT 'PREPARED、APPLIED、CANCELLED', + remark VARCHAR(500) NULL COMMENT '拆分原因或备注', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + applied_at DATETIME(3) NULL COMMENT '批次生效时间', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_split_operation (tenant_id, operation_id), + UNIQUE KEY uk_inbound_split_idempotency (tenant_id, parent_inbound_id, idempotency_key), + KEY idx_inbound_split_parent (tenant_id, parent_inbound_id, created_at, id), + CONSTRAINT chk_inbound_split_package CHECK (package_count > 0), + CONSTRAINT chk_inbound_split_status CHECK (status IN ('PREPARED', 'APPLIED', 'CANCELLED')), + CONSTRAINT fk_inbound_split_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='主入仓单拆分批次;数量口径仅为箱数'; + +CREATE TABLE IF NOT EXISTS fl_inbound_split_item ( + id BIGINT UNSIGNED NOT NULL COMMENT '拆分明细ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', + split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID', + parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID', + child_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '子入仓单ID', + 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', + 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_child (tenant_id, child_inbound_id), + UNIQUE KEY uk_inbound_split_batch_child (tenant_id, split_batch_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), + CONSTRAINT fk_inbound_split_item_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_inbound_split_item_child FOREIGN KEY (child_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_inbound_split_item_measurement FOREIGN KEY (source_measurement_id) REFERENCES fl_inbound_measurement (id), + CONSTRAINT fk_inbound_split_item_measure_item FOREIGN KEY (source_measurement_item_id) REFERENCES fl_inbound_measurement_item (id) +) ENGINE=InnoDB COMMENT='主子入仓单拆分数量台账'; + +CREATE TABLE IF NOT EXISTS fl_inbound_split_history ( + id BIGINT UNSIGNED NOT NULL COMMENT '拆分历史ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', + split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID', + operation_id VARCHAR(64) NOT NULL COMMENT '历史操作幂等号', + action_type VARCHAR(24) NOT NULL COMMENT 'CREATE、APPLY、CANCEL', + before_snapshot JSON NULL COMMENT '操作前快照', + after_snapshot JSON NOT NULL COMMENT '操作后快照', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + operation_reason VARCHAR(500) NULL COMMENT '操作原因', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_split_history_operation (tenant_id, operation_id), + KEY idx_inbound_split_history (tenant_id, split_batch_id, created_at, id), + CONSTRAINT chk_inbound_split_action CHECK (action_type IN ('CREATE', 'APPLY', 'CANCEL')), + CONSTRAINT fk_inbound_split_history_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id) +) ENGINE=InnoDB COMMENT='不可变主子入仓单拆分审计历史'; + +DROP PROCEDURE IF EXISTS phase3_inbound_add_column; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_add_column( + IN p_table_name VARCHAR(64), + IN p_column_name 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_name + AND column_name = p_column_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `', + p_column_name, '` ', p_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; +CALL phase3_inbound_add_column( + 'fl_inbound_measurement', 'split_batch_id', + "BIGINT UNSIGNED NULL COMMENT '产生该测量批次的入仓拆分批次ID' AFTER inbound_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_measurement', 'source_measurement_id', + "BIGINT UNSIGNED NULL COMMENT '子单继承或复核时的来源测量批次ID' AFTER split_batch_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_measurement_item', 'split_item_id', + "BIGINT UNSIGNED NULL COMMENT '产生该子单测量明细的拆分台账ID' AFTER measurement_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_measurement_item', 'source_measurement_item_id', + "BIGINT UNSIGNED NULL COMMENT '被拆分继承的来源测量箱规明细ID' AFTER split_item_id"); +DROP PROCEDURE IF EXISTS phase3_inbound_add_column; + +DROP PROCEDURE IF EXISTS phase3_inbound_add_relation; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_add_relation( + IN p_table_name VARCHAR(64), + IN p_constraint_name VARCHAR(64), + IN p_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema = DATABASE() + AND table_name = p_table_name + AND constraint_name = p_constraint_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD CONSTRAINT `', + p_constraint_name, '` ', p_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; +CALL phase3_inbound_add_relation( + 'fl_inbound_order', 'fk_inbound_parent_order', + 'FOREIGN KEY (`parent_inbound_id`) REFERENCES `fl_inbound_order` (`id`)'); +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_measurement', 'fk_measure_split_batch', + 'FOREIGN KEY (`split_batch_id`) REFERENCES `fl_inbound_split_batch` (`id`)'); +CALL phase3_inbound_add_relation( + 'fl_inbound_measurement', 'fk_measure_source_batch', + 'FOREIGN KEY (`source_measurement_id`) REFERENCES `fl_inbound_measurement` (`id`)'); +CALL phase3_inbound_add_relation( + 'fl_inbound_measurement_item', 'fk_measure_item_split_item', + 'FOREIGN KEY (`split_item_id`) REFERENCES `fl_inbound_split_item` (`id`)'); +CALL phase3_inbound_add_relation( + 'fl_inbound_measurement_item', 'fk_measure_item_source_item', + 'FOREIGN KEY (`source_measurement_item_id`) REFERENCES `fl_inbound_measurement_item` (`id`)'); +DROP PROCEDURE IF EXISTS phase3_inbound_add_relation; + +DROP PROCEDURE IF EXISTS phase3_inbound_add_index; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_add_index( + IN p_table_name VARCHAR(64), + IN p_index_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_name + AND index_name = p_index_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD ', p_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; +CALL phase3_inbound_add_index( + 'fl_inbound_measurement', 'idx_measure_split_source', + 'KEY `idx_measure_split_source` (`tenant_id`, `split_batch_id`, `source_measurement_id`, `created_at`)'); +CALL phase3_inbound_add_index( + 'fl_inbound_measurement_item', 'idx_measure_item_split_source', + 'KEY `idx_measure_item_split_source` (`tenant_id`, `split_item_id`, `source_measurement_item_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; +DROP TRIGGER IF EXISTS trg_inbound_split_item_before_insert; +DROP TRIGGER IF EXISTS trg_inbound_split_batch_before_insert; +DROP TRIGGER IF EXISTS trg_inbound_split_batch_before_update; +DROP TRIGGER IF EXISTS trg_inbound_split_item_before_update; +DROP TRIGGER IF EXISTS trg_inbound_split_item_before_delete; +DROP TRIGGER IF EXISTS trg_inbound_split_history_before_update; +DROP TRIGGER IF EXISTS trg_inbound_split_history_before_delete; + +DELIMITER $$ +CREATE TRIGGER trg_inbound_order_parent_before_insert +BEFORE INSERT ON fl_inbound_order +FOR EACH ROW +BEGIN + IF NEW.inbound_role NOT IN ('MASTER', 'CHILD') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单层级非法'; + END IF; + IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') 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 = '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 = '主子仅两级,子入仓单不可继续拆分'; + END IF; + IF 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.deleted = 0 + ) OR NEW.root_inbound_id <> NEW.parent_inbound_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单必须绑定同一客户的有效主单'; + END IF; + END IF; +END$$ + +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) + 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 + 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'; + END IF; + 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 = '主子仅两级,子入仓单不可继续拆分'; + 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 +BEFORE INSERT ON fl_inbound_split_item +FOR EACH ROW +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM fl_inbound_split_batch batch + JOIN fl_inbound_order parent_order ON parent_order.id = batch.parent_inbound_id + JOIN fl_inbound_order child_order ON child_order.id = NEW.child_inbound_id + WHERE batch.id = NEW.split_batch_id + AND batch.tenant_id = NEW.tenant_id + AND batch.parent_inbound_id = NEW.parent_inbound_id + AND batch.status = 'PREPARED' + 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.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 ( + 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 + )) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分明细与批次、主单或子单不一致'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_batch_before_insert +BEFORE INSERT ON fl_inbound_split_batch +FOR EACH ROW +BEGIN + IF NEW.status <> 'PREPARED' OR NEW.applied_at IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次必须从PREPARED创建'; + END IF; + IF 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.deleted = 0 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次主单不存在或客户归属不一致'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_batch_before_update +BEFORE UPDATE ON fl_inbound_split_batch +FOR EACH ROW +BEGIN + IF OLD.status IN ('APPLIED', 'CANCELLED') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已结束拆分批次不可修改'; + END IF; + IF NEW.status = 'APPLIED' THEN + IF NEW.applied_at IS NULL + 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 = 'FULLY_SPLIT' + AND parent_order.version > NEW.expected_parent_version + ) 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 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单可用箱数未归零或子单箱数合计不一致'; + END IF; + IF EXISTS ( + SELECT 1 + FROM fl_inbound_split_item split_item + JOIN fl_inbound_measurement_item source_item + ON source_item.id = split_item.source_measurement_item_id + WHERE split_item.tenant_id = NEW.tenant_id + AND split_item.split_batch_id = NEW.id + GROUP BY split_item.source_measurement_item_id, source_item.package_count + HAVING SUM(split_item.package_count) > source_item.package_count + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子单承接箱数超过来源测量箱规箱数'; + END IF; + ELSEIF NEW.status = 'CANCELLED' THEN + IF EXISTS ( + SELECT 1 FROM fl_inbound_split_item item + WHERE item.tenant_id = NEW.tenant_id + AND item.split_batch_id = NEW.id + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有子单明细的拆分批次不可取消'; + END IF; + ELSEIF NEW.status <> 'PREPARED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次状态流转非法'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_item_before_update +BEFORE UPDATE ON fl_inbound_split_item +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分数量台账不可修改'; +END$$ + +CREATE TRIGGER trg_inbound_split_item_before_delete +BEFORE DELETE ON fl_inbound_split_item +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分数量台账不可删除'; +END$$ + +CREATE TRIGGER trg_inbound_split_history_before_update +BEFORE UPDATE ON fl_inbound_split_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分历史不可修改'; +END$$ + +CREATE TRIGGER trg_inbound_split_history_before_delete +BEFORE DELETE ON fl_inbound_split_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分历史不可删除'; +END$$ +DELIMITER ; + +DROP PROCEDURE IF EXISTS phase3_assert_inbound_parent_child_schema; +DELIMITER $$ +CREATE PROCEDURE phase3_assert_inbound_parent_child_schema() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name IN ('fl_inbound_split_batch', 'fl_inbound_split_item', + 'fl_inbound_split_history')) <> 3 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_order' + AND column_name IN ('inbound_role', 'parent_inbound_id', 'root_inbound_id', + 'split_sequence', 'split_status', 'package_count', + 'master_order_id')) <> 7 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_parent_sequence', + 'uk_inbound_split_operation', 'uk_inbound_split_idempotency', + 'uk_inbound_split_child', 'uk_inbound_split_history_operation')) <> 6 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_measure_split_batch', 'fk_measure_source_batch', + 'fk_measure_item_split_item', 'fk_measure_item_source_item')) <> 6 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单与测量关联约束未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.triggers + WHERE trigger_schema = DATABASE() + AND trigger_name IN ('trg_inbound_order_parent_before_insert', + 'trg_inbound_order_parent_before_update', + 'trg_inbound_split_item_before_insert', + 'trg_inbound_split_batch_before_insert', + 'trg_inbound_split_batch_before_update', + 'trg_inbound_split_item_before_update', + 'trg_inbound_split_item_before_delete', + 'trg_inbound_split_history_before_update', + 'trg_inbound_split_history_before_delete')) <> 9 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单数据库保护触发器未完整创建'; + 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_tenant_order' + ) THEN + 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; diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index f11c7df8f..7ae37492e 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -139,7 +139,8 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item ( COMMENT '更新时间', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), - UNIQUE KEY uk_loading_item_active_detail (account_id, active_inbound_detail_id), + UNIQUE KEY uk_loading_item_plan_detail (loading_plan_id, active_inbound_detail_id), + KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id), KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id), KEY idx_loading_item_account (account_id, created_at, id), KEY idx_loading_item_inbound (account_id, inbound_id, deleted), @@ -705,8 +706,8 @@ BEGIN END IF; IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item' - AND index_name = 'uk_loading_item_active_detail' AND non_unique = 0) <> 2 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:唯一活动明细锁缺失'; + AND index_name = 'uk_loading_item_plan_detail' AND non_unique = 0) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失'; END IF; IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = 'baiyue_firstleg' diff --git a/firstleg/src/main/resources/db/phase3-split-preload-schema.sql b/firstleg/src/main/resources/db/phase3-split-preload-schema.sql index c521497b7..8a0414b9b 100644 --- a/firstleg/src/main/resources/db/phase3-split-preload-schema.sql +++ b/firstleg/src/main/resources/db/phase3-split-preload-schema.sql @@ -37,3 +37,236 @@ CREATE TABLE IF NOT EXISTS fl_split_operation ( KEY idx_split_operation_time (tenant_id, inbound_id, created_at), CONSTRAINT fk_split_operation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) ) ENGINE=InnoDB COMMENT='分柜操作幂等与审计索引'; + +/* + * 部分装柜拆单契约: + * 1. parent_inbound_id 为空的是主单,非空的是唯一二级子单; + * 2. 锁定阶段只记录箱数,完成阶段绑定由剩余箱数生成的子单; + * 3. 子单不能再作为父单,锁定数量创建后不可修改。 + */ +DROP PROCEDURE IF EXISTS phase3_split_add_column; +DELIMITER $$ +CREATE PROCEDURE phase3_split_add_column( + IN p_table_name VARCHAR(64), IN p_column_name 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_name AND column_name = p_column_name + ) THEN + SET @phase3_split_ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `', + p_column_name, '` ', p_definition); + PREPARE phase3_split_stmt FROM @phase3_split_ddl; + EXECUTE phase3_split_stmt; + DEALLOCATE PREPARE phase3_split_stmt; + END IF; +END$$ +DELIMITER ; + +CALL phase3_split_add_column('fl_inbound_order', 'parent_inbound_id', + 'BIGINT UNSIGNED NULL COMMENT ''生成本单的主入库单ID;仅二级子单有值'' AFTER id'); +CALL phase3_split_add_column('fl_inbound_order', 'split_level', + 'TINYINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN parent_inbound_id IS NULL THEN 0 ELSE 1 END) STORED COMMENT ''拆单层级:0主单、1二级子单'' AFTER parent_inbound_id'); +CALL phase3_split_add_column('fl_inbound_order', 'split_status', + 'VARCHAR(24) NOT NULL DEFAULT ''NOT_SPLIT'' COMMENT ''NOT_SPLIT/PARTIALLY_SPLIT/FULLY_SPLIT'' AFTER split_level'); +DROP PROCEDURE IF EXISTS phase3_split_add_column; + +SET @phase3_split_index_exists = ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_one_secondary' +); +SET @phase3_split_ddl = IF(@phase3_split_index_exists = 0, + 'ALTER TABLE fl_inbound_order ADD UNIQUE KEY uk_inbound_one_secondary (tenant_id, parent_inbound_id)', + 'SELECT 1'); +PREPARE phase3_split_stmt FROM @phase3_split_ddl; +EXECUTE phase3_split_stmt; +DEALLOCATE PREPARE phase3_split_stmt; + +SET @phase3_split_fk_exists = ( + SELECT COUNT(*) FROM information_schema.table_constraints + WHERE constraint_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND constraint_name = 'fk_inbound_parent' AND constraint_type = 'FOREIGN KEY' +); +SET @phase3_split_ddl = IF(@phase3_split_fk_exists = 0, + 'ALTER TABLE fl_inbound_order ADD CONSTRAINT fk_inbound_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id)', + 'SELECT 1'); +PREPARE phase3_split_stmt FROM @phase3_split_ddl; +EXECUTE phase3_split_stmt; +DEALLOCATE PREPARE phase3_split_stmt; + +CREATE TABLE IF NOT EXISTS fl_inbound_split_detail ( + id BIGINT UNSIGNED NOT NULL COMMENT '部分锁定明细ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', + parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入库单ID', + child_inbound_id BIGINT UNSIGNED NULL COMMENT '完成拆单后生成的二级子入库单ID', + inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '原订单货物明细ID,不建立跨库外键', + source_package_count INT UNSIGNED NOT NULL COMMENT '拆分前该明细总箱数快照', + locked_package_count INT UNSIGNED NOT NULL COMMENT '本次装柜锁定箱数', + remaining_package_count INT UNSIGNED GENERATED ALWAYS AS ( + source_package_count - locked_package_count + ) STORED COMMENT '生成二级子单的剩余箱数', + status VARCHAR(16) NOT NULL DEFAULT 'LOCKED' COMMENT 'LOCKED/COMPLETED', + operation_id VARCHAR(128) NOT NULL COMMENT '部分锁定操作幂等号', + created_by BIGINT UNSIGNED NOT NULL, + created_by_name VARCHAR(64) NULL, + completed_by BIGINT UNSIGNED NULL, + completed_by_name VARCHAR(64) NULL, + completed_at DATETIME(3) NULL, + 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), + version INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_split_parent_detail (tenant_id, parent_inbound_id, inbound_detail_id), + UNIQUE KEY uk_inbound_split_child_detail (tenant_id, child_inbound_id, inbound_detail_id), + UNIQUE KEY uk_inbound_split_operation (operation_id), + KEY idx_inbound_split_status (tenant_id, parent_inbound_id, status), + CONSTRAINT fk_inbound_split_detail_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_inbound_split_detail_child FOREIGN KEY (child_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT chk_inbound_split_partial CHECK ( + source_package_count > 1 AND locked_package_count > 0 + AND locked_package_count < source_package_count + ), + CONSTRAINT chk_inbound_split_status CHECK (status IN ('LOCKED', 'COMPLETED')), + CONSTRAINT chk_inbound_split_completion CHECK ( + (status = 'LOCKED' AND child_inbound_id IS NULL AND completed_at IS NULL) + OR (status = 'COMPLETED' AND child_inbound_id IS NOT NULL AND completed_at IS NOT NULL) + ) +) ENGINE=InnoDB COMMENT='按箱数部分锁定及剩余箱数生成二级子入库单的不可变契约'; + +DROP TRIGGER IF EXISTS trg_inbound_split_before_insert; +DROP TRIGGER IF EXISTS trg_inbound_split_before_update; +DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_insert; +DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_update; +DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_inbound_split_before_insert +BEFORE INSERT ON fl_inbound_order +FOR EACH ROW +BEGIN + DECLARE v_parent_tenant BIGINT UNSIGNED; + DECLARE v_parent_parent BIGINT UNSIGNED; + IF NEW.parent_inbound_id IS NOT NULL THEN + SELECT tenant_id, parent_inbound_id INTO v_parent_tenant, v_parent_parent + FROM fl_inbound_order WHERE id = NEW.parent_inbound_id AND deleted = 0; + IF v_parent_tenant <> NEW.tenant_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单与主单客户归属不一致'; + END IF; + IF v_parent_parent IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单不能继续拆分'; + END IF; + IF NEW.split_status <> 'NOT_SPLIT' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单拆单状态必须为NOT_SPLIT'; + END IF; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_before_update +BEFORE UPDATE ON fl_inbound_order +FOR EACH ROW +BEGIN + IF NOT (NEW.parent_inbound_id <=> OLD.parent_inbound_id) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入库单主子关系创建后不可修改'; + END IF; + IF OLD.parent_inbound_id IS NOT NULL AND NEW.split_status <> 'NOT_SPLIT' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单不能继续拆分'; + END IF; + IF OLD.parent_inbound_id IS NULL + AND NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主入库单拆单状态非法'; + END IF; + IF OLD.split_status = 'FULLY_SPLIT' AND NEW.split_status <> 'FULLY_SPLIT' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成拆单不能回退'; + END IF; + IF NEW.split_status = 'FULLY_SPLIT' AND ( + (SELECT COUNT(*) FROM fl_inbound_order child + WHERE child.tenant_id = NEW.tenant_id AND child.parent_inbound_id = NEW.id + AND child.deleted = 0) <> 1 + OR (SELECT COUNT(*) FROM fl_inbound_split_detail detail + WHERE detail.tenant_id = NEW.tenant_id AND detail.parent_inbound_id = NEW.id) = 0 + OR EXISTS (SELECT 1 FROM fl_inbound_split_detail detail + WHERE detail.tenant_id = NEW.tenant_id AND detail.parent_inbound_id = NEW.id + AND detail.status <> 'COMPLETED') + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '剩余箱数二级子单及拆分明细未完成'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_detail_before_insert +BEFORE INSERT ON fl_inbound_split_detail +FOR EACH ROW +BEGIN + DECLARE v_parent_tenant BIGINT UNSIGNED; + DECLARE v_parent_parent BIGINT UNSIGNED; + SELECT tenant_id, parent_inbound_id INTO v_parent_tenant, v_parent_parent + FROM fl_inbound_order WHERE id = NEW.parent_inbound_id AND deleted = 0; + IF v_parent_tenant <> NEW.tenant_id OR v_parent_parent IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定只能发生在客户所属主入库单'; + END IF; + IF NEW.status <> 'LOCKED' OR NEW.child_inbound_id IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定创建时不能提前绑定二级子入库单'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_detail_before_update +BEFORE UPDATE ON fl_inbound_split_detail +FOR EACH ROW +BEGIN + DECLARE v_child_tenant BIGINT UNSIGNED; + DECLARE v_child_parent BIGINT UNSIGNED; + IF NEW.tenant_id <> OLD.tenant_id OR NEW.parent_inbound_id <> OLD.parent_inbound_id + OR NEW.inbound_detail_id <> OLD.inbound_detail_id + OR NEW.source_package_count <> OLD.source_package_count + OR NEW.locked_package_count <> OLD.locked_package_count + OR NEW.operation_id <> OLD.operation_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定的归属和箱数创建后不可修改'; + END IF; + IF OLD.status <> 'LOCKED' OR NEW.status <> 'COMPLETED' OR NEW.child_inbound_id IS NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定只允许完成并绑定二级子入库单'; + END IF; + SELECT tenant_id, parent_inbound_id INTO v_child_tenant, v_child_parent + FROM fl_inbound_order WHERE id = NEW.child_inbound_id AND deleted = 0; + IF v_child_tenant <> NEW.tenant_id OR v_child_parent <> NEW.parent_inbound_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '剩余箱数必须绑定主单唯一二级子入库单'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_detail_before_delete +BEFORE DELETE ON fl_inbound_split_detail +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定与拆单明细不可删除'; +END$$ +DELIMITER ; + +DROP PROCEDURE IF EXISTS phase3_split_assert_contract; +DELIMITER $$ +CREATE PROCEDURE phase3_split_assert_contract() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND column_name IN ('parent_inbound_id', 'split_level', 'split_status')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:入库单主子关系字段未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_split_detail') <> 1 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_one_secondary', 'uk_inbound_split_parent_detail', + 'uk_inbound_split_child_detail', 'uk_inbound_split_operation')) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:唯一子单或部分锁定幂等索引缺失'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.triggers + WHERE trigger_schema = DATABASE() + AND trigger_name IN ('trg_inbound_split_before_insert', 'trg_inbound_split_before_update', + 'trg_inbound_split_detail_before_insert', + 'trg_inbound_split_detail_before_update', + 'trg_inbound_split_detail_before_delete')) <> 5 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:主子层级或部分锁定触发器未完整创建'; + END IF; +END$$ +DELIMITER ; +CALL phase3_split_assert_contract(); +DROP PROCEDURE IF EXISTS phase3_split_assert_contract; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index 7cd2228db..b41fd1c18 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -2,7 +2,6 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; @@ -27,8 +26,6 @@ class LoadingListControllerContractTest { authority("preloadDetail", "platform:firstleg:preload:view", AuthenticatedUser.class, long.class); authority("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.class, long.class, String.class, TransportUpdateRequest.class); - authority("complete", "platform:firstleg:preload:complete", AuthenticatedUser.class, - long.class, String.class, AdvanceRequest.class); authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class, ConfirmedQuery.class); authority("confirmedDetail", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class); @@ -38,8 +35,6 @@ class LoadingListControllerContractTest { void writesAudited() throws Exception { audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, String.class, String.class, TransportUpdateRequest.class); - audit("complete", "PRELOAD_COMPLETE", long.class, long.class, String.class, - String.class, AdvanceRequest.class); } @Test @@ -69,6 +64,12 @@ class LoadingListControllerContractTest { .doesNotContain("/items/{itemId}"); } + @Test + void doesNotExposeManualCompletion() { + assertThat(PlatformLoadingListController.class.getDeclaredMethods()) + .noneMatch(method -> method.getName().equals("complete")); + } + @Test void yamlParses() throws Exception { String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java new file mode 100644 index 000000000..57677aa44 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java @@ -0,0 +1,105 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("主子入仓单SQL契约") +class InboundParentChildSqlContractTest { + + private final String sql = loadSql(); + + @Test + void definesParentChildRelation() { + assertThat(sql) + .contains("'fl_inbound_order', 'inbound_role'") + .contains("'fl_inbound_order', 'parent_inbound_id'") + .contains("'fl_inbound_order', 'root_inbound_id'") + .contains("'fl_inbound_order', 'package_count'") + .contains("GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER'") + .contains("DROP INDEX uk_inbound_tenant_order") + .contains("uk_inbound_master_order") + .contains("uk_inbound_parent_sequence"); + } + + @Test + void definesSplitLedger() { + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history") + .contains("package_count INT UNSIGNED NOT NULL") + .doesNotContain("quantity INT", "item_count INT", "carton_count INT"); + } + + @Test + void protectsOwnershipAndTotals() { + assertThat(sql) + .contains("子入仓单必须绑定同一客户的有效主单") + .contains("parent_order.package_count = 0") + .contains("parent_order.split_status = 'FULLY_SPLIT'") + .contains("parent_order.version > NEW.expected_parent_version") + .contains("主单可用箱数未归零或子单箱数合计不一致") + .contains("SUM(split_item.package_count) > source_item.package_count") + .contains("子单承接箱数超过来源测量箱规箱数") + .contains("child_order.package_count = NEW.package_count") + .contains("拆分数量台账不可修改") + .contains("拆分数量台账不可删除") + .contains("拆分历史不可修改") + .contains("拆分历史不可删除"); + } + + @Test + void tracesMeasurements() { + assertThat(sql) + .contains("'fl_inbound_measurement', 'split_batch_id'") + .contains("'fl_inbound_measurement', 'source_measurement_id'") + .contains("'fl_inbound_measurement_item', 'split_item_id'") + .contains("'fl_inbound_measurement_item', 'source_measurement_item_id'") + .contains("source_measurement_id BIGINT UNSIGNED NULL") + .contains("fk_inbound_split_item_measurement") + .contains("fk_measure_split_batch") + .contains("fk_measure_source_batch") + .contains("fk_measure_item_split_item") + .contains("fk_measure_item_source_item") + .contains("idx_measure_split_source") + .contains("idx_measure_item_split_source"); + } + + @Test + void syncsUnifiedMigration() throws Exception { + String module = normalize(sql).trim(); + String unified = normalize(Files.readString(unifiedPath(), StandardCharsets.UTF_8)); + assertThat(unified) + .contains("-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql") + .contains(module); + } + + private static String loadSql() { + try { + return new ClassPathResource("db/phase3-inbound-parent-child-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + private static Path unifiedPath() { + Path working = Path.of("").toAbsolutePath(); + Path path = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(path)) { + path = working.resolve("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize(); + } + return path; + } + + private static String normalize(String value) { + return value.replace("\r\n", "\n").replace("\uFEFF", ""); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java new file mode 100644 index 000000000..a53052832 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.LoadingPickItemMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class LoadingPickItemMapperContractTest { + @Test + void completionCheckCoversAllActivePlanItems() throws Exception { + Method method = LoadingPickItemMapper.class.getDeclaredMethod("unfinishedCount", long.class); + String sql = method.getAnnotation(Select.class).value()[0]; + + assertThat(sql) + .contains("plan_item.loading_plan_id = #{planId}") + .contains("plan_item.deleted = 0") + .contains("pick_task.loading_plan_id = #{planId}") + .contains("pick_task.task_status = 'LOADING'") + .contains("pick_item.picked_package_count = plan_item.package_count"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java index 4dcf91f3b..ce8093cce 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -20,6 +20,8 @@ class SmartLoadingCandidateMapperContractTest { .contains("SELECT MAX(m2.id)") .contains("m2.status='APPROVED'") .contains("i.inbound_detail_id IS NOT NULL") + .contains("SELECT SUM(li.package_count)") + .contains("li.deleted=0") .contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC"); } @@ -32,6 +34,8 @@ class SmartLoadingCandidateMapperContractTest { assertThat(sql) .contains("o.destination_warehouse_id destinationWarehouseId") .contains("m.status='APPROVED'") - .contains("SELECT MAX(m2.id)"); + .contains("SELECT MAX(m2.id)") + .contains("SELECT SUM(li.package_count)") + .contains("FOR UPDATE"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index 3d278e8e2..114805d7c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -64,7 +64,8 @@ class SmartLoadingSqlContractTest { assertThat(migration()) .contains("active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS") .contains("CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END") - .contains("UNIQUE KEY uk_loading_item_active_detail (account_id, active_inbound_detail_id)") + .contains("UNIQUE KEY uk_loading_item_plan_detail (loading_plan_id, active_inbound_detail_id)") + .contains("KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id)") .contains("UNIQUE KEY uk_container_booking_no (booking_no)") .contains("UNIQUE KEY uk_container_booking_cabinet (cabinet_no)") .contains("UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key)") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 7c72b7501..cbe3b9e48 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -3,13 +3,13 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow; import com.baiyue.firstleg.repository.LoadingListRepository; +import com.baiyue.firstleg.repository.LoadingPickRepository; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; @@ -36,6 +36,8 @@ import static org.mockito.Mockito.when; class LoadingListServiceTest { @MockBean private LoadingListRepository repository; + @MockBean + private LoadingPickRepository pickRepository; @Resource private LoadingListService service; @@ -95,19 +97,32 @@ class LoadingListServiceTest { } @Test - void shouldAdvanceOnlyLoadingToCompleted() { + void shouldCompleteLoadingAfterAllPdaItemsPicked() { AggregateRow before = row("LOADING", 2); - AggregateRow after = row("COMPLETED", 3); + when(pickRepository.allPlanItemsPicked(10L)).thenReturn(true); when(repository.history(anyString())).thenReturn(null); - when(repository.aggregate(10L)).thenReturn(before, after); + when(repository.aggregate(10L)).thenReturn(before); when(repository.advance(10L, "LOADING", "COMPLETED", 2, 9L, "operator")) .thenReturn(1); - when(repository.preloadDetail(after)).thenReturn( - new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); - service.complete(10L, 9L, "operator", "complete-key", new AdvanceRequest(2, null)); + service.finishFromPda(10L, 21L, 9L, "operator"); verify(repository).advance(10L, "LOADING", "COMPLETED", 2, 9L, "operator"); + verify(repository).appendHistory(eq(10L), eq("PDA-COMPLETE-10-21"), + eq("PDA_COMPLETE_LOADING"), eq("LOADING"), eq("COMPLETED"), anyString(), anyString(), + eq(9L), eq("operator"), eq("全部箱数已完成PDA拣货")); + } + + @Test + void shouldRejectAutomaticCompletionWhenPdaItemsRemain() { + when(pickRepository.allPlanItemsPicked(10L)).thenReturn(false); + + assertThatThrownBy(() -> service.finishFromPda(10L, 21L, 9L, "operator")) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + verify(repository, never()).advance(eq(10L), anyString(), anyString(), + org.mockito.ArgumentMatchers.anyInt(), eq(9L), anyString()); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java new file mode 100644 index 000000000..ad8f65dc2 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -0,0 +1,85 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; +import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; +import com.baiyue.firstleg.entity.LoadingPickItemEntity; +import com.baiyue.firstleg.entity.LoadingPickTaskEntity; +import com.baiyue.firstleg.repository.LoadingPickRepository; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(LoadingPickService.class) +class LoadingPickServiceTest { + @MockBean + private LoadingPickRepository repository; + @MockBean + private LoadingListService loadingListService; + @Resource + private LoadingPickService service; + + @Test + void keepsPlanLoadingWhenItemsRemain() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity item = item(); + when(repository.history("pick-1")).thenReturn(null); + when(repository.task(20L)).thenReturn(task); + when(repository.item(20L, 30L)).thenReturn(item); + when(repository.pick(30L, 2, 0)).thenReturn(1); + when(repository.allPlanItemsPicked(10L)).thenReturn(false); + when(repository.items(20L)).thenReturn(List.of(item)); + + service.pick(20L, 30L, new PickRequest(2, List.of(), 0, "pick-1"), 9L, "operator"); + + verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator"); + } + + @Test + void completesPlanWhenLastItemIsPicked() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity item = item(); + when(repository.history("pick-2")).thenReturn(null); + when(repository.task(20L)).thenReturn(task); + when(repository.item(20L, 30L)).thenReturn(item); + when(repository.pick(30L, 2, 0)).thenReturn(1); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + when(repository.items(20L)).thenReturn(List.of(item)); + + service.pick(20L, 30L, new PickRequest(2, List.of(), 0, "pick-2"), 9L, "operator"); + + verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); + verify(repository).insert(any(LoadingPickHistoryEntity.class)); + } + + private static LoadingPickTaskEntity task() { + LoadingPickTaskEntity task = new LoadingPickTaskEntity(); + task.setId(20L); + task.setLoadingPlanId(10L); + task.setContainerNo("CSLU1234567"); + task.setTaskStatus("LOADING"); + task.setVersion(0); + return task; + } + + private static LoadingPickItemEntity item() { + LoadingPickItemEntity item = new LoadingPickItemEntity(); + item.setId(30L); + item.setPickTaskId(20L); + item.setLoadingPlanItemId(40L); + item.setInboundId(50L); + item.setInboundDetailId(60L); + item.setPlannedPackageCount(2); + item.setPickedPackageCount(0); + item.setItemStatus("PENDING"); + item.setVersion(0); + return item; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 0a780ecab..f9b56d397 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -95,10 +95,10 @@ class SmartLoadingServiceTest { LoadingPlanEntity plan = plan("EDITING", 0); when(repository.plan(1)).thenReturn(plan); when(repository.candidate(9, 10, 20)).thenReturn(candidate(5)); - when(repository.activeItem(9, 20)).thenReturn(item(7, 9, 20, 4)); + when(repository.activeItem(1L, 9L, 20L)).thenReturn(item(7, 9, 20, 4)); BizException error = catchThrowableOfType(() -> service.assign(1, - new AssignRequest(9L, 10L, 20L, 2, 0, "assign-1"), 7, "u"), BizException.class); + new AssignRequest(9L, 10L, 20L, 6, 0, "assign-1"), 7, "u"), BizException.class); assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any()); @@ -297,7 +297,7 @@ class SmartLoadingServiceTest { when(repository.booking(99)).thenReturn(booking()); when(repository.items(1)).thenReturn(List.of()); when(repository.candidate(9, 10, 20)).thenReturn(candidate(10)); - when(repository.activeItem(9, 20)).thenReturn(null); + when(repository.activeItem(1L, 9L, 20L)).thenReturn(null); when(repository.touch(1, 0, 7, "u")).thenReturn(1); } 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 new file mode 100644 index 000000000..b75bb30af --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java @@ -0,0 +1,80 @@ +package com.baiyue.firstleg.split.service; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class SplitPreloadSqlContractTest { + @Test + void supportsPartialPackageLock() throws Exception { + assertPartialContract(moduleMigration()); + } + + @Test + void limitsSecondaryInboundDepth() throws Exception { + String sql = moduleMigration(); + assertThat(sql) + .contains("parent_inbound_id") + .contains("split_level") + .contains("split_status") + .contains("UNIQUE KEY uk_inbound_one_secondary (tenant_id, parent_inbound_id)") + .contains("CONSTRAINT fk_inbound_parent FOREIGN KEY (parent_inbound_id)") + .contains("二级子入库单不能继续拆分") + .contains("入库单主子关系创建后不可修改") + .contains("已完成拆单不能回退"); + } + + @Test + void remainsRepeatable() throws Exception { + String sql = moduleMigration(); + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_detail") + .contains("information_schema.columns") + .contains("information_schema.statistics") + .contains("information_schema.table_constraints") + .contains("DROP TRIGGER IF EXISTS trg_inbound_split_before_insert") + .contains("DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_delete") + .contains("CREATE PROCEDURE phase3_split_assert_contract()") + .contains("拆单迁移失败:唯一子单或部分锁定幂等索引缺失") + .contains("拆单迁移失败:主子层级或部分锁定触发器未完整创建"); + } + + @Test + void syncsUnifiedMigration() throws Exception { + assertPartialContract(unifiedMigration()); + } + + private static void assertPartialContract(String sql) { + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_detail") + .contains("remaining_package_count INT UNSIGNED GENERATED ALWAYS AS") + .contains("source_package_count - locked_package_count") + .contains("locked_package_count < source_package_count") + .contains("status = 'LOCKED' AND child_inbound_id IS NULL") + .contains("status = 'COMPLETED' AND child_inbound_id IS NOT NULL") + .contains("UNIQUE KEY uk_inbound_split_parent_detail") + .contains("UNIQUE KEY uk_inbound_split_operation (operation_id)") + .contains("部分锁定的归属和箱数创建后不可修改") + .contains("部分锁定与拆单明细不可删除") + .contains("剩余箱数必须绑定主单唯一二级子入库单"); + } + + private static String moduleMigration() throws Exception { + return new ClassPathResource("db/phase3-split-preload-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + } + + private static String unifiedMigration() throws Exception { + Path working = Path.of("").toAbsolutePath(); + Path file = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(file)) { + file = working.resolve("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize(); + } + return Files.readString(file, StandardCharsets.UTF_8); + } +}