装柜
This commit is contained in:
@@ -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()
|
||||
|
||||
-15
@@ -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<ConfirmedDetail> 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')")
|
||||
|
||||
@@ -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<LoadingPickItemEntity>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<Candidate> 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);
|
||||
|
||||
@@ -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<LoadingPickHistoryEntity>()
|
||||
.eq(LoadingPickHistoryEntity::getOperationId, operationId));
|
||||
|
||||
@@ -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<LoadingPlanItemEntity>()
|
||||
.eq(LoadingPlanItemEntity::getLoadingPlanId, planId)
|
||||
.eq(LoadingPlanItemEntity::getAccountId, accountId)
|
||||
.eq(LoadingPlanItemEntity::getInboundDetailId, detailId)
|
||||
.eq(LoadingPlanItemEntity::getDeleted, false));
|
||||
|
||||
@@ -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<PreloadSummary> 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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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<LoadingPlanItemEntity> 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<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));
|
||||
others.removeIf(x -> Objects.equals(x.getId(), itemId));
|
||||
|
||||
@@ -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;
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
+6
-5
@@ -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")
|
||||
|
||||
+105
@@ -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", "");
|
||||
}
|
||||
}
|
||||
+24
@@ -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");
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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)")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+80
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user