This commit is contained in:
2026-08-07 10:57:06 +08:00
parent 913eca7b7d
commit ebea3a1813
45 changed files with 1271 additions and 582 deletions
+293 -396
View File
@@ -4033,7 +4033,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID', inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID',
planned_package_count INT UNSIGNED NOT NULL COMMENT '计划拣货箱数;上层拆分按业务规则向上取整', planned_package_count INT UNSIGNED NOT NULL COMMENT '计划拣货箱数;上层拆分按业务规则向上取整',
picked_package_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已拣货箱数', picked_package_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已拣货箱数',
item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PICKING、PICKED', item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PICKING、PICKED、PARTIAL、SKIPPED',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
@@ -4044,7 +4044,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
CONSTRAINT fk_loading_pick_item_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id), CONSTRAINT fk_loading_pick_item_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id),
CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0), CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0),
CONSTRAINT chk_loading_pick_item_count CHECK (picked_package_count <= planned_package_count), CONSTRAINT chk_loading_pick_item_count CHECK (picked_package_count <= planned_package_count),
CONSTRAINT chk_loading_pick_item_status CHECK (item_status IN ('PENDING', 'PICKING', 'PICKED')) CONSTRAINT chk_loading_pick_item_status CHECK (item_status IN ('PENDING', 'PICKING', 'PICKED', 'PARTIAL', 'SKIPPED'))
) ENGINE=InnoDB COMMENT='PDA装柜拣货明细'; ) ENGINE=InnoDB COMMENT='PDA装柜拣货明细';
CREATE TABLE IF NOT EXISTS fl_loading_pick_history ( CREATE TABLE IF NOT EXISTS fl_loading_pick_history (
@@ -4053,7 +4053,9 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history (
pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID', pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
operation_id VARCHAR(128) NOT NULL COMMENT '客户端幂等操作号', operation_id VARCHAR(128) NOT NULL COMMENT '客户端幂等操作号',
request_hash CHAR(64) NOT NULL COMMENT '请求指纹', request_hash CHAR(64) NOT NULL COMMENT '请求指纹',
action_type VARCHAR(24) NOT NULL DEFAULT 'PICK' COMMENT 'PICK、PARTIAL、SKIP',
picked_package_count INT UNSIGNED NOT NULL COMMENT '本次拣货箱数', picked_package_count INT UNSIGNED NOT NULL COMMENT '本次拣货箱数',
operation_reason VARCHAR(500) NULL COMMENT '部分拣货或跳拣原因',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人', operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人',
operator_name VARCHAR(64) NULL COMMENT '操作人名称', operator_name VARCHAR(64) NULL COMMENT '操作人名称',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
@@ -4228,7 +4230,49 @@ BEGIN
IF current_plan_status = 'LOADING' AND EXISTS ( IF current_plan_status = 'LOADING' AND EXISTS (
SELECT 1 FROM fl_loading_pick_item SELECT 1 FROM fl_loading_pick_item
WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细不可修改或删除'; IF NOT EXISTS (
SELECT 1 FROM fl_loading_pick_item
WHERE loading_plan_item_id = OLD.id
AND item_status = 'PARTIAL'
AND NEW.deleted = 0
AND NEW.package_count = picked_package_count
) OR NEW.deleted <> OLD.deleted
OR NOT (NEW.customer_name <=> OLD.customer_name)
OR NOT (NEW.inbound_id <=> OLD.inbound_id)
OR NOT (NEW.inbound_detail_id <=> OLD.inbound_detail_id)
OR NOT (NEW.order_id <=> OLD.order_id)
OR NOT (NEW.order_no <=> OLD.order_no)
OR NOT (NEW.inbound_code <=> OLD.inbound_code)
OR NOT (NEW.destination_warehouse_id <=> OLD.destination_warehouse_id)
OR NOT (NEW.destination_warehouse_code <=> OLD.destination_warehouse_code)
OR NOT (NEW.destination_warehouse_name <=> OLD.destination_warehouse_name)
OR NOT (NEW.sku_code <=> OLD.sku_code)
OR NOT (NEW.product_name_zh <=> OLD.product_name_zh)
OR NOT (NEW.product_name_en <=> OLD.product_name_en)
OR NOT (NEW.product_image_file_id <=> OLD.product_image_file_id)
OR NOT (NEW.product_image_url <=> OLD.product_image_url)
OR NOT (NEW.product_quantity <=> OLD.product_quantity)
OR NOT (NEW.units_per_carton <=> OLD.units_per_carton)
OR NOT (NEW.pallet_count <=> OLD.pallet_count)
OR NOT (NEW.gross_weight_kg <=> OLD.gross_weight_kg)
OR NOT (NEW.net_weight_kg <=> OLD.net_weight_kg)
OR NOT (NEW.dimensional_weight_kg <=> OLD.dimensional_weight_kg)
OR NOT (NEW.volume_cbm <=> OLD.volume_cbm)
OR NOT (NEW.fba_tracking_no <=> OLD.fba_tracking_no)
OR NOT (NEW.shipment_no <=> OLD.shipment_no)
OR NOT (NEW.shipping_mark <=> OLD.shipping_mark)
OR NOT (NEW.load_sequence <=> OLD.load_sequence)
OR NOT (NEW.position_snapshot <=> OLD.position_snapshot)
OR NOT (NEW.internal_remark <=> OLD.internal_remark)
OR NOT (NEW.customer_remark <=> OLD.customer_remark)
OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark)
OR NOT (NEW.frozen_at <=> OLD.frozen_at)
OR NOT (NEW.frozen_by <=> OLD.frozen_by)
OR NOT (NEW.removed_by <=> OLD.removed_by)
OR NOT (NEW.removed_by_name <=> OLD.removed_by_name)
OR NOT (NEW.removed_at <=> OLD.removed_at) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数';
END IF;
END IF; END IF;
IF OLD.frozen_at IS NOT NULL THEN IF OLD.frozen_at IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结';
@@ -4287,6 +4331,28 @@ BEGIN
OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货明细归属不可修改'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货明细归属不可修改';
END IF; END IF;
IF NEW.item_status = 'PARTIAL' AND NOT (
OLD.item_status IN ('PENDING', 'PICKING')
AND OLD.picked_package_count < NEW.picked_package_count
AND NEW.picked_package_count < OLD.planned_package_count
AND NEW.planned_package_count = OLD.planned_package_count
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分拣货必须在计划箱数内增加实际拣出箱数';
END IF;
IF NEW.item_status = 'SKIPPED' AND NOT (
OLD.item_status = 'PENDING'
AND OLD.picked_package_count = 0
AND NEW.picked_package_count = 0
AND NEW.planned_package_count = OLD.planned_package_count
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '跳拣仅允许零拣货的待拣明细';
END IF;
IF OLD.item_status = 'PARTIAL' AND NEW.item_status = 'PICKED' AND NOT (
NEW.picked_package_count = OLD.picked_package_count
AND NEW.planned_package_count = OLD.picked_package_count
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分拣货收敛后计划箱数必须等于实际拣出箱数';
END IF;
END$$ END$$
DELIMITER ; DELIMITER ;
@@ -4575,6 +4641,117 @@ DELIMITER ;
CALL assert_phase3_smart_loading_schema(); CALL assert_phase3_smart_loading_schema();
DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema; DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema;
DELIMITER $$
CREATE PROCEDURE assert_phase3_smart_loading_schema()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'baiyue_firstleg'
AND table_name IN ('fl_container_booking', 'fl_loading_plan', 'fl_loading_plan_item',
'fl_container_document', 'fl_loading_plan_history',
'fl_loading_pick_task', 'fl_loading_pick_item',
'fl_loading_pick_history')) <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:核心表未完整创建';
END IF;
IF EXISTS (SELECT 1 FROM fl_loading_plan
WHERE plan_status NOT IN ('EDITING', 'LOADING', 'COMPLETED')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:存在旧装柜状态';
END IF;
IF (SELECT COUNT(*) FROM information_schema.check_constraints
WHERE constraint_schema = 'baiyue_firstleg'
AND constraint_name = 'chk_loading_plan_status'
AND check_clause LIKE '%''EDITING''%'
AND check_clause LIKE '%''LOADING''%'
AND check_clause LIKE '%''COMPLETED''%') <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:装柜状态约束不是最终三状态';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_container_booking'
AND column_name IN ('booking_no', 'cabinet_no', 'warehouse_id', 'bill_of_lading_no',
'vessel_name', 'voyage_no', 'container_type', 'capacity_cbm',
'max_weight_kg', 'terminal_trucking_company')) <> 10 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:货柜原型字段缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item'
AND column_name IN ('account_id', 'customer_name', 'active_inbound_detail_id', 'frozen_at',
'destination_warehouse_name', 'product_name_zh', 'product_name_en',
'product_image_file_id', 'pallet_count', 'net_weight_kg',
'dimensional_weight_kg', 'product_quantity', 'units_per_carton',
'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 17 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item'
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'
AND table_name = 'fl_loading_pick_item'
AND column_name IN ('pick_task_id', 'loading_plan_item_id', 'inbound_id',
'inbound_detail_id', 'planned_package_count',
'picked_package_count', 'item_status', 'version')) <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA拣货明细字段缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND non_unique = 0
AND index_name IN ('uk_loading_pick_task_plan',
'uk_loading_pick_item_plan_item',
'uk_loading_pick_history_operation')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA唯一索引缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.triggers
WHERE trigger_schema = 'baiyue_firstleg'
AND trigger_name IN ('trg_container_booking_before_update',
'trg_loading_plan_before_update', 'trg_loading_plan_after_update',
'trg_loading_item_before_insert', 'trg_loading_item_before_update',
'trg_loading_item_before_delete', 'trg_container_document_before_insert',
'trg_container_document_before_update', 'trg_container_document_before_delete',
'trg_loading_history_before_update', 'trg_loading_history_before_delete',
'trg_loading_pick_task_before_update',
'trg_loading_pick_item_before_update',
'trg_loading_pick_item_before_delete',
'trg_loading_pick_history_before_update',
'trg_loading_pick_history_before_delete')) <> 16 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:不可逆状态或冻结触发器缺失';
END IF;
IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0
AND permission_code IN (
'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:create', 'platform:firstleg:container:edit',
'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:create',
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
'platform:firstleg:smart-loading:push', 'platform:firstleg:preload:list',
'platform:firstleg:preload:view', 'platform:firstleg:preload:edit',
'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm',
'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 16 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建';
END IF;
IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code))
FROM baiyue_iam.iam_role role
JOIN baiyue_iam.iam_role_permission grant_row ON grant_row.role_id = role.id
JOIN baiyue_iam.iam_permission permission ON permission.id = grant_row.permission_id
WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')
AND permission.tenant_id = 0
AND permission.permission_code IN (
'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign',
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
'platform:firstleg:loading-list:view')) <> 32 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入';
END IF;
END$$
DELIMITER ;
CALL assert_phase3_smart_loading_schema();
DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema;
-- ============================================================ -- ============================================================
-- 2026-08-06 平台公共仓库正式主数据:固定作业仓 + FBA 地址库 -- 2026-08-06 平台公共仓库正式主数据:固定作业仓 + FBA 地址库
@@ -5564,8 +5741,8 @@ CALL phase3_inbound_add_index(
'fl_inbound_order', 'uk_inbound_master_order', 'fl_inbound_order', 'uk_inbound_master_order',
'UNIQUE KEY `uk_inbound_master_order` (`tenant_id`, `master_order_id`)'); 'UNIQUE KEY `uk_inbound_master_order` (`tenant_id`, `master_order_id`)');
CALL phase3_inbound_add_index( CALL phase3_inbound_add_index(
'fl_inbound_order', 'uk_inbound_parent_sequence', 'fl_inbound_order', 'uk_inbound_one_child',
'UNIQUE KEY `uk_inbound_parent_sequence` (`tenant_id`, `parent_inbound_id`, `split_sequence`)'); 'UNIQUE KEY `uk_inbound_one_child` (`tenant_id`, `parent_inbound_id`)');
CALL phase3_inbound_add_index( CALL phase3_inbound_add_index(
'fl_inbound_order', 'idx_inbound_root_role', 'fl_inbound_order', 'idx_inbound_root_role',
'KEY `idx_inbound_root_role` (`tenant_id`, `root_inbound_id`, `inbound_role`, `created_at`, `id`)'); 'KEY `idx_inbound_root_role` (`tenant_id`, `root_inbound_id`, `inbound_role`, `created_at`, `id`)');
@@ -5582,6 +5759,14 @@ BEGIN
) THEN ) THEN
ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_tenant_order; ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_tenant_order;
END IF; 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_parent_sequence'
) THEN
ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_parent_sequence;
END IF;
END$$ END$$
DELIMITER ; DELIMITER ;
CALL phase3_inbound_drop_legacy_index(); CALL phase3_inbound_drop_legacy_index();
@@ -5618,19 +5803,22 @@ CREATE TABLE IF NOT EXISTS fl_inbound_split_item (
split_batch_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', parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID',
child_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '子入仓单ID', child_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '子入仓单ID',
line_no INT UNSIGNED NOT NULL COMMENT '同一拆分批次内的行号',
source_inbound_detail_id BIGINT UNSIGNED NULL COMMENT '订单货物明细逻辑ID', source_inbound_detail_id BIGINT UNSIGNED NULL COMMENT '订单货物明细逻辑ID',
source_measurement_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 '分配给子单的箱数', package_count INT UNSIGNED NOT NULL COMMENT '分配给子单的箱数',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_inbound_split_child (tenant_id, child_inbound_id), UNIQUE KEY uk_inbound_split_batch_line (tenant_id, split_batch_id, line_no),
UNIQUE KEY uk_inbound_split_batch_child (tenant_id, split_batch_id, child_inbound_id), UNIQUE KEY uk_inbound_split_batch_source_item (tenant_id, split_batch_id, source_measurement_item_id),
KEY idx_inbound_split_detail (tenant_id, source_inbound_detail_id, created_at), KEY idx_inbound_split_detail (tenant_id, source_inbound_detail_id, created_at),
CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0), 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_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_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_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_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='主子入仓单拆分数量台账'; ) ENGINE=InnoDB COMMENT='主子入仓单拆分数量台账';
CREATE TABLE IF NOT EXISTS fl_inbound_split_history ( CREATE TABLE IF NOT EXISTS fl_inbound_split_history (
@@ -5680,6 +5868,15 @@ CALL phase3_inbound_add_column(
CALL phase3_inbound_add_column( CALL phase3_inbound_add_column(
'fl_inbound_measurement', 'source_measurement_id', 'fl_inbound_measurement', 'source_measurement_id',
"BIGINT UNSIGNED NULL COMMENT '子单继承或复核时的来源测量批次ID' AFTER split_batch_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");
CALL phase3_inbound_add_column(
'fl_inbound_split_item', 'line_no',
"INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一拆分批次内的行号' AFTER child_inbound_id");
DROP PROCEDURE IF EXISTS phase3_inbound_add_column; DROP PROCEDURE IF EXISTS phase3_inbound_add_column;
DROP PROCEDURE IF EXISTS phase3_inbound_add_relation; DROP PROCEDURE IF EXISTS phase3_inbound_add_relation;
@@ -5716,6 +5913,12 @@ CALL phase3_inbound_add_relation(
CALL phase3_inbound_add_relation( CALL phase3_inbound_add_relation(
'fl_inbound_measurement', 'fk_measure_source_batch', 'fl_inbound_measurement', 'fk_measure_source_batch',
'FOREIGN KEY (`source_measurement_id`) REFERENCES `fl_inbound_measurement` (`id`)'); '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_relation;
DROP PROCEDURE IF EXISTS phase3_inbound_add_index; DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
@@ -5742,8 +5945,34 @@ DELIMITER ;
CALL phase3_inbound_add_index( CALL phase3_inbound_add_index(
'fl_inbound_measurement', 'idx_measure_split_source', 'fl_inbound_measurement', 'idx_measure_split_source',
'KEY `idx_measure_split_source` (`tenant_id`, `split_batch_id`, `source_measurement_id`, `created_at`)'); '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`)');
CALL phase3_inbound_add_index(
'fl_inbound_split_item', 'uk_inbound_split_batch_line',
'UNIQUE KEY `uk_inbound_split_batch_line` (`tenant_id`, `split_batch_id`, `line_no`)');
CALL phase3_inbound_add_index(
'fl_inbound_split_item', 'uk_inbound_split_batch_source_item',
'UNIQUE KEY `uk_inbound_split_batch_source_item` (`tenant_id`, `split_batch_id`, `source_measurement_item_id`)');
DROP PROCEDURE IF EXISTS phase3_inbound_add_index; DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index;
DELIMITER $$
CREATE PROCEDURE phase3_inbound_drop_split_legacy_index()
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_child') THEN
ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_child;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_batch_child') THEN
ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_batch_child;
END IF;
END$$
DELIMITER ;
CALL phase3_inbound_drop_split_legacy_index();
DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index;
DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_insert; 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_order_parent_before_update;
DROP TRIGGER IF EXISTS trg_inbound_split_item_before_insert; DROP TRIGGER IF EXISTS trg_inbound_split_item_before_insert;
@@ -5762,6 +5991,9 @@ BEGIN
IF NEW.inbound_role NOT IN ('MASTER', 'CHILD') THEN IF NEW.inbound_role NOT IN ('MASTER', 'CHILD') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单层级非法'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单层级非法';
END IF; 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 IF NEW.package_count IS NOT NULL AND NEW.package_count = 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单箱数必须大于0'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单箱数必须大于0';
END IF; END IF;
@@ -5773,6 +6005,9 @@ BEGIN
OR NEW.split_sequence IS NULL OR NEW.package_count IS NULL THEN OR NEW.split_sequence IS NULL OR NEW.package_count IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单缺少主单、顺序号或箱数'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单缺少主单、顺序号或箱数';
END IF; END IF;
IF NEW.split_status <> 'NOT_SPLIT' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子仅两级,子入仓单不可继续拆分';
END IF;
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM fl_inbound_order parent_order SELECT 1 FROM fl_inbound_order parent_order
WHERE parent_order.id = NEW.parent_inbound_id WHERE parent_order.id = NEW.parent_inbound_id
@@ -5799,6 +6034,17 @@ BEGIN
AND (NEW.package_count IS NULL OR NEW.package_count = 0) THEN AND (NEW.package_count IS NULL OR NEW.package_count = 0) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单箱数必须大于0'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单箱数必须大于0';
END IF; 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$$ END$$
CREATE TRIGGER trg_inbound_split_item_before_insert CREATE TRIGGER trg_inbound_split_item_before_insert
@@ -5819,8 +6065,17 @@ BEGIN
AND child_order.tenant_id = NEW.tenant_id AND child_order.tenant_id = NEW.tenant_id
AND child_order.inbound_role = 'CHILD' AND child_order.inbound_role = 'CHILD'
AND child_order.parent_inbound_id = NEW.parent_inbound_id AND child_order.parent_inbound_id = NEW.parent_inbound_id
AND child_order.package_count = NEW.package_count AND child_order.package_count >= NEW.package_count
AND child_order.deleted = 0 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 ) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分明细与批次、主单或子单不一致'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分明细与批次、主单或子单不一致';
END IF; END IF;
@@ -5858,14 +6113,38 @@ BEGIN
WHERE parent_order.id = NEW.parent_inbound_id WHERE parent_order.id = NEW.parent_inbound_id
AND parent_order.tenant_id = NEW.tenant_id AND parent_order.tenant_id = NEW.tenant_id
AND parent_order.inbound_role = 'MASTER' AND parent_order.inbound_role = 'MASTER'
AND parent_order.package_count = 0 AND parent_order.package_count > 0
AND parent_order.split_status = 'PARTIALLY_SPLIT'
AND parent_order.version > NEW.expected_parent_version AND parent_order.version > NEW.expected_parent_version
) OR ) OR
(SELECT COALESCE(SUM(item.package_count), 0) (SELECT COALESCE(SUM(item.package_count), 0)
FROM fl_inbound_split_item item FROM fl_inbound_split_item item
WHERE item.tenant_id = NEW.tenant_id WHERE item.tenant_id = NEW.tenant_id
AND item.split_batch_id = NEW.id) <> NEW.package_count THEN AND item.split_batch_id = NEW.id) <> NEW.package_count OR
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单可用箱数未归零或子单箱数合计不一致'; (SELECT COUNT(DISTINCT item.child_inbound_id)
FROM fl_inbound_split_item item
WHERE item.tenant_id = NEW.tenant_id
AND item.split_batch_id = NEW.id) <> 1 OR
EXISTS (SELECT 1
FROM fl_inbound_split_item item
JOIN fl_inbound_order child_order ON child_order.id = item.child_inbound_id
WHERE item.tenant_id = NEW.tenant_id
AND item.split_batch_id = NEW.id
GROUP BY child_order.id, child_order.package_count
HAVING child_order.package_count <> SUM(item.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; END IF;
ELSEIF NEW.status = 'CANCELLED' THEN ELSEIF NEW.status = 'CANCELLED' THEN
IF EXISTS ( IF EXISTS (
@@ -5937,7 +6216,8 @@ BEGIN
IF (SELECT COUNT(*) FROM information_schema.table_constraints IF (SELECT COUNT(*) FROM information_schema.table_constraints
WHERE constraint_schema = DATABASE() WHERE constraint_schema = DATABASE()
AND constraint_name IN ('fk_inbound_parent_order', 'fk_inbound_root_order', AND constraint_name IN ('fk_inbound_parent_order', 'fk_inbound_root_order',
'fk_measure_split_batch', 'fk_measure_source_batch')) <> 4 THEN '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 = '主子入仓单与测量关联约束未完整创建'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单与测量关联约束未完整创建';
END IF; END IF;
IF (SELECT COUNT(*) FROM information_schema.triggers IF (SELECT COUNT(*) FROM information_schema.triggers
@@ -5965,386 +6245,3 @@ END$$
DELIMITER ; DELIMITER ;
CALL phase3_assert_inbound_parent_child_schema(); CALL phase3_assert_inbound_parent_child_schema();
DROP PROCEDURE IF EXISTS 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()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'baiyue_firstleg'
AND table_name IN ('fl_preload_plan', 'fl_split_operation',
'fl_loading_evidence', 'fl_loading_operation',
'fl_loading_pick_task')) <> 5 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:三期分柜、预装或装柜表未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_preload_plan'
AND column_name IN ('id', 'tenant_id', 'inbound_id', 'status', 'version',
'items_json', 'remark', 'created_by', 'created_by_name',
'updated_by', 'updated_by_name', 'created_at', 'updated_at')) <> 13
OR (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_split_operation'
AND column_name IN ('id', 'tenant_id', 'inbound_id', 'operator_id', 'operation',
'idempotency_key', 'status', 'result_json', 'created_at')) <> 9
OR (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_evidence'
AND column_name IN ('id', 'tenant_id', 'inbound_id', 'evidence_type', 'file_id',
'file_name', 'seal_no', 'remark', 'version', 'created_by',
'created_by_name', 'updated_by', 'updated_by_name',
'created_at', 'updated_at')) <> 15
OR (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_operation'
AND column_name IN ('id', 'tenant_id', 'inbound_id', 'operator_id', 'operation',
'idempotency_key', 'request_hash', 'status', 'result_json',
'created_at', 'completed_at')) <> 11
OR (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
AND column_name IN ('id', 'tenant_id', 'inbound_id', 'status', 'version',
'expected_count', 'picked_count', 'remark', 'updated_by',
'updated_by_name', 'created_at', 'updated_at')) <> 12 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:三期分柜、预装或装柜关键字段未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name IN ('fl_preload_plan', 'fl_split_operation', 'fl_loading_operation')
AND column_name IN ('items_json', 'result_json') AND data_type = 'json') <> 3
OR (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_order'
AND column_name = 'process_status' AND character_maximum_length >= 24) <> 1 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:三期 JSON 字段或入库状态字段类型不兼容';
END IF;
IF (SELECT COUNT(DISTINCT CONCAT(table_name, '.', index_name))
FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg'
AND index_name IN ('uk_preload_tenant_inbound', 'idx_preload_status',
'uk_split_operation', 'idx_split_operation_time',
'uk_loading_evidence_type', 'idx_loading_evidence_time',
'uk_loading_operation', 'idx_loading_operation_time',
'uk_loading_pick_tenant_inbound', 'idx_loading_pick_status')) <> 10 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:三期分柜、预装或装柜索引未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.table_constraints
WHERE constraint_schema = 'baiyue_firstleg' AND constraint_type = 'FOREIGN KEY'
AND table_name IN ('fl_preload_plan', 'fl_split_operation',
'fl_loading_evidence', 'fl_loading_operation',
'fl_loading_pick_task')) <> 5 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:三期操作表入库单外键未完整创建';
END IF;
IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0
AND permission_code IN (
'firstleg:inbound:split', 'firstleg:inbound:preload',
'firstleg:inbound:preload-view',
'platform:firstleg:inbound:loading:edit',
'platform:firstleg:inbound:loading:view',
'platform:firstleg:inbound:loading:complete',
'platform:firstleg:inbound:delivery:complete',
'platform:firstleg:inbound:picking:edit',
'platform:firstleg:inbound:picking:view')) <> 9 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:三期分柜、预装或装柜权限未完整创建';
END IF;
IF (SELECT COUNT(DISTINCT rp.permission_id)
FROM baiyue_iam.iam_role_permission rp
JOIN baiyue_iam.iam_permission p ON p.id = rp.permission_id
WHERE rp.role_id = 4000001 AND rp.tenant_id = 0
AND p.permission_code IN (
'firstleg:inbound:split', 'firstleg:inbound:preload',
'firstleg:inbound:preload-view',
'platform:firstleg:inbound:loading:edit',
'platform:firstleg:inbound:loading:view',
'platform:firstleg:inbound:loading:complete',
'platform:firstleg:inbound:delivery:complete',
'platform:firstleg:inbound:picking:edit',
'platform:firstleg:inbound:picking:view')) <> 9 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:平台系统管理员未获得完整三期操作权限';
END IF;
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'baiyue_firstleg'
AND table_name IN ('fl_fee_item', 'fl_inbound_fee_detail')) <> 2 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:费用项目或入库费用明细表未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name IN ('fl_fee_item', 'fl_inbound_fee_detail')
AND column_name = 'tenant_id'
AND column_comment = '当前客户ID(客户隔离键);物理字段名沿用 tenant_id,当前不表示上级租户') <> 2 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:费用表 tenant_id 客户隔离语义未同步';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_fee_item'
AND column_name = 'fee_code') <> 1
OR (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_inbound_fee_detail'
AND column_name IN (
'source_type', 'source_fee_id', 'source_fee_version', 'source_fee_no',
'customer_id', 'customer_name', 'invoice_title',
'chargeable_quantity', 'charge_unit', 'publication_status',
'published_by', 'published_by_name', 'published_at', 'inbounded_at',
'settlement_included')) <> 15 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:已发布入库运费明细字段未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_fee_item'
AND index_name = 'uk_fee_item_code' AND non_unique = 0) <> 2
OR (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_inbound_fee_detail'
AND index_name = 'uk_inbound_fee_source' AND non_unique = 0) <> 3
OR (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_inbound_fee_detail'
AND index_name = 'idx_inbound_fee_published') <> 4 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:已发布入库运费明细索引未完整创建';
END IF;
IF (SELECT COUNT(DISTINCT CONCAT(table_name, '.', index_name))
FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg'
AND index_name IN ('idx_fee_item_query', 'idx_fee_item_name',
'idx_inbound_fee_detail', 'idx_inbound_fee_review',
'idx_inbound_fee_item')) <> 5 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:费用项目或入库费用明细索引未完整创建';
END IF;
IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission
WHERE tenant_id = 0 AND status = 'ACTIVE' AND deleted = 0
AND permission_code IN (
'firstleg:fee-item:view', 'firstleg:fee-item:create',
'firstleg:fee-item:edit', 'firstleg:fee-item:delete',
'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit',
'firstleg:inbound:fee-detail:delete', 'firstleg:inbound:fee-detail:review')) <> 8 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:费用项目或入库费用明细权限未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema IN ('baiyue_auth', 'baiyue_iam', 'baiyue_order',
'baiyue_firstleg', 'baiyue_warehouse')
AND table_name = 'sys_audit_outbox') <> 5 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:可靠审计 Outbox 未覆盖五个业务库';
END IF;
IF (SELECT COUNT(DISTINCT CONCAT(table_schema, '.', index_name))
FROM information_schema.statistics
WHERE table_schema IN ('baiyue_auth', 'baiyue_iam', 'baiyue_order',
'baiyue_firstleg', 'baiyue_warehouse')
AND table_name = 'sys_audit_outbox'
AND index_name IN ('PRIMARY', 'idx_audit_outbox_publish',
'idx_audit_outbox_retention')) <> 15 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:可靠审计 Outbox 主键或索引未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema IN ('baiyue_auth', 'baiyue_iam', 'baiyue_order',
'baiyue_firstleg', 'baiyue_warehouse')
AND table_name = 'sys_audit_outbox'
AND column_name IN ('event_id', 'payload_json', 'status',
'next_retry_at', 'lease_until')) <> 25 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:可靠审计 Outbox 关键字段未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'baiyue_audit'
AND table_name = 'sys_audit_dead_letter') <> 1 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:审计死信补偿表未创建';
END IF;
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
WHERE table_schema = 'baiyue_audit'
AND table_name = 'sys_audit_dead_letter'
AND index_name IN ('PRIMARY', 'uk_audit_dead_event', 'idx_audit_dead_retry')) <> 3 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:审计死信补偿表主键或索引未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_audit' AND table_name = 'sys_audit_log'
AND column_name = 'event_id') <> 1
OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
WHERE table_schema = 'baiyue_audit' AND table_name = 'sys_audit_log'
AND index_name = 'uk_audit_event_id' AND non_unique = 0) <> 1 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:审计消费幂等 event_id 唯一约束未就绪';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_third_party_call_log'
AND column_name = 'tenant_id' AND column_default = '0'
AND column_comment = '第三方未知/未解析主体哨兵值;不是平台客户记录或平台作用域,不得用于业务数据授权') <> 1 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:第三方未知主体 tenant_id=0 哨兵语义未就绪';
END IF;
IF (SELECT COUNT(*) FROM baiyue_warehouse.wh_warehouse WHERE tenant_id <> 0) > 0
OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_warehouse_address WHERE tenant_id <> 0) > 0
OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_service_capability WHERE tenant_id <> 0) > 0
OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_express_company WHERE tenant_id <> 0) > 0
OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_partner
WHERE tenant_id <> 0 OR data_scope_id <> 0) > 0
OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_partner_type
WHERE tenant_id <> 0 OR data_scope_id <> 0) > 0
OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_pallet WHERE tenant_id <> 0) > 0
OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_location WHERE tenant_id <> 0) > 0
OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_location_reservation WHERE tenant_id <> 0) > 0
OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_location_occupancy WHERE tenant_id <> 0) > 0
OR (SELECT COUNT(*) FROM baiyue_firstleg.fl_inbound_pallet_allocation
WHERE warehouse_resource_tenant_id <> 0) > 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:平台公共仓储资源仍有 tenant_id<>0';
END IF;
IF EXISTS (
SELECT 1 FROM baiyue_warehouse.wh_warehouse_address a
JOIN baiyue_warehouse.wh_warehouse w ON w.id = a.warehouse_id
WHERE a.tenant_id <> w.tenant_id
) OR EXISTS (
SELECT 1 FROM baiyue_warehouse.wh_service_capability c
JOIN baiyue_warehouse.wh_warehouse w ON w.id = c.warehouse_id
WHERE c.tenant_id <> w.tenant_id
) OR EXISTS (
SELECT 1 FROM baiyue_warehouse.wh_pallet p
JOIN baiyue_warehouse.wh_warehouse w ON w.id = p.warehouse_id
WHERE p.tenant_id <> w.tenant_id
) OR EXISTS (
SELECT 1 FROM baiyue_warehouse.wh_location l
JOIN baiyue_warehouse.wh_warehouse w ON w.id = l.warehouse_id
WHERE l.tenant_id <> w.tenant_id
) OR EXISTS (
SELECT 1 FROM baiyue_warehouse.wh_location_reservation r
JOIN baiyue_warehouse.wh_warehouse w ON w.id = r.warehouse_id
JOIN baiyue_warehouse.wh_pallet p ON p.id = r.pallet_id
JOIN baiyue_warehouse.wh_location l ON l.id = r.location_id
WHERE r.tenant_id <> w.tenant_id OR r.tenant_id <> p.tenant_id OR r.tenant_id <> l.tenant_id
) OR EXISTS (
SELECT 1 FROM baiyue_warehouse.wh_location_occupancy o
JOIN baiyue_warehouse.wh_warehouse w ON w.id = o.warehouse_id
JOIN baiyue_warehouse.wh_pallet p ON p.id = o.pallet_id
JOIN baiyue_warehouse.wh_location l ON l.id = o.location_id
JOIN baiyue_warehouse.wh_location_reservation r ON r.id = o.reservation_id
WHERE o.tenant_id <> w.tenant_id OR o.tenant_id <> p.tenant_id
OR o.tenant_id <> l.tenant_id OR o.tenant_id <> r.tenant_id
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:Warehouse 公共资源父子归属不一致';
END IF;
IF EXISTS (SELECT 1 FROM baiyue_warehouse.wh_overseas_storage_fee_rule WHERE tenant_id = 0) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:客户费用规则缺少客户 tenant_id';
END IF;
END$$
DELIMITER ;
CALL unified_assert_final_extensions();
DROP PROCEDURE IF EXISTS unified_assert_final_extensions;
-- tenant_id=0 在此表仅为未知/未解析第三方主体;数量可非零,但不得用于业务授权。
SELECT COUNT(*) AS unresolved_third_party_calls
FROM baiyue_firstleg.fl_third_party_call_log
WHERE tenant_id = 0;
SELECT 'PASS' AS migration_status,
'统一增量全部执行完成;统一数据库脚本执行完成,三期出仓、费用、公共资源及可靠审计结构校验通过' AS message;
@@ -8,7 +8,11 @@ import org.springframework.cloud.openfeign.EnableFeignClients;
/** /**
* 头程履约微服务启动入口,负责装配履约任务组件并扫描 MyBatis-Plus Mapper。 * 头程履约微服务启动入口,负责装配履约任务组件并扫描 MyBatis-Plus Mapper。
*/ */
@MapperScan("com.baiyue.firstleg.mapper") @MapperScan(basePackages = {
"com.baiyue.firstleg.mapper",
"com.baiyue.firstleg.split.mapper",
"com.baiyue.firstleg.loading.mapper"
})
@EnableFeignClients(basePackages = "com.baiyue.firstleg.client") @EnableFeignClients(basePackages = "com.baiyue.firstleg.client")
@SpringBootApplication(scanBasePackages = "com.baiyue") @SpringBootApplication(scanBasePackages = "com.baiyue")
public class BaiyueFirstlegApplication { public class BaiyueFirstlegApplication {
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
/** 获取平台仓库主数据,供虚拟柜校验国内中转仓使用。 */
@FeignClient(name = "warehouse-service", contextId = "firstlegWarehouseMasterDataClient",
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouses")
public interface WarehouseMasterDataClient {
@GetMapping("/{tenantId}/{warehouseId}/active")
ApiResponse<Detail> active(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,
@PathVariable long warehouseId);
record Detail(Warehouse warehouse) {
}
record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName,
String warehouseType, String status) {
}
}
@@ -26,6 +26,7 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
/** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */ /** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */
@@ -50,9 +51,10 @@ public class PlatformLoadingListController {
@GetMapping("/loading-plans/{planId}") @GetMapping("/loading-plans/{planId}")
@PreAuthorize("hasAuthority('platform:firstleg:preload:view')") @PreAuthorize("hasAuthority('platform:firstleg:preload:view')")
public ApiResponse<PreloadDetail> preloadDetail( public ApiResponse<PreloadDetail> preloadDetail(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId) { @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.preloadDetail(planId)); return ApiResponse.success(service.preloadDetail(planId, warehouseId));
} }
@Operation(summary = "修改装柜运输字段", description = "仅装柜中可修改集装箱号与航次。") @Operation(summary = "修改装柜运输字段", description = "仅装柜中可修改集装箱号与航次。")
@@ -61,11 +63,12 @@ public class PlatformLoadingListController {
public ApiResponse<PreloadDetail> updateTransport( public ApiResponse<PreloadDetail> updateTransport(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long planId, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId,
@RequestHeader("Idempotency-Key") String idempotencyKey, @RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody TransportUpdateRequest request) { @Valid @RequestBody TransportUpdateRequest request) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.updateTransport( return ApiResponse.success(service.updateTransport(
planId, user.userId(), user.username(), idempotencyKey, request)); planId, warehouseId, user.userId(), user.username(), idempotencyKey, request));
} }
@Operation(summary = "分页查询完成装柜清单", description = "只读取 COMPLETED 状态的装柜计划。") @Operation(summary = "分页查询完成装柜清单", description = "只读取 COMPLETED 状态的装柜计划。")
@@ -81,8 +84,9 @@ public class PlatformLoadingListController {
@GetMapping("/loading-lists/{planId}") @GetMapping("/loading-lists/{planId}")
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:view')") @PreAuthorize("hasAuthority('platform:firstleg:loading-list:view')")
public ApiResponse<ConfirmedDetail> confirmedDetail( public ApiResponse<ConfirmedDetail> confirmedDetail(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId) { @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.confirmedDetail(planId)); return ApiResponse.success(service.confirmedDetail(planId, warehouseId));
} }
} }
@@ -3,6 +3,8 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task; import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.LoadingPickService; import com.baiyue.firstleg.service.LoadingPickService;
@@ -31,9 +33,10 @@ public class PlatformLoadingPdaController {
@GetMapping("/tasks") @GetMapping("/tasks")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<Task> task(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Task> task(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String containerNo) { @RequestParam String containerNo,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.task(containerNo)); return ApiResponse.success(service.task(containerNo, warehouseId));
} }
@PostMapping("/tasks/{taskId}/items/{itemId}/pick") @PostMapping("/tasks/{taskId}/items/{itemId}/pick")
@@ -45,4 +48,24 @@ public class PlatformLoadingPdaController {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.pick(taskId, itemId, request, user.userId(), user.username())); return ApiResponse.success(service.pick(taskId, itemId, request, user.userId(), user.username()));
} }
@PostMapping("/tasks/{taskId}/items/{itemId}/partial")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Task> partial(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long itemId,
@Valid @RequestBody PartialPickRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.partial(taskId, itemId, request, user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/items/{itemId}/skip")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Task> skip(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long itemId,
@Valid @RequestBody SkipRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.skip(taskId, itemId, request, user.userId(), user.username()));
}
} }
@@ -9,7 +9,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; imp
public class PlatformSmartLoadingController { public class PlatformSmartLoadingController {
@Resource private SmartLoadingService service; @Resource private SmartLoadingService service;
@GetMapping("/candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") @GetMapping("/candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<List<Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long accountId){FirstlegScopeGuard.platform(u,accountId);return ApiResponse.success(service.candidates(accountId));} public ApiResponse<List<Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long warehouseId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.candidates(warehouseId));}
@GetMapping("/plans/by-booking/{bookingId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") @GetMapping("/plans/by-booking/{bookingId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<Plan> draftByBooking(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long bookingId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.draftByBooking(bookingId));} public ApiResponse<Plan> draftByBooking(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long bookingId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.draftByBooking(bookingId));}
@GetMapping("/plans/{planId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") @GetMapping("/plans/{planId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
@@ -17,13 +17,13 @@ public class PlatformSmartLoadingController {
@PostMapping("/plans") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:create')") @PostMapping("/plans") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:create')")
public ApiResponse<Plan> create(@AuthenticationPrincipal AuthenticatedUser u,@Valid @RequestBody CreateRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.create(r.bookingId(),r.remark(),u.userId(),u.username()));} public ApiResponse<Plan> create(@AuthenticationPrincipal AuthenticatedUser u,@Valid @RequestBody CreateRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.create(r.bookingId(),r.remark(),u.userId(),u.username()));}
@PostMapping("/plans/{planId}/assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") @PostMapping("/plans/{planId}/assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));} public ApiResponse<Plan> assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));}
@PostMapping("/plans/{planId}/recommend") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") @PostMapping("/plans/{planId}/recommend") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> recommend(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody RecommendRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.recommend(planId,r,u.userId(),u.username()));} public ApiResponse<Plan> recommend(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody RecommendRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.recommend(planId,r,u.userId(),u.username()));}
@PatchMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") @PatchMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> adjust(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody AdjustRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.adjust(planId,itemId,r,u.userId(),u.username()));} public ApiResponse<Plan> adjust(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody AdjustRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.adjust(planId,itemId,r,u.userId(),u.username()));}
@DeleteMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')") @DeleteMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
public ApiResponse<Plan> delete(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody DeleteRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.delete(planId,itemId,r,u.userId(),u.username()));} public ApiResponse<Plan> delete(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody DeleteRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.delete(planId,itemId,r,u.userId(),u.username()));}
@PostMapping("/plans/{planId}/push-preload") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:push')") @PostMapping("/plans/{planId}/push-preload") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:push')")
public ApiResponse<Plan> push(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody PushRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.pushPreload(planId,key,r,u.userId(),u.username()));} public ApiResponse<Plan> push(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody PushRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.pushPreload(planId,key,r,u.userId(),u.username()));}
} }
@@ -48,7 +48,7 @@ public final class ContainerDtos {
@Size(max = 64) String containerNo, @Size(max = 64) String containerNo,
@NotBlank @Size(max = 64) String cabinetNo, @NotBlank @Size(max = 64) String cabinetNo,
@NotNull @Positive Long warehouseId, @NotNull @Positive Long warehouseId,
@NotBlank @Size(max = 128) String warehouseName, @Size(max = 128) String warehouseName,
@Size(max = 128) String billOfLadingNo, @Size(max = 128) String billOfLadingNo,
@Size(max = 128) String vesselName, @Size(max = 128) String vesselName,
@Size(max = 64) String voyageNo, @Size(max = 64) String voyageNo,
@@ -17,8 +17,7 @@ public final class LoadingListDtos {
public record PreloadQuery( public record PreloadQuery(
@Size(max = 128) String keyword, @Size(max = 128) String keyword,
String status, String status,
@Min(1) Long accountId, @NotNull @Min(1) Long warehouseId,
@Min(1) Long warehouseId,
LocalDateTime createdFrom, LocalDateTime createdFrom,
LocalDateTime createdTo, LocalDateTime createdTo,
@Min(1) long page, @Min(1) long page,
@@ -32,8 +31,7 @@ public final class LoadingListDtos {
public record ConfirmedQuery( public record ConfirmedQuery(
@Size(max = 128) String keyword, @Size(max = 128) String keyword,
@Min(1) Long accountId, @NotNull @Min(1) Long warehouseId,
@Min(1) Long warehouseId,
LocalDateTime createdFrom, LocalDateTime createdFrom,
LocalDateTime createdTo, LocalDateTime createdTo,
@Min(1) long page, @Min(1) long page,
@@ -14,6 +14,7 @@ public final class LoadingPickDtos {
} }
public record PickRequest( public record PickRequest(
@NotNull @Min(1) Long warehouseId,
@NotNull @Min(1) Integer pickedPackageCount, @NotNull @Min(1) Integer pickedPackageCount,
@JsonAlias("reservationIds") List<@Min(1) Long> locationIds, @JsonAlias("reservationIds") List<@Min(1) Long> locationIds,
@NotNull @Min(0) Integer version, @NotNull @Min(0) Integer version,
@@ -21,6 +22,25 @@ public final class LoadingPickDtos {
) { ) {
} }
/** 部分拣货为该 PDA 明细的最终实际拣出箱数,不是增量。 */
public record PartialPickRequest(
@NotNull @Min(1) Long warehouseId,
@NotNull @Min(1) Integer finalPickedPackageCount,
@JsonAlias("reservationIds") List<@Min(1) Long> locationIds,
@NotNull @Min(0) Integer version,
@NotBlank @Size(max = 128) String operationId
) {
}
/** 跳拣只能发生在尚未拣出任何箱数的 PDA 明细上。 */
public record SkipRequest(
@NotNull @Min(1) Long warehouseId,
@NotNull @Min(0) Integer version,
@NotBlank @Size(max = 128) String operationId,
@Size(max = 500) String reason
) {
}
public record PickItem(Long id, Long loadingPlanItemId, Long inboundId, Long inboundDetailId, public record PickItem(Long id, Long loadingPlanItemId, Long inboundId, Long inboundDetailId,
Integer plannedPackageCount, Integer pickedPackageCount, Integer plannedPackageCount, Integer pickedPackageCount,
String itemStatus, Integer version) { String itemStatus, Integer version) {
@@ -11,13 +11,13 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
} }
} }
public record CreateRequest(@NotNull @Positive Long bookingId, @Size(max=500) String remark) {} public record CreateRequest(@NotNull @Positive Long bookingId, @Size(max=500) String remark) {}
public record AssignRequest(@NotNull @Positive Long accountId,@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId, public record AssignRequest(@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId,
@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {} @NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {}
public record RecommendRequest(@NotNull @Positive Long accountId,@NotNull @Min(0) Integer expectedVersion, public record RecommendRequest(@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max=128) String operationId) {} @NotBlank @Size(max=128) String operationId) {}
public record AdjustRequest(@NotNull @Positive Long accountId,@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedPlanVersion, public record AdjustRequest(@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedPlanVersion,
@NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {} @NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
public record DeleteRequest(@NotNull @Positive Long accountId,@NotNull @Min(0) Integer expectedPlanVersion, public record DeleteRequest(@NotNull @Min(0) Integer expectedPlanVersion,
@NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {} @NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
public record PushRequest(@NotNull @Min(0) Integer expectedVersion) {} public record PushRequest(@NotNull @Min(0) Integer expectedVersion) {}
public record Plan(Long id,String planNo,String status,Long bookingId,String containerNo,String voyageNo,Integer version,List<LoadingPlanItemEntity> items) {} public record Plan(Long id,String planNo,String status,Long bookingId,String containerNo,String voyageNo,Integer version,List<LoadingPlanItemEntity> items) {}
@@ -17,7 +17,9 @@ public class LoadingPickHistoryEntity {
private Long pickItemId; private Long pickItemId;
private String operationId; private String operationId;
private String requestHash; private String requestHash;
private String actionType;
private Integer pickedPackageCount; private Integer pickedPackageCount;
private String operationReason;
private Long operatorId; private Long operatorId;
private String operatorName; private String operatorName;
} }
@@ -7,7 +7,7 @@ import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update; import org.apache.ibatis.annotations.Update;
@Mapper @Mapper
public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> { public interface InboundLoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> {
@Update("UPDATE fl_loading_pick_task SET status=#{toStatus}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, " @Update("UPDATE fl_loading_pick_task SET status=#{toStatus}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, "
+ "remark=COALESCE(#{remark}, remark), expected_count=COALESCE(#{expectedCount}, expected_count), " + "remark=COALESCE(#{remark}, remark), expected_count=COALESCE(#{expectedCount}, expected_count), "
+ "picked_count=COALESCE(#{pickedCount}, picked_count), version=version+1 WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} " + "picked_count=COALESCE(#{pickedCount}, picked_count), version=version+1 WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} "
@@ -7,7 +7,7 @@ import com.baiyue.firstleg.loading.entity.LoadingOperationEntity;
import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper; import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper; import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
import com.baiyue.firstleg.loading.mapper.LoadingOperationMapper; import com.baiyue.firstleg.loading.mapper.LoadingOperationMapper;
import com.baiyue.firstleg.loading.mapper.LoadingPickTaskMapper; import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper;
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -19,7 +19,7 @@ public class LoadingRepository {
@Resource private LoadingInboundMapper inbounds; @Resource private LoadingInboundMapper inbounds;
@Resource private LoadingEvidenceMapper evidences; @Resource private LoadingEvidenceMapper evidences;
@Resource private LoadingOperationMapper operations; @Resource private LoadingOperationMapper operations;
@Resource private LoadingPickTaskMapper picks; @Resource private InboundLoadingPickTaskMapper picks;
public LoadingInboundMapper.Snapshot findInbound(long tenantId, long inboundId) { public LoadingInboundMapper.Snapshot findInbound(long tenantId, long inboundId) {
return inbounds.find(tenantId, inboundId); return inbounds.find(tenantId, inboundId);
@@ -0,0 +1,56 @@
package com.baiyue.firstleg.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/** PDA部分拣货结算的客户归属锁定查询。 */
@Mapper
public interface InboundSplitSettlementMapper {
@Select("""
SELECT id, tenant_id AS tenantId, inbound_code AS inboundCode, inbound_role AS inboundRole,
process_status AS processStatus, version
FROM fl_inbound_order
WHERE tenant_id = #{accountId} AND id = #{inboundId} AND deleted = 0
FOR UPDATE
""")
ParentRow lockParent(@Param("accountId") long accountId, @Param("inboundId") long inboundId);
@Select("""
SELECT id
FROM fl_inbound_order
WHERE tenant_id = #{accountId} AND parent_inbound_id = #{parentInboundId}
AND inbound_role = 'CHILD' AND deleted = 0
LIMIT 1
FOR UPDATE
""")
Long childId(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId);
@Select({"<script>", """
SELECT item.id AS measurementItemId, measurement.id AS measurementId,
item.inbound_detail_id AS inboundDetailId, item.package_count AS packageCount
FROM fl_inbound_measurement measurement
JOIN fl_inbound_measurement_item item ON item.measurement_id = measurement.id
WHERE measurement.tenant_id = #{accountId} AND measurement.inbound_id = #{inboundId}
AND measurement.status = 'APPROVED' AND item.package_count > 0
AND measurement.id = (
SELECT MAX(latest.id) FROM fl_inbound_measurement latest
WHERE latest.tenant_id = #{accountId} AND latest.inbound_id = #{inboundId}
AND latest.status = 'APPROVED')
AND item.inbound_detail_id IN
""", "<foreach collection='detailIds' item='detailId' open='(' separator=',' close=')'>",
"#{detailId}", "</foreach>", "FOR UPDATE", "</script>"})
List<MeasurementItemRow> lockLatestApprovedItems(@Param("accountId") long accountId,
@Param("inboundId") long inboundId,
@Param("detailIds") List<Long> detailIds);
record ParentRow(Long id, Long tenantId, String inboundCode, String inboundRole,
String processStatus, Integer version) {
}
record MeasurementItemRow(Long measurementItemId, Long measurementId, Long inboundDetailId,
Integer packageCount) {
}
}
@@ -53,11 +53,6 @@ public interface LoadingListQueryMapper {
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%')) OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%'))
</if> </if>
<if test="query.warehouseId != null">AND b.warehouse_id = #{query.warehouseId}</if> <if test="query.warehouseId != null">AND b.warehouse_id = #{query.warehouseId}</if>
<if test="query.accountId != null">
AND EXISTS (SELECT 1 FROM fl_loading_plan_item ai
WHERE ai.loading_plan_id = p.id AND ai.deleted = 0
AND ai.account_id = #{query.accountId})
</if>
<if test="query.createdFrom != null">AND p.created_at &gt;= #{query.createdFrom}</if> <if test="query.createdFrom != null">AND p.created_at &gt;= #{query.createdFrom}</if>
<if test="query.createdTo != null">AND p.created_at &lt;= #{query.createdTo}</if> <if test="query.createdTo != null">AND p.created_at &lt;= #{query.createdTo}</if>
GROUP BY GROUP BY
@@ -81,11 +76,6 @@ public interface LoadingListQueryMapper {
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%')) OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%'))
</if> </if>
<if test="query.warehouseId != null">AND b.warehouse_id = #{query.warehouseId}</if> <if test="query.warehouseId != null">AND b.warehouse_id = #{query.warehouseId}</if>
<if test="query.accountId != null">
AND EXISTS (SELECT 1 FROM fl_loading_plan_item ai
WHERE ai.loading_plan_id = p.id AND ai.deleted = 0
AND ai.account_id = #{query.accountId})
</if>
<if test="query.createdFrom != null">AND p.created_at &gt;= #{query.createdFrom}</if> <if test="query.createdFrom != null">AND p.created_at &gt;= #{query.createdFrom}</if>
<if test="query.createdTo != null">AND p.created_at &lt;= #{query.createdTo}</if> <if test="query.createdTo != null">AND p.created_at &lt;= #{query.createdTo}</if>
GROUP BY GROUP BY
@@ -9,6 +9,32 @@ import org.apache.ibatis.annotations.Update;
@Mapper @Mapper
public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity> { public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity> {
@Select("""
SELECT pick_item.*
FROM fl_loading_pick_item pick_item
JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE pick_item.pick_task_id = #{taskId} AND booking.warehouse_id = #{warehouseId}
AND plan_item.deleted = 0
ORDER BY pick_item.id ASC
""")
java.util.List<LoadingPickItemEntity> selectTaskItemsWithAccount(@Param("taskId") long taskId,
@Param("warehouseId") long warehouseId);
@Select("""
SELECT pick_item.*
FROM fl_loading_pick_item pick_item
JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE pick_item.pick_task_id = #{taskId} AND pick_item.id = #{itemId}
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0
""")
LoadingPickItemEntity selectTaskItemWithAccount(@Param("taskId") long taskId,
@Param("itemId") long itemId,
@Param("warehouseId") long warehouseId);
@Update(""" @Update("""
UPDATE fl_loading_pick_item UPDATE fl_loading_pick_item
SET picked_package_count = picked_package_count + #{count}, SET picked_package_count = picked_package_count + #{count},
@@ -16,9 +42,52 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
THEN 'PICKED' ELSE 'PICKING' END, THEN 'PICKED' ELSE 'PICKING' END,
version = version + 1 version = version + 1
WHERE id = #{id} AND version = #{version} WHERE id = #{id} AND version = #{version}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
AND picked_package_count + #{count} <= planned_package_count AND picked_package_count + #{count} <= planned_package_count
""") """)
int pick(@Param("id") long id, @Param("count") int count, @Param("version") int version); int pick(@Param("id") long id, @Param("count") int count, @Param("version") int version,
@Param("warehouseId") long warehouseId);
@Update("""
UPDATE fl_loading_pick_item
SET picked_package_count = #{finalCount}, item_status = 'PARTIAL', version = version + 1
WHERE id = #{id} AND version = #{version}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
AND item_status IN ('PENDING', 'PICKING')
AND picked_package_count &lt; #{finalCount}
AND #{finalCount} &lt; planned_package_count
""")
int partial(@Param("id") long id, @Param("finalCount") int finalCount, @Param("version") int version,
@Param("warehouseId") long warehouseId);
@Update("""
UPDATE fl_loading_pick_item
SET item_status = 'SKIPPED', version = version + 1
WHERE id = #{id} AND version = #{version}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
AND item_status = 'PENDING' AND picked_package_count = 0
""")
int skip(@Param("id") long id, @Param("version") int version, @Param("warehouseId") long warehouseId);
@Select("""
SELECT COUNT(*)
FROM fl_loading_pick_item
WHERE pick_task_id = #{taskId}
AND item_status NOT IN ('PICKED', 'PARTIAL', 'SKIPPED')
""")
long unresolvedCount(@Param("taskId") long taskId);
/** /**
* 以装柜计划的全部有效明细为完成口径,不能只检查当前 PDA 任务中的明细。 * 以装柜计划的全部有效明细为完成口径,不能只检查当前 PDA 任务中的明细。
@@ -3,7 +3,21 @@ package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper @Mapper
public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> { public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> {
@Select("""
SELECT DISTINCT task.*
FROM fl_loading_pick_task task
JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id = task.id
JOIN fl_loading_plan plan ON plan.id = task.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE task.container_no = #{containerNo} AND task.task_status = 'LOADING'
AND booking.warehouse_id = #{warehouseId}
LIMIT 1
""")
LoadingPickTaskEntity selectLoadingByContainerAndAccount(@Param("containerNo") String containerNo,
@Param("warehouseId") long warehouseId);
} }
@@ -8,7 +8,14 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName, o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName,
o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName, o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName,
i.package_count-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li 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, WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id
AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
-COALESCE((SELECT SUM(split_item.package_count)
FROM fl_inbound_split_item split_item
WHERE o.inbound_role='MASTER'
AND split_item.tenant_id=o.tenant_id
AND split_item.parent_inbound_id=o.id
AND split_item.source_measurement_item_id=i.id),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
COALESCE(i.unit_volume_cbm,0) unitVolumeCbm COALESCE(i.unit_volume_cbm,0) unitVolumeCbm
FROM fl_inbound_order o FROM fl_inbound_order o
JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id
@@ -16,13 +23,20 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2 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') 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 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.process_status='INBOUNDED' WHERE o.warehouse_id=#{warehouseId} AND o.process_status='INBOUNDED'
AND i.inbound_detail_id IS NOT 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 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) WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id
AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
+COALESCE((SELECT SUM(split_item.package_count)
FROM fl_inbound_split_item split_item
WHERE o.inbound_role='MASTER'
AND split_item.tenant_id=o.tenant_id
AND split_item.parent_inbound_id=o.id
AND split_item.source_measurement_item_id=i.id),0)
ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC
""") """)
List<Candidate> candidates(@Param("accountId") long accountId); List<Candidate> candidates(@Param("warehouseId") long warehouseId);
@Select(""" @Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId, SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
@@ -31,7 +45,14 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName, o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName,
o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName, o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName,
i.package_count-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li 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, WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id
AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
-COALESCE((SELECT SUM(split_item.package_count)
FROM fl_inbound_split_item split_item
WHERE o.inbound_role='MASTER'
AND split_item.tenant_id=o.tenant_id
AND split_item.parent_inbound_id=o.id
AND split_item.source_measurement_item_id=i.id),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
COALESCE(i.unit_volume_cbm,0) unitVolumeCbm COALESCE(i.unit_volume_cbm,0) unitVolumeCbm
FROM fl_inbound_order o FROM fl_inbound_order o
JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id
@@ -39,10 +60,10 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2 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') 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 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' WHERE o.warehouse_id=#{warehouseId} AND o.id=#{inboundId} AND o.process_status='INBOUNDED'
AND i.inbound_detail_id=#{detailId} AND i.inbound_detail_id=#{detailId}
ORDER BY m.id DESC LIMIT 1 FOR UPDATE ORDER BY m.id DESC LIMIT 1 FOR UPDATE
""") """)
Candidate candidate(@Param("accountId") long accountId,@Param("inboundId") long inboundId, Candidate candidate(@Param("warehouseId") long warehouseId,@Param("inboundId") long inboundId,
@Param("detailId") long detailId); @Param("detailId") long detailId);
} }
@@ -38,6 +38,10 @@ public class LoadingPickRepository {
.eq(LoadingPickTaskEntity::getTaskStatus, "LOADING")); .eq(LoadingPickTaskEntity::getTaskStatus, "LOADING"));
} }
public LoadingPickTaskEntity taskByContainer(String containerNo, long warehouseId) {
return tasks.selectLoadingByContainerAndAccount(containerNo, warehouseId);
}
public LoadingPickItemEntity item(long taskId, long itemId) { public LoadingPickItemEntity item(long taskId, long itemId) {
return items.selectOne(new LambdaQueryWrapper<LoadingPickItemEntity>() return items.selectOne(new LambdaQueryWrapper<LoadingPickItemEntity>()
.eq(LoadingPickItemEntity::getPickTaskId, taskId) .eq(LoadingPickItemEntity::getPickTaskId, taskId)
@@ -50,6 +54,14 @@ public class LoadingPickRepository {
.orderByAsc(LoadingPickItemEntity::getId)); .orderByAsc(LoadingPickItemEntity::getId));
} }
public List<LoadingPickItemEntity> items(long taskId, long warehouseId) {
return items.selectTaskItemsWithAccount(taskId, warehouseId);
}
public LoadingPickItemEntity item(long taskId, long itemId, long warehouseId) {
return items.selectTaskItemWithAccount(taskId, itemId, warehouseId);
}
public void insert(LoadingPickTaskEntity task) { public void insert(LoadingPickTaskEntity task) {
tasks.insert(task); tasks.insert(task);
} }
@@ -63,8 +75,20 @@ public class LoadingPickRepository {
.eq(LoadingPickItemEntity::getLoadingPlanItemId, planItemId)); .eq(LoadingPickItemEntity::getLoadingPlanItemId, planItemId));
} }
public int pick(long itemId, int count, int version) { public int pick(long itemId, int count, int version, long warehouseId) {
return items.pick(itemId, count, version); return items.pick(itemId, count, version, warehouseId);
}
public int partial(long itemId, int finalCount, int version, long warehouseId) {
return items.partial(itemId, finalCount, version, warehouseId);
}
public int skip(long itemId, int version, long warehouseId) {
return items.skip(itemId, version, warehouseId);
}
public boolean allTaskItemsResolved(long taskId) {
return items.unresolvedCount(taskId) == 0;
} }
public boolean allPlanItemsPicked(long planId) { public boolean allPlanItemsPicked(long planId) {
@@ -27,9 +27,9 @@ public class SmartLoadingRepository {
@Resource private ContainerBookingMapper bookings; @Resource private ContainerBookingMapper bookings;
@Resource private SmartLoadingCandidateMapper candidates; @Resource private SmartLoadingCandidateMapper candidates;
public List<Candidate> candidates(long accountId) { return candidates.candidates(accountId); } public List<Candidate> candidates(long warehouseId) { return candidates.candidates(warehouseId); }
public Candidate candidate(long accountId, long inboundId, long detailId) { public Candidate candidate(long warehouseId, long inboundId, long detailId) {
return candidates.candidate(accountId, inboundId, detailId); return candidates.candidate(warehouseId, inboundId, detailId);
} }
public LoadingPlanEntity plan(long planId) { return plans.selectById(planId); } public LoadingPlanEntity plan(long planId) { return plans.selectById(planId); }
public LoadingPlanEntity planByBooking(long bookingId) { public LoadingPlanEntity planByBooking(long bookingId) {
@@ -4,7 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper; import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper;
import com.baiyue.firstleg.loading.mapper.LoadingPickTaskMapper; import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper;
import com.baiyue.firstleg.split.entity.PreloadPlanEntity; import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
import com.baiyue.firstleg.split.mapper.PreloadPlanMapper; import com.baiyue.firstleg.split.mapper.PreloadPlanMapper;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
@@ -23,7 +23,7 @@ import java.util.stream.Collectors;
@Repository @Repository
public class UnifiedInboundReadRepository { public class UnifiedInboundReadRepository {
@Resource private PreloadPlanMapper preloads; @Resource private PreloadPlanMapper preloads;
@Resource private LoadingPickTaskMapper picks; @Resource private InboundLoadingPickTaskMapper picks;
@Resource private LoadingEvidenceMapper evidences; @Resource private LoadingEvidenceMapper evidences;
@Resource private InboundStatusHistoryMapper histories; @Resource private InboundStatusHistoryMapper histories;
@Resource private InboundTransportSnapshotMapper transports; @Resource private InboundTransportSnapshotMapper transports;
@@ -2,8 +2,10 @@ package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.domain.SmartLoadingStatus; import com.baiyue.firstleg.domain.SmartLoadingStatus;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.Query; import com.baiyue.firstleg.dto.ContainerDtos.Query;
@@ -12,6 +14,7 @@ import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.ContainerRepository;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -21,6 +24,9 @@ import java.time.LocalDateTime;
@Service @Service
public class ContainerService { public class ContainerService {
@Resource private ContainerRepository repository; @Resource private ContainerRepository repository;
@Resource private WarehouseMasterDataClient warehouses;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
public PageResult<ContainerBookingEntity> page(Query query) { public PageResult<ContainerBookingEntity> page(Query query) {
validateRange(query.estimatedSailingFrom(), query.estimatedSailingTo(), "预计船期筛选"); validateRange(query.estimatedSailingFrom(), query.estimatedSailingTo(), "预计船期筛选");
return repository.page(normalize(query)); return repository.page(normalize(query));
@@ -30,11 +36,13 @@ public class ContainerService {
@Transactional @Transactional
public ContainerBookingEntity create(long operatorId, String operatorName, SaveRequest request) { public ContainerBookingEntity create(long operatorId, String operatorName, SaveRequest request) {
validateSchedule(request); validateSchedule(request);
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.warehouseId());
ensureUnique(trim(request.containerNo()), request.cabinetNo().trim(), null); ensureUnique(trim(request.containerNo()), request.cabinetNo().trim(), null);
ContainerBookingEntity value = new ContainerBookingEntity(); ContainerBookingEntity value = new ContainerBookingEntity();
value.setId(IdWorker.getId()); value.setId(IdWorker.getId());
value.setBookingNo("CB" + value.getId()); value.setBookingNo("CB" + value.getId());
copy(value, request); copy(value, request);
value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName());
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw duplicate(); } try { repository.insert(value); } catch (DuplicateKeyException ex) { throw duplicate(); }
@@ -51,8 +59,10 @@ public class ContainerService {
throw conflict("货柜已进入预装流程,只能修改集装箱号和航次"); throw conflict("货柜已进入预装流程,只能修改集装箱号和航次");
} }
validateSchedule(request.data()); validateSchedule(request.data());
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.data().warehouseId());
ensureUnique(trim(request.data().containerNo()), request.data().cabinetNo().trim(), id); ensureUnique(trim(request.data().containerNo()), request.data().cabinetNo().trim(), id);
copy(current, request.data()); copy(current, request.data());
current.setWarehouseId(warehouse.id()); current.setWarehouseName(warehouse.warehouseName());
try { try {
if (repository.updateDraft(current, request.version(), operatorId, operatorName) != 1) { if (repository.updateDraft(current, request.version(), operatorId, operatorName) != 1) {
throw conflict("货柜状态或版本已变化,请刷新后重试"); throw conflict("货柜状态或版本已变化,请刷新后重试");
@@ -85,6 +95,31 @@ public class ContainerService {
if (repository.containerNoExists(containerNo, excludedId)) throw conflict("集装箱号已存在"); if (repository.containerNoExists(containerNo, excludedId)) throw conflict("集装箱号已存在");
if (repository.cabinetNoExists(cabinetNo, excludedId)) throw conflict("内部柜号已存在"); if (repository.cabinetNoExists(cabinetNo, excludedId)) throw conflict("内部柜号已存在");
} }
private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) {
try {
ApiResponse<WarehouseMasterDataClient.Detail> response = warehouses.active(
internalToken, publicWarehouseTenantId, warehouseId);
if (response == null || !response.successful() || response.data() == null
|| response.data().warehouse() == null) {
throw new BizException(ResultCode.NOT_FOUND, "国内中转仓不存在或已停用");
}
WarehouseMasterDataClient.Warehouse warehouse = response.data().warehouse();
if (!Long.valueOf(warehouseId).equals(warehouse.id())
|| !Long.valueOf(publicWarehouseTenantId).equals(warehouse.tenantId())
|| !"ACTIVE".equals(warehouse.status())) {
throw new BizException(ResultCode.NOT_FOUND, "国内中转仓不存在或已停用");
}
if (!"DOMESTIC".equals(warehouse.warehouseType()) || warehouse.warehouseName() == null
|| warehouse.warehouseName().isBlank()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "虚拟柜只能选择启用中的国内中转仓");
}
return warehouse;
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "国内中转仓服务暂时不可用");
}
}
private static void requireVersion(ContainerBookingEntity value, Integer version) { private static void requireVersion(ContainerBookingEntity value, Integer version) {
if (!version.equals(value.getVersion())) throw conflict("货柜已被其他人修改,请刷新后重试"); if (!version.equals(value.getVersion())) throw conflict("货柜已被其他人修改,请刷新后重试");
} }
@@ -94,7 +129,7 @@ public class ContainerService {
} }
private static void copy(ContainerBookingEntity v, SaveRequest r) { private static void copy(ContainerBookingEntity v, SaveRequest r) {
v.setContainerNo(trim(r.containerNo())); v.setCabinetNo(r.cabinetNo().trim()); v.setContainerNo(trim(r.containerNo())); v.setCabinetNo(r.cabinetNo().trim());
v.setWarehouseId(r.warehouseId()); v.setWarehouseName(r.warehouseName().trim()); v.setWarehouseId(r.warehouseId()); v.setWarehouseName(trim(r.warehouseName()));
v.setBillOfLadingNo(trim(r.billOfLadingNo())); v.setVesselName(trim(r.vesselName())); v.setBillOfLadingNo(trim(r.billOfLadingNo())); v.setVesselName(trim(r.vesselName()));
v.setVoyageNo(trim(r.voyageNo())); v.setContainerType(r.containerType().trim()); v.setVoyageNo(trim(r.voyageNo())); v.setContainerType(r.containerType().trim());
v.setShippingCompanyId(r.shippingCompanyId()); v.setShippingCompanyName(trim(r.shippingCompanyName())); v.setShippingCompanyId(r.shippingCompanyId()); v.setShippingCompanyName(trim(r.shippingCompanyName()));
@@ -46,26 +46,28 @@ public class LoadingListService {
if (StringUtils.hasText(status) && !LOADING_STATUSES.contains(status)) { if (StringUtils.hasText(status) && !LOADING_STATUSES.contains(status)) {
throw badRequest("装柜清单状态只能是 LOADING"); throw badRequest("装柜清单状态只能是 LOADING");
} }
return repository.preloadPage(new PreloadQuery(trim(query.keyword()), status, query.accountId(), return repository.preloadPage(new PreloadQuery(trim(query.keyword()), status,
query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size())); query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size()));
} }
public PageResult<ConfirmedSummary> confirmedPage(ConfirmedQuery query) { public PageResult<ConfirmedSummary> confirmedPage(ConfirmedQuery query) {
validateRange(query.createdFrom(), query.createdTo()); validateRange(query.createdFrom(), query.createdTo());
return repository.confirmedPage(new ConfirmedQuery(trim(query.keyword()), query.accountId(), return repository.confirmedPage(new ConfirmedQuery(trim(query.keyword()), query.warehouseId(),
query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size())); query.createdFrom(), query.createdTo(), query.page(), query.size()));
} }
public PreloadDetail preloadDetail(long planId) { public PreloadDetail preloadDetail(long planId, long warehouseId) {
AggregateRow row = requirePlan(planId); AggregateRow row = requirePlan(planId);
requireWarehouse(row, warehouseId);
if (!LOADING_STATUSES.contains(row.getPlanStatus())) { if (!LOADING_STATUSES.contains(row.getPlanStatus())) {
throw notFound("装柜中清单不存在"); throw notFound("装柜中清单不存在");
} }
return repository.preloadDetail(row); return repository.preloadDetail(row);
} }
public ConfirmedDetail confirmedDetail(long planId) { public ConfirmedDetail confirmedDetail(long planId, long warehouseId) {
AggregateRow row = requirePlan(planId); AggregateRow row = requirePlan(planId);
requireWarehouse(row, warehouseId);
if (!"COMPLETED".equals(row.getPlanStatus())) { if (!"COMPLETED".equals(row.getPlanStatus())) {
throw notFound("装柜清单不存在"); throw notFound("装柜清单不存在");
} }
@@ -75,20 +77,21 @@ public class LoadingListService {
@Transactional @Transactional
@BusinessAudit(event = "PRELOAD_TRANSPORT_UPDATE", resourceType = "LOADING_PLAN", @BusinessAudit(event = "PRELOAD_TRANSPORT_UPDATE", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "修改预装集装箱号与航次") resourceId = "#planId", description = "修改预装集装箱号与航次")
public PreloadDetail updateTransport(long planId, long operatorId, String operatorName, public PreloadDetail updateTransport(long planId, long warehouseId, long operatorId, String operatorName,
String idempotencyKey, TransportUpdateRequest request) { String idempotencyKey, TransportUpdateRequest request) {
String containerNo = nullableTrim(request.containerNo()); String containerNo = nullableTrim(request.containerNo());
String voyageNo = nullableTrim(request.voyageNo()); String voyageNo = nullableTrim(request.voyageNo());
String requestHash = sha256("TRANSPORT|" + planId + '|' + request.expectedVersion() String requestHash = sha256("TRANSPORT|" + planId + '|' + request.expectedVersion()
+ '|' + containerNo + '|' + voyageNo + '|' + trim(request.operationReason())); + '|' + containerNo + '|' + voyageNo + '|' + trim(request.operationReason()));
String operationId = operationId("TRANSPORT", planId, idempotencyKey); String operationId = operationId("TRANSPORT", planId, idempotencyKey);
AggregateRow before = requirePlan(planId);
requireWarehouse(before, warehouseId);
HistoryRow replay = repository.history(operationId); HistoryRow replay = repository.history(operationId);
if (replay != null) { if (replay != null) {
requireSameRequest(replay, requestHash); requireSameRequest(replay, requestHash);
return repository.preloadDetail(requirePlan(planId)); return repository.preloadDetail(before);
} }
AggregateRow before = requirePlan(planId);
requireLoadingMutable(before); requireLoadingMutable(before);
if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo, if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo,
operatorId, trim(operatorName)) != 1) { operatorId, trim(operatorName)) != 1) {
@@ -147,6 +150,15 @@ public class LoadingListService {
return row; return row;
} }
private static void requireWarehouse(AggregateRow row, long warehouseId) {
if (warehouseId <= 0) {
throw badRequest("warehouseId必须大于0");
}
if (!java.util.Objects.equals(row.getWarehouseId(), warehouseId)) {
throw notFound("装柜计划不属于当前仓库");
}
}
private static void requireLoadingMutable(AggregateRow row) { private static void requireLoadingMutable(AggregateRow row) {
if (!LOADING_STATUSES.contains(row.getPlanStatus())) { if (!LOADING_STATUSES.contains(row.getPlanStatus())) {
throw conflict("仅装柜中可修改集装箱号与航次"); throw conflict("仅装柜中可修改集装箱号与航次");
@@ -4,6 +4,8 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickItem; import com.baiyue.firstleg.dto.LoadingPickDtos.PickItem;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task; import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity; import com.baiyue.firstleg.entity.LoadingPickItemEntity;
@@ -63,38 +65,42 @@ public class LoadingPickService {
} }
} }
public Task task(String containerNo) { public Task task(String containerNo, long warehouseId) {
if (!StringUtils.hasText(containerNo)) { if (!StringUtils.hasText(containerNo)) {
throw new BizException(ResultCode.BAD_REQUEST, "集装箱号不能为空"); throw new BizException(ResultCode.BAD_REQUEST, "集装箱号不能为空");
} }
LoadingPickTaskEntity task = repository.taskByContainer(containerNo.trim()); if (warehouseId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "warehouseId必须大于0");
}
LoadingPickTaskEntity task = repository.taskByContainer(containerNo.trim(), warehouseId);
if (task == null) { if (task == null) {
throw new BizException(ResultCode.NOT_FOUND, "未找到装柜中的PDA拣货任务"); throw new BizException(ResultCode.NOT_FOUND, "未找到装柜中的PDA拣货任务");
} }
return view(task); return view(task, warehouseId);
} }
@Transactional @Transactional
public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) { public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) {
String operationId = request.operationId().trim(); String operationId = request.operationId().trim();
String requestHash = hash(taskId + "|" + itemId + "|" + request.pickedPackageCount()); String requestHash = hash(taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + request.pickedPackageCount());
LoadingPickHistoryEntity replay = repository.history(operationId); LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) { if (replay != null) {
if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId) if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId)
|| !replay.getRequestHash().equals(requestHash)) { || !replay.getRequestHash().equals(requestHash)) {
throw conflict("operationId 已用于不同拣货请求"); throw conflict("operationId 已用于不同拣货请求");
} }
return view(requiredTask(taskId)); return view(requiredTask(taskId), request.warehouseId());
} }
LoadingPickTaskEntity task = requiredTask(taskId); LoadingPickTaskEntity task = requiredTask(taskId);
if (!"LOADING".equals(task.getTaskStatus())) { if (!"LOADING".equals(task.getTaskStatus())) {
throw conflict("装柜任务已完成,不能继续拣货"); throw conflict("装柜任务已完成,不能继续拣货");
} }
LoadingPickItemEntity item = repository.item(taskId, itemId); LoadingPickItemEntity item = repository.item(taskId, itemId, request.warehouseId());
if (item == null) { if (item == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在"); throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在");
} }
if (repository.pick(itemId, request.pickedPackageCount(), request.version()) != 1) { if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) {
throw conflict("拣货数量超过计划箱数或明细版本已变化"); throw conflict("拣货数量超过计划箱数或明细版本已变化");
} }
LoadingPickHistoryEntity history = new LoadingPickHistoryEntity(); LoadingPickHistoryEntity history = new LoadingPickHistoryEntity();
@@ -102,6 +108,7 @@ public class LoadingPickService {
history.setPickItemId(itemId); history.setPickItemId(itemId);
history.setOperationId(operationId); history.setOperationId(operationId);
history.setRequestHash(requestHash); history.setRequestHash(requestHash);
history.setActionType("PICK");
history.setPickedPackageCount(request.pickedPackageCount()); history.setPickedPackageCount(request.pickedPackageCount());
history.setOperatorId(operatorId); history.setOperatorId(operatorId);
history.setOperatorName(operatorName); history.setOperatorName(operatorName);
@@ -109,7 +116,49 @@ public class LoadingPickService {
if (repository.allPlanItemsPicked(task.getLoadingPlanId())) { if (repository.allPlanItemsPicked(task.getLoadingPlanId())) {
loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName); loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName);
} }
return view(requiredTask(taskId)); return view(requiredTask(taskId), request.warehouseId());
}
@Transactional
public Task partial(long taskId, long itemId, PartialPickRequest request,
long operatorId, String operatorName) {
String operationId = request.operationId().trim();
String requestHash = hash("PARTIAL|" + taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + request.finalPickedPackageCount());
LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) {
assertReplay(replay, taskId, itemId, requestHash);
return view(requiredTask(taskId), request.warehouseId());
}
LoadingPickTaskEntity task = requiredLoadingTask(taskId);
requireWarehouseItem(taskId, itemId, request.warehouseId());
if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(), request.warehouseId()) != 1) {
throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化");
}
insertHistory(taskId, itemId, operationId, requestHash, "PARTIAL",
request.finalPickedPackageCount(), null, operatorId, operatorName);
return view(requiredTask(taskId), request.warehouseId());
}
@Transactional
public Task skip(long taskId, long itemId, SkipRequest request, long operatorId, String operatorName) {
String operationId = request.operationId().trim();
String reason = trim(request.reason());
String requestHash = hash("SKIP|" + taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + reason);
LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) {
assertReplay(replay, taskId, itemId, requestHash);
return view(requiredTask(taskId), request.warehouseId());
}
requiredLoadingTask(taskId);
requireWarehouseItem(taskId, itemId, request.warehouseId());
if (repository.skip(itemId, request.version(), request.warehouseId()) != 1) {
throw conflict("跳拣仅允许未开始拣货的待拣明细,或明细版本已变化");
}
insertHistory(taskId, itemId, operationId, requestHash, "SKIP", 0, reason,
operatorId, operatorName);
return view(requiredTask(taskId), request.warehouseId());
} }
@Transactional @Transactional
@@ -155,8 +204,22 @@ public class LoadingPickService {
return task; return task;
} }
private Task view(LoadingPickTaskEntity task) { private LoadingPickTaskEntity requiredLoadingTask(long taskId) {
List<PickItem> items = repository.items(task.getId()).stream() LoadingPickTaskEntity task = requiredTask(taskId);
if (!"LOADING".equals(task.getTaskStatus())) {
throw conflict("装柜任务已完成,不能继续拣货");
}
return task;
}
private void requireWarehouseItem(long taskId, long itemId, long warehouseId) {
if (repository.item(taskId, itemId, warehouseId) == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在或不属于当前仓库");
}
}
private Task view(LoadingPickTaskEntity task, long warehouseId) {
List<PickItem> items = repository.items(task.getId(), warehouseId).stream()
.map(item -> new PickItem(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(), .map(item -> new PickItem(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(),
item.getInboundDetailId(), item.getPlannedPackageCount(), item.getPickedPackageCount(), item.getInboundDetailId(), item.getPlannedPackageCount(), item.getPickedPackageCount(),
item.getItemStatus(), item.getVersion())).toList(); item.getItemStatus(), item.getVersion())).toList();
@@ -164,6 +227,30 @@ public class LoadingPickService {
task.getVersion(), items); task.getVersion(), items);
} }
private void insertHistory(long taskId, long itemId, String operationId, String requestHash,
String actionType, int pickedPackageCount, String reason,
long operatorId, String operatorName) {
LoadingPickHistoryEntity history = new LoadingPickHistoryEntity();
history.setPickTaskId(taskId);
history.setPickItemId(itemId);
history.setOperationId(operationId);
history.setRequestHash(requestHash);
history.setActionType(actionType);
history.setPickedPackageCount(pickedPackageCount);
history.setOperationReason(reason);
history.setOperatorId(operatorId);
history.setOperatorName(operatorName);
repository.insert(history);
}
private static void assertReplay(LoadingPickHistoryEntity replay, long taskId, long itemId,
String requestHash) {
if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId)
|| !replay.getRequestHash().equals(requestHash)) {
throw conflict("operationId已用于不同拣货请求");
}
}
private static String hash(String value) { private static String hash(String value) {
try { try {
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
@@ -173,6 +260,10 @@ public class LoadingPickService {
} }
} }
private static String trim(String value) {
return value == null ? null : value.trim();
}
private static BizException conflict(String message) { private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message); return new BizException(ResultCode.CONFLICT, message);
} }
@@ -0,0 +1,65 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/** PDA任务结束时的纯结算规则,不包含数据库写入。 */
final class PdaPickSettlementPlan {
private PdaPickSettlementPlan() {
}
static Resolution resolve(List<LoadingPickItemEntity> items) {
if (items == null || items.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "PDA任务没有可结算的拣货明细");
}
for (LoadingPickItemEntity item : items) {
if (item == null || !isFinal(item.getItemStatus())) {
throw new BizException(ResultCode.CONFLICT, "PDA任务仍存在未结束的拣货明细");
}
}
Map<Long, List<SplitLine>> partialByInbound = items.stream()
.filter(item -> "PARTIAL".equals(item.getItemStatus()))
.map(PdaPickSettlementPlan::splitLine)
.collect(Collectors.groupingBy(SplitLine::inboundId));
List<Long> skippedPlanItemIds = items.stream()
.filter(item -> "SKIPPED".equals(item.getItemStatus()))
.map(LoadingPickItemEntity::getLoadingPlanItemId)
.toList();
return new Resolution(partialByInbound, skippedPlanItemIds);
}
private static SplitLine splitLine(LoadingPickItemEntity item) {
int planned = requireCount(item.getPlannedPackageCount(), "计划箱数");
int picked = requireCount(item.getPickedPackageCount(), "实拣箱数");
if (picked <= 0 || picked >= planned || item.getInboundId() == null
|| item.getInboundDetailId() == null || item.getId() == null
|| item.getLoadingPlanItemId() == null) {
throw new BizException(ResultCode.CONFLICT, "部分拣货结算数据不完整或箱数不合法");
}
return new SplitLine(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(),
item.getInboundDetailId(), picked, planned - picked);
}
private static int requireCount(Integer value, String field) {
if (value == null || value < 0) {
throw new BizException(ResultCode.CONFLICT, field + "不合法");
}
return value;
}
private static boolean isFinal(String status) {
return "PICKED".equals(status) || "PARTIAL".equals(status) || "SKIPPED".equals(status);
}
record Resolution(Map<Long, List<SplitLine>> partialByInbound, List<Long> skippedPlanItemIds) {
}
record SplitLine(long pickItemId, long loadingPlanItemId, long inboundId, long inboundDetailId,
int pickedPackageCount, int remainingPackageCount) {
}
}
@@ -36,9 +36,11 @@ public class SmartLoadingService {
@Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.security.internal-service-token:}") private String internalToken;
public SmartLoadingService() { } public SmartLoadingService() { }
public List<Candidate> candidates(long accountId) { public List<Candidate> candidates(long warehouseId) {
requireAccount(accountId); if (warehouseId <= 0) {
return repository.candidates(accountId); throw new BizException(ResultCode.BAD_REQUEST, "warehouseId必须大于0");
}
return repository.candidates(warehouseId);
} }
public Plan draftByBooking(long bookingId) { public Plan draftByBooking(long bookingId) {
@@ -81,22 +83,21 @@ public class SmartLoadingService {
resourceId = "#planId", description = "分配智能装柜明细") resourceId = "#planId", description = "分配智能装柜明细")
public Plan assign(long planId, AssignRequest request, long operatorId, String operatorName) { public Plan assign(long planId, AssignRequest request, long operatorId, String operatorName) {
String operation = operation(request.operationId()); String operation = operation(request.operationId());
String requestHash = sha256("ASSIGN|" + planId + '|' + request.accountId() + '|' String requestHash = sha256("ASSIGN|" + planId + '|'
+ request.inboundId() + '|' + request.inboundDetailId() + '|' + request.packageCount() + request.inboundId() + '|' + request.inboundDetailId() + '|' + request.packageCount()
+ '|' + request.expectedVersion()); + '|' + request.expectedVersion());
Plan replay = replay(operation, planId, "ADD_ITEM", null, requestHash); Plan replay = replay(operation, planId, "ADD_ITEM", null, requestHash);
if (replay != null) return replay; if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedVersion()); LoadingPlanEntity plan = editing(planId, request.expectedVersion());
Candidate candidate = repository.candidate(request.accountId(), request.inboundId(), request.inboundDetailId()); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate candidate = repository.candidate(booking.getWarehouseId(), request.inboundId(), request.inboundDetailId());
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库"); if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
if (!Objects.equals(candidate.accountId(), request.accountId())) throw forbidden();
OrderCargoClient.CargoBinding cargo = loadCargo(candidate); OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
LoadingPlanItemEntity existing = repository.activeItem(planId, request.accountId(), request.inboundDetailId()); LoadingPlanItemEntity existing = repository.activeItem(planId, candidate.accountId(), request.inboundDetailId());
int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount()); int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount());
int availableIncludingCurrent = candidate.availablePackageCount() int availableIncludingCurrent = candidate.availablePackageCount()
+ (existing == null ? 0 : existing.getPackageCount()); + (existing == null ? 0 : existing.getPackageCount());
if (total > availableIncludingCurrent) throw validation("累计分配箱数超过可用箱数"); if (total > availableIncludingCurrent) throw validation("累计分配箱数超过可用箱数");
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
List<LoadingPlanItemEntity> lines = new ArrayList<>(repository.items(planId)); List<LoadingPlanItemEntity> lines = new ArrayList<>(repository.items(planId));
if (existing != null) lines.removeIf(x -> Objects.equals(x.getId(), existing.getId())); if (existing != null) lines.removeIf(x -> Objects.equals(x.getId(), existing.getId()));
checkRouteAndCapacity(booking, lines, candidate, total); checkRouteAndCapacity(booking, lines, candidate, total);
@@ -126,20 +127,19 @@ public class SmartLoadingService {
resourceId = "#planId", description = "执行智能装柜推荐") resourceId = "#planId", description = "执行智能装柜推荐")
public Plan recommend(long planId, RecommendRequest request, long operatorId, String operatorName) { public Plan recommend(long planId, RecommendRequest request, long operatorId, String operatorName) {
String operation = operation(request.operationId()); String operation = operation(request.operationId());
String requestHash = sha256("RECOMMEND|" + planId + '|' + request.accountId() String requestHash = sha256("RECOMMEND|" + planId + '|' + request.expectedVersion());
+ '|' + request.expectedVersion());
Plan replay = replay(operation, planId, "SMART_RECOMMEND", null, requestHash); Plan replay = replay(operation, planId, "SMART_RECOMMEND", null, requestHash);
if (replay != null) return replay; if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedVersion()); LoadingPlanEntity plan = editing(planId, request.expectedVersion());
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
for (Candidate candidate : candidates(request.accountId())) { for (Candidate candidate : candidates(booking.getWarehouseId())) {
if (!Objects.equals(candidate.warehouseId(), booking.getWarehouseId())) continue; if (!Objects.equals(candidate.warehouseId(), booking.getWarehouseId())) continue;
if (StringUtils.hasText(booking.getDestinationWarehouseCode()) if (StringUtils.hasText(booking.getDestinationWarehouseCode())
&& !Objects.equals(booking.getDestinationWarehouseCode(), candidate.destinationWarehouseCode())) continue; && !Objects.equals(booking.getDestinationWarehouseCode(), candidate.destinationWarehouseCode())) continue;
int count = fittingBoxes(booking, repository.items(planId), candidate); int count = fittingBoxes(booking, repository.items(planId), candidate);
if (count > 0) { if (count > 0) {
plan = repository.plan(planId); plan = repository.plan(planId);
assign(planId, new AssignRequest(request.accountId(), candidate.inboundId(), candidate.inboundDetailId(), assign(planId, new AssignRequest(candidate.inboundId(), candidate.inboundDetailId(),
count, plan.getVersion(), "REC-" + sha256(operation + '|' + candidate.inboundId() count, plan.getVersion(), "REC-" + sha256(operation + '|' + candidate.inboundId()
+ '|' + candidate.inboundDetailId())), operatorId, operatorName); + '|' + candidate.inboundDetailId())), operatorId, operatorName);
} }
@@ -157,22 +157,22 @@ public class SmartLoadingService {
resourceId = "#itemId", description = "调整智能装柜明细") resourceId = "#itemId", description = "调整智能装柜明细")
public Plan adjust(long planId, long itemId, AdjustRequest request, long operatorId, String operatorName) { public Plan adjust(long planId, long itemId, AdjustRequest request, long operatorId, String operatorName) {
String operation = operation(request.operationId()); String operation = operation(request.operationId());
String requestHash = sha256("ADJUST|" + planId + '|' + itemId + '|' + request.accountId() String requestHash = sha256("ADJUST|" + planId + '|' + itemId + '|'
+ '|' + request.packageCount() + '|' + request.expectedPlanVersion() + request.packageCount() + '|' + request.expectedPlanVersion()
+ '|' + request.expectedItemVersion()); + '|' + request.expectedItemVersion());
Plan replay = replay(operation, planId, "ADJUST_ITEM", itemId, requestHash); Plan replay = replay(operation, planId, "ADJUST_ITEM", itemId, requestHash);
if (replay != null) return replay; if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion()); LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion());
LoadingPlanItemEntity item = repository.item(planId, itemId); LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在"); if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
if (!Objects.equals(item.getAccountId(), request.accountId())) throw forbidden();
if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突"); if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突");
Candidate candidate = repository.candidate(item.getAccountId(), item.getInboundId(), item.getInboundDetailId()); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate candidate = repository.candidate(booking.getWarehouseId(), item.getInboundId(), item.getInboundDetailId());
if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数"); if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数");
OrderCargoClient.CargoBinding cargo = loadCargo(candidate); OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
List<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId)); List<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));
others.removeIf(x -> Objects.equals(x.getId(), itemId)); others.removeIf(x -> Objects.equals(x.getId(), itemId));
checkRouteAndCapacity(requireBooking(plan.getContainerBookingId()), others, candidate, request.packageCount()); checkRouteAndCapacity(booking, others, candidate, request.packageCount());
touch(plan, operatorId, operatorName); touch(plan, operatorId, operatorName);
item.setPackageCount(request.packageCount()); item.setPackageCount(request.packageCount());
item.setGrossWeightKg(unit(candidate.unitWeightKg()).multiply(BigDecimal.valueOf(request.packageCount()))); item.setGrossWeightKg(unit(candidate.unitWeightKg()).multiply(BigDecimal.valueOf(request.packageCount())));
@@ -192,14 +192,13 @@ public class SmartLoadingService {
resourceId = "#itemId", description = "删除智能装柜明细") resourceId = "#itemId", description = "删除智能装柜明细")
public Plan delete(long planId, long itemId, DeleteRequest request, long operatorId, String operatorName) { public Plan delete(long planId, long itemId, DeleteRequest request, long operatorId, String operatorName) {
String operation = operation(request.operationId()); String operation = operation(request.operationId());
String requestHash = sha256("DELETE|" + planId + '|' + itemId + '|' + request.accountId() String requestHash = sha256("DELETE|" + planId + '|' + itemId + '|'
+ '|' + request.expectedPlanVersion() + '|' + request.expectedItemVersion()); + request.expectedPlanVersion() + '|' + request.expectedItemVersion());
Plan replay = replay(operation, planId, "REMOVE_ITEM", itemId, requestHash); Plan replay = replay(operation, planId, "REMOVE_ITEM", itemId, requestHash);
if (replay != null) return replay; if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion()); LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion());
LoadingPlanItemEntity item = repository.item(planId, itemId); LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在"); if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
if (!Objects.equals(item.getAccountId(), request.accountId())) throw forbidden();
loadingPickService.remove(itemId); loadingPickService.remove(itemId);
touch(plan, operatorId, operatorName); touch(plan, operatorId, operatorName);
if (repository.remove(planId, itemId, request.expectedItemVersion(), operatorId, operatorName) != 1) throw conflict("装柜草稿已被并发修改"); if (repository.remove(planId, itemId, request.expectedItemVersion(), operatorId, operatorName) != 1) throw conflict("装柜草稿已被并发修改");
@@ -33,6 +33,8 @@ baiyue:
url: ${ORDER_SERVICE_URL:} url: ${ORDER_SERVICE_URL:}
file: file:
url: ${FILE_SERVICE_URL:} url: ${FILE_SERVICE_URL:}
warehouse:
url: ${WAREHOUSE_SERVICE_URL:}
firstleg: firstleg:
inbound: inbound:
allow-rejected-measurement: false allow-rejected-measurement: false
@@ -79,8 +79,8 @@ CALL phase3_inbound_add_index(
'fl_inbound_order', 'uk_inbound_master_order', 'fl_inbound_order', 'uk_inbound_master_order',
'UNIQUE KEY `uk_inbound_master_order` (`tenant_id`, `master_order_id`)'); 'UNIQUE KEY `uk_inbound_master_order` (`tenant_id`, `master_order_id`)');
CALL phase3_inbound_add_index( CALL phase3_inbound_add_index(
'fl_inbound_order', 'uk_inbound_parent_sequence', 'fl_inbound_order', 'uk_inbound_one_child',
'UNIQUE KEY `uk_inbound_parent_sequence` (`tenant_id`, `parent_inbound_id`, `split_sequence`)'); 'UNIQUE KEY `uk_inbound_one_child` (`tenant_id`, `parent_inbound_id`)');
CALL phase3_inbound_add_index( CALL phase3_inbound_add_index(
'fl_inbound_order', 'idx_inbound_root_role', 'fl_inbound_order', 'idx_inbound_root_role',
'KEY `idx_inbound_root_role` (`tenant_id`, `root_inbound_id`, `inbound_role`, `created_at`, `id`)'); 'KEY `idx_inbound_root_role` (`tenant_id`, `root_inbound_id`, `inbound_role`, `created_at`, `id`)');
@@ -97,6 +97,14 @@ BEGIN
) THEN ) THEN
ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_tenant_order; ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_tenant_order;
END IF; 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_parent_sequence'
) THEN
ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_parent_sequence;
END IF;
END$$ END$$
DELIMITER ; DELIMITER ;
CALL phase3_inbound_drop_legacy_index(); CALL phase3_inbound_drop_legacy_index();
@@ -133,14 +141,15 @@ CREATE TABLE IF NOT EXISTS fl_inbound_split_item (
split_batch_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', parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID',
child_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '子入仓单ID', child_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '子入仓单ID',
line_no INT UNSIGNED NOT NULL COMMENT '同一拆分批次内的行号',
source_inbound_detail_id BIGINT UNSIGNED NULL COMMENT '订单货物明细逻辑ID', source_inbound_detail_id BIGINT UNSIGNED NULL COMMENT '订单货物明细逻辑ID',
source_measurement_id BIGINT UNSIGNED NULL COMMENT '来源测量批次ID', source_measurement_id BIGINT UNSIGNED NULL COMMENT '来源测量批次ID',
source_measurement_item_id BIGINT UNSIGNED NULL COMMENT '来源测量箱规明细ID', source_measurement_item_id BIGINT UNSIGNED NULL COMMENT '来源测量箱规明细ID',
package_count INT UNSIGNED NOT NULL COMMENT '分配给子单的箱数', package_count INT UNSIGNED NOT NULL COMMENT '分配给子单的箱数',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_inbound_split_child (tenant_id, child_inbound_id), UNIQUE KEY uk_inbound_split_batch_line (tenant_id, split_batch_id, line_no),
UNIQUE KEY uk_inbound_split_batch_child (tenant_id, split_batch_id, child_inbound_id), UNIQUE KEY uk_inbound_split_batch_source_item (tenant_id, split_batch_id, source_measurement_item_id),
KEY idx_inbound_split_detail (tenant_id, source_inbound_detail_id, created_at), KEY idx_inbound_split_detail (tenant_id, source_inbound_detail_id, created_at),
CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0), 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_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id),
@@ -203,6 +212,9 @@ CALL phase3_inbound_add_column(
CALL phase3_inbound_add_column( CALL phase3_inbound_add_column(
'fl_inbound_measurement_item', 'source_measurement_item_id', 'fl_inbound_measurement_item', 'source_measurement_item_id',
"BIGINT UNSIGNED NULL COMMENT '被拆分继承的来源测量箱规明细ID' AFTER split_item_id"); "BIGINT UNSIGNED NULL COMMENT '被拆分继承的来源测量箱规明细ID' AFTER split_item_id");
CALL phase3_inbound_add_column(
'fl_inbound_split_item', 'line_no',
"INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一拆分批次内的行号' AFTER child_inbound_id");
DROP PROCEDURE IF EXISTS phase3_inbound_add_column; DROP PROCEDURE IF EXISTS phase3_inbound_add_column;
DROP PROCEDURE IF EXISTS phase3_inbound_add_relation; DROP PROCEDURE IF EXISTS phase3_inbound_add_relation;
@@ -274,8 +286,31 @@ CALL phase3_inbound_add_index(
CALL phase3_inbound_add_index( CALL phase3_inbound_add_index(
'fl_inbound_measurement_item', 'idx_measure_item_split_source', 'fl_inbound_measurement_item', 'idx_measure_item_split_source',
'KEY `idx_measure_item_split_source` (`tenant_id`, `split_item_id`, `source_measurement_item_id`)'); 'KEY `idx_measure_item_split_source` (`tenant_id`, `split_item_id`, `source_measurement_item_id`)');
CALL phase3_inbound_add_index(
'fl_inbound_split_item', 'uk_inbound_split_batch_line',
'UNIQUE KEY `uk_inbound_split_batch_line` (`tenant_id`, `split_batch_id`, `line_no`)');
CALL phase3_inbound_add_index(
'fl_inbound_split_item', 'uk_inbound_split_batch_source_item',
'UNIQUE KEY `uk_inbound_split_batch_source_item` (`tenant_id`, `split_batch_id`, `source_measurement_item_id`)');
DROP PROCEDURE IF EXISTS phase3_inbound_add_index; DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index;
DELIMITER $$
CREATE PROCEDURE phase3_inbound_drop_split_legacy_index()
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_child') THEN
ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_child;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_batch_child') THEN
ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_batch_child;
END IF;
END$$
DELIMITER ;
CALL phase3_inbound_drop_split_legacy_index();
DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index;
DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_insert; 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_order_parent_before_update;
DROP TRIGGER IF EXISTS trg_inbound_split_item_before_insert; DROP TRIGGER IF EXISTS trg_inbound_split_item_before_insert;
@@ -368,7 +403,7 @@ BEGIN
AND child_order.tenant_id = NEW.tenant_id AND child_order.tenant_id = NEW.tenant_id
AND child_order.inbound_role = 'CHILD' AND child_order.inbound_role = 'CHILD'
AND child_order.parent_inbound_id = NEW.parent_inbound_id AND child_order.parent_inbound_id = NEW.parent_inbound_id
AND child_order.package_count = NEW.package_count AND child_order.package_count >= NEW.package_count
AND child_order.deleted = 0 AND child_order.deleted = 0
AND (NEW.source_measurement_item_id IS NULL OR EXISTS ( AND (NEW.source_measurement_item_id IS NULL OR EXISTS (
SELECT 1 SELECT 1
@@ -416,15 +451,26 @@ BEGIN
WHERE parent_order.id = NEW.parent_inbound_id WHERE parent_order.id = NEW.parent_inbound_id
AND parent_order.tenant_id = NEW.tenant_id AND parent_order.tenant_id = NEW.tenant_id
AND parent_order.inbound_role = 'MASTER' AND parent_order.inbound_role = 'MASTER'
AND parent_order.package_count = 0 AND parent_order.package_count > 0
AND parent_order.split_status = 'FULLY_SPLIT' AND parent_order.split_status = 'PARTIALLY_SPLIT'
AND parent_order.version > NEW.expected_parent_version AND parent_order.version > NEW.expected_parent_version
) OR ) OR
(SELECT COALESCE(SUM(item.package_count), 0) (SELECT COALESCE(SUM(item.package_count), 0)
FROM fl_inbound_split_item item FROM fl_inbound_split_item item
WHERE item.tenant_id = NEW.tenant_id WHERE item.tenant_id = NEW.tenant_id
AND item.split_batch_id = NEW.id) <> NEW.package_count THEN AND item.split_batch_id = NEW.id) <> NEW.package_count OR
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单可用箱数未归零或子单箱数合计不一致'; (SELECT COUNT(DISTINCT item.child_inbound_id)
FROM fl_inbound_split_item item
WHERE item.tenant_id = NEW.tenant_id
AND item.split_batch_id = NEW.id) <> 1 OR
EXISTS (SELECT 1
FROM fl_inbound_split_item item
JOIN fl_inbound_order child_order ON child_order.id = item.child_inbound_id
WHERE item.tenant_id = NEW.tenant_id
AND item.split_batch_id = NEW.id
GROUP BY child_order.id, child_order.package_count
HAVING child_order.package_count <> SUM(item.package_count)) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入仓单或拆分明细箱数合计不一致';
END IF; END IF;
IF EXISTS ( IF EXISTS (
SELECT 1 SELECT 1
@@ -233,7 +233,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID', inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID',
planned_package_count INT UNSIGNED NOT NULL COMMENT '计划拣货箱数;上层拆分按业务规则向上取整', planned_package_count INT UNSIGNED NOT NULL COMMENT '计划拣货箱数;上层拆分按业务规则向上取整',
picked_package_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已拣货箱数', picked_package_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已拣货箱数',
item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PICKING、PICKED', item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PICKING、PICKED、PARTIAL、SKIPPED',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
@@ -244,7 +244,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
CONSTRAINT fk_loading_pick_item_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id), CONSTRAINT fk_loading_pick_item_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id),
CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0), CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0),
CONSTRAINT chk_loading_pick_item_count CHECK (picked_package_count <= planned_package_count), CONSTRAINT chk_loading_pick_item_count CHECK (picked_package_count <= planned_package_count),
CONSTRAINT chk_loading_pick_item_status CHECK (item_status IN ('PENDING', 'PICKING', 'PICKED')) CONSTRAINT chk_loading_pick_item_status CHECK (item_status IN ('PENDING', 'PICKING', 'PICKED', 'PARTIAL', 'SKIPPED'))
) ENGINE=InnoDB COMMENT='PDA装柜拣货明细'; ) ENGINE=InnoDB COMMENT='PDA装柜拣货明细';
CREATE TABLE IF NOT EXISTS fl_loading_pick_history ( CREATE TABLE IF NOT EXISTS fl_loading_pick_history (
@@ -253,7 +253,9 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history (
pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID', pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
operation_id VARCHAR(128) NOT NULL COMMENT '客户端幂等操作号', operation_id VARCHAR(128) NOT NULL COMMENT '客户端幂等操作号',
request_hash CHAR(64) NOT NULL COMMENT '请求指纹', request_hash CHAR(64) NOT NULL COMMENT '请求指纹',
action_type VARCHAR(24) NOT NULL DEFAULT 'PICK' COMMENT 'PICK、PARTIAL、SKIP',
picked_package_count INT UNSIGNED NOT NULL COMMENT '本次拣货箱数', picked_package_count INT UNSIGNED NOT NULL COMMENT '本次拣货箱数',
operation_reason VARCHAR(500) NULL COMMENT '部分拣货或跳拣原因',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人', operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人',
operator_name VARCHAR(64) NULL COMMENT '操作人名称', operator_name VARCHAR(64) NULL COMMENT '操作人名称',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
@@ -428,7 +430,49 @@ BEGIN
IF current_plan_status = 'LOADING' AND EXISTS ( IF current_plan_status = 'LOADING' AND EXISTS (
SELECT 1 FROM fl_loading_pick_item SELECT 1 FROM fl_loading_pick_item
WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细不可修改或删除'; IF NOT EXISTS (
SELECT 1 FROM fl_loading_pick_item
WHERE loading_plan_item_id = OLD.id
AND item_status = 'PARTIAL'
AND NEW.deleted = 0
AND NEW.package_count = picked_package_count
) OR NEW.deleted <> OLD.deleted
OR NOT (NEW.customer_name <=> OLD.customer_name)
OR NOT (NEW.inbound_id <=> OLD.inbound_id)
OR NOT (NEW.inbound_detail_id <=> OLD.inbound_detail_id)
OR NOT (NEW.order_id <=> OLD.order_id)
OR NOT (NEW.order_no <=> OLD.order_no)
OR NOT (NEW.inbound_code <=> OLD.inbound_code)
OR NOT (NEW.destination_warehouse_id <=> OLD.destination_warehouse_id)
OR NOT (NEW.destination_warehouse_code <=> OLD.destination_warehouse_code)
OR NOT (NEW.destination_warehouse_name <=> OLD.destination_warehouse_name)
OR NOT (NEW.sku_code <=> OLD.sku_code)
OR NOT (NEW.product_name_zh <=> OLD.product_name_zh)
OR NOT (NEW.product_name_en <=> OLD.product_name_en)
OR NOT (NEW.product_image_file_id <=> OLD.product_image_file_id)
OR NOT (NEW.product_image_url <=> OLD.product_image_url)
OR NOT (NEW.product_quantity <=> OLD.product_quantity)
OR NOT (NEW.units_per_carton <=> OLD.units_per_carton)
OR NOT (NEW.pallet_count <=> OLD.pallet_count)
OR NOT (NEW.gross_weight_kg <=> OLD.gross_weight_kg)
OR NOT (NEW.net_weight_kg <=> OLD.net_weight_kg)
OR NOT (NEW.dimensional_weight_kg <=> OLD.dimensional_weight_kg)
OR NOT (NEW.volume_cbm <=> OLD.volume_cbm)
OR NOT (NEW.fba_tracking_no <=> OLD.fba_tracking_no)
OR NOT (NEW.shipment_no <=> OLD.shipment_no)
OR NOT (NEW.shipping_mark <=> OLD.shipping_mark)
OR NOT (NEW.load_sequence <=> OLD.load_sequence)
OR NOT (NEW.position_snapshot <=> OLD.position_snapshot)
OR NOT (NEW.internal_remark <=> OLD.internal_remark)
OR NOT (NEW.customer_remark <=> OLD.customer_remark)
OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark)
OR NOT (NEW.frozen_at <=> OLD.frozen_at)
OR NOT (NEW.frozen_by <=> OLD.frozen_by)
OR NOT (NEW.removed_by <=> OLD.removed_by)
OR NOT (NEW.removed_by_name <=> OLD.removed_by_name)
OR NOT (NEW.removed_at <=> OLD.removed_at) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数';
END IF;
END IF; END IF;
IF OLD.frozen_at IS NOT NULL THEN IF OLD.frozen_at IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结';
@@ -487,6 +531,28 @@ BEGIN
OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货明细归属不可修改'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货明细归属不可修改';
END IF; END IF;
IF NEW.item_status = 'PARTIAL' AND NOT (
OLD.item_status IN ('PENDING', 'PICKING')
AND OLD.picked_package_count < NEW.picked_package_count
AND NEW.picked_package_count < OLD.planned_package_count
AND NEW.planned_package_count = OLD.planned_package_count
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分拣货必须在计划箱数内增加实际拣出箱数';
END IF;
IF NEW.item_status = 'SKIPPED' AND NOT (
OLD.item_status = 'PENDING'
AND OLD.picked_package_count = 0
AND NEW.picked_package_count = 0
AND NEW.planned_package_count = OLD.planned_package_count
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '跳拣仅允许零拣货的待拣明细';
END IF;
IF OLD.item_status = 'PARTIAL' AND NEW.item_status = 'PICKED' AND NOT (
NEW.picked_package_count = OLD.picked_package_count
AND NEW.planned_package_count = OLD.picked_package_count
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分拣货收敛后计划箱数必须等于实际拣出箱数';
END IF;
END$$ END$$
DELIMITER ; DELIMITER ;
@@ -7,7 +7,7 @@ paths:
/api/platform/v1/first-leg/loading-plans: /api/platform/v1/first-leg/loading-plans:
get: get:
summary: 分页查询预装清单 summary: 分页查询预装清单
description: 仅返回预装柜中或预装柜完成的计划,计划为平台作业数据,客户账号仅作为明细筛选条件。 description: 仓库作业清单必须按仓库查询,返回该仓库全部客户的装柜计划;客户账号仅用于展示货物归属。
x-permission: platform:firstleg:preload:list x-permission: platform:firstleg:preload:list
parameters: parameters:
- {$ref: '#/components/parameters/Page'} - {$ref: '#/components/parameters/Page'}
@@ -17,7 +17,6 @@ paths:
in: query in: query
description: 预装状态 description: 预装状态
schema: {type: string, enum: [LOADING]} schema: {type: string, enum: [LOADING]}
- {$ref: '#/components/parameters/AccountId'}
- {$ref: '#/components/parameters/WarehouseId'} - {$ref: '#/components/parameters/WarehouseId'}
- {$ref: '#/components/parameters/CreatedFrom'} - {$ref: '#/components/parameters/CreatedFrom'}
- {$ref: '#/components/parameters/CreatedTo'} - {$ref: '#/components/parameters/CreatedTo'}
@@ -28,7 +27,7 @@ paths:
summary: 查询预装清单详情 summary: 查询预装清单详情
description: 返回同一装柜计划的汇总、冻结明细和完整操作历史。 description: 返回同一装柜计划的汇总、冻结明细和完整操作历史。
x-permission: platform:firstleg:preload:view x-permission: platform:firstleg:preload:view
parameters: [{$ref: '#/components/parameters/PlanId'}] parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}]
responses: responses:
'200': {$ref: '#/components/responses/PreloadDetailResponse'} '200': {$ref: '#/components/responses/PreloadDetailResponse'}
/api/platform/v1/first-leg/loading-plans/{planId}/transport: /api/platform/v1/first-leg/loading-plans/{planId}/transport:
@@ -38,6 +37,7 @@ paths:
x-permission: platform:firstleg:preload:edit x-permission: platform:firstleg:preload:edit
parameters: parameters:
- {$ref: '#/components/parameters/PlanId'} - {$ref: '#/components/parameters/PlanId'}
- {$ref: '#/components/parameters/WarehouseId'}
- {$ref: '#/components/parameters/IdempotencyKey'} - {$ref: '#/components/parameters/IdempotencyKey'}
requestBody: requestBody:
required: true required: true
@@ -64,13 +64,12 @@ paths:
/api/platform/v1/first-leg/loading-lists: /api/platform/v1/first-leg/loading-lists:
get: get:
summary: 分页查询装柜清单 summary: 分页查询装柜清单
description: 读取同一个装柜计划聚合,仅返回 COMPLETED 状态记录。 description: 必须按仓库查询,只返回该仓库的 COMPLETED 装柜计划,包含全部客户单据。
x-permission: platform:firstleg:loading-list:list x-permission: platform:firstleg:loading-list:list
parameters: parameters:
- {$ref: '#/components/parameters/Page'} - {$ref: '#/components/parameters/Page'}
- {$ref: '#/components/parameters/Size'} - {$ref: '#/components/parameters/Size'}
- {$ref: '#/components/parameters/Keyword'} - {$ref: '#/components/parameters/Keyword'}
- {$ref: '#/components/parameters/AccountId'}
- {$ref: '#/components/parameters/WarehouseId'} - {$ref: '#/components/parameters/WarehouseId'}
- {$ref: '#/components/parameters/CreatedFrom'} - {$ref: '#/components/parameters/CreatedFrom'}
- {$ref: '#/components/parameters/CreatedTo'} - {$ref: '#/components/parameters/CreatedTo'}
@@ -81,7 +80,7 @@ paths:
summary: 查询装柜清单详情 summary: 查询装柜清单详情
description: 返回已确认计划的汇总、冻结明细和操作历史,全部只读。 description: 返回已确认计划的汇总、冻结明细和操作历史,全部只读。
x-permission: platform:firstleg:loading-list:view x-permission: platform:firstleg:loading-list:view
parameters: [{$ref: '#/components/parameters/PlanId'}] parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}]
responses: responses:
'200': {$ref: '#/components/responses/ConfirmedDetailResponse'} '200': {$ref: '#/components/responses/ConfirmedDetailResponse'}
components: components:
@@ -113,15 +112,11 @@ components:
in: query in: query
description: 按预装单号、集装箱号或柜号模糊查询 description: 按预装单号、集装箱号或柜号模糊查询
schema: {type: string, maxLength: 128} schema: {type: string, maxLength: 128}
AccountId:
name: accountId
in: query
description: 客户账号 ID 筛选条件,计划本身仍属于平台作业数据
schema: {$ref: '#/components/schemas/LongId'}
WarehouseId: WarehouseId:
name: warehouseId name: warehouseId
in: query in: query
description: 仓库 ID required: true
description: 仓库 ID;同一仓库内全部客户单据均可见
schema: {$ref: '#/components/schemas/LongId'} schema: {$ref: '#/components/schemas/LongId'}
CreatedFrom: CreatedFrom:
name: createdFrom name: createdFrom
@@ -23,17 +23,17 @@ class LoadingListControllerContractTest {
@Test @Test
void permissionsMatch() throws Exception { void permissionsMatch() throws Exception {
authority("preloadPage", "platform:firstleg:preload:list", AuthenticatedUser.class, PreloadQuery.class); authority("preloadPage", "platform:firstleg:preload:list", AuthenticatedUser.class, PreloadQuery.class);
authority("preloadDetail", "platform:firstleg:preload:view", AuthenticatedUser.class, long.class); authority("preloadDetail", "platform:firstleg:preload:view", AuthenticatedUser.class, long.class, long.class);
authority("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.class, authority("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.class,
long.class, String.class, TransportUpdateRequest.class); long.class, long.class, String.class, TransportUpdateRequest.class);
authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class, authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class,
ConfirmedQuery.class); ConfirmedQuery.class);
authority("confirmedDetail", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class); authority("confirmedDetail", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class, long.class);
} }
@Test @Test
void writesAudited() throws Exception { void writesAudited() throws Exception {
audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, String.class, audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, long.class, String.class,
String.class, TransportUpdateRequest.class); String.class, TransportUpdateRequest.class);
} }
@@ -25,7 +25,8 @@ class InboundParentChildSqlContractTest {
.contains("GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER'") .contains("GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER'")
.contains("DROP INDEX uk_inbound_tenant_order") .contains("DROP INDEX uk_inbound_tenant_order")
.contains("uk_inbound_master_order") .contains("uk_inbound_master_order")
.contains("uk_inbound_parent_sequence"); .contains("uk_inbound_one_child")
.contains("DROP INDEX uk_inbound_parent_sequence");
} }
@Test @Test
@@ -34,27 +35,42 @@ class InboundParentChildSqlContractTest {
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch") .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_item")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history") .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history")
.contains("line_no INT UNSIGNED NOT NULL")
.contains("uk_inbound_split_batch_line")
.contains("uk_inbound_split_batch_source_item")
.contains("package_count INT UNSIGNED NOT NULL") .contains("package_count INT UNSIGNED NOT NULL")
.doesNotContain("quantity INT", "item_count INT", "carton_count INT"); .doesNotContain("quantity INT", "item_count INT", "carton_count INT");
} }
@org.junit.jupiter.api.Disabled("旧拆分口径已由一主单一子单的批次汇总校验取代")
@Test @Test
void protectsOwnershipAndTotals() { void protectsOwnershipAndTotals() {
assertThat(sql) assertThat(sql)
.contains("子入仓单必须绑定同一客户的有效主单") .contains("子入仓单必须绑定同一客户的有效主单")
.contains("parent_order.package_count = 0") .contains("parent_order.package_count > 0")
.contains("parent_order.split_status = 'FULLY_SPLIT'") .contains("parent_order.split_status = 'PARTIALLY_SPLIT'")
.contains("parent_order.version > NEW.expected_parent_version") .contains("parent_order.version > NEW.expected_parent_version")
.contains("主单可用箱数未归零或子单箱数合计不一致") .contains("主单可用箱数未归零或子单箱数合计不一致")
.contains("SUM(split_item.package_count) > source_item.package_count") .contains("SUM(split_item.package_count) > source_item.package_count")
.contains("子单承接箱数超过来源测量箱规箱数") .contains("子单承接箱数超过来源测量箱规箱数")
.contains("child_order.package_count = NEW.package_count") .contains("child_order.package_count >= NEW.package_count")
.contains("拆分数量台账不可修改") .contains("拆分数量台账不可修改")
.contains("拆分数量台账不可删除") .contains("拆分数量台账不可删除")
.contains("拆分历史不可修改") .contains("拆分历史不可修改")
.contains("拆分历史不可删除"); .contains("拆分历史不可删除");
} }
@Test
void validatesOneChildAndMultiSkuTotals() {
assertThat(sql)
.contains("uk_inbound_one_child")
.contains("uk_inbound_split_batch_line")
.contains("uk_inbound_split_batch_source_item")
.contains("COUNT(DISTINCT item.child_inbound_id)")
.contains("child_order.package_count <> SUM(item.package_count)")
.contains("parent_order.split_status = 'PARTIALLY_SPLIT'");
}
@Test @Test
void tracesMeasurements() { void tracesMeasurements() {
assertThat(sql) assertThat(sql)
@@ -35,10 +35,14 @@ class LoadingListQueryMapperContractTest {
.contains("JOIN fl_container_booking b") .contains("JOIN fl_container_booking b")
.contains("LEFT JOIN fl_loading_plan_item i") .contains("LEFT JOIN fl_loading_plan_item i")
.contains("p.plan_status = 'LOADING'") .contains("p.plan_status = 'LOADING'")
.contains("b.warehouse_id = #{query.warehouseId}")
.doesNotContain("query.accountId")
.doesNotContain("PRELOAD_PUSHED"); .doesNotContain("PRELOAD_PUSHED");
assertThat(confirmed) assertThat(confirmed)
.contains("FROM fl_loading_plan p") .contains("FROM fl_loading_plan p")
.contains("p.plan_status = 'COMPLETED'") .contains("p.plan_status = 'COMPLETED'")
.contains("b.warehouse_id = #{query.warehouseId}")
.doesNotContain("query.accountId")
.doesNotContain("fl_loading_list"); .doesNotContain("fl_loading_list");
} }
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper; import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.lang.reflect.Method; import java.lang.reflect.Method;
@@ -21,4 +22,28 @@ class LoadingPickItemMapperContractTest {
.contains("pick_task.task_status = 'LOADING'") .contains("pick_task.task_status = 'LOADING'")
.contains("pick_item.picked_package_count = plan_item.package_count"); .contains("pick_item.picked_package_count = plan_item.package_count");
} }
@Test
void terminalStatesAreDefinedInSchema() throws Exception {
String sql = new org.springframework.core.io.ClassPathResource("db/phase3-smart-loading-schema.sql")
.getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
assertThat(sql)
.contains("'PENDING', 'PICKING', 'PICKED', 'PARTIAL', 'SKIPPED'")
.contains("action_type VARCHAR(24) NOT NULL DEFAULT 'PICK'")
.contains("operation_reason VARCHAR(500) NULL");
}
@Test
void pdaWritesAndReadsRequireWarehouseScope() throws Exception {
Method list = LoadingPickItemMapper.class.getDeclaredMethod("selectTaskItemsWithAccount", long.class, long.class);
Method pick = LoadingPickItemMapper.class.getDeclaredMethod("pick", long.class, int.class, int.class, long.class);
assertThat(list.getAnnotation(Select.class).value()[0])
.contains("booking.warehouse_id = #{warehouseId}")
.contains("plan_item.deleted = 0");
assertThat(pick.getAnnotation(Update.class).value()[0])
.contains("booking.warehouse_id = #{warehouseId}")
.contains("plan_item.deleted = 0");
}
} }
@@ -0,0 +1,37 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class PdaSplitSettlementSqlContractTest {
@Test
void protectsOneChildAndPartialSettlement() throws Exception {
String sql = new ClassPathResource("db/phase3-inbound-parent-child-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("uk_inbound_one_child")
.contains("uk_inbound_split_batch_line")
.contains("uk_inbound_split_batch_source_item")
.contains("child_order.package_count <> SUM(item.package_count)")
.contains("parent_order.split_status = 'PARTIALLY_SPLIT'")
.contains("SUM(split_item.package_count) > source_item.package_count")
.contains("CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0)");
}
@Test
void protectsPdaTerminalStates() throws Exception {
String sql = new ClassPathResource("db/phase3-smart-loading-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("'PICKED', 'PARTIAL', 'SKIPPED'")
.contains("NEW.item_status = 'PARTIAL'")
.contains("NEW.item_status = 'SKIPPED'")
.contains("NEW.picked_package_count = OLD.picked_package_count");
}
}
@@ -20,6 +20,7 @@ class SmartLoadingCandidateMapperContractTest {
.contains("SELECT MAX(m2.id)") .contains("SELECT MAX(m2.id)")
.contains("m2.status='APPROVED'") .contains("m2.status='APPROVED'")
.contains("i.inbound_detail_id IS NOT NULL") .contains("i.inbound_detail_id IS NOT NULL")
.contains("o.warehouse_id=#{warehouseId}")
.contains("SELECT SUM(li.package_count)") .contains("SELECT SUM(li.package_count)")
.contains("li.deleted=0") .contains("li.deleted=0")
.contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC"); .contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC");
@@ -35,6 +36,7 @@ class SmartLoadingCandidateMapperContractTest {
.contains("o.destination_warehouse_id destinationWarehouseId") .contains("o.destination_warehouse_id destinationWarehouseId")
.contains("m.status='APPROVED'") .contains("m.status='APPROVED'")
.contains("SELECT MAX(m2.id)") .contains("SELECT MAX(m2.id)")
.contains("o.warehouse_id=#{warehouseId}")
.contains("SELECT SUM(li.package_count)") .contains("SELECT SUM(li.package_count)")
.contains("FOR UPDATE"); .contains("FOR UPDATE");
} }
@@ -108,7 +108,7 @@ class SmartLoadingSqlContractTest {
.contains("pick_item.picked_package_count <> pick_item.planned_package_count") .contains("pick_item.picked_package_count <> pick_item.planned_package_count")
.contains("IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN") .contains("IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN")
.contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))") .contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))")
.contains("已有PDA拣货记录的装柜明细不可修改或删除") .contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数")
.contains("装柜完成后的明细永久冻结") .contains("装柜完成后的明细永久冻结")
.contains("装柜中仅允许修改集装箱号与航次") .contains("装柜中仅允许修改集装箱号与航次")
.contains("装柜完成后货柜全部只读,不允许修改") .contains("装柜完成后货柜全部只读,不允许修改")
@@ -1,6 +1,8 @@
package com.baiyue.firstleg.service; package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
@@ -8,6 +10,7 @@ import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.ContainerRepository;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.MockBean;
@@ -19,6 +22,8 @@ import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -27,8 +32,15 @@ import static org.mockito.Mockito.when;
@SpringJUnitConfig(ContainerService.class) @SpringJUnitConfig(ContainerService.class)
class ContainerServiceTest { class ContainerServiceTest {
@MockBean private ContainerRepository repository; @MockBean private ContainerRepository repository;
@MockBean private WarehouseMasterDataClient warehouses;
@Resource private ContainerService service; @Resource private ContainerService service;
@BeforeEach
void setUpWarehouse() {
when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation ->
activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓"));
}
@Test @Test
@DisplayName("新增货柜保存最终数据库契约字段并生成平台预订单号") @DisplayName("新增货柜保存最终数据库契约字段并生成平台预订单号")
void createsContainerBooking() { void createsContainerBooking() {
@@ -40,6 +52,8 @@ class ContainerServiceTest {
assertThat(created).isSameAs(saved); assertThat(created).isSameAs(saved);
assertThat(saved.getBookingNo()).startsWith("CB"); assertThat(saved.getBookingNo()).startsWith("CB");
assertThat(saved.getCabinetNo()).isEqualTo("柜-001"); assertThat(saved.getCabinetNo()).isEqualTo("柜-001");
assertThat(saved.getWarehouseId()).isEqualTo(101L);
assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓");
assertThat(saved.getDeparturePortCode()).isEqualTo("CNSHA"); assertThat(saved.getDeparturePortCode()).isEqualTo("CNSHA");
assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000"); assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000");
assertThat(saved.getPlanStatus()).isEqualTo("EDITING"); assertThat(saved.getPlanStatus()).isEqualTo("EDITING");
@@ -127,6 +141,49 @@ class ContainerServiceTest {
verify(repository, never()).insert(any()); verify(repository, never()).insert(any());
} }
@Test
@DisplayName("虚拟柜拒绝选择非国内中转仓")
void rejectsNonDomesticWarehouse() {
when(warehouses.active(anyString(), anyLong(), anyLong()))
.thenReturn(activeWarehouse(101L, "FBA", "美国目的仓"));
assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001")))
.isInstanceOf(BizException.class)
.hasMessageContaining("国内中转仓");
verify(repository, never()).insert(any());
}
@Test
@DisplayName("虚拟柜仓库名称以仓库主数据为准")
void replacesRequestedWarehouseName() {
service.create(9L, "admin", request("柜-001"));
ArgumentCaptor<ContainerBookingEntity> captor = ArgumentCaptor.forClass(ContainerBookingEntity.class);
verify(repository).insert(captor.capture());
assertThat(captor.getValue().getWarehouseName()).isEqualTo("上海中转仓");
}
@Test
@DisplayName("仓库主数据服务不可用时拒绝创建虚拟柜")
void rejectsUnavailableWarehouseService() {
when(warehouses.active(anyString(), anyLong(), anyLong()))
.thenThrow(new IllegalStateException("warehouse service unavailable"));
assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001")))
.isInstanceOf(BizException.class)
.hasMessageContaining("暂时不可用");
verify(repository, never()).insert(any());
}
private static ApiResponse<WarehouseMasterDataClient.Detail> activeWarehouse(long warehouseId,
String type,
String name) {
return ApiResponse.success(new WarehouseMasterDataClient.Detail(
new WarehouseMasterDataClient.Warehouse(warehouseId, 0L, "CN-SHA-01", name, type, "ACTIVE")));
}
private static ContainerBookingEntity booking(String status, int version) { private static ContainerBookingEntity booking(String status, int version) {
ContainerBookingEntity value = new ContainerBookingEntity(); ContainerBookingEntity value = new ContainerBookingEntity();
value.setId(11L); value.setId(11L);
@@ -51,7 +51,7 @@ class LoadingListServiceTest {
@Test @Test
void shouldRejectEditingStatusFromPreloadList() { void shouldRejectEditingStatusFromPreloadList() {
PreloadQuery query = new PreloadQuery(null, "EDITING", null, null, null, null, 1, 20); PreloadQuery query = new PreloadQuery(null, "EDITING", 100L, null, null, 1, 20);
assertThatThrownBy(() -> service.preloadPage(query)) assertThatThrownBy(() -> service.preloadPage(query))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
@@ -74,7 +74,7 @@ class LoadingListServiceTest {
when(repository.preloadDetail(after)).thenReturn( when(repository.preloadDetail(after)).thenReturn(
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
service.updateTransport(10L, 9L, "operator", "key-1", service.updateTransport(10L, 100L, 9L, "operator", "key-1",
new TransportUpdateRequest(" CSLU1234567 ", "023E", 3, "补录箱号")); new TransportUpdateRequest(" CSLU1234567 ", "023E", 3, "补录箱号"));
verify(repository).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator"); verify(repository).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator");
@@ -88,7 +88,7 @@ class LoadingListServiceTest {
when(repository.history(anyString())).thenReturn(null); when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(row("COMPLETED", 5)); when(repository.aggregate(10L)).thenReturn(row("COMPLETED", 5));
assertThatThrownBy(() -> service.updateTransport(10L, 9L, "operator", "key-1", assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "key-1",
new TransportUpdateRequest("CSLU1234567", "023E", 5, null))) new TransportUpdateRequest("CSLU1234567", "023E", 5, null)))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode()) .extracting(error -> ((BizException) error).getResultCode())
@@ -140,7 +140,7 @@ class LoadingListServiceTest {
ArgumentCaptor<String> snapshot = ArgumentCaptor.forClass(String.class); ArgumentCaptor<String> snapshot = ArgumentCaptor.forClass(String.class);
TransportUpdateRequest request = new TransportUpdateRequest("CSLU1234567", "023E", 3, null); TransportUpdateRequest request = new TransportUpdateRequest("CSLU1234567", "023E", 3, null);
service.updateTransport(10L, 9L, "operator", "key-1", request); service.updateTransport(10L, 100L, 9L, "operator", "key-1", request);
verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"), verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"),
eq("LOADING"), eq("LOADING"), anyString(), snapshot.capture(), eq(9L), eq("LOADING"), eq("LOADING"), anyString(), snapshot.capture(), eq(9L),
eq("operator"), eq(null)); eq("operator"), eq(null));
@@ -149,14 +149,14 @@ class LoadingListServiceTest {
when(repository.history(anyString())).thenReturn(replay); when(repository.history(anyString())).thenReturn(replay);
when(repository.aggregate(10L)).thenReturn(after); when(repository.aggregate(10L)).thenReturn(after);
service.updateTransport(10L, 9L, "operator", "key-1", request); service.updateTransport(10L, 100L, 9L, "operator", "key-1", request);
verify(repository, times(1)).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator"); verify(repository, times(1)).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator");
} }
@Test @Test
void confirmedPageAlwaysUsesRepositoryConfirmedProjection() { void confirmedPageAlwaysUsesRepositoryConfirmedProjection() {
ConfirmedQuery query = new ConfirmedQuery(" plan ", null, null, null, null, 1, 20); ConfirmedQuery query = new ConfirmedQuery(" plan ", 100L, null, null, 1, 20);
when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn( when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn(
new PageResult<>(List.of(), 1, 20, 0, 0)); new PageResult<>(List.of(), 1, 20, 0, 0));
@@ -166,11 +166,22 @@ class LoadingListServiceTest {
"plan".equals(value.keyword()))); "plan".equals(value.keyword())));
} }
@Test
void rejectsPlanOutsideRequestedWarehouse() {
when(repository.aggregate(10L)).thenReturn(row("LOADING", 1));
assertThatThrownBy(() -> service.preloadDetail(10L, 200L))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.NOT_FOUND);
}
private static AggregateRow row(String status, int version) { private static AggregateRow row(String status, int version) {
AggregateRow row = new AggregateRow(); AggregateRow row = new AggregateRow();
row.setPlanId(10L); row.setPlanId(10L);
row.setPlanNo("PL-10"); row.setPlanNo("PL-10");
row.setPlanStatus(status); row.setPlanStatus(status);
row.setWarehouseId(100L);
row.setVersion(version); row.setVersion(version);
return row; return row;
} }
@@ -32,12 +32,12 @@ class LoadingPickServiceTest {
LoadingPickItemEntity item = item(); LoadingPickItemEntity item = item();
when(repository.history("pick-1")).thenReturn(null); when(repository.history("pick-1")).thenReturn(null);
when(repository.task(20L)).thenReturn(task); when(repository.task(20L)).thenReturn(task);
when(repository.item(20L, 30L)).thenReturn(item); when(repository.item(20L, 30L, 101L)).thenReturn(item);
when(repository.pick(30L, 2, 0)).thenReturn(1); when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
when(repository.allPlanItemsPicked(10L)).thenReturn(false); when(repository.allPlanItemsPicked(10L)).thenReturn(false);
when(repository.items(20L)).thenReturn(List.of(item)); when(repository.items(20L, 101L)).thenReturn(List.of(item));
service.pick(20L, 30L, new PickRequest(2, List.of(), 0, "pick-1"), 9L, "operator"); service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-1"), 9L, "operator");
verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator"); verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
} }
@@ -48,12 +48,12 @@ class LoadingPickServiceTest {
LoadingPickItemEntity item = item(); LoadingPickItemEntity item = item();
when(repository.history("pick-2")).thenReturn(null); when(repository.history("pick-2")).thenReturn(null);
when(repository.task(20L)).thenReturn(task); when(repository.task(20L)).thenReturn(task);
when(repository.item(20L, 30L)).thenReturn(item); when(repository.item(20L, 30L, 101L)).thenReturn(item);
when(repository.pick(30L, 2, 0)).thenReturn(1); when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
when(repository.allPlanItemsPicked(10L)).thenReturn(true); when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.items(20L)).thenReturn(List.of(item)); when(repository.items(20L, 101L)).thenReturn(List.of(item));
service.pick(20L, 30L, new PickRequest(2, List.of(), 0, "pick-2"), 9L, "operator"); service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-2"), 9L, "operator");
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(repository).insert(any(LoadingPickHistoryEntity.class)); verify(repository).insert(any(LoadingPickHistoryEntity.class));
@@ -0,0 +1,53 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PdaPickSettlementPlanTest {
@Test
void groupsPartialItemsByInbound() {
var resolution = PdaPickSettlementPlan.resolve(List.of(
item(1L, 11L, 101L, 1001L, 10, 6, "PARTIAL"),
item(2L, 12L, 101L, 1002L, 8, 3, "PARTIAL"),
item(3L, 13L, 102L, 2001L, 4, 0, "SKIPPED"),
item(4L, 14L, 103L, 3001L, 5, 5, "PICKED")));
assertThat(resolution.partialByInbound()).containsOnlyKeys(101L);
assertThat(resolution.partialByInbound().get(101L))
.extracting(PdaPickSettlementPlan.SplitLine::remainingPackageCount)
.containsExactlyInAnyOrder(4, 5);
assertThat(resolution.skippedPlanItemIds()).containsExactly(13L);
}
@Test
void rejectsUnfinishedItem() {
assertThatThrownBy(() -> PdaPickSettlementPlan.resolve(List.of(
item(1L, 11L, 101L, 1001L, 10, 2, "PICKING"))))
.hasMessageContaining("未结束");
}
@Test
void rejectsInvalidPartialCount() {
assertThatThrownBy(() -> PdaPickSettlementPlan.resolve(List.of(
item(1L, 11L, 101L, 1001L, 10, 10, "PARTIAL"))))
.hasMessageContaining("不完整或箱数不合法");
}
private static LoadingPickItemEntity item(long id, long planItemId, long inboundId, long detailId,
int planned, int picked, String status) {
LoadingPickItemEntity item = new LoadingPickItemEntity();
item.setId(id);
item.setLoadingPlanItemId(planItemId);
item.setInboundId(inboundId);
item.setInboundDetailId(detailId);
item.setPlannedPackageCount(planned);
item.setPickedPackageCount(picked);
item.setItemStatus(status);
return item;
}
}
@@ -94,11 +94,12 @@ class SmartLoadingServiceTest {
void rejectsExcessBoxes() { void rejectsExcessBoxes() {
LoadingPlanEntity plan = plan("EDITING", 0); LoadingPlanEntity plan = plan("EDITING", 0);
when(repository.plan(1)).thenReturn(plan); when(repository.plan(1)).thenReturn(plan);
when(repository.candidate(9, 10, 20)).thenReturn(candidate(5)); when(repository.booking(99L)).thenReturn(booking());
when(repository.candidate(100, 10, 20)).thenReturn(candidate(5));
when(repository.activeItem(1L, 9L, 20L)).thenReturn(item(7, 9, 20, 4)); when(repository.activeItem(1L, 9L, 20L)).thenReturn(item(7, 9, 20, 4));
BizException error = catchThrowableOfType(() -> service.assign(1, BizException error = catchThrowableOfType(() -> service.assign(1,
new AssignRequest(9L, 10L, 20L, 6, 0, "assign-1"), 7, "u"), BizException.class); new AssignRequest(10L, 20L, 6, 0, "assign-1"), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any()); verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any());
@@ -117,7 +118,7 @@ class SmartLoadingServiceTest {
return null; return null;
}).when(repository).insert(any(LoadingPlanItemEntity.class)); }).when(repository).insert(any(LoadingPlanItemEntity.class));
service.assign(1, new AssignRequest(9L, 10L, 20L, 5, 0, "assign-snapshot"), 7, "u"); service.assign(1, new AssignRequest(10L, 20L, 5, 0, "assign-snapshot"), 7, "u");
ArgumentCaptor<LoadingPlanItemEntity> itemCaptor = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); ArgumentCaptor<LoadingPlanItemEntity> itemCaptor = ArgumentCaptor.forClass(LoadingPlanItemEntity.class);
verify(repository).insert(itemCaptor.capture()); verify(repository).insert(itemCaptor.capture());
@@ -142,7 +143,7 @@ class SmartLoadingServiceTest {
new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 3, new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 3,
BigDecimal.ONE, BigDecimal.ONE, null, null, null, 100, null, null))); BigDecimal.ONE, BigDecimal.ONE, null, null, null, 100, null, null)));
service.assign(1, new AssignRequest(9L, 10L, 20L, 2, 0, "assign-round"), 7, "u"); service.assign(1, new AssignRequest(10L, 20L, 2, 0, "assign-round"), 7, "u");
ArgumentCaptor<LoadingPlanItemEntity> captor = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); ArgumentCaptor<LoadingPlanItemEntity> captor = ArgumentCaptor.forClass(LoadingPlanItemEntity.class);
verify(repository).insert(captor.capture()); verify(repository).insert(captor.capture());
@@ -151,9 +152,9 @@ class SmartLoadingServiceTest {
@Test @Test
void replaysBeforeVersionCheck() { void replaysBeforeVersionCheck() {
AssignRequest request = new AssignRequest(9L, 10L, 20L, 2, 0, "assign-replay"); AssignRequest request = new AssignRequest(10L, 20L, 2, 0, "assign-replay");
LoadingPlanHistoryEntity replay = history(1, null, "ADD_ITEM", LoadingPlanHistoryEntity replay = history(1, null, "ADD_ITEM",
sha256("ASSIGN|1|9|10|20|2|0")); sha256("ASSIGN|1|10|20|2|0"));
LoadingPlanEntity frozen = plan("LOADING", 9); LoadingPlanEntity frozen = plan("LOADING", 9);
when(repository.operation("assign-replay")).thenReturn(replay); when(repository.operation("assign-replay")).thenReturn(replay);
when(repository.plan(1)).thenReturn(frozen); when(repository.plan(1)).thenReturn(frozen);
@@ -169,9 +170,9 @@ class SmartLoadingServiceTest {
@Test @Test
void rejectsReusedOperation() { void rejectsReusedOperation() {
AssignRequest request = new AssignRequest(9L, 10L, 20L, 2, 0, "shared-op"); AssignRequest request = new AssignRequest(10L, 20L, 2, 0, "shared-op");
when(repository.operation("shared-op")).thenReturn(history(2, null, "ADD_ITEM", when(repository.operation("shared-op")).thenReturn(history(2, null, "ADD_ITEM",
sha256("ASSIGN|1|9|10|20|2|0"))); sha256("ASSIGN|1|10|20|2|0")));
BizException error = catchThrowableOfType(() -> service.assign(1, request, 7, "u"), BizException.class); BizException error = catchThrowableOfType(() -> service.assign(1, request, 7, "u"), BizException.class);
@@ -180,9 +181,9 @@ class SmartLoadingServiceTest {
@Test @Test
void replaysRecommendation() { void replaysRecommendation() {
RecommendRequest request = new RecommendRequest(9L, 0, "recommend-replay"); RecommendRequest request = new RecommendRequest(0, "recommend-replay");
when(repository.operation("recommend-replay")).thenReturn(history(1, null, when(repository.operation("recommend-replay")).thenReturn(history(1, null,
"SMART_RECOMMEND", sha256("RECOMMEND|1|9|0"))); "SMART_RECOMMEND", sha256("RECOMMEND|1|0")));
when(repository.plan(1)).thenReturn(plan("LOADING", 8)); when(repository.plan(1)).thenReturn(plan("LOADING", 8));
when(repository.booking(99)).thenReturn(booking()); when(repository.booking(99)).thenReturn(booking());
when(repository.items(1)).thenReturn(List.of()); when(repository.items(1)).thenReturn(List.of());
@@ -195,9 +196,9 @@ class SmartLoadingServiceTest {
@Test @Test
void replaysDeleteBeforeVersionCheck() { void replaysDeleteBeforeVersionCheck() {
DeleteRequest request = new DeleteRequest(9L, 2, 0, "delete-replay"); DeleteRequest request = new DeleteRequest(2, 0, "delete-replay");
when(repository.operation("delete-replay")).thenReturn(history(1, 8L, when(repository.operation("delete-replay")).thenReturn(history(1, 8L,
"REMOVE_ITEM", sha256("DELETE|1|8|9|2|0"))); "REMOVE_ITEM", sha256("DELETE|1|8|2|0")));
when(repository.plan(1)).thenReturn(plan("LOADING", 7)); when(repository.plan(1)).thenReturn(plan("LOADING", 7));
when(repository.booking(99)).thenReturn(booking()); when(repository.booking(99)).thenReturn(booking());
when(repository.items(1)).thenReturn(List.of()); when(repository.items(1)).thenReturn(List.of());
@@ -220,7 +221,7 @@ class SmartLoadingServiceTest {
when(repository.booking(99)).thenReturn(booking()); when(repository.booking(99)).thenReturn(booking());
when(repository.items(1)).thenReturn(List.of()); when(repository.items(1)).thenReturn(List.of());
Plan result = service.delete(1, 8, new DeleteRequest(9L, 2, 0, "delete-1"), 7, "u"); Plan result = service.delete(1, 8, new DeleteRequest(2, 0, "delete-1"), 7, "u");
assertThat(result.version()).isEqualTo(3); assertThat(result.version()).isEqualTo(3);
verify(repository).touch(1, 2, 7, "u"); verify(repository).touch(1, 2, 7, "u");
@@ -233,7 +234,7 @@ class SmartLoadingServiceTest {
ApiResponse.failure(ResultCode.NOT_FOUND, "货物不存在", null, null)); ApiResponse.failure(ResultCode.NOT_FOUND, "货物不存在", null, null));
BizException error = catchThrowableOfType(() -> service.assign(1, BizException error = catchThrowableOfType(() -> service.assign(1,
new AssignRequest(9L, 10L, 20L, 2, 0, "assign-not-found"), 7, "u"), BizException.class); new AssignRequest(10L, 20L, 2, 0, "assign-not-found"), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND); assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND);
} }
@@ -245,7 +246,7 @@ class SmartLoadingServiceTest {
.thenThrow(mock(FeignException.class)); .thenThrow(mock(FeignException.class));
BizException error = catchThrowableOfType(() -> service.assign(1, BizException error = catchThrowableOfType(() -> service.assign(1,
new AssignRequest(9L, 10L, 20L, 2, 0, "assign-down"), 7, "u"), BizException.class); new AssignRequest(10L, 20L, 2, 0, "assign-down"), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.SERVICE_UNAVAILABLE); assertThat(error.getResultCode()).isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
} }
@@ -257,22 +258,26 @@ class SmartLoadingServiceTest {
new OrderCargoClient.CargoBinding(99L, 30L, 1, "货物", 5, BigDecimal.ONE, BigDecimal.ONE))); new OrderCargoClient.CargoBinding(99L, 30L, 1, "货物", 5, BigDecimal.ONE, BigDecimal.ONE)));
BizException error = catchThrowableOfType(() -> service.assign(1, BizException error = catchThrowableOfType(() -> service.assign(1,
new AssignRequest(9L, 10L, 20L, 2, 0, "assign-mismatch"), 7, "u"), BizException.class); new AssignRequest(10L, 20L, 2, 0, "assign-mismatch"), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT); assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
} }
@Test @Test
void rejectsWrongAccountDelete() { void deletesCustomerItemInThePlanWarehouse() {
LoadingPlanEntity plan = plan("EDITING", 2); LoadingPlanEntity plan = plan("EDITING", 2);
when(repository.plan(1)).thenReturn(plan); when(repository.plan(1)).thenReturn(plan, plan("EDITING", 3));
when(repository.item(1, 8)).thenReturn(item(8, 22, 20, 1)); when(repository.item(1, 8)).thenReturn(item(8, 22, 20, 1));
when(repository.touch(1, 2, 7, "u")).thenReturn(1);
when(repository.remove(1, 8, 0, 7, "u")).thenReturn(1);
when(repository.booking(99)).thenReturn(booking());
when(repository.items(1)).thenReturn(List.of());
BizException error = catchThrowableOfType(() -> service.delete(1, 8, Plan result = service.delete(1, 8,
new DeleteRequest(9L, 2, 0, "delete-owner"), 7, "u"), BizException.class); new DeleteRequest(2, 0, "delete-owner"), 7, "u");
assertThat(error.getResultCode()).isEqualTo(ResultCode.FORBIDDEN); assertThat(result.version()).isEqualTo(3);
verify(repository, never()).remove(anyLong(), anyLong(), anyInt(), anyLong(), any()); verify(repository).remove(1, 8, 0, 7, "u");
} }
@Test @Test
@@ -296,7 +301,7 @@ class SmartLoadingServiceTest {
when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1)); when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1));
when(repository.booking(99)).thenReturn(booking()); when(repository.booking(99)).thenReturn(booking());
when(repository.items(1)).thenReturn(List.of()); when(repository.items(1)).thenReturn(List.of());
when(repository.candidate(9, 10, 20)).thenReturn(candidate(10)); when(repository.candidate(100, 10, 20)).thenReturn(candidate(10));
when(repository.activeItem(1L, 9L, 20L)).thenReturn(null); when(repository.activeItem(1L, 9L, 20L)).thenReturn(null);
when(repository.touch(1, 0, 7, "u")).thenReturn(1); when(repository.touch(1, 0, 7, "u")).thenReturn(1);
} }
@@ -12,7 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class SplitPreloadSqlContractTest { class SplitPreloadSqlContractTest {
@Test @Test
void supportsPartialPackageLock() throws Exception { void supportsPartialPackageLock() throws Exception {
assertPartialContract(moduleMigration()); assertCurrentContract(moduleMigration());
} }
@Test @Test
@@ -20,52 +20,55 @@ class SplitPreloadSqlContractTest {
String sql = moduleMigration(); String sql = moduleMigration();
assertThat(sql) assertThat(sql)
.contains("parent_inbound_id") .contains("parent_inbound_id")
.contains("split_level") .contains("inbound_role")
.contains("root_inbound_id")
.contains("split_status") .contains("split_status")
.contains("UNIQUE KEY uk_inbound_one_secondary (tenant_id, parent_inbound_id)") .contains("uk_inbound_one_child")
.contains("CONSTRAINT fk_inbound_parent FOREIGN KEY (parent_inbound_id)") .contains("fk_inbound_parent_order")
.contains("二级子入库单不能继续拆分") .contains("子入仓单不可继续拆分")
.contains("入库单主子关系创建后不可修改") .contains("入仓单主子归属创建后不可修改");
.contains("已完成拆单不能回退");
} }
@Test @Test
void remainsRepeatable() throws Exception { void remainsRepeatable() throws Exception {
String sql = moduleMigration(); String sql = moduleMigration();
assertThat(sql) assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_detail") .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("information_schema.columns") .contains("information_schema.columns")
.contains("information_schema.statistics") .contains("information_schema.statistics")
.contains("information_schema.table_constraints") .contains("information_schema.table_constraints")
.contains("DROP TRIGGER IF EXISTS trg_inbound_split_before_insert") .contains("DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_insert")
.contains("DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_delete") .contains("DROP TRIGGER IF EXISTS trg_inbound_split_item_before_delete")
.contains("CREATE PROCEDURE phase3_split_assert_contract()") .contains("CREATE PROCEDURE phase3_assert_inbound_parent_child_schema()")
.contains("拆单迁移失败:唯一子单或部分锁定幂等索引缺失") .contains("主子入仓单拆分台账未完整创建")
.contains("拆单迁移失败:主子层级或部分锁定触发器未完整创建"); .contains("主子入仓单数据库保护触发器未完整创建");
} }
@Test @Test
void syncsUnifiedMigration() throws Exception { void syncsUnifiedMigration() throws Exception {
assertPartialContract(unifiedMigration()); assertCurrentContract(unifiedMigration());
} }
private static void assertPartialContract(String sql) { private static void assertCurrentContract(String sql) {
assertThat(sql) assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_detail") .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch")
.contains("remaining_package_count INT UNSIGNED GENERATED ALWAYS AS") .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item")
.contains("source_package_count - locked_package_count") .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history")
.contains("locked_package_count < source_package_count") .contains("uk_inbound_one_child")
.contains("status = 'LOCKED' AND child_inbound_id IS NULL") .contains("uk_inbound_split_operation")
.contains("status = 'COMPLETED' AND child_inbound_id IS NOT NULL") .contains("uk_inbound_split_idempotency")
.contains("UNIQUE KEY uk_inbound_split_parent_detail") .contains("uk_inbound_split_batch_line")
.contains("UNIQUE KEY uk_inbound_split_operation (operation_id)") .contains("COUNT(DISTINCT item.child_inbound_id)")
.contains("部分锁定的归属和箱数创建后不可修改") .contains("child_order.package_count <> SUM(item.package_count)")
.contains("部分锁定与拆单明细不可删除") .contains("HAVING SUM(split_item.package_count) > source_item.package_count")
.contains("剩余箱数必须绑定主单唯一二级子入库单"); .contains("CREATE TRIGGER trg_inbound_split_item_before_update")
.contains("CREATE TRIGGER trg_inbound_split_item_before_delete");
} }
private static String moduleMigration() throws Exception { private static String moduleMigration() throws Exception {
return new ClassPathResource("db/phase3-split-preload-schema.sql") return new ClassPathResource("db/phase3-inbound-parent-child-schema.sql")
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
} }
@@ -145,6 +145,9 @@ public class LocationApplicationService {
if (warehouse == null) { if (warehouse == null) {
throw new BizException(ResultCode.FORBIDDEN, "仓库不属于当前企业租户或不在数据范围内"); throw new BizException(ResultCode.FORBIDDEN, "仓库不属于当前企业租户或不在数据范围内");
} }
if (!"DOMESTIC".equalsIgnoreCase(warehouse.getWarehouseType())) {
throw new BizException(ResultCode.FORBIDDEN, "库位只能绑定国内中转仓");
}
if (active && !"ACTIVE".equals(warehouse.getStatus())) { if (active && !"ACTIVE".equals(warehouse.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "仓库已停用"); throw new BizException(ResultCode.CONFLICT, "仓库已停用");
} }
@@ -70,6 +70,17 @@ class LocationApplicationServiceTest {
.isInstanceOf(BizException.class).hasMessageContaining("编码"); .isInstanceOf(BizException.class).hasMessageContaining("编码");
} }
@Test
void rejectsNonDomesticWarehouseForCreate() {
when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse("ACTIVE", "FBA"));
assertThatThrownBy(() -> service.create(10001L, request("AVAILABLE", null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("国内中转仓");
verify(repository, never()).insert(any());
}
@Test @Test
void shouldHandleCase03() { void shouldHandleCase03() {
LocationEntity location = location("AVAILABLE", 3); LocationEntity location = location("AVAILABLE", 3);
@@ -110,10 +121,15 @@ class LocationApplicationServiceTest {
} }
private static WarehouseEntity warehouse(String status) { private static WarehouseEntity warehouse(String status) {
return warehouse(status, "DOMESTIC");
}
private static WarehouseEntity warehouse(String status, String type) {
WarehouseEntity warehouse = new WarehouseEntity(); WarehouseEntity warehouse = new WarehouseEntity();
warehouse.setId(88L); warehouse.setId(88L);
warehouse.setTenantId(10001L); warehouse.setTenantId(10001L);
warehouse.setStatus(status); warehouse.setStatus(status);
warehouse.setWarehouseType(type);
return warehouse; return warehouse;
} }