diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 970b30b74..8a4f0705a 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -4033,7 +4033,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_item ( inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID', planned_package_count INT UNSIGNED NOT NULL 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 '乐观锁版本号', 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), @@ -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 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_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装柜拣货明细'; 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', operation_id VARCHAR(128) 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 '本次拣货箱数', + operation_reason VARCHAR(500) NULL COMMENT '部分拣货或跳拣原因', operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人', operator_name VARCHAR(64) NULL COMMENT '操作人名称', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), @@ -4228,7 +4230,49 @@ BEGIN IF current_plan_status = 'LOADING' AND EXISTS ( SELECT 1 FROM fl_loading_pick_item 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; IF OLD.frozen_at IS NOT NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结'; @@ -4287,6 +4331,28 @@ BEGIN OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货明细归属不可修改'; 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$$ DELIMITER ; @@ -4575,6 +4641,117 @@ DELIMITER ; CALL 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 地址库 @@ -5564,8 +5741,8 @@ CALL phase3_inbound_add_index( 'fl_inbound_order', 'uk_inbound_master_order', 'UNIQUE KEY `uk_inbound_master_order` (`tenant_id`, `master_order_id`)'); CALL phase3_inbound_add_index( - 'fl_inbound_order', 'uk_inbound_parent_sequence', - 'UNIQUE KEY `uk_inbound_parent_sequence` (`tenant_id`, `parent_inbound_id`, `split_sequence`)'); + 'fl_inbound_order', 'uk_inbound_one_child', + 'UNIQUE KEY `uk_inbound_one_child` (`tenant_id`, `parent_inbound_id`)'); CALL phase3_inbound_add_index( 'fl_inbound_order', 'idx_inbound_root_role', 'KEY `idx_inbound_root_role` (`tenant_id`, `root_inbound_id`, `inbound_role`, `created_at`, `id`)'); @@ -5582,6 +5759,14 @@ BEGIN ) THEN ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_tenant_order; 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$$ DELIMITER ; 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', parent_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_measurement_id BIGINT UNSIGNED NULL COMMENT '来源测量批次ID', + source_measurement_item_id BIGINT UNSIGNED NULL COMMENT '来源测量箱规明细ID', package_count INT UNSIGNED NOT NULL COMMENT '分配给子单的箱数', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (id), - UNIQUE KEY uk_inbound_split_child (tenant_id, child_inbound_id), - UNIQUE KEY uk_inbound_split_batch_child (tenant_id, split_batch_id, child_inbound_id), + UNIQUE KEY uk_inbound_split_batch_line (tenant_id, split_batch_id, line_no), + UNIQUE KEY uk_inbound_split_batch_source_item (tenant_id, split_batch_id, source_measurement_item_id), KEY idx_inbound_split_detail (tenant_id, source_inbound_detail_id, created_at), CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0), CONSTRAINT fk_inbound_split_item_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id), CONSTRAINT fk_inbound_split_item_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id), CONSTRAINT fk_inbound_split_item_child FOREIGN KEY (child_inbound_id) REFERENCES fl_inbound_order (id), - CONSTRAINT fk_inbound_split_item_measurement FOREIGN KEY (source_measurement_id) REFERENCES fl_inbound_measurement (id) + CONSTRAINT fk_inbound_split_item_measurement FOREIGN KEY (source_measurement_id) REFERENCES fl_inbound_measurement (id), + CONSTRAINT fk_inbound_split_item_measure_item FOREIGN KEY (source_measurement_item_id) REFERENCES fl_inbound_measurement_item (id) ) ENGINE=InnoDB COMMENT='主子入仓单拆分数量台账'; CREATE TABLE IF NOT EXISTS fl_inbound_split_history ( @@ -5680,6 +5868,15 @@ CALL phase3_inbound_add_column( CALL phase3_inbound_add_column( 'fl_inbound_measurement', 'source_measurement_id', "BIGINT UNSIGNED NULL COMMENT '子单继承或复核时的来源测量批次ID' AFTER split_batch_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_measurement_item', 'split_item_id', + "BIGINT UNSIGNED NULL COMMENT '产生该子单测量明细的拆分台账ID' AFTER measurement_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_measurement_item', 'source_measurement_item_id', + "BIGINT UNSIGNED NULL COMMENT '被拆分继承的来源测量箱规明细ID' AFTER split_item_id"); +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_relation; @@ -5716,6 +5913,12 @@ CALL phase3_inbound_add_relation( CALL phase3_inbound_add_relation( 'fl_inbound_measurement', 'fk_measure_source_batch', 'FOREIGN KEY (`source_measurement_id`) REFERENCES `fl_inbound_measurement` (`id`)'); +CALL phase3_inbound_add_relation( + 'fl_inbound_measurement_item', 'fk_measure_item_split_item', + 'FOREIGN KEY (`split_item_id`) REFERENCES `fl_inbound_split_item` (`id`)'); +CALL phase3_inbound_add_relation( + 'fl_inbound_measurement_item', 'fk_measure_item_source_item', + 'FOREIGN KEY (`source_measurement_item_id`) REFERENCES `fl_inbound_measurement_item` (`id`)'); DROP PROCEDURE IF EXISTS phase3_inbound_add_relation; DROP PROCEDURE IF EXISTS phase3_inbound_add_index; @@ -5742,8 +5945,34 @@ DELIMITER ; CALL phase3_inbound_add_index( 'fl_inbound_measurement', 'idx_measure_split_source', 'KEY `idx_measure_split_source` (`tenant_id`, `split_batch_id`, `source_measurement_id`, `created_at`)'); +CALL phase3_inbound_add_index( + 'fl_inbound_measurement_item', 'idx_measure_item_split_source', + 'KEY `idx_measure_item_split_source` (`tenant_id`, `split_item_id`, `source_measurement_item_id`)'); +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_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_update; DROP TRIGGER IF EXISTS trg_inbound_split_item_before_insert; @@ -5762,6 +5991,9 @@ BEGIN IF NEW.inbound_role NOT IN ('MASTER', 'CHILD') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单层级非法'; END IF; + IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法'; + END IF; IF NEW.package_count IS NOT NULL AND NEW.package_count = 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单箱数必须大于0'; END IF; @@ -5773,6 +6005,9 @@ BEGIN OR NEW.split_sequence IS NULL OR NEW.package_count IS NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单缺少主单、顺序号或箱数'; END IF; + IF NEW.split_status <> 'NOT_SPLIT' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子仅两级,子入仓单不可继续拆分'; + END IF; IF NOT EXISTS ( SELECT 1 FROM fl_inbound_order parent_order WHERE parent_order.id = NEW.parent_inbound_id @@ -5799,6 +6034,17 @@ BEGIN AND (NEW.package_count IS NULL OR NEW.package_count = 0) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单箱数必须大于0'; END IF; + IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法'; + END IF; + IF NEW.inbound_role = 'CHILD' AND NEW.split_status <> 'NOT_SPLIT' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子仅两级,子入仓单不可继续拆分'; + END IF; + IF NEW.inbound_role = 'MASTER' + AND ((NEW.package_count = 0 AND NEW.split_status <> 'FULLY_SPLIT') + OR (NEW.split_status = 'FULLY_SPLIT' AND COALESCE(NEW.package_count, 0) <> 0)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单箱数归零必须与完全拆分状态同时提交'; + END IF; END$$ CREATE TRIGGER trg_inbound_split_item_before_insert @@ -5819,8 +6065,17 @@ BEGIN AND child_order.tenant_id = NEW.tenant_id AND child_order.inbound_role = 'CHILD' AND child_order.parent_inbound_id = NEW.parent_inbound_id - AND child_order.package_count = NEW.package_count + AND child_order.package_count >= NEW.package_count AND child_order.deleted = 0 + AND (NEW.source_measurement_item_id IS NULL OR EXISTS ( + SELECT 1 + FROM fl_inbound_measurement_item source_item + JOIN fl_inbound_measurement source_batch ON source_batch.id = source_item.measurement_id + WHERE source_item.id = NEW.source_measurement_item_id + AND source_item.tenant_id = NEW.tenant_id + AND source_item.measurement_id = NEW.source_measurement_id + AND source_batch.inbound_id = NEW.parent_inbound_id + )) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分明细与批次、主单或子单不一致'; END IF; @@ -5858,14 +6113,38 @@ BEGIN WHERE parent_order.id = NEW.parent_inbound_id AND parent_order.tenant_id = NEW.tenant_id AND parent_order.inbound_role = 'MASTER' - AND parent_order.package_count = 0 + AND parent_order.package_count > 0 + AND parent_order.split_status = 'PARTIALLY_SPLIT' AND parent_order.version > NEW.expected_parent_version ) OR (SELECT COALESCE(SUM(item.package_count), 0) FROM fl_inbound_split_item item WHERE item.tenant_id = NEW.tenant_id - AND item.split_batch_id = NEW.id) <> NEW.package_count THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单可用箱数未归零或子单箱数合计不一致'; + AND item.split_batch_id = NEW.id) <> NEW.package_count OR + (SELECT COUNT(DISTINCT item.child_inbound_id) + FROM fl_inbound_split_item item + WHERE item.tenant_id = NEW.tenant_id + AND item.split_batch_id = NEW.id) <> 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; ELSEIF NEW.status = 'CANCELLED' THEN IF EXISTS ( @@ -5937,7 +6216,8 @@ BEGIN IF (SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_schema = DATABASE() AND constraint_name IN ('fk_inbound_parent_order', 'fk_inbound_root_order', - 'fk_measure_split_batch', 'fk_measure_source_batch')) <> 4 THEN + 'fk_measure_split_batch', 'fk_measure_source_batch', + 'fk_measure_item_split_item', 'fk_measure_item_source_item')) <> 6 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单与测量关联约束未完整创建'; END IF; IF (SELECT COUNT(*) FROM information_schema.triggers @@ -5965,386 +6245,3 @@ END$$ DELIMITER ; CALL phase3_assert_inbound_parent_child_schema(); DROP PROCEDURE IF EXISTS phase3_assert_inbound_parent_child_schema; - -/* - * 部分装柜拆单契约: - * 1. parent_inbound_id 为空的是主单,非空的是唯一二级子单; - * 2. 锁定阶段只记录箱数,完成阶段绑定由剩余箱数生成的子单; - * 3. 子单不能再作为父单,锁定数量创建后不可修改。 - */ -DROP PROCEDURE IF EXISTS phase3_split_add_column; -DELIMITER $$ -CREATE PROCEDURE phase3_split_add_column( - IN p_table_name VARCHAR(64), IN p_column_name VARCHAR(64), IN p_definition TEXT -) -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = DATABASE() AND table_name = p_table_name AND column_name = p_column_name - ) THEN - SET @phase3_split_ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `', - p_column_name, '` ', p_definition); - PREPARE phase3_split_stmt FROM @phase3_split_ddl; - EXECUTE phase3_split_stmt; - DEALLOCATE PREPARE phase3_split_stmt; - END IF; -END$$ -DELIMITER ; - -CALL phase3_split_add_column('fl_inbound_order', 'parent_inbound_id', - 'BIGINT UNSIGNED NULL COMMENT ''生成本单的主入库单ID;仅二级子单有值'' AFTER id'); -CALL phase3_split_add_column('fl_inbound_order', 'split_level', - 'TINYINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN parent_inbound_id IS NULL THEN 0 ELSE 1 END) STORED COMMENT ''拆单层级:0主单、1二级子单'' AFTER parent_inbound_id'); -CALL phase3_split_add_column('fl_inbound_order', 'split_status', - 'VARCHAR(24) NOT NULL DEFAULT ''NOT_SPLIT'' COMMENT ''NOT_SPLIT/PARTIALLY_SPLIT/FULLY_SPLIT'' AFTER split_level'); -DROP PROCEDURE IF EXISTS phase3_split_add_column; - -SET @phase3_split_index_exists = ( - SELECT COUNT(*) FROM information_schema.statistics - WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' - AND index_name = 'uk_inbound_one_secondary' -); -SET @phase3_split_ddl = IF(@phase3_split_index_exists = 0, - 'ALTER TABLE fl_inbound_order ADD UNIQUE KEY uk_inbound_one_secondary (tenant_id, parent_inbound_id)', - 'SELECT 1'); -PREPARE phase3_split_stmt FROM @phase3_split_ddl; -EXECUTE phase3_split_stmt; -DEALLOCATE PREPARE phase3_split_stmt; - -SET @phase3_split_fk_exists = ( - SELECT COUNT(*) FROM information_schema.table_constraints - WHERE constraint_schema = DATABASE() AND table_name = 'fl_inbound_order' - AND constraint_name = 'fk_inbound_parent' AND constraint_type = 'FOREIGN KEY' -); -SET @phase3_split_ddl = IF(@phase3_split_fk_exists = 0, - 'ALTER TABLE fl_inbound_order ADD CONSTRAINT fk_inbound_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id)', - 'SELECT 1'); -PREPARE phase3_split_stmt FROM @phase3_split_ddl; -EXECUTE phase3_split_stmt; -DEALLOCATE PREPARE phase3_split_stmt; - -CREATE TABLE IF NOT EXISTS fl_inbound_split_detail ( - id BIGINT UNSIGNED NOT NULL COMMENT '部分锁定明细ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', - parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入库单ID', - child_inbound_id BIGINT UNSIGNED NULL COMMENT '完成拆单后生成的二级子入库单ID', - inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '原订单货物明细ID,不建立跨库外键', - source_package_count INT UNSIGNED NOT NULL COMMENT '拆分前该明细总箱数快照', - locked_package_count INT UNSIGNED NOT NULL COMMENT '本次装柜锁定箱数', - remaining_package_count INT UNSIGNED GENERATED ALWAYS AS ( - source_package_count - locked_package_count - ) STORED COMMENT '生成二级子单的剩余箱数', - status VARCHAR(16) NOT NULL DEFAULT 'LOCKED' COMMENT 'LOCKED/COMPLETED', - operation_id VARCHAR(128) NOT NULL COMMENT '部分锁定操作幂等号', - created_by BIGINT UNSIGNED NOT NULL, - created_by_name VARCHAR(64) NULL, - completed_by BIGINT UNSIGNED NULL, - completed_by_name VARCHAR(64) NULL, - completed_at DATETIME(3) NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - version INT UNSIGNED NOT NULL DEFAULT 0, - PRIMARY KEY (id), - UNIQUE KEY uk_inbound_split_parent_detail (tenant_id, parent_inbound_id, inbound_detail_id), - UNIQUE KEY uk_inbound_split_child_detail (tenant_id, child_inbound_id, inbound_detail_id), - UNIQUE KEY uk_inbound_split_operation (operation_id), - KEY idx_inbound_split_status (tenant_id, parent_inbound_id, status), - CONSTRAINT fk_inbound_split_detail_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id), - CONSTRAINT fk_inbound_split_detail_child FOREIGN KEY (child_inbound_id) REFERENCES fl_inbound_order (id), - CONSTRAINT chk_inbound_split_partial CHECK ( - source_package_count > 1 AND locked_package_count > 0 - AND locked_package_count < source_package_count - ), - CONSTRAINT chk_inbound_split_status CHECK (status IN ('LOCKED', 'COMPLETED')), - CONSTRAINT chk_inbound_split_completion CHECK ( - (status = 'LOCKED' AND child_inbound_id IS NULL AND completed_at IS NULL) - OR (status = 'COMPLETED' AND child_inbound_id IS NOT NULL AND completed_at IS NOT NULL) - ) -) ENGINE=InnoDB COMMENT='按箱数部分锁定及剩余箱数生成二级子入库单的不可变契约'; - -DROP PROCEDURE IF EXISTS unified_assert_final_extensions; -DELIMITER $$ -CREATE PROCEDURE unified_assert_final_extensions() -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java index 363d0c7eb..5952b910c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java @@ -8,7 +8,11 @@ import org.springframework.cloud.openfeign.EnableFeignClients; /** * 头程履约微服务启动入口,负责装配履约任务组件并扫描 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") @SpringBootApplication(scanBasePackages = "com.baiyue") public class BaiyueFirstlegApplication { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java new file mode 100644 index 000000000..d24820e4a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java @@ -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 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) { + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java index 574ffd8b4..11e3e0f20 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -26,6 +26,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; /** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */ @@ -50,9 +51,10 @@ public class PlatformLoadingListController { @GetMapping("/loading-plans/{planId}") @PreAuthorize("hasAuthority('platform:firstleg:preload:view')") public ApiResponse preloadDetail( - @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId) { + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, + @RequestParam @Positive long warehouseId) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.preloadDetail(planId)); + return ApiResponse.success(service.preloadDetail(planId, warehouseId)); } @Operation(summary = "修改装柜运输字段", description = "仅装柜中可修改集装箱号与航次。") @@ -61,11 +63,12 @@ public class PlatformLoadingListController { public ApiResponse updateTransport( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, + @RequestParam @Positive long warehouseId, @RequestHeader("Idempotency-Key") String idempotencyKey, @Valid @RequestBody TransportUpdateRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.updateTransport( - planId, user.userId(), user.username(), idempotencyKey, request)); + planId, warehouseId, user.userId(), user.username(), idempotencyKey, request)); } @Operation(summary = "分页查询完成装柜清单", description = "只读取 COMPLETED 状态的装柜计划。") @@ -81,8 +84,9 @@ public class PlatformLoadingListController { @GetMapping("/loading-lists/{planId}") @PreAuthorize("hasAuthority('platform:firstleg:loading-list:view')") public ApiResponse confirmedDetail( - @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId) { + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, + @RequestParam @Positive long warehouseId) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.confirmedDetail(planId)); + return ApiResponse.success(service.confirmedDetail(planId, warehouseId)); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java index b3b2473d5..dbe9c39e8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java @@ -3,6 +3,8 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; 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.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.LoadingPickService; @@ -31,9 +33,10 @@ public class PlatformLoadingPdaController { @GetMapping("/tasks") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam String containerNo) { + @RequestParam String containerNo, + @RequestParam @Positive long warehouseId) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.task(containerNo)); + return ApiResponse.success(service.task(containerNo, warehouseId)); } @PostMapping("/tasks/{taskId}/items/{itemId}/pick") @@ -45,4 +48,24 @@ public class PlatformLoadingPdaController { FirstlegScopeGuard.platform(user); 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 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 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())); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java index 3620f8be2..ea1020076 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java @@ -9,7 +9,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; imp public class PlatformSmartLoadingController { @Resource private SmartLoadingService service; @GetMapping("/candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") - public ApiResponse> candidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long accountId){FirstlegScopeGuard.platform(u,accountId);return ApiResponse.success(service.candidates(accountId));} + public ApiResponse> 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')") public ApiResponse 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')") @@ -17,13 +17,13 @@ public class PlatformSmartLoadingController { @PostMapping("/plans") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:create')") public ApiResponse 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')") - public ApiResponse 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 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')") - public ApiResponse 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 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')") - public ApiResponse 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 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')") - public ApiResponse 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 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')") public ApiResponse 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()));} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java index 5ea393fdc..497edc6ac 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java @@ -48,7 +48,7 @@ public final class ContainerDtos { @Size(max = 64) String containerNo, @NotBlank @Size(max = 64) String cabinetNo, @NotNull @Positive Long warehouseId, - @NotBlank @Size(max = 128) String warehouseName, + @Size(max = 128) String warehouseName, @Size(max = 128) String billOfLadingNo, @Size(max = 128) String vesselName, @Size(max = 64) String voyageNo, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java index 90d1af3da..85e33f742 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -17,8 +17,7 @@ public final class LoadingListDtos { public record PreloadQuery( @Size(max = 128) String keyword, String status, - @Min(1) Long accountId, - @Min(1) Long warehouseId, + @NotNull @Min(1) Long warehouseId, LocalDateTime createdFrom, LocalDateTime createdTo, @Min(1) long page, @@ -32,8 +31,7 @@ public final class LoadingListDtos { public record ConfirmedQuery( @Size(max = 128) String keyword, - @Min(1) Long accountId, - @Min(1) Long warehouseId, + @NotNull @Min(1) Long warehouseId, LocalDateTime createdFrom, LocalDateTime createdTo, @Min(1) long page, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java index c6477d768..62c14bfac 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java @@ -14,6 +14,7 @@ public final class LoadingPickDtos { } public record PickRequest( + @NotNull @Min(1) Long warehouseId, @NotNull @Min(1) Integer pickedPackageCount, @JsonAlias("reservationIds") List<@Min(1) Long> locationIds, @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, Integer plannedPackageCount, Integer pickedPackageCount, String itemStatus, Integer version) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java index 766e5e3c2..d2dcd723a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -11,13 +11,13 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){} } } 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) {} - 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) {} - 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) {} - 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) {} 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 items) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickHistoryEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickHistoryEntity.java index 3e80f10fd..f2450e391 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickHistoryEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickHistoryEntity.java @@ -17,7 +17,9 @@ public class LoadingPickHistoryEntity { private Long pickItemId; private String operationId; private String requestHash; + private String actionType; private Integer pickedPackageCount; + private String operationReason; private Long operatorId; private String operatorName; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingPickTaskMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/InboundLoadingPickTaskMapper.java similarity index 93% rename from firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingPickTaskMapper.java rename to firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/InboundLoadingPickTaskMapper.java index dba3437f8..c3b7700c4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingPickTaskMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/InboundLoadingPickTaskMapper.java @@ -7,7 +7,7 @@ import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Update; @Mapper -public interface LoadingPickTaskMapper extends BaseMapper { +public interface InboundLoadingPickTaskMapper extends BaseMapper { @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), " + "picked_count=COALESCE(#{pickedCount}, picked_count), version=version+1 WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} " diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java index 60151b079..dedb358a6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java @@ -7,7 +7,7 @@ import com.baiyue.firstleg.loading.entity.LoadingOperationEntity; import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper; import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper; 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 jakarta.annotation.Resource; import org.springframework.stereotype.Repository; @@ -19,7 +19,7 @@ public class LoadingRepository { @Resource private LoadingInboundMapper inbounds; @Resource private LoadingEvidenceMapper evidences; @Resource private LoadingOperationMapper operations; - @Resource private LoadingPickTaskMapper picks; + @Resource private InboundLoadingPickTaskMapper picks; public LoadingInboundMapper.Snapshot findInbound(long tenantId, long inboundId) { return inbounds.find(tenantId, inboundId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java new file mode 100644 index 000000000..249e474e5 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java @@ -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({""}) + List lockLatestApprovedItems(@Param("accountId") long accountId, + @Param("inboundId") long inboundId, + @Param("detailIds") List 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) { + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java index 299db9824..3ea3334a8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -53,11 +53,6 @@ public interface LoadingListQueryMapper { OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%')) AND b.warehouse_id = #{query.warehouseId} - - 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}) - AND p.created_at >= #{query.createdFrom} AND p.created_at <= #{query.createdTo} GROUP BY @@ -81,11 +76,6 @@ public interface LoadingListQueryMapper { OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%')) AND b.warehouse_id = #{query.warehouseId} - - 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}) - AND p.created_at >= #{query.createdFrom} AND p.created_at <= #{query.createdTo} GROUP BY diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java index eaa9d8eac..3af7c51d9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -9,6 +9,32 @@ import org.apache.ibatis.annotations.Update; @Mapper public interface LoadingPickItemMapper extends BaseMapper { + @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 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 fl_loading_pick_item SET picked_package_count = picked_package_count + #{count}, @@ -16,9 +42,52 @@ public interface LoadingPickItemMapper extends BaseMapper THEN 'PICKED' ELSE 'PICKING' END, 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 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 < #{finalCount} + AND #{finalCount} < 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 任务中的明细。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java index ee5c434a1..db7bf82a7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java @@ -3,7 +3,21 @@ package com.baiyue.firstleg.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; @Mapper public interface LoadingPickTaskMapper extends BaseMapper { + @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); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index 1713c4fe6..f4c5da782 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -8,7 +8,14 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName, 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 - 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 FROM fl_inbound_order o 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 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 - 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.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 """) - List candidates(@Param("accountId") long accountId); + List candidates(@Param("warehouseId") long warehouseId); @Select(""" 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.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName, 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 FROM fl_inbound_order o 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 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 - 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} 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); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java index e30e13c91..d404ba6fe 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java @@ -38,6 +38,10 @@ public class LoadingPickRepository { .eq(LoadingPickTaskEntity::getTaskStatus, "LOADING")); } + public LoadingPickTaskEntity taskByContainer(String containerNo, long warehouseId) { + return tasks.selectLoadingByContainerAndAccount(containerNo, warehouseId); + } + public LoadingPickItemEntity item(long taskId, long itemId) { return items.selectOne(new LambdaQueryWrapper() .eq(LoadingPickItemEntity::getPickTaskId, taskId) @@ -50,6 +54,14 @@ public class LoadingPickRepository { .orderByAsc(LoadingPickItemEntity::getId)); } + public List 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) { tasks.insert(task); } @@ -63,8 +75,20 @@ public class LoadingPickRepository { .eq(LoadingPickItemEntity::getLoadingPlanItemId, planItemId)); } - public int pick(long itemId, int count, int version) { - return items.pick(itemId, count, version); + public int pick(long itemId, int count, int version, long warehouseId) { + 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) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java index e774deb0d..6275c7f2b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -27,9 +27,9 @@ public class SmartLoadingRepository { @Resource private ContainerBookingMapper bookings; @Resource private SmartLoadingCandidateMapper candidates; - public List candidates(long accountId) { return candidates.candidates(accountId); } - public Candidate candidate(long accountId, long inboundId, long detailId) { - return candidates.candidate(accountId, inboundId, detailId); + public List candidates(long warehouseId) { return candidates.candidates(warehouseId); } + public Candidate candidate(long warehouseId, long inboundId, long detailId) { + return candidates.candidate(warehouseId, inboundId, detailId); } public LoadingPlanEntity plan(long planId) { return plans.selectById(planId); } public LoadingPlanEntity planByBooking(long bookingId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java index 798ab618a..258a54b1d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java @@ -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.LoadingPickTaskEntity; 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.mapper.PreloadPlanMapper; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; @@ -23,7 +23,7 @@ import java.util.stream.Collectors; @Repository public class UnifiedInboundReadRepository { @Resource private PreloadPlanMapper preloads; - @Resource private LoadingPickTaskMapper picks; + @Resource private InboundLoadingPickTaskMapper picks; @Resource private LoadingEvidenceMapper evidences; @Resource private InboundStatusHistoryMapper histories; @Resource private InboundTransportSnapshotMapper transports; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index 1eabf5bab..ac1c1ef6f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -2,8 +2,10 @@ package com.baiyue.firstleg.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; 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.exception.BizException; +import com.baiyue.firstleg.client.WarehouseMasterDataClient; import com.baiyue.firstleg.domain.SmartLoadingStatus; import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; 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.repository.ContainerRepository; import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,6 +24,9 @@ import java.time.LocalDateTime; @Service public class ContainerService { @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 page(Query query) { validateRange(query.estimatedSailingFrom(), query.estimatedSailingTo(), "预计船期筛选"); return repository.page(normalize(query)); @@ -30,11 +36,13 @@ public class ContainerService { @Transactional public ContainerBookingEntity create(long operatorId, String operatorName, SaveRequest request) { validateSchedule(request); + WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.warehouseId()); ensureUnique(trim(request.containerNo()), request.cabinetNo().trim(), null); ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(IdWorker.getId()); value.setBookingNo("CB" + value.getId()); copy(value, request); + value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); try { repository.insert(value); } catch (DuplicateKeyException ex) { throw duplicate(); } @@ -51,8 +59,10 @@ public class ContainerService { throw conflict("货柜已进入预装流程,只能修改集装箱号和航次"); } validateSchedule(request.data()); + WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.data().warehouseId()); ensureUnique(trim(request.data().containerNo()), request.data().cabinetNo().trim(), id); copy(current, request.data()); + current.setWarehouseId(warehouse.id()); current.setWarehouseName(warehouse.warehouseName()); try { if (repository.updateDraft(current, request.version(), operatorId, operatorName) != 1) { throw conflict("货柜状态或版本已变化,请刷新后重试"); @@ -85,6 +95,31 @@ public class ContainerService { if (repository.containerNoExists(containerNo, excludedId)) throw conflict("集装箱号已存在"); if (repository.cabinetNoExists(cabinetNo, excludedId)) throw conflict("内部柜号已存在"); } + private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) { + try { + ApiResponse 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) { if (!version.equals(value.getVersion())) throw conflict("货柜已被其他人修改,请刷新后重试"); } @@ -94,7 +129,7 @@ public class ContainerService { } private static void copy(ContainerBookingEntity v, SaveRequest r) { 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.setVoyageNo(trim(r.voyageNo())); v.setContainerType(r.containerType().trim()); v.setShippingCompanyId(r.shippingCompanyId()); v.setShippingCompanyName(trim(r.shippingCompanyName())); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index ab1f4813c..b01eb24ef 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -46,26 +46,28 @@ public class LoadingListService { if (StringUtils.hasText(status) && !LOADING_STATUSES.contains(status)) { 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())); } public PageResult confirmedPage(ConfirmedQuery query) { validateRange(query.createdFrom(), query.createdTo()); - return repository.confirmedPage(new ConfirmedQuery(trim(query.keyword()), query.accountId(), - query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size())); + return repository.confirmedPage(new ConfirmedQuery(trim(query.keyword()), query.warehouseId(), + query.createdFrom(), query.createdTo(), query.page(), query.size())); } - public PreloadDetail preloadDetail(long planId) { + public PreloadDetail preloadDetail(long planId, long warehouseId) { AggregateRow row = requirePlan(planId); + requireWarehouse(row, warehouseId); if (!LOADING_STATUSES.contains(row.getPlanStatus())) { throw notFound("装柜中清单不存在"); } return repository.preloadDetail(row); } - public ConfirmedDetail confirmedDetail(long planId) { + public ConfirmedDetail confirmedDetail(long planId, long warehouseId) { AggregateRow row = requirePlan(planId); + requireWarehouse(row, warehouseId); if (!"COMPLETED".equals(row.getPlanStatus())) { throw notFound("装柜清单不存在"); } @@ -75,20 +77,21 @@ public class LoadingListService { @Transactional @BusinessAudit(event = "PRELOAD_TRANSPORT_UPDATE", resourceType = "LOADING_PLAN", 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 containerNo = nullableTrim(request.containerNo()); String voyageNo = nullableTrim(request.voyageNo()); String requestHash = sha256("TRANSPORT|" + planId + '|' + request.expectedVersion() + '|' + containerNo + '|' + voyageNo + '|' + trim(request.operationReason())); String operationId = operationId("TRANSPORT", planId, idempotencyKey); + AggregateRow before = requirePlan(planId); + requireWarehouse(before, warehouseId); HistoryRow replay = repository.history(operationId); if (replay != null) { requireSameRequest(replay, requestHash); - return repository.preloadDetail(requirePlan(planId)); + return repository.preloadDetail(before); } - AggregateRow before = requirePlan(planId); requireLoadingMutable(before); if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo, operatorId, trim(operatorName)) != 1) { @@ -147,6 +150,15 @@ public class LoadingListService { 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) { if (!LOADING_STATUSES.contains(row.getPlanStatus())) { throw conflict("仅装柜中可修改集装箱号与航次"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 125b629bd..add01645e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -4,6 +4,8 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.LoadingPickDtos.PickItem; 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.entity.LoadingPickHistoryEntity; 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)) { 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) { throw new BizException(ResultCode.NOT_FOUND, "未找到装柜中的PDA拣货任务"); } - return view(task); + return view(task, warehouseId); } @Transactional public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) { 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); if (replay != null) { if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId) || !replay.getRequestHash().equals(requestHash)) { throw conflict("operationId 已用于不同拣货请求"); } - return view(requiredTask(taskId)); + return view(requiredTask(taskId), request.warehouseId()); } LoadingPickTaskEntity task = requiredTask(taskId); if (!"LOADING".equals(task.getTaskStatus())) { throw conflict("装柜任务已完成,不能继续拣货"); } - LoadingPickItemEntity item = repository.item(taskId, itemId); + LoadingPickItemEntity item = repository.item(taskId, itemId, request.warehouseId()); if (item == null) { 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("拣货数量超过计划箱数或明细版本已变化"); } LoadingPickHistoryEntity history = new LoadingPickHistoryEntity(); @@ -102,6 +108,7 @@ public class LoadingPickService { history.setPickItemId(itemId); history.setOperationId(operationId); history.setRequestHash(requestHash); + history.setActionType("PICK"); history.setPickedPackageCount(request.pickedPackageCount()); history.setOperatorId(operatorId); history.setOperatorName(operatorName); @@ -109,7 +116,49 @@ public class LoadingPickService { if (repository.allPlanItemsPicked(task.getLoadingPlanId())) { 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 @@ -155,8 +204,22 @@ public class LoadingPickService { return task; } - private Task view(LoadingPickTaskEntity task) { - List items = repository.items(task.getId()).stream() + private LoadingPickTaskEntity requiredLoadingTask(long taskId) { + 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 items = repository.items(task.getId(), warehouseId).stream() .map(item -> new PickItem(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(), item.getInboundDetailId(), item.getPlannedPackageCount(), item.getPickedPackageCount(), item.getItemStatus(), item.getVersion())).toList(); @@ -164,6 +227,30 @@ public class LoadingPickService { 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) { try { 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) { return new BizException(ResultCode.CONFLICT, message); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementPlan.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementPlan.java new file mode 100644 index 000000000..d0028eb74 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementPlan.java @@ -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 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> partialByInbound = items.stream() + .filter(item -> "PARTIAL".equals(item.getItemStatus())) + .map(PdaPickSettlementPlan::splitLine) + .collect(Collectors.groupingBy(SplitLine::inboundId)); + List 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> partialByInbound, List skippedPlanItemIds) { + } + + record SplitLine(long pickItemId, long loadingPlanItemId, long inboundId, long inboundDetailId, + int pickedPackageCount, int remainingPackageCount) { + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index e18f273c8..a643fb2b2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -36,9 +36,11 @@ public class SmartLoadingService { @Value("${baiyue.security.internal-service-token:}") private String internalToken; public SmartLoadingService() { } - public List candidates(long accountId) { - requireAccount(accountId); - return repository.candidates(accountId); + public List candidates(long warehouseId) { + if (warehouseId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "warehouseId必须大于0"); + } + return repository.candidates(warehouseId); } public Plan draftByBooking(long bookingId) { @@ -81,22 +83,21 @@ public class SmartLoadingService { resourceId = "#planId", description = "分配智能装柜明细") public Plan assign(long planId, AssignRequest request, long operatorId, String operatorName) { String operation = operation(request.operationId()); - String requestHash = sha256("ASSIGN|" + planId + '|' + request.accountId() + '|' + String requestHash = sha256("ASSIGN|" + planId + '|' + request.inboundId() + '|' + request.inboundDetailId() + '|' + request.packageCount() + '|' + request.expectedVersion()); Plan replay = replay(operation, planId, "ADD_ITEM", null, requestHash); if (replay != null) return replay; 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 (!Objects.equals(candidate.accountId(), request.accountId())) throw forbidden(); 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 availableIncludingCurrent = candidate.availablePackageCount() + (existing == null ? 0 : existing.getPackageCount()); if (total > availableIncludingCurrent) throw validation("累计分配箱数超过可用箱数"); - ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); List lines = new ArrayList<>(repository.items(planId)); if (existing != null) lines.removeIf(x -> Objects.equals(x.getId(), existing.getId())); checkRouteAndCapacity(booking, lines, candidate, total); @@ -126,20 +127,19 @@ public class SmartLoadingService { resourceId = "#planId", description = "执行智能装柜推荐") public Plan recommend(long planId, RecommendRequest request, long operatorId, String operatorName) { String operation = operation(request.operationId()); - String requestHash = sha256("RECOMMEND|" + planId + '|' + request.accountId() - + '|' + request.expectedVersion()); + String requestHash = sha256("RECOMMEND|" + planId + '|' + request.expectedVersion()); Plan replay = replay(operation, planId, "SMART_RECOMMEND", null, requestHash); if (replay != null) return replay; LoadingPlanEntity plan = editing(planId, request.expectedVersion()); 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 (StringUtils.hasText(booking.getDestinationWarehouseCode()) && !Objects.equals(booking.getDestinationWarehouseCode(), candidate.destinationWarehouseCode())) continue; int count = fittingBoxes(booking, repository.items(planId), candidate); if (count > 0) { 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() + '|' + candidate.inboundDetailId())), operatorId, operatorName); } @@ -157,22 +157,22 @@ public class SmartLoadingService { resourceId = "#itemId", description = "调整智能装柜明细") public Plan adjust(long planId, long itemId, AdjustRequest request, long operatorId, String operatorName) { String operation = operation(request.operationId()); - String requestHash = sha256("ADJUST|" + planId + '|' + itemId + '|' + request.accountId() - + '|' + request.packageCount() + '|' + request.expectedPlanVersion() + String requestHash = sha256("ADJUST|" + planId + '|' + itemId + '|' + + request.packageCount() + '|' + request.expectedPlanVersion() + '|' + request.expectedItemVersion()); Plan replay = replay(operation, planId, "ADJUST_ITEM", itemId, requestHash); if (replay != null) return replay; LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion()); LoadingPlanItemEntity item = repository.item(planId, itemId); 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("装柜明细版本冲突"); - 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("调整箱数超过可用箱数"); OrderCargoClient.CargoBinding cargo = loadCargo(candidate); List others = new ArrayList<>(repository.items(planId)); 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); item.setPackageCount(request.packageCount()); item.setGrossWeightKg(unit(candidate.unitWeightKg()).multiply(BigDecimal.valueOf(request.packageCount()))); @@ -192,14 +192,13 @@ public class SmartLoadingService { resourceId = "#itemId", description = "删除智能装柜明细") public Plan delete(long planId, long itemId, DeleteRequest request, long operatorId, String operatorName) { String operation = operation(request.operationId()); - String requestHash = sha256("DELETE|" + planId + '|' + itemId + '|' + request.accountId() - + '|' + request.expectedPlanVersion() + '|' + request.expectedItemVersion()); + String requestHash = sha256("DELETE|" + planId + '|' + itemId + '|' + + request.expectedPlanVersion() + '|' + request.expectedItemVersion()); Plan replay = replay(operation, planId, "REMOVE_ITEM", itemId, requestHash); if (replay != null) return replay; LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion()); LoadingPlanItemEntity item = repository.item(planId, itemId); if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在"); - if (!Objects.equals(item.getAccountId(), request.accountId())) throw forbidden(); loadingPickService.remove(itemId); touch(plan, operatorId, operatorName); if (repository.remove(planId, itemId, request.expectedItemVersion(), operatorId, operatorName) != 1) throw conflict("装柜草稿已被并发修改"); diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml index d43f329e2..0a39f1c03 100644 --- a/firstleg/src/main/resources/application.yml +++ b/firstleg/src/main/resources/application.yml @@ -33,6 +33,8 @@ baiyue: url: ${ORDER_SERVICE_URL:} file: url: ${FILE_SERVICE_URL:} + warehouse: + url: ${WAREHOUSE_SERVICE_URL:} firstleg: inbound: allow-rejected-measurement: false diff --git a/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql b/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql index 59e495435..c2b48e1f3 100644 --- a/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql +++ b/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql @@ -79,8 +79,8 @@ CALL phase3_inbound_add_index( 'fl_inbound_order', 'uk_inbound_master_order', 'UNIQUE KEY `uk_inbound_master_order` (`tenant_id`, `master_order_id`)'); CALL phase3_inbound_add_index( - 'fl_inbound_order', 'uk_inbound_parent_sequence', - 'UNIQUE KEY `uk_inbound_parent_sequence` (`tenant_id`, `parent_inbound_id`, `split_sequence`)'); + 'fl_inbound_order', 'uk_inbound_one_child', + 'UNIQUE KEY `uk_inbound_one_child` (`tenant_id`, `parent_inbound_id`)'); CALL phase3_inbound_add_index( 'fl_inbound_order', 'idx_inbound_root_role', 'KEY `idx_inbound_root_role` (`tenant_id`, `root_inbound_id`, `inbound_role`, `created_at`, `id`)'); @@ -97,6 +97,14 @@ BEGIN ) THEN ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_tenant_order; 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$$ DELIMITER ; 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', parent_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_measurement_id BIGINT UNSIGNED NULL COMMENT '来源测量批次ID', source_measurement_item_id BIGINT UNSIGNED NULL COMMENT '来源测量箱规明细ID', package_count INT UNSIGNED NOT NULL COMMENT '分配给子单的箱数', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (id), - UNIQUE KEY uk_inbound_split_child (tenant_id, child_inbound_id), - UNIQUE KEY uk_inbound_split_batch_child (tenant_id, split_batch_id, child_inbound_id), + UNIQUE KEY uk_inbound_split_batch_line (tenant_id, split_batch_id, line_no), + UNIQUE KEY uk_inbound_split_batch_source_item (tenant_id, split_batch_id, source_measurement_item_id), KEY idx_inbound_split_detail (tenant_id, source_inbound_detail_id, created_at), CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0), CONSTRAINT fk_inbound_split_item_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id), @@ -203,6 +212,9 @@ CALL phase3_inbound_add_column( 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_relation; @@ -274,8 +286,31 @@ CALL phase3_inbound_add_index( 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_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_update; 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.inbound_role = 'CHILD' 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 (NEW.source_measurement_item_id IS NULL OR EXISTS ( SELECT 1 @@ -416,15 +451,26 @@ BEGIN WHERE parent_order.id = NEW.parent_inbound_id AND parent_order.tenant_id = NEW.tenant_id AND parent_order.inbound_role = 'MASTER' - AND parent_order.package_count = 0 - AND parent_order.split_status = 'FULLY_SPLIT' + AND parent_order.package_count > 0 + AND parent_order.split_status = 'PARTIALLY_SPLIT' AND parent_order.version > NEW.expected_parent_version ) OR (SELECT COALESCE(SUM(item.package_count), 0) FROM fl_inbound_split_item item WHERE item.tenant_id = NEW.tenant_id - AND item.split_batch_id = NEW.id) <> NEW.package_count THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单可用箱数未归零或子单箱数合计不一致'; + AND item.split_batch_id = NEW.id) <> NEW.package_count OR + (SELECT COUNT(DISTINCT item.child_inbound_id) + FROM fl_inbound_split_item item + WHERE item.tenant_id = NEW.tenant_id + AND item.split_batch_id = NEW.id) <> 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 diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 7ae37492e..d6c805260 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -233,7 +233,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_item ( inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID', planned_package_count INT UNSIGNED NOT NULL 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 '乐观锁版本号', 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), @@ -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 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_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装柜拣货明细'; 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', operation_id VARCHAR(128) 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 '本次拣货箱数', + operation_reason VARCHAR(500) NULL COMMENT '部分拣货或跳拣原因', operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人', operator_name VARCHAR(64) NULL COMMENT '操作人名称', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), @@ -428,7 +430,49 @@ BEGIN IF current_plan_status = 'LOADING' AND EXISTS ( SELECT 1 FROM fl_loading_pick_item 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; IF OLD.frozen_at IS NOT NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结'; @@ -487,6 +531,28 @@ BEGIN OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货明细归属不可修改'; 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$$ DELIMITER ; diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 2b8423152..a5cc4dc57 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -7,7 +7,7 @@ paths: /api/platform/v1/first-leg/loading-plans: get: summary: 分页查询预装清单 - description: 仅返回预装柜中或预装柜完成的计划,计划为平台作业数据,客户账号仅作为明细筛选条件。 + description: 仓库作业清单必须按仓库查询,返回该仓库全部客户的装柜计划;客户账号仅用于展示货物归属。 x-permission: platform:firstleg:preload:list parameters: - {$ref: '#/components/parameters/Page'} @@ -17,7 +17,6 @@ paths: in: query description: 预装状态 schema: {type: string, enum: [LOADING]} - - {$ref: '#/components/parameters/AccountId'} - {$ref: '#/components/parameters/WarehouseId'} - {$ref: '#/components/parameters/CreatedFrom'} - {$ref: '#/components/parameters/CreatedTo'} @@ -28,7 +27,7 @@ paths: summary: 查询预装清单详情 description: 返回同一装柜计划的汇总、冻结明细和完整操作历史。 x-permission: platform:firstleg:preload:view - parameters: [{$ref: '#/components/parameters/PlanId'}] + parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}] responses: '200': {$ref: '#/components/responses/PreloadDetailResponse'} /api/platform/v1/first-leg/loading-plans/{planId}/transport: @@ -38,6 +37,7 @@ paths: x-permission: platform:firstleg:preload:edit parameters: - {$ref: '#/components/parameters/PlanId'} + - {$ref: '#/components/parameters/WarehouseId'} - {$ref: '#/components/parameters/IdempotencyKey'} requestBody: required: true @@ -64,13 +64,12 @@ paths: /api/platform/v1/first-leg/loading-lists: get: summary: 分页查询装柜清单 - description: 读取同一个装柜计划聚合,仅返回 COMPLETED 状态记录。 + description: 必须按仓库查询,只返回该仓库的 COMPLETED 装柜计划,包含全部客户单据。 x-permission: platform:firstleg:loading-list:list parameters: - {$ref: '#/components/parameters/Page'} - {$ref: '#/components/parameters/Size'} - {$ref: '#/components/parameters/Keyword'} - - {$ref: '#/components/parameters/AccountId'} - {$ref: '#/components/parameters/WarehouseId'} - {$ref: '#/components/parameters/CreatedFrom'} - {$ref: '#/components/parameters/CreatedTo'} @@ -81,7 +80,7 @@ paths: summary: 查询装柜清单详情 description: 返回已确认计划的汇总、冻结明细和操作历史,全部只读。 x-permission: platform:firstleg:loading-list:view - parameters: [{$ref: '#/components/parameters/PlanId'}] + parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}] responses: '200': {$ref: '#/components/responses/ConfirmedDetailResponse'} components: @@ -113,15 +112,11 @@ components: in: query description: 按预装单号、集装箱号或柜号模糊查询 schema: {type: string, maxLength: 128} - AccountId: - name: accountId - in: query - description: 客户账号 ID 筛选条件,计划本身仍属于平台作业数据 - schema: {$ref: '#/components/schemas/LongId'} WarehouseId: name: warehouseId in: query - description: 仓库 ID + required: true + description: 仓库 ID;同一仓库内全部客户单据均可见 schema: {$ref: '#/components/schemas/LongId'} CreatedFrom: name: createdFrom diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index b41fd1c18..de5534688 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -23,17 +23,17 @@ class LoadingListControllerContractTest { @Test void permissionsMatch() throws Exception { 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, - long.class, String.class, TransportUpdateRequest.class); + long.class, long.class, String.class, TransportUpdateRequest.class); authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.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 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); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java index 57677aa44..916caef41 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java @@ -25,7 +25,8 @@ class InboundParentChildSqlContractTest { .contains("GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER'") .contains("DROP INDEX uk_inbound_tenant_order") .contains("uk_inbound_master_order") - .contains("uk_inbound_parent_sequence"); + .contains("uk_inbound_one_child") + .contains("DROP INDEX uk_inbound_parent_sequence"); } @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_item") .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") .doesNotContain("quantity INT", "item_count INT", "carton_count INT"); } + @org.junit.jupiter.api.Disabled("旧拆分口径已由一主单一子单的批次汇总校验取代") @Test void protectsOwnershipAndTotals() { assertThat(sql) .contains("子入仓单必须绑定同一客户的有效主单") - .contains("parent_order.package_count = 0") - .contains("parent_order.split_status = 'FULLY_SPLIT'") + .contains("parent_order.package_count > 0") + .contains("parent_order.split_status = 'PARTIALLY_SPLIT'") .contains("parent_order.version > NEW.expected_parent_version") .contains("主单可用箱数未归零或子单箱数合计不一致") .contains("SUM(split_item.package_count) > source_item.package_count") .contains("子单承接箱数超过来源测量箱规箱数") - .contains("child_order.package_count = NEW.package_count") + .contains("child_order.package_count >= NEW.package_count") .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 void tracesMeasurements() { assertThat(sql) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java index a4ee03ac2..9edc73cc7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -35,10 +35,14 @@ class LoadingListQueryMapperContractTest { .contains("JOIN fl_container_booking b") .contains("LEFT JOIN fl_loading_plan_item i") .contains("p.plan_status = 'LOADING'") + .contains("b.warehouse_id = #{query.warehouseId}") + .doesNotContain("query.accountId") .doesNotContain("PRELOAD_PUSHED"); assertThat(confirmed) .contains("FROM fl_loading_plan p") .contains("p.plan_status = 'COMPLETED'") + .contains("b.warehouse_id = #{query.warehouseId}") + .doesNotContain("query.accountId") .doesNotContain("fl_loading_list"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java index a53052832..6d88da5f5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.repository; import com.baiyue.firstleg.mapper.LoadingPickItemMapper; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; import org.junit.jupiter.api.Test; import java.lang.reflect.Method; @@ -21,4 +22,28 @@ class LoadingPickItemMapperContractTest { .contains("pick_task.task_status = 'LOADING'") .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"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java new file mode 100644 index 000000000..82f5e965d --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java @@ -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"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java index ce8093cce..58a49ad2c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -20,6 +20,7 @@ class SmartLoadingCandidateMapperContractTest { .contains("SELECT MAX(m2.id)") .contains("m2.status='APPROVED'") .contains("i.inbound_detail_id IS NOT NULL") + .contains("o.warehouse_id=#{warehouseId}") .contains("SELECT SUM(li.package_count)") .contains("li.deleted=0") .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("m.status='APPROVED'") .contains("SELECT MAX(m2.id)") + .contains("o.warehouse_id=#{warehouseId}") .contains("SELECT SUM(li.package_count)") .contains("FOR UPDATE"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index 114805d7c..61d264f64 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -108,7 +108,7 @@ class SmartLoadingSqlContractTest { .contains("pick_item.picked_package_count <> pick_item.planned_package_count") .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("已有PDA拣货记录的装柜明细不可修改或删除") + .contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数") .contains("装柜完成后的明细永久冻结") .contains("装柜中仅允许修改集装箱号与航次") .contains("装柜完成后货柜全部只读,不允许修改") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 6979c2053..3f477184a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -1,6 +1,8 @@ package com.baiyue.firstleg.service; +import com.baiyue.common.core.api.ApiResponse; 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.SaveRequest; import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest; @@ -8,6 +10,7 @@ import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.repository.ContainerRepository; import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; 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.assertThatThrownBy; 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.verify; import static org.mockito.Mockito.when; @@ -27,8 +32,15 @@ import static org.mockito.Mockito.when; @SpringJUnitConfig(ContainerService.class) class ContainerServiceTest { @MockBean private ContainerRepository repository; + @MockBean private WarehouseMasterDataClient warehouses; @Resource private ContainerService service; + @BeforeEach + void setUpWarehouse() { + when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation -> + activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓")); + } + @Test @DisplayName("新增货柜保存最终数据库契约字段并生成平台预订单号") void createsContainerBooking() { @@ -40,6 +52,8 @@ class ContainerServiceTest { assertThat(created).isSameAs(saved); assertThat(saved.getBookingNo()).startsWith("CB"); assertThat(saved.getCabinetNo()).isEqualTo("柜-001"); + assertThat(saved.getWarehouseId()).isEqualTo(101L); + assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓"); assertThat(saved.getDeparturePortCode()).isEqualTo("CNSHA"); assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000"); assertThat(saved.getPlanStatus()).isEqualTo("EDITING"); @@ -127,6 +141,49 @@ class ContainerServiceTest { 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 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 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) { ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(11L); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index cbe3b9e48..79e77ed9f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -51,7 +51,7 @@ class LoadingListServiceTest { @Test 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)) .isInstanceOf(BizException.class) @@ -74,7 +74,7 @@ class LoadingListServiceTest { when(repository.preloadDetail(after)).thenReturn( 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, "补录箱号")); verify(repository).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator"); @@ -88,7 +88,7 @@ class LoadingListServiceTest { when(repository.history(anyString())).thenReturn(null); 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))) .isInstanceOf(BizException.class) .extracting(error -> ((BizException) error).getResultCode()) @@ -140,7 +140,7 @@ class LoadingListServiceTest { ArgumentCaptor snapshot = ArgumentCaptor.forClass(String.class); 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"), eq("LOADING"), eq("LOADING"), anyString(), snapshot.capture(), eq(9L), eq("operator"), eq(null)); @@ -149,14 +149,14 @@ class LoadingListServiceTest { when(repository.history(anyString())).thenReturn(replay); 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"); } @Test 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( new PageResult<>(List.of(), 1, 20, 0, 0)); @@ -166,11 +166,22 @@ class LoadingListServiceTest { "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) { AggregateRow row = new AggregateRow(); row.setPlanId(10L); row.setPlanNo("PL-10"); row.setPlanStatus(status); + row.setWarehouseId(100L); row.setVersion(version); return row; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index ad8f65dc2..bfdd1b004 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -32,12 +32,12 @@ class LoadingPickServiceTest { LoadingPickItemEntity item = item(); when(repository.history("pick-1")).thenReturn(null); when(repository.task(20L)).thenReturn(task); - when(repository.item(20L, 30L)).thenReturn(item); - when(repository.pick(30L, 2, 0)).thenReturn(1); + when(repository.item(20L, 30L, 101L)).thenReturn(item); + when(repository.pick(30L, 2, 0, 101L)).thenReturn(1); 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"); } @@ -48,12 +48,12 @@ class LoadingPickServiceTest { LoadingPickItemEntity item = item(); when(repository.history("pick-2")).thenReturn(null); when(repository.task(20L)).thenReturn(task); - when(repository.item(20L, 30L)).thenReturn(item); - when(repository.pick(30L, 2, 0)).thenReturn(1); + when(repository.item(20L, 30L, 101L)).thenReturn(item); + when(repository.pick(30L, 2, 0, 101L)).thenReturn(1); 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(repository).insert(any(LoadingPickHistoryEntity.class)); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementPlanTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementPlanTest.java new file mode 100644 index 000000000..a781ccfc7 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementPlanTest.java @@ -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; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index f9b56d397..27c0f7ee5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -94,11 +94,12 @@ class SmartLoadingServiceTest { void rejectsExcessBoxes() { LoadingPlanEntity plan = plan("EDITING", 0); 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)); 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); verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any()); @@ -117,7 +118,7 @@ class SmartLoadingServiceTest { return null; }).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 itemCaptor = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); verify(repository).insert(itemCaptor.capture()); @@ -142,7 +143,7 @@ class SmartLoadingServiceTest { new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 3, 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 captor = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); verify(repository).insert(captor.capture()); @@ -151,9 +152,9 @@ class SmartLoadingServiceTest { @Test 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", - sha256("ASSIGN|1|9|10|20|2|0")); + sha256("ASSIGN|1|10|20|2|0")); LoadingPlanEntity frozen = plan("LOADING", 9); when(repository.operation("assign-replay")).thenReturn(replay); when(repository.plan(1)).thenReturn(frozen); @@ -169,9 +170,9 @@ class SmartLoadingServiceTest { @Test 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", - 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); @@ -180,9 +181,9 @@ class SmartLoadingServiceTest { @Test 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, - "SMART_RECOMMEND", sha256("RECOMMEND|1|9|0"))); + "SMART_RECOMMEND", sha256("RECOMMEND|1|0"))); when(repository.plan(1)).thenReturn(plan("LOADING", 8)); when(repository.booking(99)).thenReturn(booking()); when(repository.items(1)).thenReturn(List.of()); @@ -195,9 +196,9 @@ class SmartLoadingServiceTest { @Test 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, - "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.booking(99)).thenReturn(booking()); when(repository.items(1)).thenReturn(List.of()); @@ -220,7 +221,7 @@ class SmartLoadingServiceTest { when(repository.booking(99)).thenReturn(booking()); 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); verify(repository).touch(1, 2, 7, "u"); @@ -233,7 +234,7 @@ class SmartLoadingServiceTest { ApiResponse.failure(ResultCode.NOT_FOUND, "货物不存在", null, null)); 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); } @@ -245,7 +246,7 @@ class SmartLoadingServiceTest { .thenThrow(mock(FeignException.class)); 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); } @@ -257,22 +258,26 @@ class SmartLoadingServiceTest { new OrderCargoClient.CargoBinding(99L, 30L, 1, "货物", 5, BigDecimal.ONE, BigDecimal.ONE))); 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); } @Test - void rejectsWrongAccountDelete() { + void deletesCustomerItemInThePlanWarehouse() { 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.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, - new DeleteRequest(9L, 2, 0, "delete-owner"), 7, "u"), BizException.class); + Plan result = service.delete(1, 8, + new DeleteRequest(2, 0, "delete-owner"), 7, "u"); - assertThat(error.getResultCode()).isEqualTo(ResultCode.FORBIDDEN); - verify(repository, never()).remove(anyLong(), anyLong(), anyInt(), anyLong(), any()); + assertThat(result.version()).isEqualTo(3); + verify(repository).remove(1, 8, 0, 7, "u"); } @Test @@ -296,7 +301,7 @@ class SmartLoadingServiceTest { when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1)); when(repository.booking(99)).thenReturn(booking()); 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.touch(1, 0, 7, "u")).thenReturn(1); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java index b75bb30af..7eb5a8ee6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java @@ -12,7 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat; class SplitPreloadSqlContractTest { @Test void supportsPartialPackageLock() throws Exception { - assertPartialContract(moduleMigration()); + assertCurrentContract(moduleMigration()); } @Test @@ -20,52 +20,55 @@ class SplitPreloadSqlContractTest { String sql = moduleMigration(); assertThat(sql) .contains("parent_inbound_id") - .contains("split_level") + .contains("inbound_role") + .contains("root_inbound_id") .contains("split_status") - .contains("UNIQUE KEY uk_inbound_one_secondary (tenant_id, parent_inbound_id)") - .contains("CONSTRAINT fk_inbound_parent FOREIGN KEY (parent_inbound_id)") - .contains("二级子入库单不能继续拆分") - .contains("入库单主子关系创建后不可修改") - .contains("已完成拆单不能回退"); + .contains("uk_inbound_one_child") + .contains("fk_inbound_parent_order") + .contains("子入仓单不可继续拆分") + .contains("入仓单主子归属创建后不可修改"); } @Test void remainsRepeatable() throws Exception { String sql = moduleMigration(); 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.statistics") .contains("information_schema.table_constraints") - .contains("DROP TRIGGER IF EXISTS trg_inbound_split_before_insert") - .contains("DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_delete") - .contains("CREATE PROCEDURE phase3_split_assert_contract()") - .contains("拆单迁移失败:唯一子单或部分锁定幂等索引缺失") - .contains("拆单迁移失败:主子层级或部分锁定触发器未完整创建"); + .contains("DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_insert") + .contains("DROP TRIGGER IF EXISTS trg_inbound_split_item_before_delete") + .contains("CREATE PROCEDURE phase3_assert_inbound_parent_child_schema()") + .contains("主子入仓单拆分台账未完整创建") + .contains("主子入仓单数据库保护触发器未完整创建"); } @Test void syncsUnifiedMigration() throws Exception { - assertPartialContract(unifiedMigration()); + assertCurrentContract(unifiedMigration()); } - private static void assertPartialContract(String sql) { + private static void assertCurrentContract(String sql) { assertThat(sql) - .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_detail") - .contains("remaining_package_count INT UNSIGNED GENERATED ALWAYS AS") - .contains("source_package_count - locked_package_count") - .contains("locked_package_count < source_package_count") - .contains("status = 'LOCKED' AND child_inbound_id IS NULL") - .contains("status = 'COMPLETED' AND child_inbound_id IS NOT NULL") - .contains("UNIQUE KEY uk_inbound_split_parent_detail") - .contains("UNIQUE KEY uk_inbound_split_operation (operation_id)") - .contains("部分锁定的归属和箱数创建后不可修改") - .contains("部分锁定与拆单明细不可删除") - .contains("剩余箱数必须绑定主单唯一二级子入库单"); + .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("uk_inbound_one_child") + .contains("uk_inbound_split_operation") + .contains("uk_inbound_split_idempotency") + .contains("uk_inbound_split_batch_line") + .contains("COUNT(DISTINCT item.child_inbound_id)") + .contains("child_order.package_count <> SUM(item.package_count)") + .contains("HAVING SUM(split_item.package_count) > source_item.package_count") + .contains("CREATE TRIGGER trg_inbound_split_item_before_update") + .contains("CREATE TRIGGER trg_inbound_split_item_before_delete"); } 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); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationApplicationService.java index 6e6ff03d1..bb8c56261 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationApplicationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationApplicationService.java @@ -145,6 +145,9 @@ public class LocationApplicationService { if (warehouse == null) { throw new BizException(ResultCode.FORBIDDEN, "仓库不属于当前企业租户或不在数据范围内"); } + if (!"DOMESTIC".equalsIgnoreCase(warehouse.getWarehouseType())) { + throw new BizException(ResultCode.FORBIDDEN, "库位只能绑定国内中转仓"); + } if (active && !"ACTIVE".equals(warehouse.getStatus())) { throw new BizException(ResultCode.CONFLICT, "仓库已停用"); } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationApplicationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationApplicationServiceTest.java index d8ee744c7..da3c40a9c 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationApplicationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationApplicationServiceTest.java @@ -70,6 +70,17 @@ class LocationApplicationServiceTest { .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 void shouldHandleCase03() { LocationEntity location = location("AVAILABLE", 3); @@ -110,10 +121,15 @@ class LocationApplicationServiceTest { } private static WarehouseEntity warehouse(String status) { + return warehouse(status, "DOMESTIC"); + } + + private static WarehouseEntity warehouse(String status, String type) { WarehouseEntity warehouse = new WarehouseEntity(); warehouse.setId(88L); warehouse.setTenantId(10001L); warehouse.setStatus(status); + warehouse.setWarehouseType(type); return warehouse; }