diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 3bf2c83f8..edca4ecc2 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -3761,6 +3761,786 @@ CREATE TABLE IF NOT EXISTS sys_audit_dead_letter ( KEY idx_audit_dead_retry(status,next_retry_at,lease_until) ) ENGINE=InnoDB COMMENT='审计消息死信补偿表'; +-- ============================================================ +-- 三期智能装柜、预装冻结与装柜清单 +-- 来源:firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +-- ============================================================ +SET NAMES utf8mb4; + +CREATE DATABASE IF NOT EXISTS baiyue_firstleg + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_firstleg; + +CREATE TABLE IF NOT EXISTS fl_container_booking ( + id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)', + booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号', + container_no VARCHAR(64) NULL COMMENT '集装箱号;确认前允许修订', + cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '装柜仓库ID;平台公共资源', + warehouse_name VARCHAR(128) NOT NULL COMMENT '装柜仓库名称快照', + bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', + vessel_name VARCHAR(128) NULL COMMENT '船名快照', + voyage_no VARCHAR(64) NULL COMMENT '航次;确认前允许修订', + container_type VARCHAR(32) NOT NULL COMMENT '箱型,例如20GP、40GP、40HQ', + shipping_company_id BIGINT UNSIGNED NULL COMMENT '船司ID快照', + shipping_company_name VARCHAR(128) NULL COMMENT '船司名称快照', + departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照', + departure_port_name VARCHAR(128) NULL COMMENT '起运港名称快照', + destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照', + destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照', + destination_warehouse_code VARCHAR(64) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(128) NULL COMMENT '目的仓名称快照', + capacity_cbm DECIMAL(18,4) NULL COMMENT '货柜容量m3', + max_weight_kg DECIMAL(18,3) NULL COMMENT '货柜最大承重kg', + terminal_trucking_company VARCHAR(128) NULL COMMENT '码头拖车公司', + loading_at DATETIME(3) NULL COMMENT '计划或实际装柜时间', + estimated_sailing_at DATETIME(3) NULL COMMENT '预计开船时间', + actual_sailing_at DATETIME(3) NULL COMMENT '实际开船时间', + estimated_port_arrival_at DATETIME(3) NULL COMMENT '预计到港时间', + actual_port_arrival_at DATETIME(3) NULL COMMENT '实际到港时间', + estimated_warehouse_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间', + actual_warehouse_arrival_at DATETIME(3) NULL COMMENT '实际到仓时间', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', + updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) + COMMENT '更新时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_container_booking_no (booking_no), + UNIQUE KEY uk_container_booking_cabinet (cabinet_no), + KEY idx_container_booking_container (container_no), + KEY idx_container_booking_warehouse (warehouse_id, updated_at, id), + KEY idx_container_booking_voyage (voyage_no, updated_at), + CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0), + CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0) +) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划'; + +CREATE TABLE IF NOT EXISTS fl_loading_plan ( + id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID(雪花ID)', + plan_no VARCHAR(40) NOT NULL COMMENT '平台唯一装柜计划号', + container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', + plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING' + COMMENT '唯一装柜状态:EDITING、LOADING、COMPLETED', + push_idempotency_key VARCHAR(128) NULL COMMENT '推送预装幂等键', + preload_pushed_by BIGINT UNSIGNED NULL COMMENT '推送预装操作人ID', + preload_pushed_by_name VARCHAR(64) NULL COMMENT '推送预装操作人名称快照', + preload_pushed_at DATETIME(3) NULL COMMENT '开始装柜并进入LOADING的时间', + preload_completed_by BIGINT UNSIGNED NULL COMMENT '预装完成操作人ID', + preload_completed_by_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照', + preload_completed_at DATETIME(3) NULL COMMENT '预装完成时间', + confirmed_by BIGINT UNSIGNED NULL COMMENT '确认操作人ID', + confirmed_by_name VARCHAR(64) NULL COMMENT '确认操作人名称快照', + confirmed_at DATETIME(3) NULL COMMENT '确认时间', + remark VARCHAR(500) NULL COMMENT '平台装柜计划备注', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', + updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) + COMMENT '更新时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_plan_no (plan_no), + UNIQUE KEY uk_loading_plan_booking (container_booking_id), + UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key), + KEY idx_loading_plan_status (plan_status, updated_at, id), + CONSTRAINT fk_loading_plan_booking FOREIGN KEY (container_booking_id) + REFERENCES fl_container_booking (id), + CONSTRAINT chk_loading_plan_status + CHECK (plan_status IN ('EDITING', 'LOADING', 'COMPLETED')) +) ENGINE=InnoDB COMMENT='平台智能装柜计划与唯一预装状态源'; + +CREATE TABLE IF NOT EXISTS fl_loading_plan_item ( + id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划明细ID(雪花ID)', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID', + account_id BIGINT UNSIGNED NOT NULL COMMENT '客户账号ID;装柜业务唯一客户归属字段', + customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT 'Firstleg入库单ID', + inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物或入库货物明细ID,不建立跨库外键', + order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID快照,不建立跨库外键', + order_no VARCHAR(32) NOT NULL COMMENT '订单号快照', + inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', + destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '目的仓ID快照', + destination_warehouse_code VARCHAR(64) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(128) NULL COMMENT '目的仓名称快照', + sku_code VARCHAR(128) NULL COMMENT 'SKU编码快照', + product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照', + product_name_en VARCHAR(255) NULL COMMENT '英文品名快照', + product_image_file_id BIGINT UNSIGNED NULL COMMENT '产品图片File对象ID', + product_image_url VARCHAR(1000) NULL COMMENT '产品图片访问地址快照', + package_count INT UNSIGNED NOT NULL COMMENT '本次装柜箱数', + product_quantity INT UNSIGNED NULL COMMENT '产品总数量', + units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数', + pallet_count INT UNSIGNED NULL COMMENT '托盘数', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜毛重kg', + net_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜净重kg', + dimensional_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜材积重kg', + volume_cbm DECIMAL(18,4) NULL COMMENT '本次装柜体积m3', + fba_tracking_no VARCHAR(128) NULL COMMENT 'FBA追踪号快照', + shipment_no VARCHAR(128) NULL COMMENT '货件编号快照', + shipping_mark VARCHAR(500) NULL COMMENT '唛头快照', + load_sequence INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '装柜顺序', + position_snapshot JSON NULL COMMENT '智能装柜位置与摆放结果快照', + internal_remark VARCHAR(1000) NULL COMMENT '平台内部备注', + customer_remark VARCHAR(1000) NULL COMMENT '客户备注快照', + warehouse_remark VARCHAR(1000) NULL COMMENT '仓库备注快照', + frozen_at DATETIME(3) NULL COMMENT '装柜完成后的永久冻结时间', + frozen_by BIGINT UNSIGNED NULL COMMENT '冻结操作人ID', + removed_by BIGINT UNSIGNED NULL COMMENT '推送前逻辑删除操作人ID', + removed_by_name VARCHAR(64) NULL COMMENT '推送前逻辑删除操作人名称快照', + removed_at DATETIME(3) NULL COMMENT '推送前逻辑删除时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '推送前逻辑删除标记;冻结后不可修改', + active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS ( + CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END + ) STORED COMMENT '未删除明细唯一锁;删除后返回NULL释放分配资格', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', + updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) + COMMENT '更新时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_item_active_detail (account_id, active_inbound_detail_id), + KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id), + KEY idx_loading_item_account (account_id, created_at, id), + KEY idx_loading_item_inbound (account_id, inbound_id, deleted), + KEY idx_loading_item_order (account_id, order_id, deleted), + CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), + CONSTRAINT chk_loading_item_account CHECK (account_id > 0), + CONSTRAINT chk_loading_item_package CHECK (package_count > 0), + CONSTRAINT chk_loading_item_product_quantity CHECK (product_quantity IS NULL OR product_quantity > 0), + CONSTRAINT chk_loading_item_units CHECK (units_per_carton IS NULL OR units_per_carton > 0), + CONSTRAINT chk_loading_item_pallet CHECK (pallet_count IS NULL OR pallet_count >= 0), + CONSTRAINT chk_loading_item_weight CHECK ( + (gross_weight_kg IS NULL OR gross_weight_kg >= 0) + AND (net_weight_kg IS NULL OR net_weight_kg >= 0) + AND (dimensional_weight_kg IS NULL OR dimensional_weight_kg >= 0) + ), + CONSTRAINT chk_loading_item_volume CHECK (volume_cbm IS NULL OR volume_cbm >= 0), + CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1)) +) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层'; + +CREATE TABLE IF NOT EXISTS fl_container_document ( + id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)', + container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', + document_type VARCHAR(32) NOT NULL COMMENT 'CUSTOMS_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER', + file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键', + file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照', + file_url VARCHAR(1000) NULL COMMENT '文件访问地址快照', + uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID', + uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) + COMMENT '更新时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_container_document_type (container_booking_id, document_type), + KEY idx_container_document_file (file_id), + CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id) + REFERENCES fl_container_booking (id), + CONSTRAINT chk_container_document_type + CHECK (document_type IN ('CUSTOMS_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER')) +) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联'; + +CREATE TABLE IF NOT EXISTS fl_loading_plan_history ( + id BIGINT UNSIGNED NOT NULL COMMENT '装柜操作历史ID(雪花ID)', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID', + loading_plan_item_id BIGINT UNSIGNED NULL COMMENT '装柜计划明细ID', + operation_id VARCHAR(128) NOT NULL COMMENT '操作幂等ID', + action_type VARCHAR(32) NOT NULL + COMMENT 'CREATE_PLAN、ADD_ITEM、ADJUST_ITEM、REMOVE_ITEM、START_LOADING、COMPLETE_LOADING、PICK_ITEM', + from_status VARCHAR(24) NULL COMMENT '变更前状态', + to_status VARCHAR(24) NULL COMMENT '变更后状态', + before_snapshot JSON NULL COMMENT '变更前快照', + after_snapshot JSON NULL COMMENT '变更后快照', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + operation_reason VARCHAR(500) NULL COMMENT '操作原因', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_history_operation (operation_id), + KEY idx_loading_history_plan (loading_plan_id, created_at, id), + KEY idx_loading_history_item (loading_plan_item_id, created_at, id), + CONSTRAINT fk_loading_history_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), + CONSTRAINT fk_loading_history_item FOREIGN KEY (loading_plan_item_id) + REFERENCES fl_loading_plan_item (id) +) ENGINE=InnoDB COMMENT='智能装柜追加式不可变操作历史'; + +CREATE TABLE IF NOT EXISTS fl_loading_pick_task ( + id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID', + container_no VARCHAR(64) NOT NULL COMMENT '集装箱号快照', + task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING' COMMENT 'LOADING、COMPLETED', + started_by BIGINT UNSIGNED NOT NULL COMMENT '开始装柜人', + started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称', + started_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '开始时间', + completed_by BIGINT UNSIGNED NULL COMMENT '完成装柜人', + completed_by_name VARCHAR(64) NULL COMMENT '完成装柜人名称', + completed_at DATETIME(3) NULL COMMENT '完成时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id), + KEY idx_loading_pick_container (container_no, task_status, started_at), + CONSTRAINT fk_loading_pick_task_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), + CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING', 'COMPLETED')) +) ENGINE=InnoDB COMMENT='PDA装柜拣货任务'; + +CREATE TABLE IF NOT EXISTS fl_loading_pick_item ( + id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID', + pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID', + loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划明细ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '父入库单ID', + 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', + 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), + PRIMARY KEY (id), + UNIQUE KEY uk_loading_pick_item_plan_item (loading_plan_item_id), + KEY idx_loading_pick_item_task (pick_task_id, item_status, id), + CONSTRAINT fk_loading_pick_item_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT fk_loading_pick_item_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id), + CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0), + CONSTRAINT chk_loading_pick_item_count CHECK (picked_package_count <= planned_package_count), + CONSTRAINT chk_loading_pick_item_status CHECK (item_status IN ('PENDING', 'PICKING', 'PICKED')) +) ENGINE=InnoDB COMMENT='PDA装柜拣货明细'; + +CREATE TABLE IF NOT EXISTS fl_loading_pick_history ( + id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货操作历史ID', + pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID', + pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID', + operation_id VARCHAR(128) NOT NULL COMMENT '客户端幂等操作号', + request_hash CHAR(64) NOT NULL COMMENT '请求指纹', + picked_package_count INT UNSIGNED NOT 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), + PRIMARY KEY (id), + UNIQUE KEY uk_loading_pick_history_operation (operation_id), + KEY idx_loading_pick_history_item (pick_item_id, created_at), + CONSTRAINT fk_loading_pick_history_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id) +) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史'; + +DROP TRIGGER IF EXISTS trg_container_booking_before_update; +DELIMITER $$ +CREATE TRIGGER trg_container_booking_before_update +BEFORE UPDATE ON fl_container_booking +FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status + FROM fl_loading_plan WHERE container_booking_id = OLD.id; + IF current_plan_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改'; + END IF; + IF current_plan_status = 'LOADING' AND ( + NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) + OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) + OR NOT (OLD.warehouse_id <=> NEW.warehouse_id) + OR NOT (OLD.warehouse_name <=> NEW.warehouse_name) + OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no) + OR NOT (OLD.vessel_name <=> NEW.vessel_name) + OR NOT (OLD.container_type <=> NEW.container_type) + OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id) + OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name) + OR NOT (OLD.departure_port_code <=> NEW.departure_port_code) + OR NOT (OLD.departure_port_name <=> NEW.departure_port_name) + OR NOT (OLD.destination_port_code <=> NEW.destination_port_code) + OR NOT (OLD.destination_port_name <=> NEW.destination_port_name) + OR NOT (OLD.destination_warehouse_code <=> NEW.destination_warehouse_code) + OR NOT (OLD.destination_warehouse_name <=> NEW.destination_warehouse_name) + OR NOT (OLD.capacity_cbm <=> NEW.capacity_cbm) + OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg) + OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company) + OR NOT (OLD.loading_at <=> NEW.loading_at) + OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at) + OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) + OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at) + OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at) + OR NOT (OLD.estimated_warehouse_arrival_at <=> NEW.estimated_warehouse_arrival_at) + OR NOT (OLD.actual_warehouse_arrival_at <=> NEW.actual_warehouse_arrival_at) + OR NOT (OLD.created_by <=> NEW.created_by) + OR NOT (OLD.created_by_name <=> NEW.created_by_name) + OR NOT (OLD.created_at <=> NEW.created_at) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '装柜中仅允许修改集装箱号与航次'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_plan_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_plan_before_update +BEFORE UPDATE ON fl_loading_plan +FOR EACH ROW +BEGIN + IF OLD.plan_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成装柜计划全部只读'; + END IF; + IF NOT ( + (OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING')) + OR (OLD.plan_status = 'LOADING' + AND NEW.plan_status IN ('LOADING', 'COMPLETED')) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜状态只允许单向推进'; + END IF; + IF OLD.plan_status = 'LOADING' AND ( + NOT (OLD.id <=> NEW.id) OR NOT (OLD.plan_no <=> NEW.plan_no) + OR NOT (OLD.container_booking_id <=> NEW.container_booking_id) + OR NOT (OLD.push_idempotency_key <=> NEW.push_idempotency_key) + OR NOT (OLD.preload_pushed_by <=> NEW.preload_pushed_by) + OR NOT (OLD.preload_pushed_by_name <=> NEW.preload_pushed_by_name) + OR NOT (OLD.preload_pushed_at <=> NEW.preload_pushed_at) + OR NOT (OLD.remark <=> NEW.remark) + OR NOT (OLD.created_by <=> NEW.created_by) + OR NOT (OLD.created_by_name <=> NEW.created_by_name) + OR NOT (OLD.created_at <=> NEW.created_at) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '装柜中计划仅允许乐观锁触碰或完成装柜'; + END IF; + IF OLD.plan_status = NEW.plan_status + AND OLD.plan_status = 'LOADING' AND ( + NOT (OLD.preload_completed_by <=> NEW.preload_completed_by) + OR NOT (OLD.preload_completed_by_name <=> NEW.preload_completed_by_name) + OR NOT (OLD.preload_completed_at <=> NEW.preload_completed_at) + OR NOT (OLD.confirmed_by <=> NEW.confirmed_by) + OR NOT (OLD.confirmed_by_name <=> NEW.confirmed_by_name) + OR NOT (OLD.confirmed_at <=> NEW.confirmed_at) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同状态乐观锁触碰不得修改阶段审计字段'; + END IF; + IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN + IF NEW.push_idempotency_key IS NULL OR CHAR_LENGTH(TRIM(NEW.push_idempotency_key)) = 0 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '开始装柜必须提供幂等键'; + END IF; + SET NEW.preload_pushed_at = COALESCE(NEW.preload_pushed_at, CURRENT_TIMESTAMP(3)); + ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN + IF EXISTS (SELECT 1 FROM fl_loading_plan_item item + LEFT JOIN fl_loading_pick_item pick_item + ON pick_item.loading_plan_item_id = item.id + LEFT JOIN fl_loading_pick_task pick_task + ON pick_task.id = pick_item.pick_task_id + AND pick_task.loading_plan_id = OLD.id + WHERE item.loading_plan_id = OLD.id AND item.deleted = 0 + AND (pick_task.id IS NULL + OR pick_item.picked_package_count <> pick_item.planned_package_count)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仍有未完成拣货明细,不能完成装柜'; + END IF; + SET NEW.confirmed_at = COALESCE(NEW.confirmed_at, CURRENT_TIMESTAMP(3)); + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_plan_after_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_plan_after_update +AFTER UPDATE ON fl_loading_plan +FOR EACH ROW +BEGIN + IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN + UPDATE fl_loading_plan_item + SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3)), + frozen_by = COALESCE(frozen_by, NEW.updated_by), + updated_by = NEW.updated_by, + updated_by_name = NEW.updated_by_name, + version = version + 1 + WHERE loading_plan_id = NEW.id AND deleted = 0; + UPDATE fl_loading_pick_task + SET task_status = 'COMPLETED', completed_by = NEW.updated_by, + completed_by_name = NEW.updated_by_name, completed_at = CURRENT_TIMESTAMP(3), + version = version + 1 + WHERE loading_plan_id = NEW.id AND task_status = 'LOADING'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_item_before_insert; +DELIMITER $$ +CREATE TRIGGER trg_loading_item_before_insert +BEFORE INSERT ON fl_loading_plan_item +FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE id = NEW.loading_plan_id; + IF current_plan_status IS NULL OR current_plan_status NOT IN ('EDITING', 'LOADING') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仅编辑中或装柜中的计划允许新增明细'; + END IF; + IF NEW.deleted <> 0 OR NEW.frozen_at IS NOT NULL OR NEW.frozen_by IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '新增装柜明细必须为未删除且未冻结状态'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_item_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_item_before_update +BEFORE UPDATE ON fl_loading_plan_item +FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status + FROM fl_loading_plan WHERE id = OLD.loading_plan_id; + 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拣货记录的装柜明细不可修改或删除'; + END IF; + IF OLD.frozen_at IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结'; + END IF; + IF OLD.deleted = 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已删除装柜明细不可恢复或修改'; + END IF; + IF NEW.loading_plan_id <> OLD.loading_plan_id OR NEW.account_id <> OLD.account_id + OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜明细归属键不可修改,请删除后重新分配'; + END IF; + IF NEW.deleted = 1 THEN + IF NEW.removed_by IS NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除装柜明细必须记录操作人'; + END IF; + SET NEW.removed_at = COALESCE(NEW.removed_at, CURRENT_TIMESTAMP(3)); + ELSEIF NEW.removed_by IS NOT NULL OR NEW.removed_by_name IS NOT NULL OR NEW.removed_at IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除审计字段只能在逻辑删除时写入'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_pick_task_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_task_before_update +BEFORE UPDATE ON fl_loading_pick_task +FOR EACH ROW +BEGIN + IF OLD.task_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成PDA拣货任务不可修改'; + END IF; + IF NEW.loading_plan_id <> OLD.loading_plan_id OR NOT (NEW.container_no <=> OLD.container_no) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务归属不可修改'; + END IF; + IF NOT (OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING', 'COMPLETED')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态只允许完成'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_pick_item_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_item_before_update +BEFORE UPDATE ON fl_loading_pick_item +FOR EACH ROW +BEGIN + DECLARE current_task_status VARCHAR(16); + SELECT MAX(task_status) INTO current_task_status + FROM fl_loading_pick_task WHERE id = OLD.pick_task_id; + IF current_task_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后PDA拣货明细不可修改'; + END IF; + IF NEW.pick_task_id <> OLD.pick_task_id + OR NEW.loading_plan_item_id <> OLD.loading_plan_item_id + OR NEW.inbound_id <> OLD.inbound_id + OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货明细归属不可修改'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_pick_item_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_item_before_delete +BEFORE DELETE ON fl_loading_pick_item +FOR EACH ROW +BEGIN + DECLARE current_task_status VARCHAR(16); + SELECT MAX(task_status) INTO current_task_status + FROM fl_loading_pick_task WHERE id = OLD.pick_task_id; + IF current_task_status = 'COMPLETED' OR OLD.picked_package_count > 0 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已拣货或已完成的PDA明细不可删除'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_pick_history_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_history_before_update +BEFORE UPDATE ON fl_loading_pick_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货历史不可修改'; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_pick_history_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_history_before_delete +BEFORE DELETE ON fl_loading_pick_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货历史不可删除'; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_item_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_loading_item_before_delete +BEFORE DELETE ON fl_loading_plan_item +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜明细只允许推送前逻辑删除'; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_container_document_before_insert; +DELIMITER $$ +CREATE TRIGGER trg_container_document_before_insert +BEFORE INSERT ON fl_container_document +FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan + WHERE container_booking_id = NEW.container_booking_id; + IF current_plan_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_container_document_before_update; +DELIMITER $$ +CREATE TRIGGER trg_container_document_before_update +BEFORE UPDATE ON fl_container_document +FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan + WHERE container_booking_id = OLD.container_booking_id; + IF current_plan_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_container_document_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_container_document_before_delete +BEFORE DELETE ON fl_container_document +FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan + WHERE container_booking_id = OLD.container_booking_id; + IF current_plan_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可删除'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_history_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_history_before_update +BEFORE UPDATE ON fl_loading_plan_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜操作历史不可修改'; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_history_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_loading_history_before_delete +BEFORE DELETE ON fl_loading_plan_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜操作历史不可删除'; +END$$ +DELIMITER ; + +-- 三期智能装柜、预装清单和装柜清单平台权限;与一期平台根菜单幂等合并。 +USE baiyue_iam; +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, built_in, deleted) +VALUES + (5102090, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:container:list', + '货柜', '/first-leg/containers', 'GET', 40, 'ACTIVE', 1, 0), + (5102091, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:view', + '查看货柜', NULL, 'GET', 10, 'ACTIVE', 1, 0), + (5102092, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:create', + '建立货柜', NULL, 'POST', 20, 'ACTIVE', 1, 0), + (5102093, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:edit', + '编辑货柜', NULL, 'PUT', 30, 'ACTIVE', 1, 0), + (5102094, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:smart-loading:list', + '智能装柜', '/first-leg/smart-loading', 'GET', 50, 'ACTIVE', 1, 0), + (5102096, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:create', + '创建智能装柜计划', NULL, 'POST', 20, 'ACTIVE', 1, 0), + (5102097, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:assign', + '分配装柜明细', NULL, 'POST', 30, 'ACTIVE', 1, 0), + (5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete', + '删除装柜明细', NULL, 'DELETE', 40, 'ACTIVE', 1, 0), + (5102099, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:push', + '推送预装', NULL, 'POST', 50, 'ACTIVE', 1, 0), + (5102100, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:preload:list', + '预装清单', '/first-leg/preload-list', 'GET', 60, 'ACTIVE', 1, 0), + (5102102, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:view', + '查看预装详情', NULL, 'GET', 20, 'ACTIVE', 1, 0), + (5102103, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:edit', + '编辑预装运输字段', NULL, 'PUT', 30, 'ACTIVE', 1, 0), + (5102104, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:complete', + '完成预装', NULL, 'POST', 40, 'ACTIVE', 1, 0), + (5102105, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:confirm', + '确认预装', NULL, 'POST', 50, 'ACTIVE', 1, 0), + (5102106, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:loading-list:list', + '装柜清单', '/first-leg/loading-list', 'GET', 70, 'ACTIVE', 1, 0), + (5102108, 0, 'PLATFORM', 'BUTTON', 5102106, 'platform:firstleg:loading-list:view', + '查看装柜详情', NULL, 'GET', 20, 'ACTIVE', 1, 0) + ON DUPLICATE KEY UPDATE + permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), + permission_name = VALUES(permission_name), route_path = VALUES(route_path), + http_method = VALUES(http_method), sort_no = VALUES(sort_no), status = 'ACTIVE', + built_in = 1, deleted = 0; + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT role.id, permission.id, role.tenant_id +FROM iam_role role + JOIN iam_permission permission + ON 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') +WHERE role.tenant_id = 0 + AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR'); + +USE baiyue_firstleg; + +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_active_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; + -- ============================================================ -- 全部增量完成后的最终结构强校验;只有通过此段才输出唯一 PASS -- ============================================================ diff --git a/firstleg/pom.xml b/firstleg/pom.xml index 355127dd0..ea82d81ad 100644 --- a/firstleg/pom.xml +++ b/firstleg/pom.xml @@ -21,6 +21,7 @@ org.springframework.bootspring-boot-starter-actuator com.mysqlmysql-connector-jruntime org.springframework.bootspring-boot-starter-testtest + org.projectlomboklomboktrue org.springframework.bootspring-boot-maven-plugin diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java index 296382585..14d224680 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java @@ -19,6 +19,14 @@ public interface OrderCargoClient { record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, Integer originalPackageCount, BigDecimal originalGrossWeightKg, - BigDecimal originalVolumeCbm) { + BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn, + Integer unitsPerPackage, Integer totalQuantity, BigDecimal netWeightKg, + String cargoRemark) { + public CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, + Integer originalPackageCount, BigDecimal originalGrossWeightKg, + BigDecimal originalVolumeCbm) { + this(cargoId, orderId, itemNo, cargoName, originalPackageCount, + originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null); + } } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java index 7c2b5539c..eee112077 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java @@ -25,6 +25,11 @@ public interface WarehousePalletLocationClient { ApiResponse> release(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody ReleaseRequest request); + @PostMapping("/release-selected") + ApiResponse> releaseSelected( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestBody SelectiveReleaseRequest request); + record ReserveRequest(long tenantId, long warehouseId, String sourceService, long businessId, String inboundCode, String idempotencyKey, long operatorId, BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm, String palletType, @@ -33,10 +38,16 @@ public interface WarehousePalletLocationClient { long operatorId, BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm) {} record ReleaseRequest(long tenantId, String sourceService, long businessId, long operatorId, String reason) {} + record SelectiveReleaseRequest(long tenantId, String sourceService, long businessId, + long operatorId, String reason, int totalPackageCount, + List locationIds, Integer releaseLocationCount) {} record AllocationResult(long reservationId, String reservationNo, int allocationNo, long palletId, String palletCode, long locationId, String locationCode, BigDecimal allocatedVolumeCbm, String state) {} record BatchResult(int palletCount, List allocations) {} record ActiveResource(long reservationId, long warehouseId, long palletId, String palletCode, long locationId, String locationCode, String state) {} + record SelectiveReleaseResult(long reservationId, long occupancyId, int allocationNo, + long palletId, long locationId, String locationCode, + int releasedPackageCount) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java new file mode 100644 index 000000000..2b6b81b73 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java @@ -0,0 +1,63 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; +import com.baiyue.firstleg.dto.ContainerDtos.Query; +import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; +import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.service.ContainerService; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "货柜基础资料") @Validated @RestController +@RequestMapping("/api/platform/v1/containers") +public class PlatformContainerController { + @Resource private ContainerService service; + @GetMapping @PreAuthorize("hasAuthority('platform:firstleg:container:list')") + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid Query query) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query)); + } + @GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:view')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long id) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(id)); + } + @PostMapping @PreAuthorize("hasAuthority('platform:firstleg:container:create')") + @BusinessAudit(event="CONTAINER_CREATE", resourceType="CONTAINER", description="新增货柜基础资料", captureBefore=false) + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody SaveRequest request) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.create(user.userId(), user.username(), request)); + } + @PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") + @BusinessAudit(event="CONTAINER_UPDATE", resourceType="CONTAINER", resourceId="#id", description="完整编辑货柜基础资料") + @Operation(summary="完整编辑货柜", description="仅无装柜计划或计划为EDITING时允许") + public ApiResponse updateDraft(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long id, + @Valid @RequestBody FullUpdateRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.updateDraft(id, user.userId(), user.username(), request)); + } + @PatchMapping("/{id}/transport") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") + @BusinessAudit(event="CONTAINER_TRANSPORT_UPDATE", resourceType="CONTAINER", resourceId="#id", + description="修改货柜集装箱号和航次") + @Operation(summary="修改集装箱号和航次", description="LOADING仅开放此白名单;COMPLETED全部只读") + public ApiResponse updateTransport(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long id, + @Valid @RequestBody TransportUpdateRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.updateTransport(id, user.userId(), user.username(), request)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java new file mode 100644 index 000000000..fca7a552b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -0,0 +1,103 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedDetail; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary; +import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import com.baiyue.firstleg.service.LoadingListService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */ +@Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询") +@Validated +@RestController +@RequestMapping("/api/platform/v1/first-leg") +public class PlatformLoadingListController { + @Resource + private LoadingListService service; + + @Operation(summary = "分页查询预装清单") + @GetMapping("/loading-plans") + @PreAuthorize("hasAuthority('platform:firstleg:preload:list')") + public ApiResponse> preloadPage( + @AuthenticationPrincipal AuthenticatedUser user, @Valid PreloadQuery query) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.preloadPage(query)); + } + + @Operation(summary = "查询装柜详情", description = "装柜中可维护未拣货明细;完成装柜后永久只读。") + @GetMapping("/loading-plans/{planId}") + @PreAuthorize("hasAuthority('platform:firstleg:preload:view')") + public ApiResponse preloadDetail( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.preloadDetail(planId)); + } + + @Operation(summary = "修改装柜运输字段", description = "仅装柜中可修改集装箱号与航次。") + @PatchMapping("/loading-plans/{planId}/transport") + @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')") + public ApiResponse updateTransport( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long planId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody TransportUpdateRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.updateTransport( + planId, user.userId(), user.username(), idempotencyKey, request)); + } + + @Operation(summary = "完成装柜", description = "状态仅允许 LOADING 推进到 COMPLETED,完成后主单和明细冻结。") + @PostMapping("/loading-plans/{planId}/complete") + @PreAuthorize("hasAuthority('platform:firstleg:preload:complete')") + public ApiResponse complete( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long planId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody AdvanceRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.complete( + planId, user.userId(), user.username(), idempotencyKey, request)); + } + + @Operation(summary = "分页查询完成装柜清单", description = "只读取 COMPLETED 状态的装柜计划。") + @GetMapping("/loading-lists") + @PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')") + public ApiResponse> confirmedPage( + @AuthenticationPrincipal AuthenticatedUser user, @Valid ConfirmedQuery query) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.confirmedPage(query)); + } + + @Operation(summary = "查询完成装柜清单明细", description = "已完成主单和明细全部只读。") + @GetMapping("/loading-lists/{planId}") + @PreAuthorize("hasAuthority('platform:firstleg:loading-list:view')") + public ApiResponse confirmedDetail( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.confirmedDetail(planId)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java new file mode 100644 index 000000000..b3b2473d5 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java @@ -0,0 +1,48 @@ +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.Task; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import com.baiyue.firstleg.service.LoadingPickService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** PDA 按集装箱号执行装柜拣货。 */ +@Validated +@RestController +@RequestMapping("/api/platform/v1/first-leg/pda-loading") +public class PlatformLoadingPdaController { + @Resource + private LoadingPickService service; + + @GetMapping("/tasks") + @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") + public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String containerNo) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.task(containerNo)); + } + + @PostMapping("/tasks/{taskId}/items/{itemId}/pick") + @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") + public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @PathVariable @Positive long itemId, + @Valid @RequestBody PickRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.pick(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 new file mode 100644 index 000000000..3620f8be2 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java @@ -0,0 +1,29 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.dto.SmartLoadingDtos.*; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.SmartLoadingService; +import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; import jakarta.annotation.Resource; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List; + +@Validated @RestController @RequestMapping("/api/platform/v1/first-leg/smart-loading") +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));} + @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')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.detail(planId));} + @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()));} + @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()));} + @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()));} + @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()));} + @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/domain/SmartLoadingStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/SmartLoadingStatus.java new file mode 100644 index 000000000..0dc401816 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/SmartLoadingStatus.java @@ -0,0 +1,18 @@ +package com.baiyue.firstleg.domain; + +/** 智能装柜状态,只允许沿此方向推进。 */ +public enum SmartLoadingStatus { + EDITING, + LOADING, + COMPLETED; + + /** 装柜完成前可维护集装箱号和航次。 */ + public boolean allowsTransportUpdate() { return this != COMPLETED; } + + /** 编辑中和装柜中均允许维护尚未拣货的明细。 */ + public boolean mutable() { return this != COMPLETED; } + + public boolean canAdvanceTo(SmartLoadingStatus next) { + return next != null && next.ordinal() == ordinal() + 1; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java new file mode 100644 index 000000000..5ea393fdc --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java @@ -0,0 +1,89 @@ +package com.baiyue.firstleg.dto; + +import com.baiyue.firstleg.domain.SmartLoadingStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** 平台级货柜基础资料请求模型。 */ +public final class ContainerDtos { + private ContainerDtos() {} + + @Schema(name = "ContainerQuery", description = "货柜分页筛选条件") + public record Query( + @Size(max = 64) String containerNo, + @Size(max = 64) String cabinetNo, + @Size(max = 128) String billOfLadingNo, + @Size(max = 128) String keyword, + @Positive Long warehouseId, + @Size(max = 32) String containerType, + @Positive Long shippingCompanyId, + @Size(max = 32) String departurePortCode, + @Size(max = 32) String destinationPortCode, + @Size(max = 64) String destinationWarehouseCode, + SmartLoadingStatus status, + LocalDateTime estimatedSailingFrom, + LocalDateTime estimatedSailingTo, + @Min(1) long page, + @Min(1) @Max(200) long size + ) { + public Query { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } + + @Schema(name = "ContainerSaveRequest", description = "货柜基础资料;智能装柜草稿阶段可完整编辑") + public record SaveRequest( + @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 billOfLadingNo, + @Size(max = 128) String vesselName, + @Size(max = 64) String voyageNo, + @NotBlank @Size(max = 32) String containerType, + @Positive Long shippingCompanyId, + @Size(max = 128) String shippingCompanyName, + @Size(max = 32) String departurePortCode, + @Size(max = 128) String departurePortName, + @Size(max = 32) String destinationPortCode, + @Size(max = 128) String destinationPortName, + @Size(max = 64) String destinationWarehouseCode, + @Size(max = 128) String destinationWarehouseName, + @DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm, + @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg, + @Size(max = 128) String terminalTruckingCompany, + LocalDateTime loadingAt, + LocalDateTime estimatedSailingAt, + LocalDateTime actualSailingAt, + LocalDateTime estimatedPortArrivalAt, + LocalDateTime actualPortArrivalAt, + LocalDateTime estimatedWarehouseArrivalAt, + LocalDateTime actualWarehouseArrivalAt + ) {} + + @Schema(name = "ContainerFullUpdateRequest", description = "智能装柜 EDITING 阶段完整编辑") + public record FullUpdateRequest( + @NotNull @PositiveOrZero Integer version, + @Valid @NotNull SaveRequest data + ) {} + + @Schema(name = "ContainerTransportUpdateRequest", + description = "LOADING 阶段只允许修改集装箱号和航次") + public record TransportUpdateRequest( + @Size(max = 64) String containerNo, + @Size(max = 64) String voyageNo, + @NotNull @PositiveOrZero Integer version + ) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java new file mode 100644 index 000000000..90d1af3da --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -0,0 +1,169 @@ +package com.baiyue.firstleg.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** 同一装柜计划聚合的预装清单和已确认装柜清单接口模型。 */ +public final class LoadingListDtos { + private LoadingListDtos() { + } + + public record PreloadQuery( + @Size(max = 128) String keyword, + String status, + @Min(1) Long accountId, + @Min(1) Long warehouseId, + LocalDateTime createdFrom, + LocalDateTime createdTo, + @Min(1) long page, + @Min(1) @Max(200) long size + ) { + public PreloadQuery { + page = page == 0 ? 1 : page; + size = size == 0 ? 20 : size; + } + } + + public record ConfirmedQuery( + @Size(max = 128) String keyword, + @Min(1) Long accountId, + @Min(1) Long warehouseId, + LocalDateTime createdFrom, + LocalDateTime createdTo, + @Min(1) long page, + @Min(1) @Max(200) long size + ) { + public ConfirmedQuery { + page = page == 0 ? 1 : page; + size = size == 0 ? 20 : size; + } + } + + public record TransportUpdateRequest( + @Size(max = 64) String containerNo, + @Size(max = 64) String voyageNo, + @NotNull @Min(0) Integer expectedVersion, + @Size(max = 500) String operationReason + ) { + } + + public record AdvanceRequest( + @NotNull @Min(0) Integer expectedVersion, + @Size(max = 500) String operationReason + ) { + } + + public record Customer(Long accountId, String customerName) { + } + + public record PreloadSummary( + Long planId, + String preloadNo, + String containerNo, + String internalContainerNo, + List customers, + Long warehouseId, + String warehouseName, + String vesselName, + String voyageNo, + String containerType, + String status, + long skuCount, + long totalPackageCount, + BigDecimal totalVolumeCbm, + BigDecimal totalGrossWeightKg, + LocalDateTime createdAt, + Integer version + ) { + public PreloadSummary { + customers = customers == null ? List.of() : List.copyOf(customers); + } + } + + public record ConfirmedSummary( + Long planId, + String loadingListNo, + Long warehouseId, + String warehouseName, + String containerNo, + String internalContainerNo, + String status, + long totalPackageCount, + BigDecimal totalGrossWeightKg, + BigDecimal totalNetWeightKg, + BigDecimal totalVolumeCbm, + long totalQuantity, + String remark, + Long createdBy, + String createdByName, + LocalDateTime createdAt, + Integer version + ) { + } + + public record Item( + Long itemId, + Long accountId, + String customerName, + Long inboundId, + Long inboundDetailId, + String inboundCode, + String skuCode, + String destinationWarehouse, + String chineseName, + String englishName, + String goodsImageUrl, + int packageCount, + int palletCount, + BigDecimal grossWeightKg, + BigDecimal netWeightKg, + BigDecimal volumeCbm, + BigDecimal dimensionalWeightKg, + long productQuantity, + int unitsPerPackage, + String fbaTrackingNo, + String shipmentNo, + String shippingMark, + String internalRemark, + String customerRemark, + String warehouseRemark, + int loadSequence, + LocalDateTime frozenAt + ) { + } + + public record History( + Long historyId, + Long itemId, + String actionType, + String fromStatus, + String toStatus, + String beforeSnapshot, + String afterSnapshot, + Long operatorId, + String operatorName, + String operationReason, + LocalDateTime occurredAt + ) { + } + + public record PreloadDetail(PreloadSummary summary, List items, List histories) { + public PreloadDetail { + items = items == null ? List.of() : List.copyOf(items); + histories = histories == null ? List.of() : List.copyOf(histories); + } + } + + public record ConfirmedDetail(ConfirmedSummary summary, List items, List histories) { + public ConfirmedDetail { + items = items == null ? List.of() : List.copyOf(items); + histories = histories == null ? List.of() : List.copyOf(histories); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java new file mode 100644 index 000000000..c6477d768 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java @@ -0,0 +1,32 @@ +package com.baiyue.firstleg.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +/** PDA 装柜拣货接口模型。 */ +public final class LoadingPickDtos { + private LoadingPickDtos() { + } + + public record PickRequest( + @NotNull @Min(1) Integer pickedPackageCount, + @JsonAlias("reservationIds") List<@Min(1) Long> locationIds, + @NotNull @Min(0) Integer version, + @NotBlank @Size(max = 128) String operationId + ) { + } + + public record PickItem(Long id, Long loadingPlanItemId, Long inboundId, Long inboundDetailId, + Integer plannedPackageCount, Integer pickedPackageCount, + String itemStatus, Integer version) { + } + + public record Task(Long id, Long loadingPlanId, String containerNo, String taskStatus, + Integer version, List items) { + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java new file mode 100644 index 000000000..766e5e3c2 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.dto; +import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.util.List; +public final class SmartLoadingDtos { private SmartLoadingDtos(){} + public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode, + Long warehouseId, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount, + BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) { + public Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode, + Long warehouseId, String destinationWarehouseCode, String customerName, String goodsName, Integer availablePackageCount, + BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) { + this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,destinationWarehouseCode,null,customerName,goodsName,availablePackageCount,unitWeightKg,unitVolumeCbm); + } + } + 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, + @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, + @NotBlank @Size(max=128) String operationId) {} + public record AdjustRequest(@NotNull @Positive Long accountId,@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, + @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/ContainerBookingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java new file mode 100644 index 000000000..778735c24 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java @@ -0,0 +1,56 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.Version; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** 平台级货柜预订资料;预装状态由关联装柜计划聚合。 */ +@Getter @Setter +@TableName("fl_container_booking") +public class ContainerBookingEntity { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String bookingNo; + private String containerNo; + private String cabinetNo; + private Long warehouseId; + private String warehouseName; + private String billOfLadingNo; + private String vesselName; + private String voyageNo; + private String containerType; + private Long shippingCompanyId; + private String shippingCompanyName; + private String departurePortCode; + private String departurePortName; + private String destinationPortCode; + private String destinationPortName; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private BigDecimal capacityCbm; + private BigDecimal maxWeightKg; + private String terminalTruckingCompany; + private LocalDateTime loadingAt; + private LocalDateTime estimatedSailingAt; + private LocalDateTime actualSailingAt; + private LocalDateTime estimatedPortArrivalAt; + private LocalDateTime actualPortArrivalAt; + private LocalDateTime estimatedWarehouseArrivalAt; + private LocalDateTime actualWarehouseArrivalAt; + private Long createdBy; + private String createdByName; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; + private Long updatedBy; + private String updatedByName; + @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updatedAt; + @Version @TableField(fill = FieldFill.INSERT) private Integer version; + @TableField(exist = false) private Long planId; + @TableField(exist = false) private String planStatus; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPalletAllocationEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPalletAllocationEntity.java index e6b6e1940..abb1e4afc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPalletAllocationEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPalletAllocationEntity.java @@ -26,6 +26,7 @@ public class InboundPalletAllocationEntity { private BigDecimal palletCapacityCbm; private BigDecimal allocatedVolumeCbm; private Integer allocatedPackageCount; + private Integer pickedPackageCount; private Long locationId; private String locationCode; private String reservationNo; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickHistoryEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickHistoryEntity.java new file mode 100644 index 000000000..3e80f10fd --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickHistoryEntity.java @@ -0,0 +1,23 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +/** PDA 拣货操作幂等历史。 */ +@Getter +@Setter +@TableName("fl_loading_pick_history") +public class LoadingPickHistoryEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long pickTaskId; + private Long pickItemId; + private String operationId; + private String requestHash; + private Integer pickedPackageCount; + private Long operatorId; + private String operatorName; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickItemEntity.java new file mode 100644 index 000000000..1c8349f7b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickItemEntity.java @@ -0,0 +1,30 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.Version; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** PDA 装柜拣货明细。 */ +@Getter +@Setter +@TableName("fl_loading_pick_item") +public class LoadingPickItemEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long pickTaskId; + private Long loadingPlanItemId; + private Long inboundId; + private Long inboundDetailId; + private Integer plannedPackageCount; + private Integer pickedPackageCount; + private String itemStatus; + @Version + private Integer version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickTaskEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickTaskEntity.java new file mode 100644 index 000000000..36f020992 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickTaskEntity.java @@ -0,0 +1,30 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.Version; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** PDA 装柜拣货任务。 */ +@Getter +@Setter +@TableName("fl_loading_pick_task") +public class LoadingPickTaskEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long loadingPlanId; + private String containerNo; + private String taskStatus; + private Long startedBy; + private String startedByName; + private LocalDateTime startedAt; + private Long completedBy; + private String completedByName; + private LocalDateTime completedAt; + @Version + private Integer version; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanEntity.java new file mode 100644 index 000000000..7c699544a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanEntity.java @@ -0,0 +1,10 @@ +package com.baiyue.firstleg.entity; +import com.baomidou.mybatisplus.annotation.*; import lombok.Getter; import lombok.Setter; import java.time.LocalDateTime; +@Getter @Setter @TableName("fl_loading_plan") +public class LoadingPlanEntity { + @TableId(type=IdType.ASSIGN_ID) private Long id; private String planNo; private Long containerBookingId; private String planStatus; + private String pushIdempotencyKey; private Long preloadPushedBy; private String preloadPushedByName; private LocalDateTime preloadPushedAt; + private Long preloadCompletedBy; private String preloadCompletedByName; private LocalDateTime preloadCompletedAt; private Long confirmedBy; + private String confirmedByName; private LocalDateTime confirmedAt; private String remark; private Long createdBy; private String createdByName; + private LocalDateTime createdAt; private Long updatedBy; private String updatedByName; private LocalDateTime updatedAt; @Version private Integer version; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanHistoryEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanHistoryEntity.java new file mode 100644 index 000000000..f9a1dd613 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanHistoryEntity.java @@ -0,0 +1,8 @@ +package com.baiyue.firstleg.entity; +import com.baomidou.mybatisplus.annotation.*; import lombok.Getter; import lombok.Setter; import java.time.LocalDateTime; +@Getter @Setter @TableName("fl_loading_plan_history") +public class LoadingPlanHistoryEntity { + @TableId(type=IdType.ASSIGN_ID) private Long id; private Long loadingPlanId; private Long loadingPlanItemId; private String operationId; + private String actionType; private String fromStatus; private String toStatus; private String beforeSnapshot; private String afterSnapshot; + private Long operatorId; private String operatorName; private String operationReason; private LocalDateTime createdAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java new file mode 100644 index 000000000..957eb1b9e --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java @@ -0,0 +1,15 @@ +package com.baiyue.firstleg.entity; +import com.baomidou.mybatisplus.annotation.*; import lombok.Getter; import lombok.Setter; import java.math.BigDecimal; import java.time.LocalDateTime; +@Getter @Setter @TableName("fl_loading_plan_item") +public class LoadingPlanItemEntity { + @TableId(type=IdType.ASSIGN_ID) private Long id; private Long loadingPlanId; private Long accountId; private String customerName; + private Long inboundId; private Long inboundDetailId; private Long orderId; private String orderNo; private String inboundCode; private Long destinationWarehouseId; + private String destinationWarehouseCode; private String destinationWarehouseName; private String skuCode; private String productNameZh; private String productNameEn; + private Long productImageFileId; private String productImageUrl; private Integer packageCount; private Integer productQuantity; private Integer unitsPerCarton; + private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm; + private String fbaTrackingNo; private String shipmentNo; private String shippingMark; private Integer loadSequence; private String positionSnapshot; + private String internalRemark; private String customerRemark; private String warehouseRemark; private LocalDateTime frozenAt; private Long frozenBy; + private Long removedBy; private String removedByName; private LocalDateTime removedAt; @TableLogic private Boolean deleted; + @TableField(exist=false) private Long activeInboundDetailId; private Long createdBy; private String createdByName; private LocalDateTime createdAt; + private Long updatedBy; private String updatedByName; private LocalDateTime updatedAt; @Version private Integer version; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java new file mode 100644 index 000000000..ad18775f4 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -0,0 +1,82 @@ +package com.baiyue.firstleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.firstleg.dto.ContainerDtos.Query; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface ContainerBookingMapper extends BaseMapper { + @Select(""" + + """) + Page selectBookingPage(Page page, @Param("query") Query query); + + @Select(""" + SELECT b.*, p.id AS plan_id, COALESCE(p.plan_status, 'EDITING') AS plan_status + FROM fl_container_booking b LEFT JOIN fl_loading_plan p ON p.container_booking_id = b.id + WHERE b.id = #{id} + """) + ContainerBookingEntity selectBooking(@Param("id") long id); + + @Update(""" + UPDATE fl_container_booking SET + container_no=#{value.containerNo}, cabinet_no=#{value.cabinetNo}, + warehouse_id=#{value.warehouseId}, warehouse_name=#{value.warehouseName}, + bill_of_lading_no=#{value.billOfLadingNo}, vessel_name=#{value.vesselName}, voyage_no=#{value.voyageNo}, + container_type=#{value.containerType}, shipping_company_id=#{value.shippingCompanyId}, + shipping_company_name=#{value.shippingCompanyName}, departure_port_code=#{value.departurePortCode}, + departure_port_name=#{value.departurePortName}, destination_port_code=#{value.destinationPortCode}, + destination_port_name=#{value.destinationPortName}, destination_warehouse_code=#{value.destinationWarehouseCode}, + destination_warehouse_name=#{value.destinationWarehouseName}, capacity_cbm=#{value.capacityCbm}, + max_weight_kg=#{value.maxWeightKg}, terminal_trucking_company=#{value.terminalTruckingCompany}, + loading_at=#{value.loadingAt}, estimated_sailing_at=#{value.estimatedSailingAt}, + actual_sailing_at=#{value.actualSailingAt}, estimated_port_arrival_at=#{value.estimatedPortArrivalAt}, + actual_port_arrival_at=#{value.actualPortArrivalAt}, + estimated_warehouse_arrival_at=#{value.estimatedWarehouseArrivalAt}, + actual_warehouse_arrival_at=#{value.actualWarehouseArrivalAt}, updated_by=#{operatorId}, + updated_by_name=#{operatorName}, updated_at=CURRENT_TIMESTAMP(3), version=version+1 + WHERE id=#{value.id} AND version=#{expectedVersion} + AND NOT EXISTS (SELECT 1 FROM fl_loading_plan p + WHERE p.container_booking_id=fl_container_booking.id AND p.plan_status<>'EDITING') + """) + int updateDraft(@Param("value") ContainerBookingEntity value, @Param("expectedVersion") int expectedVersion, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + + @Update(""" + UPDATE fl_container_booking SET container_no=#{containerNo}, voyage_no=#{voyageNo}, + updated_by=#{operatorId}, updated_by_name=#{operatorName}, + updated_at=CURRENT_TIMESTAMP(3), version=version+1 + WHERE id=#{id} AND version=#{expectedVersion} + AND NOT EXISTS (SELECT 1 FROM fl_loading_plan p + WHERE p.container_booking_id=fl_container_booking.id AND p.plan_status='COMPLETED') + """) + int updateTransport(@Param("id") long id, @Param("containerNo") String containerNo, + @Param("voyageNo") String voyageNo, @Param("expectedVersion") int expectedVersion, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPalletAllocationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPalletAllocationMapper.java index 8bc179064..52506815a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPalletAllocationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPalletAllocationMapper.java @@ -3,7 +3,38 @@ package com.baiyue.firstleg.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; @Mapper public interface InboundPalletAllocationMapper extends BaseMapper { + @Select(""" + SELECT * + FROM fl_inbound_pallet_allocation + WHERE tenant_id = #{tenantId} + AND inbound_id = #{inboundId} + AND allocation_status = 'CONFIRMED' + ORDER BY allocation_no ASC + FOR UPDATE + """) + java.util.List selectConfirmedForUpdate( + @Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + + @Update(""" + UPDATE fl_inbound_pallet_allocation + SET picked_package_count = picked_package_count + #{count}, + allocation_status = CASE + WHEN picked_package_count + #{count} = allocated_package_count THEN 'RELEASED' + ELSE 'CONFIRMED' + END, + updated_at = CURRENT_TIMESTAMP(3) + WHERE id = #{id} + AND tenant_id = #{tenantId} + AND inbound_id = #{inboundId} + AND allocation_status = 'CONFIRMED' + AND picked_package_count + #{count} <= allocated_package_count + """) + int consume(@Param("id") long id, @Param("tenantId") long tenantId, + @Param("inboundId") long inboundId, @Param("count") int count); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java new file mode 100644 index 000000000..299db9824 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -0,0 +1,297 @@ +package com.baiyue.firstleg.mapper; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; +import lombok.Getter; +import lombok.Setter; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** 基于共享装柜计划表提供清单查询投影和状态推进持久化操作。 */ +@Mapper +public interface LoadingListQueryMapper { + String AGGREGATE_COLUMNS = """ + p.id AS plan_id, p.plan_no, p.container_booking_id, p.plan_status, p.remark, + p.created_by, p.created_by_name, p.created_at, p.version, + b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name, + b.vessel_name, b.voyage_no, b.container_type, + COUNT(DISTINCT NULLIF(i.sku_code, '')) AS sku_count, + COALESCE(SUM(i.package_count), 0) AS total_package_count, + COALESCE(SUM(i.gross_weight_kg), 0) AS total_gross_weight_kg, + COALESCE(SUM(i.net_weight_kg), 0) AS total_net_weight_kg, + COALESCE(SUM(i.volume_cbm), 0) AS total_volume_cbm, + COALESCE(SUM(i.product_quantity), 0) AS total_quantity + """; + + String AGGREGATE_GROUP = """ + p.id, p.plan_no, p.container_booking_id, p.plan_status, p.remark, + p.created_by, p.created_by_name, p.created_at, p.version, + b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name, + b.vessel_name, b.voyage_no, b.container_type + """; + + @Select(""" + + """) + Page selectPreloadPage(Page page, @Param("query") PreloadQuery query); + + @Select(""" + + """) + Page selectConfirmedPage(Page page, @Param("query") ConfirmedQuery query); + + @Select(""" + SELECT + """ + AGGREGATE_COLUMNS + """ + FROM fl_loading_plan p + JOIN fl_container_booking b ON b.id = p.container_booking_id + LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 + WHERE p.id = #{planId} + GROUP BY + """ + AGGREGATE_GROUP) + AggregateRow selectAggregate(@Param("planId") long planId); + + @Select(""" + SELECT id AS item_id, account_id, customer_name, inbound_id, inbound_detail_id, + inbound_code, sku_code, destination_warehouse_name, product_name_zh, + product_name_en, product_image_url, package_count, pallet_count, + gross_weight_kg, net_weight_kg, volume_cbm, dimensional_weight_kg, + product_quantity, units_per_carton, fba_tracking_no, shipment_no, + shipping_mark, internal_remark, customer_remark, warehouse_remark, + load_sequence, frozen_at + FROM fl_loading_plan_item + WHERE loading_plan_id = #{planId} AND deleted = 0 + ORDER BY load_sequence, id + """) + List selectItems(@Param("planId") long planId); + + @Select(""" + + """) + List selectCustomers(@Param("planIds") List planIds); + + @Select(""" + SELECT id AS history_id, loading_plan_item_id AS item_id, operation_id, + action_type, from_status, to_status, before_snapshot, after_snapshot, + operator_id, operator_name, operation_reason, created_at AS occurred_at + FROM fl_loading_plan_history + WHERE loading_plan_id = #{planId} + ORDER BY created_at DESC, id DESC + """) + List selectHistories(@Param("planId") long planId); + + @Select(""" + SELECT id AS history_id, loading_plan_item_id AS item_id, operation_id, + action_type, from_status, to_status, before_snapshot, after_snapshot, + operator_id, operator_name, operation_reason, created_at AS occurred_at + FROM fl_loading_plan_history WHERE operation_id = #{operationId} + """) + HistoryRow selectHistoryByOperation(@Param("operationId") String operationId); + + @Update(""" + UPDATE fl_loading_plan + SET updated_by = #{operatorId}, updated_by_name = #{operatorName}, + updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{planId} AND plan_status = 'LOADING' + AND version = #{expectedVersion} + """) + int touchEditable(@Param("planId") long planId, @Param("expectedVersion") int expectedVersion, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + + @Update(""" + UPDATE fl_container_booking b + JOIN fl_loading_plan p ON p.container_booking_id = b.id + SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}, + b.updated_by = #{operatorId}, b.updated_by_name = #{operatorName}, + b.updated_at = CURRENT_TIMESTAMP(3), b.version = b.version + 1 + WHERE p.id = #{planId} AND p.plan_status = 'LOADING' + """) + int updateTransport(@Param("planId") long planId, @Param("containerNo") String containerNo, + @Param("voyageNo") String voyageNo, @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + + @Update(""" + UPDATE fl_loading_plan + SET plan_status = #{toStatus}, + preload_completed_by = CASE WHEN #{toStatus} = 'COMPLETED' THEN #{operatorId} + ELSE preload_completed_by END, + preload_completed_by_name = CASE WHEN #{toStatus} = 'COMPLETED' THEN #{operatorName} + ELSE preload_completed_by_name END, + preload_completed_at = CASE WHEN #{toStatus} = 'COMPLETED' THEN CURRENT_TIMESTAMP(3) + ELSE preload_completed_at END, + confirmed_by = CASE WHEN #{toStatus} = 'COMPLETED' THEN #{operatorId} ELSE confirmed_by END, + confirmed_by_name = CASE WHEN #{toStatus} = 'COMPLETED' THEN #{operatorName} + ELSE confirmed_by_name END, + confirmed_at = CASE WHEN #{toStatus} = 'COMPLETED' THEN CURRENT_TIMESTAMP(3) + ELSE confirmed_at END, + updated_by = #{operatorId}, updated_by_name = #{operatorName}, + updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{planId} AND plan_status = #{fromStatus} AND version = #{expectedVersion} + """) + int advance(@Param("planId") long planId, @Param("fromStatus") String fromStatus, + @Param("toStatus") String toStatus, @Param("expectedVersion") int expectedVersion, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + + @Insert(""" + INSERT INTO fl_loading_plan_history + (id, loading_plan_id, loading_plan_item_id, operation_id, action_type, + from_status, to_status, before_snapshot, after_snapshot, + operator_id, operator_name, operation_reason, created_at) + VALUES + (#{id}, #{planId}, NULL, #{operationId}, #{actionType}, + #{fromStatus}, #{toStatus}, #{beforeSnapshot}, #{afterSnapshot}, + #{operatorId}, #{operatorName}, #{operationReason}, CURRENT_TIMESTAMP(3)) + """) + int insertHistory(HistoryWrite value); + + @Getter + @Setter + class AggregateRow { + private Long planId; + private String planNo; + private Long containerBookingId; + private String planStatus; + private String remark; + private Long createdBy; + private String createdByName; + private LocalDateTime createdAt; + private Integer version; + private String containerNo; + private String cabinetNo; + private Long warehouseId; + private String warehouseName; + private String vesselName; + private String voyageNo; + private String containerType; + private Long skuCount; + private Long totalPackageCount; + private BigDecimal totalGrossWeightKg; + private BigDecimal totalNetWeightKg; + private BigDecimal totalVolumeCbm; + private Long totalQuantity; + } + + @Getter + @Setter + class ItemRow { + private Long itemId; + private Long accountId; + private String customerName; + private Long inboundId; + private Long inboundDetailId; + private String inboundCode; + private String skuCode; + private String destinationWarehouseName; + private String productNameZh; + private String productNameEn; + private String productImageUrl; + private Integer packageCount; + private Integer palletCount; + private BigDecimal grossWeightKg; + private BigDecimal netWeightKg; + private BigDecimal volumeCbm; + private BigDecimal dimensionalWeightKg; + private Long productQuantity; + private Integer unitsPerCarton; + private String fbaTrackingNo; + private String shipmentNo; + private String shippingMark; + private String internalRemark; + private String customerRemark; + private String warehouseRemark; + private Integer loadSequence; + private LocalDateTime frozenAt; + } + + @Getter + @Setter + class HistoryRow { + private Long historyId; + private Long itemId; + private String operationId; + private String actionType; + private String fromStatus; + private String toStatus; + private String beforeSnapshot; + private String afterSnapshot; + private Long operatorId; + private String operatorName; + private String operationReason; + private LocalDateTime occurredAt; + } + + @Getter + @Setter + class CustomerRow { + private Long planId; + private Long accountId; + private String customerName; + } + + record HistoryWrite(long id, long planId, String operationId, String actionType, + String fromStatus, String toStatus, String beforeSnapshot, + String afterSnapshot, long operatorId, String operatorName, + String operationReason) { + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickHistoryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickHistoryMapper.java new file mode 100644 index 000000000..278c611f0 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickHistoryMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.firstleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface LoadingPickHistoryMapper extends BaseMapper { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java new file mode 100644 index 000000000..cf35b5333 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.entity.LoadingPickItemEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface LoadingPickItemMapper extends BaseMapper { + @Update(""" + UPDATE fl_loading_pick_item + SET picked_package_count = picked_package_count + #{count}, + item_status = CASE WHEN picked_package_count + #{count} = planned_package_count + THEN 'PICKED' ELSE 'PICKING' END, + version = version + 1 + WHERE id = #{id} AND version = #{version} + AND picked_package_count + #{count} <= planned_package_count + """) + int pick(@Param("id") long id, @Param("count") int count, @Param("version") int version); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java new file mode 100644 index 000000000..ee5c434a1 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.firstleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.entity.LoadingPickTaskEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface LoadingPickTaskMapper extends BaseMapper { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanHistoryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanHistoryMapper.java new file mode 100644 index 000000000..f6a0aea3a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanHistoryMapper.java @@ -0,0 +1,3 @@ +package com.baiyue.firstleg.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; import org.apache.ibatis.annotations.Mapper; +@Mapper public interface LoadingPlanHistoryMapper extends BaseMapper {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java new file mode 100644 index 000000000..75b4eb3b1 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java @@ -0,0 +1,12 @@ +package com.baiyue.firstleg.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import org.apache.ibatis.annotations.*; +@Mapper public interface LoadingPlanItemMapper extends BaseMapper { + @Update(""" + UPDATE fl_loading_plan_item SET deleted=1,removed_by=#{operatorId},removed_by_name=#{operatorName}, + removed_at=CURRENT_TIMESTAMP(3),updated_by=#{operatorId},updated_by_name=#{operatorName}, + updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{itemId} AND loading_plan_id=#{planId} AND deleted=0 AND frozen_at IS NULL + AND version=#{expectedVersion} + """) + int removeEditing(@Param("planId") long planId,@Param("itemId") long itemId,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanMapper.java new file mode 100644 index 000000000..0d16a19d8 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanMapper.java @@ -0,0 +1,17 @@ +package com.baiyue.firstleg.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPlanEntity; import org.apache.ibatis.annotations.*; +@Mapper public interface LoadingPlanMapper extends BaseMapper { + @Update(""" + UPDATE fl_loading_plan SET updated_by=#{operatorId},updated_by_name=#{operatorName}, + updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{planId} AND plan_status IN ('EDITING','LOADING') AND version=#{expectedVersion} + """) + int touchEditing(@Param("planId") long planId,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + @Update(""" + UPDATE fl_loading_plan SET plan_status='LOADING',push_idempotency_key=#{key}, + preload_pushed_by=#{operatorId},preload_pushed_by_name=#{operatorName},preload_pushed_at=CURRENT_TIMESTAMP(3), + updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{planId} AND plan_status='EDITING' AND version=#{expectedVersion} + """) + int pushPreload(@Param("planId") long planId,@Param("key") String key,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java new file mode 100644 index 000000000..a1c10da33 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -0,0 +1,46 @@ +package com.baiyue.firstleg.mapper; +import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.ibatis.annotations.*; import java.util.List; +@Mapper public interface SmartLoadingCandidateMapper { + @Select(""" + SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId, + o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode, + o.warehouse_id warehouseId,o.destination_warehouse_id destinationWarehouseId, + o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName, + o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName, + i.package_count availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg, + 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 + AND m.status='APPROVED' + AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2 + WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED') + JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id + LEFT JOIN fl_loading_plan_item l + ON l.account_id=o.tenant_id AND l.active_inbound_detail_id=i.inbound_detail_id + WHERE o.tenant_id=#{accountId} AND o.process_status='INBOUNDED' + AND i.inbound_detail_id IS NOT NULL AND l.id IS NULL + ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC + """) + List candidates(@Param("accountId") long accountId); + + @Select(""" + SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId, + o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode, + o.warehouse_id warehouseId,o.destination_warehouse_id destinationWarehouseId, + o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName, + o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName, + i.package_count availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg, + 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 + AND m.status='APPROVED' + 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' + AND i.inbound_detail_id=#{detailId} + ORDER BY m.id DESC LIMIT 1 + """) + Candidate candidate(@Param("accountId") long accountId,@Param("inboundId") long inboundId, + @Param("detailId") long detailId); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java new file mode 100644 index 000000000..40f505169 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java @@ -0,0 +1,38 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.firstleg.dto.ContainerDtos.Query; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.mapper.ContainerBookingMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; + +@Repository +public class ContainerRepository { + @Resource private ContainerBookingMapper mapper; + public PageResult page(Query query) { + return PageResult.from(mapper.selectBookingPage(new Page<>(query.page(), query.size()), query)); + } + public ContainerBookingEntity find(long id) { return mapper.selectBooking(id); } + public boolean containerNoExists(String no, Long excludedId) { + if (no == null) return false; + return mapper.selectCount(new LambdaQueryWrapper() + .eq(ContainerBookingEntity::getContainerNo, no) + .ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0; + } + public boolean cabinetNoExists(String no, Long excludedId) { + return mapper.selectCount(new LambdaQueryWrapper() + .eq(ContainerBookingEntity::getCabinetNo, no) + .ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0; + } + public void insert(ContainerBookingEntity value) { mapper.insert(value); } + public int updateDraft(ContainerBookingEntity value, int version, long operatorId, String operatorName) { + return mapper.updateDraft(value, version, operatorId, operatorName); + } + public int updateTransport(long id, String containerNo, String voyageNo, int version, + long operatorId, String operatorName) { + return mapper.updateTransport(id, containerNo, voyageNo, version, operatorId, operatorName); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java index 9c5f5f5ed..f17ae052f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java @@ -12,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.Map; +import java.util.List; import java.util.function.Function; import java.util.stream.Collectors; @@ -21,8 +22,7 @@ public class InboundPalletAllocationRepository { @Transactional public void replace(long tenantId, long inboundId, long warehouseId, long warehouseResourceTenantId, - BigDecimal capacity, - BatchResult result) { + BigDecimal capacity, int totalPackageCount, BatchResult result) { Map reservationNos = mapper.selectList(new LambdaQueryWrapper() .eq(InboundPalletAllocationEntity::getTenantId, tenantId) .eq(InboundPalletAllocationEntity::getInboundId, inboundId)) @@ -32,7 +32,11 @@ public class InboundPalletAllocationRepository { .eq(InboundPalletAllocationEntity::getTenantId, tenantId) .eq(InboundPalletAllocationEntity::getInboundId, inboundId)); LocalDateTime now = LocalDateTime.now(); - result.allocations().forEach(source -> { + int allocationCount = result.allocations().size(); + int perAllocation = allocationCount == 0 ? 0 + : (int) Math.ceil((double) totalPackageCount / allocationCount); + int remaining = totalPackageCount; + for (var source : result.allocations()) { InboundPalletAllocationEntity value = new InboundPalletAllocationEntity(); value.setTenantId(tenantId); value.setInboundId(inboundId); @@ -43,6 +47,10 @@ public class InboundPalletAllocationRepository { value.setPalletCode(source.palletCode()); value.setPalletCapacityCbm(capacity); value.setAllocatedVolumeCbm(source.allocatedVolumeCbm()); + int allocatedPackages = Math.min(perAllocation, remaining); + value.setAllocatedPackageCount(allocatedPackages); + value.setPickedPackageCount(0); + remaining -= allocatedPackages; value.setLocationId(source.locationId()); value.setLocationCode(source.locationCode()); // 仓储确认响应只返回 reservationId;沿用预占阶段的预占单号快照。 @@ -52,7 +60,17 @@ public class InboundPalletAllocationRepository { value.setCreatedAt(now); value.setUpdatedAt(now); mapper.insert(value); - }); + } + } + + public List lockConfirmed(long tenantId, long inboundId) { + return mapper.selectConfirmedForUpdate(tenantId, inboundId); + } + + public void consume(long tenantId, long inboundId, long allocationId, int packageCount) { + if (mapper.consume(allocationId, tenantId, inboundId, packageCount) != 1) { + throw new IllegalStateException("库位箱数已被并发操作修改"); + } } private static String normalize(String state) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java new file mode 100644 index 000000000..a921697c3 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java @@ -0,0 +1,150 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedDetail; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary; +import com.baiyue.firstleg.dto.LoadingListDtos.Customer; +import com.baiyue.firstleg.dto.LoadingListDtos.History; +import com.baiyue.firstleg.dto.LoadingListDtos.Item; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.CustomerRow; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryWrite; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.ItemRow; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Repository +public class LoadingListRepository { + @Resource + private LoadingListQueryMapper mapper; + + public PageResult preloadPage(PreloadQuery query) { + Page page = mapper.selectPreloadPage(new Page<>(query.page(), query.size()), query); + Map> customers = customerMap(page.getRecords()); + List records = page.getRecords().stream() + .map(row -> preload(row, customers.get(row.getPlanId()))) + .toList(); + return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); + } + + public PageResult confirmedPage(ConfirmedQuery query) { + Page page = mapper.selectConfirmedPage(new Page<>(query.page(), query.size()), query); + List records = page.getRecords().stream().map(this::confirmed).toList(); + return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); + } + + public AggregateRow aggregate(long planId) { + return mapper.selectAggregate(planId); + } + + public PreloadDetail preloadDetail(AggregateRow row) { + List items = mapper.selectItems(row.getPlanId()); + List customers = items.stream() + .collect(Collectors.toMap(ItemRow::getAccountId, ItemRow::getCustomerName, + (left, right) -> left, java.util.LinkedHashMap::new)) + .entrySet().stream().map(entry -> new Customer(entry.getKey(), entry.getValue())).toList(); + return new PreloadDetail(preload(row, customers), items(items), histories(row.getPlanId())); + } + + public ConfirmedDetail confirmedDetail(AggregateRow row) { + return new ConfirmedDetail(confirmed(row), items(mapper.selectItems(row.getPlanId())), + histories(row.getPlanId())); + } + + public HistoryRow history(String operationId) { + return mapper.selectHistoryByOperation(operationId); + } + + public int updateTransport(long planId, int expectedVersion, String containerNo, String voyageNo, + long operatorId, String operatorName) { + if (mapper.touchEditable(planId, expectedVersion, operatorId, operatorName) != 1) { + return 0; + } + return mapper.updateTransport(planId, containerNo, voyageNo, operatorId, operatorName); + } + + public int advance(long planId, String fromStatus, String toStatus, int expectedVersion, + long operatorId, String operatorName) { + return mapper.advance(planId, fromStatus, toStatus, expectedVersion, operatorId, operatorName); + } + + public void appendHistory(long planId, String operationId, String actionType, + String fromStatus, String toStatus, String beforeSnapshot, + String afterSnapshot, long operatorId, String operatorName, + String operationReason) { + mapper.insertHistory(new HistoryWrite(IdWorker.getId(), planId, operationId, actionType, + fromStatus, toStatus, beforeSnapshot, afterSnapshot, operatorId, operatorName, + operationReason)); + } + + private Map> customerMap(List rows) { + List ids = rows.stream().map(AggregateRow::getPlanId).toList(); + if (ids.isEmpty()) { + return Map.of(); + } + return mapper.selectCustomers(ids).stream().collect(Collectors.groupingBy( + CustomerRow::getPlanId, java.util.LinkedHashMap::new, + Collectors.mapping(row -> new Customer(row.getAccountId(), row.getCustomerName()), + Collectors.toList()))); + } + + private PreloadSummary preload(AggregateRow row, List customers) { + return new PreloadSummary(row.getPlanId(), row.getPlanNo(), row.getContainerNo(), row.getCabinetNo(), + customers, row.getWarehouseId(), row.getWarehouseName(), row.getVesselName(), row.getVoyageNo(), + row.getContainerType(), row.getPlanStatus(), value(row.getSkuCount()), + value(row.getTotalPackageCount()), decimal(row.getTotalVolumeCbm()), + decimal(row.getTotalGrossWeightKg()), row.getCreatedAt(), row.getVersion()); + } + + private ConfirmedSummary confirmed(AggregateRow row) { + return new ConfirmedSummary(row.getPlanId(), row.getPlanNo(), row.getWarehouseId(), row.getWarehouseName(), + row.getContainerNo(), row.getCabinetNo(), row.getPlanStatus(), value(row.getTotalPackageCount()), + decimal(row.getTotalGrossWeightKg()), decimal(row.getTotalNetWeightKg()), + decimal(row.getTotalVolumeCbm()), value(row.getTotalQuantity()), row.getRemark(), + row.getCreatedBy(), row.getCreatedByName(), row.getCreatedAt(), row.getVersion()); + } + + private List items(List rows) { + return rows.stream().map(row -> new Item(row.getItemId(), row.getAccountId(), row.getCustomerName(), + row.getInboundId(), row.getInboundDetailId(), row.getInboundCode(), row.getSkuCode(), + row.getDestinationWarehouseName(), row.getProductNameZh(), row.getProductNameEn(), + row.getProductImageUrl(), integer(row.getPackageCount()), integer(row.getPalletCount()), + decimal(row.getGrossWeightKg()), decimal(row.getNetWeightKg()), decimal(row.getVolumeCbm()), + decimal(row.getDimensionalWeightKg()), value(row.getProductQuantity()), + integer(row.getUnitsPerCarton()), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(), + row.getInternalRemark(), row.getCustomerRemark(), row.getWarehouseRemark(), + integer(row.getLoadSequence()), row.getFrozenAt())).toList(); + } + + private List histories(long planId) { + return mapper.selectHistories(planId).stream().map(row -> new History(row.getHistoryId(), row.getItemId(), + row.getActionType(), row.getFromStatus(), row.getToStatus(), row.getBeforeSnapshot(), + row.getAfterSnapshot(), row.getOperatorId(), row.getOperatorName(), row.getOperationReason(), + row.getOccurredAt())).toList(); + } + + private static long value(Long value) { + return value == null ? 0 : value; + } + + private static int integer(Integer value) { + return value == null ? 0 : value; + } + + private static BigDecimal decimal(BigDecimal value) { + return value == null ? BigDecimal.ZERO : value; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java new file mode 100644 index 000000000..7051e39f6 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java @@ -0,0 +1,86 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.firstleg.entity.LoadingPickItemEntity; +import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; +import com.baiyue.firstleg.entity.LoadingPickTaskEntity; +import com.baiyue.firstleg.mapper.LoadingPickHistoryMapper; +import com.baiyue.firstleg.entity.LoadingPlanItemEntity; +import com.baiyue.firstleg.mapper.LoadingPickItemMapper; +import com.baiyue.firstleg.mapper.LoadingPickTaskMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** PDA 拣货任务持久化边界。 */ +@Repository +public class LoadingPickRepository { + @Resource + private LoadingPickTaskMapper tasks; + @Resource + private LoadingPickItemMapper items; + @Resource + private LoadingPickHistoryMapper histories; + + public LoadingPickTaskEntity task(long taskId) { + return tasks.selectById(taskId); + } + + public LoadingPickTaskEntity taskByPlan(long planId) { + return tasks.selectOne(new LambdaQueryWrapper() + .eq(LoadingPickTaskEntity::getLoadingPlanId, planId)); + } + + public LoadingPickTaskEntity taskByContainer(String containerNo) { + return tasks.selectOne(new LambdaQueryWrapper() + .eq(LoadingPickTaskEntity::getContainerNo, containerNo) + .eq(LoadingPickTaskEntity::getTaskStatus, "LOADING")); + } + + public LoadingPickItemEntity item(long taskId, long itemId) { + return items.selectOne(new LambdaQueryWrapper() + .eq(LoadingPickItemEntity::getPickTaskId, taskId) + .eq(LoadingPickItemEntity::getId, itemId)); + } + + public List items(long taskId) { + return items.selectList(new LambdaQueryWrapper() + .eq(LoadingPickItemEntity::getPickTaskId, taskId) + .orderByAsc(LoadingPickItemEntity::getId)); + } + + public void insert(LoadingPickTaskEntity task) { + tasks.insert(task); + } + + public void insert(LoadingPickItemEntity item) { + items.insert(item); + } + + public LoadingPickItemEntity itemByPlanItem(long planItemId) { + return items.selectOne(new LambdaQueryWrapper() + .eq(LoadingPickItemEntity::getLoadingPlanItemId, planItemId)); + } + + public int pick(long itemId, int count, int version) { + return items.pick(itemId, count, version); + } + + public LoadingPickHistoryEntity history(String operationId) { + return histories.selectOne(new LambdaQueryWrapper() + .eq(LoadingPickHistoryEntity::getOperationId, operationId)); + } + + public void insert(LoadingPickHistoryEntity history) { + histories.insert(history); + } + + public void update(LoadingPickItemEntity item) { + items.updateById(item); + } + + public void deleteItem(long itemId) { + items.deleteById(itemId); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java new file mode 100644 index 000000000..b3e9de55d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -0,0 +1,74 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.entity.LoadingPlanEntity; +import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; +import com.baiyue.firstleg.entity.LoadingPlanItemEntity; +import com.baiyue.firstleg.mapper.ContainerBookingMapper; +import com.baiyue.firstleg.mapper.LoadingPlanHistoryMapper; +import com.baiyue.firstleg.mapper.LoadingPlanItemMapper; +import com.baiyue.firstleg.mapper.LoadingPlanMapper; +import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper; +import org.springframework.stereotype.Repository; +import jakarta.annotation.Resource; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +import java.util.List; + +/** 智能装柜聚合的真实 MySQL 持久化边界。 */ +@Repository +@ConditionalOnProperty(name="firstleg.smart-loading.repository.enabled", havingValue="true", matchIfMissing=true) +public class SmartLoadingRepository { + @Resource private LoadingPlanMapper plans; + @Resource private LoadingPlanItemMapper items; + @Resource private LoadingPlanHistoryMapper histories; + @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 LoadingPlanEntity plan(long planId) { return plans.selectById(planId); } + public LoadingPlanEntity planByBooking(long bookingId) { + return plans.selectOne(new LambdaQueryWrapper() + .eq(LoadingPlanEntity::getContainerBookingId, bookingId)); + } + public ContainerBookingEntity booking(long bookingId) { return bookings.selectById(bookingId); } + public List items(long planId) { + return items.selectList(new LambdaQueryWrapper() + .eq(LoadingPlanItemEntity::getLoadingPlanId, planId) + .eq(LoadingPlanItemEntity::getDeleted, false) + .orderByAsc(LoadingPlanItemEntity::getLoadSequence).orderByAsc(LoadingPlanItemEntity::getId)); + } + public LoadingPlanItemEntity item(long planId, long itemId) { + return items.selectOne(new LambdaQueryWrapper() + .eq(LoadingPlanItemEntity::getLoadingPlanId, planId) + .eq(LoadingPlanItemEntity::getId, itemId).eq(LoadingPlanItemEntity::getDeleted, false)); + } + public LoadingPlanItemEntity activeItem(long accountId, long detailId) { + return items.selectOne(new LambdaQueryWrapper() + .eq(LoadingPlanItemEntity::getAccountId, accountId) + .eq(LoadingPlanItemEntity::getInboundDetailId, detailId) + .eq(LoadingPlanItemEntity::getDeleted, false)); + } + public LoadingPlanHistoryEntity operation(String operationId) { + return histories.selectOne(new LambdaQueryWrapper() + .eq(LoadingPlanHistoryEntity::getOperationId, operationId)); + } + public void insert(LoadingPlanEntity value) { plans.insert(value); } + public void insert(LoadingPlanItemEntity value) { items.insert(value); } + public void insert(LoadingPlanHistoryEntity value) { histories.insert(value); } + public int update(LoadingPlanItemEntity value) { return items.updateById(value); } + public int touch(long planId, int version, long operatorId, String operatorName) { + return plans.touchEditing(planId, version, operatorId, operatorName); + } + public int remove(long planId, long itemId, int version, long operatorId, String operatorName) { + return items.removeEditing(planId, itemId, version, operatorId, operatorName); + } + public int push(long planId, String key, int version, long operatorId, String operatorName) { + return plans.pushPreload(planId, key, version, operatorId, operatorName); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java new file mode 100644 index 000000000..1eabf5bab --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -0,0 +1,134 @@ +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.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.domain.SmartLoadingStatus; +import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; +import com.baiyue.firstleg.dto.ContainerDtos.Query; +import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; +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.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +public class ContainerService { + @Resource private ContainerRepository repository; + public PageResult page(Query query) { + validateRange(query.estimatedSailingFrom(), query.estimatedSailingTo(), "预计船期筛选"); + return repository.page(normalize(query)); + } + public ContainerBookingEntity detail(long id) { return required(id); } + + @Transactional + public ContainerBookingEntity create(long operatorId, String operatorName, SaveRequest request) { + validateSchedule(request); + 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.setCreatedBy(operatorId); value.setCreatedByName(operatorName); + value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); + try { repository.insert(value); } catch (DuplicateKeyException ex) { throw duplicate(); } + value.setPlanStatus(SmartLoadingStatus.EDITING.name()); + return value; + } + + @Transactional + public ContainerBookingEntity updateDraft(long id, long operatorId, String operatorName, + FullUpdateRequest request) { + ContainerBookingEntity current = required(id); + requireVersion(current, request.version()); + if (status(current) != SmartLoadingStatus.EDITING) { + throw conflict("货柜已进入预装流程,只能修改集装箱号和航次"); + } + validateSchedule(request.data()); + ensureUnique(trim(request.data().containerNo()), request.data().cabinetNo().trim(), id); + copy(current, request.data()); + try { + if (repository.updateDraft(current, request.version(), operatorId, operatorName) != 1) { + throw conflict("货柜状态或版本已变化,请刷新后重试"); + } + } catch (DuplicateKeyException ex) { throw duplicate(); } + return required(id); + } + + @Transactional + public ContainerBookingEntity updateTransport(long id, long operatorId, String operatorName, + TransportUpdateRequest request) { + ContainerBookingEntity current = required(id); + requireVersion(current, request.version()); + if (status(current) == SmartLoadingStatus.COMPLETED) throw conflict("装柜完成后货柜全部只读,不能修改"); + String containerNo = trim(request.containerNo()); + if (repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在"); + try { + if (repository.updateTransport(id, containerNo, trim(request.voyageNo()), request.version(), + operatorId, operatorName) != 1) throw conflict("货柜状态或版本已变化,请刷新后重试"); + } catch (DuplicateKeyException ex) { throw conflict("集装箱号已存在"); } + return required(id); + } + + private ContainerBookingEntity required(long id) { + ContainerBookingEntity value = repository.find(id); + if (value == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在"); + return value; + } + private void ensureUnique(String containerNo, String cabinetNo, Long excludedId) { + if (repository.containerNoExists(containerNo, excludedId)) throw conflict("集装箱号已存在"); + if (repository.cabinetNoExists(cabinetNo, excludedId)) throw conflict("内部柜号已存在"); + } + private static void requireVersion(ContainerBookingEntity value, Integer version) { + if (!version.equals(value.getVersion())) throw conflict("货柜已被其他人修改,请刷新后重试"); + } + private static SmartLoadingStatus status(ContainerBookingEntity value) { + try { return SmartLoadingStatus.valueOf(value.getPlanStatus()); } + catch (RuntimeException ex) { throw conflict("装柜计划状态无效,不能修改货柜"); } + } + 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.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())); + v.setDeparturePortCode(trim(r.departurePortCode())); v.setDeparturePortName(trim(r.departurePortName())); + v.setDestinationPortCode(trim(r.destinationPortCode())); v.setDestinationPortName(trim(r.destinationPortName())); + v.setDestinationWarehouseCode(trim(r.destinationWarehouseCode())); + v.setDestinationWarehouseName(trim(r.destinationWarehouseName())); + v.setCapacityCbm(r.capacityCbm()); v.setMaxWeightKg(r.maxWeightKg()); + v.setTerminalTruckingCompany(trim(r.terminalTruckingCompany())); v.setLoadingAt(r.loadingAt()); + v.setEstimatedSailingAt(r.estimatedSailingAt()); v.setActualSailingAt(r.actualSailingAt()); + v.setEstimatedPortArrivalAt(r.estimatedPortArrivalAt()); v.setActualPortArrivalAt(r.actualPortArrivalAt()); + v.setEstimatedWarehouseArrivalAt(r.estimatedWarehouseArrivalAt()); + v.setActualWarehouseArrivalAt(r.actualWarehouseArrivalAt()); + } + private static void validateSchedule(SaveRequest r) { + validateRange(r.estimatedSailingAt(), r.estimatedPortArrivalAt(), "预计船期和预计到港时间"); + validateRange(r.estimatedPortArrivalAt(), r.estimatedWarehouseArrivalAt(), "预计到港和预计到库时间"); + validateRange(r.actualSailingAt(), r.actualPortArrivalAt(), "实际船期和实际到港时间"); + validateRange(r.actualPortArrivalAt(), r.actualWarehouseArrivalAt(), "实际到港和实际到库时间"); + } + private static void validateRange(LocalDateTime from, LocalDateTime to, String label) { + if (from != null && to != null && to.isBefore(from)) + throw new BizException(ResultCode.VALIDATION_FAILED, label + "先后顺序不正确"); + } + private static Query normalize(Query q) { + return new Query(trim(q.containerNo()), trim(q.cabinetNo()), trim(q.billOfLadingNo()), trim(q.keyword()), + q.warehouseId(), trim(q.containerType()), q.shippingCompanyId(), trim(q.departurePortCode()), + trim(q.destinationPortCode()), trim(q.destinationWarehouseCode()), q.status(), q.estimatedSailingFrom(), + q.estimatedSailingTo(), q.page(), q.size()); + } + private static String trim(String value) { + if (value == null) return null; + String result = value.trim(); return result.isEmpty() ? null : result; + } + private static BizException duplicate() { return conflict("集装箱号或内部柜号已存在"); } + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java new file mode 100644 index 000000000..2f363d130 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -0,0 +1,228 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedDetail; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary; +import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow; +import com.baiyue.firstleg.repository.LoadingListRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +@Service +public class LoadingListService { + private static final Set LOADING_STATUSES = Set.of("LOADING"); + @Resource + private LoadingListRepository repository; + @Resource + private ObjectMapper objectMapper; + + public PageResult preloadPage(PreloadQuery query) { + validateRange(query.createdFrom(), query.createdTo()); + String status = trim(query.status()); + if (StringUtils.hasText(status) && !LOADING_STATUSES.contains(status)) { + throw badRequest("装柜清单状态只能是 LOADING"); + } + return repository.preloadPage(new PreloadQuery(trim(query.keyword()), status, query.accountId(), + 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())); + } + + public PreloadDetail preloadDetail(long planId) { + AggregateRow row = requirePlan(planId); + if (!LOADING_STATUSES.contains(row.getPlanStatus())) { + throw notFound("装柜中清单不存在"); + } + return repository.preloadDetail(row); + } + + public ConfirmedDetail confirmedDetail(long planId) { + AggregateRow row = requirePlan(planId); + if (!"COMPLETED".equals(row.getPlanStatus())) { + throw notFound("装柜清单不存在"); + } + return repository.confirmedDetail(row); + } + + @Transactional + @BusinessAudit(event = "PRELOAD_TRANSPORT_UPDATE", resourceType = "LOADING_PLAN", + resourceId = "#planId", description = "修改预装集装箱号与航次") + public PreloadDetail updateTransport(long planId, 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); + HistoryRow replay = repository.history(operationId); + if (replay != null) { + requireSameRequest(replay, requestHash); + return repository.preloadDetail(requirePlan(planId)); + } + + AggregateRow before = requirePlan(planId); + requireLoadingMutable(before); + if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo, + operatorId, trim(operatorName)) != 1) { + throw conflict("预装计划已被其他操作修改,请刷新后重试"); + } + repository.appendHistory(planId, operationId, "UPDATE_PRELOAD_TRANSPORT", + before.getPlanStatus(), before.getPlanStatus(), json(snapshot(before)), + json(values("requestHash", requestHash, "status", before.getPlanStatus(), + "containerNo", containerNo, "voyageNo", voyageNo, + "version", request.expectedVersion() + 1)), operatorId, trim(operatorName), + trim(request.operationReason())); + return repository.preloadDetail(requirePlan(planId)); + } + + @Transactional + @BusinessAudit(event = "PRELOAD_COMPLETE", resourceType = "LOADING_PLAN", + resourceId = "#planId", description = "完成预装") + public ConfirmedDetail complete(long planId, long operatorId, String operatorName, + String idempotencyKey, AdvanceRequest request) { + advance(planId, operatorId, operatorName, idempotencyKey, request, + "LOADING", "COMPLETED"); + return repository.confirmedDetail(requirePlan(planId)); + } + + private void advance(long planId, long operatorId, String operatorName, String idempotencyKey, + AdvanceRequest request, String fromStatus, String toStatus) { + String requestHash = sha256(toStatus + '|' + planId + '|' + request.expectedVersion() + + '|' + trim(request.operationReason())); + String operationId = operationId(toStatus, planId, idempotencyKey); + HistoryRow replay = repository.history(operationId); + if (replay != null) { + requireSameRequest(replay, requestHash); + return; + } + + AggregateRow before = requirePlan(planId); + if (!fromStatus.equals(before.getPlanStatus())) { + throw conflict("当前状态为 " + before.getPlanStatus() + ",不能推进到 " + toStatus); + } + if (repository.advance(planId, fromStatus, toStatus, request.expectedVersion(), + operatorId, trim(operatorName)) != 1) { + throw conflict("预装计划已被其他操作修改,请刷新后重试"); + } + repository.appendHistory(planId, operationId, "ADVANCE_PRELOAD_STATUS", + fromStatus, toStatus, json(snapshot(before)), + json(values("requestHash", requestHash, "status", toStatus, + "version", request.expectedVersion() + 1)), operatorId, trim(operatorName), + trim(request.operationReason())); + } + + private AggregateRow requirePlan(long planId) { + AggregateRow row = repository.aggregate(planId); + if (row == null) { + throw notFound("装柜计划不存在"); + } + return row; + } + + private static void requireLoadingMutable(AggregateRow row) { + if (!LOADING_STATUSES.contains(row.getPlanStatus())) { + throw conflict("仅装柜中可修改集装箱号与航次"); + } + } + + private static void validateRange(java.time.LocalDateTime from, java.time.LocalDateTime to) { + if (from != null && to != null && from.isAfter(to)) { + throw badRequest("创建开始时间不能晚于结束时间"); + } + } + + private String operationId(String operation, long planId, String key) { + String normalized = trim(key); + if (!StringUtils.hasText(normalized)) { + throw badRequest("必须提供 Idempotency-Key"); + } + if (normalized.length() > 128) { + throw badRequest("Idempotency-Key 长度不能超过128"); + } + return "PL-" + sha256(operation + '|' + planId + '|' + normalized); + } + + private static void requireSameRequest(HistoryRow replay, String requestHash) { + String snapshot = replay.getAfterSnapshot(); + if (snapshot == null || !snapshot.contains("\"requestHash\":\"" + requestHash + "\"")) { + throw conflict("Idempotency-Key 已用于不同请求"); + } + } + + private Map snapshot(AggregateRow row) { + return values("status", row.getPlanStatus(), "containerNo", row.getContainerNo(), + "voyageNo", row.getVoyageNo(), "version", row.getVersion()); + } + + private static Map values(Object... values) { + Map result = new LinkedHashMap<>(); + for (int index = 0; index < values.length; index += 2) { + result.put((String) values[index], values[index + 1]); + } + return result; + } + + private String json(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("无法生成装柜审计快照", exception); + } + } + + private static String sha256(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("JVM 不支持 SHA-256", impossible); + } + } + + private static String nullableTrim(String value) { + String trimmed = trim(value); + return StringUtils.hasText(trimmed) ? trimmed : null; + } + + private static String trim(String value) { + return value == null ? null : value.trim(); + } + + private static BizException badRequest(String message) { + return new BizException(ResultCode.BAD_REQUEST, message); + } + + private static BizException notFound(String message) { + return new BizException(ResultCode.NOT_FOUND, message); + } + + private static BizException conflict(String message) { + return new BizException(ResultCode.CONFLICT, message); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java new file mode 100644 index 000000000..bf75bce82 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -0,0 +1,174 @@ +package com.baiyue.firstleg.service; + +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.Task; +import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; +import com.baiyue.firstleg.entity.LoadingPickItemEntity; +import com.baiyue.firstleg.entity.LoadingPickTaskEntity; +import com.baiyue.firstleg.entity.LoadingPlanItemEntity; +import com.baiyue.firstleg.repository.LoadingPickRepository; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.List; + +/** 装柜中 PDA 拣货任务及明细同步。 */ +@Service +public class LoadingPickService { + @Resource + private LoadingPickRepository repository; + + @Transactional + public void begin(long planId, String containerNo, long operatorId, String operatorName, + List planItems) { + LoadingPickTaskEntity task = repository.taskByPlan(planId); + if (task == null) { + task = new LoadingPickTaskEntity(); + task.setLoadingPlanId(planId); + task.setContainerNo(containerNo); + task.setTaskStatus("LOADING"); + task.setStartedBy(operatorId); + task.setStartedByName(operatorName); + task.setVersion(0); + try { + repository.insert(task); + } catch (DuplicateKeyException exception) { + task = repository.taskByPlan(planId); + } + } + if (task == null || !"LOADING".equals(task.getTaskStatus())) { + throw conflict("装柜任务已完成,不能继续同步明细"); + } + for (LoadingPlanItemEntity planItem : planItems) { + syncItem(task.getId(), planItem); + } + } + + @Transactional + public void sync(long planId, LoadingPlanItemEntity planItem) { + LoadingPickTaskEntity task = repository.taskByPlan(planId); + if (task != null && "LOADING".equals(task.getTaskStatus())) { + syncItem(task.getId(), planItem); + } + } + + public Task task(String containerNo) { + if (!StringUtils.hasText(containerNo)) { + throw new BizException(ResultCode.BAD_REQUEST, "集装箱号不能为空"); + } + LoadingPickTaskEntity task = repository.taskByContainer(containerNo.trim()); + if (task == null) { + throw new BizException(ResultCode.NOT_FOUND, "未找到装柜中的PDA拣货任务"); + } + return view(task); + } + + @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()); + 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)); + } + LoadingPickTaskEntity task = requiredTask(taskId); + if (!"LOADING".equals(task.getTaskStatus())) { + throw conflict("装柜任务已完成,不能继续拣货"); + } + LoadingPickItemEntity item = repository.item(taskId, itemId); + if (item == null) { + throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在"); + } + if (repository.pick(itemId, request.pickedPackageCount(), request.version()) != 1) { + throw conflict("拣货数量超过计划箱数或明细版本已变化"); + } + LoadingPickHistoryEntity history = new LoadingPickHistoryEntity(); + history.setPickTaskId(taskId); + history.setPickItemId(itemId); + history.setOperationId(operationId); + history.setRequestHash(requestHash); + history.setPickedPackageCount(request.pickedPackageCount()); + history.setOperatorId(operatorId); + history.setOperatorName(operatorName); + repository.insert(history); + return view(requiredTask(taskId)); + } + + @Transactional + public void remove(long planItemId) { + LoadingPickItemEntity item = repository.itemByPlanItem(planItemId); + if (item == null) { + return; + } + if (item.getPickedPackageCount() != null && item.getPickedPackageCount() > 0) { + throw conflict("已有拣货记录的明细不能删除"); + } + repository.deleteItem(item.getId()); + } + + private void syncItem(long taskId, LoadingPlanItemEntity planItem) { + LoadingPickItemEntity item = repository.itemByPlanItem(planItem.getId()); + if (item == null) { + item = new LoadingPickItemEntity(); + item.setPickTaskId(taskId); + item.setLoadingPlanItemId(planItem.getId()); + item.setInboundId(planItem.getInboundId()); + item.setInboundDetailId(planItem.getInboundDetailId()); + item.setPlannedPackageCount(planItem.getPackageCount()); + item.setPickedPackageCount(0); + item.setItemStatus("PENDING"); + item.setVersion(0); + repository.insert(item); + return; + } + if (item.getPickedPackageCount() > planItem.getPackageCount()) { + throw conflict("已拣货箱数不能低于计划箱数"); + } + item.setPlannedPackageCount(planItem.getPackageCount()); + item.setItemStatus(item.getPickedPackageCount().equals(planItem.getPackageCount()) ? "PICKED" : "PICKING"); + repository.update(item); + } + + private LoadingPickTaskEntity requiredTask(long taskId) { + LoadingPickTaskEntity task = repository.task(taskId); + if (task == null) { + throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在"); + } + return task; + } + + private Task view(LoadingPickTaskEntity task) { + List items = repository.items(task.getId()).stream() + .map(item -> new PickItem(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(), + item.getInboundDetailId(), item.getPlannedPackageCount(), item.getPickedPackageCount(), + item.getItemStatus(), item.getVersion())).toList(); + return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerNo(), task.getTaskStatus(), + task.getVersion(), items); + } + + private static String hash(String value) { + try { + return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("JVM 不支持 SHA-256", exception); + } + } + + private static BizException conflict(String message) { + return new BizException(ResultCode.CONFLICT, message); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java index a713cd696..25b884bce 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java @@ -45,15 +45,16 @@ public class MeasurementLocationOccupationService { properties.getDefaultPalletCapacityCbm(), "STANDARD", LocalDateTime.now().plus(properties.getReservationTtl()), true))), "测量完成后库位预占失败"); + int packageCount = requirePackageCount(order); allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId, - properties.getDefaultPalletCapacityCbm(), reserved); + properties.getDefaultPalletCapacityCbm(), packageCount, reserved); try { WarehousePalletLocationClient.BatchResult occupied = requireSuccess(call(() -> warehouse.confirm( internalToken, new WarehousePalletLocationClient.ConfirmRequest(resourceTenantId, warehouseId, SOURCE_SERVICE, inboundId, operatorId, volume, properties.getDefaultPalletCapacityCbm()))), "测量完成后库位占用失败"); allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId, - properties.getDefaultPalletCapacityCbm(), occupied); + properties.getDefaultPalletCapacityCbm(), packageCount, occupied); } catch (RuntimeException failure) { try { warehouse.release(internalToken, new WarehousePalletLocationClient.ReleaseRequest( @@ -85,6 +86,13 @@ public class MeasurementLocationOccupationService { return order.getActualVolumeCbm(); } + private static int requirePackageCount(InboundOrderEntity order) { + if (order.getTotalPackageCount() == null || order.getTotalPackageCount() <= 0) { + throw new BizException(ResultCode.CONFLICT, "入库单缺少有效总箱数"); + } + return order.getTotalPackageCount(); + } + private static ApiResponse call(RemoteCall call) { try { return call.invoke(); } catch (FeignException exception) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java new file mode 100644 index 000000000..2dac22253 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -0,0 +1,379 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.firstleg.dto.SmartLoadingDtos.*; +import com.baiyue.firstleg.client.OrderCargoClient; +import com.baiyue.firstleg.entity.*; +import com.baiyue.firstleg.repository.SmartLoadingRepository; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.FeignException; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.*; + +/** 智能装柜的候选、推荐/人工分配、删除和不可逆推送编排。 */ +@Service +public class SmartLoadingService { + @Resource private SmartLoadingRepository repository; + @Resource private OrderCargoClient orderCargoClient; + @Resource private LoadingPickService loadingPickService; + @Resource private ObjectMapper objectMapper; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + public SmartLoadingService() { } + + public List candidates(long accountId) { + requireAccount(accountId); + return repository.candidates(accountId); + } + + public Plan draftByBooking(long bookingId) { + ContainerBookingEntity booking = requireBooking(bookingId); + LoadingPlanEntity plan = repository.planByBooking(bookingId); + return draftView(plan, booking); + } + + public Plan detail(long planId) { + LoadingPlanEntity plan = repository.plan(planId); + if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在"); + return draftView(plan, requireBooking(plan.getContainerBookingId())); + } + + @Transactional + @BusinessAudit(event = "SMART_LOADING_PLAN_CREATE", resourceType = "LOADING_PLAN", + description = "创建智能装柜计划", captureBefore = false) + public Plan create(long bookingId, String remark, long operatorId, String operatorName) { + ContainerBookingEntity booking = requireBooking(bookingId); + LoadingPlanEntity existing = repository.planByBooking(bookingId); + if (existing != null) return draftView(existing, booking); + LoadingPlanEntity plan = new LoadingPlanEntity(); + plan.setPlanNo("LP-" + UUID.randomUUID().toString().replace("-", "").substring(0, 20)); + plan.setContainerBookingId(bookingId); plan.setPlanStatus("EDITING"); plan.setRemark(trim(remark)); + plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName); plan.setUpdatedBy(operatorId); + plan.setUpdatedByName(operatorName); plan.setVersion(0); + try { + repository.insert(plan); + } catch (DuplicateKeyException exception) { + LoadingPlanEntity concurrent = repository.planByBooking(bookingId); + if (concurrent != null) return draftView(concurrent, booking); + throw conflict("装柜计划创建冲突,请刷新后重试"); + } + history(plan.getId(), null, "CREATE_PLAN", null, "EDITING", UUID.randomUUID().toString(), operatorId, operatorName); + return view(plan, booking); + } + + @Transactional + @BusinessAudit(event = "SMART_LOADING_ITEM_ASSIGN", resourceType = "LOADING_PLAN", + 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() + '|' + + 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()); + if (candidate == null) throw conflict("入库货物不存在或尚未完成入库"); + if (!Objects.equals(candidate.accountId(), request.accountId())) throw forbidden(); + OrderCargoClient.CargoBinding cargo = loadCargo(candidate); + LoadingPlanItemEntity existing = repository.activeItem(request.accountId(), request.inboundDetailId()); + if (existing != null && !Objects.equals(existing.getLoadingPlanId(), planId)) throw conflict("货物明细已被有效计划锁定"); + int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount()); + if (total > candidate.availablePackageCount()) 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); + touch(plan, operatorId, operatorName); + LoadingPlanItemEntity changed; + if (existing == null) { + LoadingPlanItemEntity item = snapshot(planId, candidate, cargo, request.packageCount(), operatorId, operatorName, lines.size() + 1); + try { repository.insert(item); } catch (DuplicateKeyException e) { throw conflict("货物明细已被并发锁定"); } + changed = item; + } else { + existing.setPackageCount(total); existing.setGrossWeightKg(unit(candidate.unitWeightKg()).multiply(BigDecimal.valueOf(total))); + existing.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(total))); applyCargo(existing, cargo, total); + existing.setUpdatedBy(operatorId); existing.setUpdatedByName(operatorName); + if (repository.update(existing) != 1) throw conflict("装柜草稿已被并发修改"); + changed = existing; + } + history(planId, changed.getId(), "ADD_ITEM", "EDITING", "EDITING", operation, + json(values("version", request.expectedVersion())), + json(values("requestHash", requestHash, "itemId", changed.getId(), + "packageCount", changed.getPackageCount())), operatorId, operatorName); + loadingPickService.sync(planId, changed); + return view(repository.plan(planId), booking); + } + + @Transactional + @BusinessAudit(event = "SMART_LOADING_RECOMMEND", resourceType = "LOADING_PLAN", + 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()); + 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())) { + 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(), + count, plan.getVersion(), "REC-" + sha256(operation + '|' + candidate.inboundId() + + '|' + candidate.inboundDetailId())), operatorId, operatorName); + } + } + LoadingPlanEntity recommended = repository.plan(planId); + history(planId, null, "SMART_RECOMMEND", "EDITING", "EDITING", operation, + json(values("version", request.expectedVersion())), + json(values("requestHash", requestHash, "version", recommended.getVersion())), + operatorId, operatorName); + return view(recommended, requireBooking(plan.getContainerBookingId())); + } + + @Transactional + @BusinessAudit(event = "SMART_LOADING_ITEM_ADJUST", resourceType = "LOADING_PLAN_ITEM", + 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() + + '|' + 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()); + if (candidate == null || request.packageCount() > candidate.availablePackageCount()) 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()); + touch(plan, operatorId, operatorName); + item.setPackageCount(request.packageCount()); + item.setGrossWeightKg(unit(candidate.unitWeightKg()).multiply(BigDecimal.valueOf(request.packageCount()))); + item.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(request.packageCount()))); applyCargo(item, cargo, request.packageCount()); + item.setUpdatedBy(operatorId); item.setUpdatedByName(operatorName); + if (repository.update(item) != 1) throw conflict("装柜明细已被并发修改"); + history(planId, itemId, "ADJUST_ITEM", "EDITING", "EDITING", operation, + json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())), + json(values("requestHash", requestHash, "packageCount", request.packageCount())), + operatorId, operatorName); + loadingPickService.sync(planId, item); + return view(repository.plan(planId), requireBooking(plan.getContainerBookingId())); + } + + @Transactional + @BusinessAudit(event = "SMART_LOADING_ITEM_DELETE", resourceType = "LOADING_PLAN_ITEM", + 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()); + 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("装柜草稿已被并发修改"); + history(planId, itemId, "REMOVE_ITEM", "EDITING", "EDITING", operation, + json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())), + json(values("requestHash", requestHash, "deleted", true)), operatorId, operatorName); + return view(repository.plan(planId), requireBooking(plan.getContainerBookingId())); + } + + @Transactional + @BusinessAudit(event = "SMART_LOADING_PUSH_PRELOAD", resourceType = "LOADING_PLAN", + resourceId = "#planId", description = "推送智能装柜计划到预装清单") + public Plan pushPreload(long planId, String idempotencyKey, PushRequest request, long operatorId, String operatorName) { + String key = trim(idempotencyKey); + if (!StringUtils.hasText(key) || key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "必须提供有效 Idempotency-Key"); + LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在"); + if ("LOADING".equals(plan.getPlanStatus()) && key.equals(plan.getPushIdempotencyKey())) return view(plan, requireBooking(plan.getContainerBookingId())); + if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配"); + ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); + if (!StringUtils.hasText(booking.getContainerNo())) throw validation("开始装柜前必须填写集装箱号"); + if (repository.items(planId).isEmpty()) throw validation("装柜明细不能为空"); + loadingPickService.begin(planId, booking.getContainerNo(), operatorId, operatorName, repository.items(planId)); + try { + if (repository.push(planId, key, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("装柜计划已被并发修改"); + } catch (DuplicateKeyException e) { throw conflict("幂等键已用于其他推送"); } + history(planId, null, "START_LOADING", "EDITING", "LOADING", key, operatorId, operatorName); + LoadingPlanEntity pushed = repository.plan(planId); + return view(pushed, requireBooking(pushed.getContainerBookingId())); + } + + private LoadingPlanItemEntity snapshot(long planId, Candidate c, OrderCargoClient.CargoBinding cargo, int count, long op, String name, int seq) { + LoadingPlanItemEntity i = new LoadingPlanItemEntity(); i.setLoadingPlanId(planId); i.setAccountId(c.accountId()); i.setCustomerName(c.customerName()); + i.setInboundId(c.inboundId()); i.setInboundDetailId(c.inboundDetailId()); i.setOrderId(c.orderId()); i.setOrderNo(c.orderNo()); i.setInboundCode(c.inboundCode()); + i.setDestinationWarehouseId(c.destinationWarehouseId()); i.setDestinationWarehouseCode(c.destinationWarehouseCode()); + i.setDestinationWarehouseName(c.destinationWarehouseName()); i.setProductNameZh(cargo.cargoName()); i.setPackageCount(count); + i.setGrossWeightKg(unit(c.unitWeightKg()).multiply(BigDecimal.valueOf(count))); i.setVolumeCbm(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count))); + applyCargo(i, cargo, count); i.setLoadSequence(seq); i.setDeleted(false); i.setCreatedBy(op); i.setCreatedByName(name); i.setUpdatedBy(op); i.setUpdatedByName(name); i.setVersion(0); return i; + } + private void applyCargo(LoadingPlanItemEntity item, OrderCargoClient.CargoBinding cargo, int boxes) { + item.setSkuCode(cargo.skuNo()); + item.setProductNameEn(cargo.cargoNameEn()); + item.setUnitsPerCarton(cargo.unitsPerPackage()); + item.setInternalRemark(cargo.cargoRemark()); + int original = cargo.originalPackageCount() == null || cargo.originalPackageCount() <= 0 + ? boxes : cargo.originalPackageCount(); + if (cargo.unitsPerPackage() != null) { + item.setProductQuantity(Math.multiplyExact(cargo.unitsPerPackage(), boxes)); + } else if (cargo.totalQuantity() != null) { + item.setProductQuantity(BigDecimal.valueOf(cargo.totalQuantity()) + .multiply(BigDecimal.valueOf(boxes)) + .divide(BigDecimal.valueOf(original), 0, RoundingMode.HALF_UP).intValueExact()); + } + if (cargo.netWeightKg() != null) { + item.setNetWeightKg(cargo.netWeightKg().multiply(BigDecimal.valueOf(boxes)) + .divide(BigDecimal.valueOf(original), 6, RoundingMode.HALF_UP)); + } + } + + private OrderCargoClient.CargoBinding loadCargo(Candidate candidate) { + try { + ApiResponse response = orderCargoClient.detail(internalToken, + candidate.accountId(), candidate.orderId(), candidate.inboundDetailId()); + if (response == null) { + throw unavailable(); + } + if (!response.successful()) { + throw new BizException(ResultCode.of(response.code()), response.message()); + } + OrderCargoClient.CargoBinding cargo = response.data(); + if (cargo == null || !Objects.equals(cargo.cargoId(), candidate.inboundDetailId()) + || !Objects.equals(cargo.orderId(), candidate.orderId())) { + throw conflict("订单货物明细返回与入库单不匹配"); + } + return cargo; + } catch (BizException exception) { + throw exception; + } catch (FeignException exception) { + throw unavailable(); + } catch (RuntimeException exception) { + throw unavailable(); + } + } + private void checkRouteAndCapacity(ContainerBookingEntity b,List lines,Candidate c,int count){ + if (!Objects.equals(b.getWarehouseId(), c.warehouseId())) throw validation("装柜仓库不一致"); + if (StringUtils.hasText(b.getDestinationWarehouseCode()) && !Objects.equals(b.getDestinationWarehouseCode(), c.destinationWarehouseCode())) throw validation("目的仓/港不一致"); + BigDecimal v=lines.stream().map(x->unit(x.getVolumeCbm())).reduce(BigDecimal.ZERO,BigDecimal::add).add(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count))); + BigDecimal w=lines.stream().map(x->unit(x.getGrossWeightKg())).reduce(BigDecimal.ZERO,BigDecimal::add).add(unit(c.unitWeightKg()).multiply(BigDecimal.valueOf(count))); + if (b.getCapacityCbm()==null || b.getMaxWeightKg()==null) throw validation("货柜容量和最大承重未配置"); + if (v.compareTo(b.getCapacityCbm())>0 || w.compareTo(b.getMaxWeightKg())>0) throw validation("超过货柜容积或重量上限"); + } + private int fittingBoxes(ContainerBookingEntity b,List lines,Candidate c){ + if(b.getCapacityCbm()==null||b.getMaxWeightKg()==null)return 0; + BigDecimal usedV=lines.stream().map(x->unit(x.getVolumeCbm())).reduce(BigDecimal.ZERO,BigDecimal::add); + BigDecimal usedW=lines.stream().map(x->unit(x.getGrossWeightKg())).reduce(BigDecimal.ZERO,BigDecimal::add); + BigDecimal leftV=b.getCapacityCbm().subtract(usedV),leftW=b.getMaxWeightKg().subtract(usedW); + int byV=unit(c.unitVolumeCbm()).signum()==0?c.availablePackageCount():leftV.max(BigDecimal.ZERO).divideToIntegralValue(c.unitVolumeCbm()).intValue(); + int byW=unit(c.unitWeightKg()).signum()==0?c.availablePackageCount():leftW.max(BigDecimal.ZERO).divideToIntegralValue(c.unitWeightKg()).intValue(); + return Math.max(0,Math.min(c.availablePackageCount(),Math.min(byV,byW))); + } + private Plan view(LoadingPlanEntity p,ContainerBookingEntity b){return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(),b.getContainerNo(),b.getVoyageNo(),p.getVersion(),repository.items(p.getId()));} + private Plan draftView(LoadingPlanEntity plan, ContainerBookingEntity booking) { + if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "该货柜尚未创建智能装柜草稿"); + if (!"EDITING".equals(plan.getPlanStatus())) throw conflict("装柜计划已推送预装,请前往预装清单查看"); + return view(plan, booking); + } + private LoadingPlanEntity editing(long id,int version){LoadingPlanEntity p=repository.plan(id);if(p==null)throw new BizException(ResultCode.NOT_FOUND,"装柜计划不存在");if(!Set.of("EDITING", "LOADING").contains(p.getPlanStatus())||!Objects.equals(p.getVersion(),version))throw conflict("计划已完成或版本冲突");return p;} + private ContainerBookingEntity requireBooking(long id){ContainerBookingEntity b=repository.booking(id);if(b==null)throw new BizException(ResultCode.NOT_FOUND,"货柜预订不存在");return b;} + private void touch(LoadingPlanEntity p,long op,String name){if(repository.touch(p.getId(),p.getVersion(),op,name)!=1)throw conflict("计划已被并发修改");} + private Plan replay(String operation, long planId, String action, Long itemId, String requestHash) { + LoadingPlanHistoryEntity history = repository.operation(operation); + if (history == null) return null; + if (!Objects.equals(history.getLoadingPlanId(), planId) + || !Objects.equals(history.getActionType(), action) + || itemId != null && !Objects.equals(history.getLoadingPlanItemId(), itemId) + || history.getAfterSnapshot() == null + || !history.getAfterSnapshot().contains("\"requestHash\":\"" + requestHash + "\"")) { + throw conflict("operationId 已用于不同请求"); + } + LoadingPlanEntity plan = repository.plan(planId); + if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在"); + return view(plan, requireBooking(plan.getContainerBookingId())); + } + + private void history(long planId, Long itemId, String action, String from, String to, + String operation, long operatorId, String operatorName) { + history(planId, itemId, action, from, to, operation, null, null, operatorId, operatorName); + } + + private void history(long planId, Long itemId, String action, String from, String to, + String operation, String before, String after, + long operatorId, String operatorName) { + LoadingPlanHistoryEntity history = new LoadingPlanHistoryEntity(); + history.setLoadingPlanId(planId); history.setLoadingPlanItemId(itemId); + history.setActionType(action); history.setFromStatus(from); history.setToStatus(to); + history.setOperationId(operation); history.setBeforeSnapshot(before); history.setAfterSnapshot(after); + history.setOperatorId(operatorId); history.setOperatorName(operatorName); + history.setCreatedAt(LocalDateTime.now()); repository.insert(history); + } + + private static String operation(String value) { + String normalized = trim(value); + if (!StringUtils.hasText(normalized)) throw new BizException(ResultCode.BAD_REQUEST, "必须提供 operationId"); + if (normalized.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "operationId 长度不能超过128"); + return normalized; + } + + private String json(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("无法生成智能装柜审计快照", exception); + } + } + + private static Map values(Object... values) { + Map result = new LinkedHashMap<>(); + for (int index = 0; index < values.length; index += 2) { + result.put((String) values[index], values[index + 1]); + } + return result; + } + + private static String sha256(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("JVM 不支持 SHA-256", impossible); + } + } + + private static BizException unavailable() { + return new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单货物服务暂不可用"); + } + + private static String trim(String x){return x==null?null:x.trim();} private static BigDecimal unit(BigDecimal x){return x==null?BigDecimal.ZERO:x.max(BigDecimal.ZERO);} + private static void requireAccount(long x){if(x<=0)throw new BizException(ResultCode.BAD_REQUEST,"accountId必须大于零");} private static BizException conflict(String x){return new BizException(ResultCode.CONFLICT,x);} private static BizException validation(String x){return new BizException(ResultCode.VALIDATION_FAILED,x);} private static BizException forbidden(){return new BizException(ResultCode.FORBIDDEN,"客户归属不匹配");} +} diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql new file mode 100644 index 000000000..f11c7df8f --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -0,0 +1,775 @@ +SET NAMES utf8mb4; + +CREATE DATABASE IF NOT EXISTS baiyue_firstleg + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_firstleg; + +CREATE TABLE IF NOT EXISTS fl_container_booking ( + id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)', + booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号', + container_no VARCHAR(64) NULL COMMENT '集装箱号;确认前允许修订', + cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '装柜仓库ID;平台公共资源', + warehouse_name VARCHAR(128) NOT NULL COMMENT '装柜仓库名称快照', + bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', + vessel_name VARCHAR(128) NULL COMMENT '船名快照', + voyage_no VARCHAR(64) NULL COMMENT '航次;确认前允许修订', + container_type VARCHAR(32) NOT NULL COMMENT '箱型,例如20GP、40GP、40HQ', + shipping_company_id BIGINT UNSIGNED NULL COMMENT '船司ID快照', + shipping_company_name VARCHAR(128) NULL COMMENT '船司名称快照', + departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照', + departure_port_name VARCHAR(128) NULL COMMENT '起运港名称快照', + destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照', + destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照', + destination_warehouse_code VARCHAR(64) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(128) NULL COMMENT '目的仓名称快照', + capacity_cbm DECIMAL(18,4) NULL COMMENT '货柜容量m3', + max_weight_kg DECIMAL(18,3) NULL COMMENT '货柜最大承重kg', + terminal_trucking_company VARCHAR(128) NULL COMMENT '码头拖车公司', + loading_at DATETIME(3) NULL COMMENT '计划或实际装柜时间', + estimated_sailing_at DATETIME(3) NULL COMMENT '预计开船时间', + actual_sailing_at DATETIME(3) NULL COMMENT '实际开船时间', + estimated_port_arrival_at DATETIME(3) NULL COMMENT '预计到港时间', + actual_port_arrival_at DATETIME(3) NULL COMMENT '实际到港时间', + estimated_warehouse_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间', + actual_warehouse_arrival_at DATETIME(3) NULL COMMENT '实际到仓时间', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', + updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) + COMMENT '更新时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_container_booking_no (booking_no), + UNIQUE KEY uk_container_booking_cabinet (cabinet_no), + KEY idx_container_booking_container (container_no), + KEY idx_container_booking_warehouse (warehouse_id, updated_at, id), + KEY idx_container_booking_voyage (voyage_no, updated_at), + CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0), + CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0) +) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划'; + +CREATE TABLE IF NOT EXISTS fl_loading_plan ( + id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID(雪花ID)', + plan_no VARCHAR(40) NOT NULL COMMENT '平台唯一装柜计划号', + container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', + plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING' + COMMENT '唯一装柜状态:EDITING、LOADING、COMPLETED', + push_idempotency_key VARCHAR(128) NULL COMMENT '推送预装幂等键', + preload_pushed_by BIGINT UNSIGNED NULL COMMENT '推送预装操作人ID', + preload_pushed_by_name VARCHAR(64) NULL COMMENT '推送预装操作人名称快照', + preload_pushed_at DATETIME(3) NULL COMMENT '开始装柜并进入LOADING的时间', + preload_completed_by BIGINT UNSIGNED NULL COMMENT '预装完成操作人ID', + preload_completed_by_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照', + preload_completed_at DATETIME(3) NULL COMMENT '预装完成时间', + confirmed_by BIGINT UNSIGNED NULL COMMENT '确认操作人ID', + confirmed_by_name VARCHAR(64) NULL COMMENT '确认操作人名称快照', + confirmed_at DATETIME(3) NULL COMMENT '确认时间', + remark VARCHAR(500) NULL COMMENT '平台装柜计划备注', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', + updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) + COMMENT '更新时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_plan_no (plan_no), + UNIQUE KEY uk_loading_plan_booking (container_booking_id), + UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key), + KEY idx_loading_plan_status (plan_status, updated_at, id), + CONSTRAINT fk_loading_plan_booking FOREIGN KEY (container_booking_id) + REFERENCES fl_container_booking (id), + CONSTRAINT chk_loading_plan_status + CHECK (plan_status IN ('EDITING', 'LOADING', 'COMPLETED')) +) ENGINE=InnoDB COMMENT='平台智能装柜计划与唯一预装状态源'; + +CREATE TABLE IF NOT EXISTS fl_loading_plan_item ( + id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划明细ID(雪花ID)', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID', + account_id BIGINT UNSIGNED NOT NULL COMMENT '客户账号ID;装柜业务唯一客户归属字段', + customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT 'Firstleg入库单ID', + inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物或入库货物明细ID,不建立跨库外键', + order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID快照,不建立跨库外键', + order_no VARCHAR(32) NOT NULL COMMENT '订单号快照', + inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', + destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '目的仓ID快照', + destination_warehouse_code VARCHAR(64) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(128) NULL COMMENT '目的仓名称快照', + sku_code VARCHAR(128) NULL COMMENT 'SKU编码快照', + product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照', + product_name_en VARCHAR(255) NULL COMMENT '英文品名快照', + product_image_file_id BIGINT UNSIGNED NULL COMMENT '产品图片File对象ID', + product_image_url VARCHAR(1000) NULL COMMENT '产品图片访问地址快照', + package_count INT UNSIGNED NOT NULL COMMENT '本次装柜箱数', + product_quantity INT UNSIGNED NULL COMMENT '产品总数量', + units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数', + pallet_count INT UNSIGNED NULL COMMENT '托盘数', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜毛重kg', + net_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜净重kg', + dimensional_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜材积重kg', + volume_cbm DECIMAL(18,4) NULL COMMENT '本次装柜体积m3', + fba_tracking_no VARCHAR(128) NULL COMMENT 'FBA追踪号快照', + shipment_no VARCHAR(128) NULL COMMENT '货件编号快照', + shipping_mark VARCHAR(500) NULL COMMENT '唛头快照', + load_sequence INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '装柜顺序', + position_snapshot JSON NULL COMMENT '智能装柜位置与摆放结果快照', + internal_remark VARCHAR(1000) NULL COMMENT '平台内部备注', + customer_remark VARCHAR(1000) NULL COMMENT '客户备注快照', + warehouse_remark VARCHAR(1000) NULL COMMENT '仓库备注快照', + frozen_at DATETIME(3) NULL COMMENT '装柜完成后的永久冻结时间', + frozen_by BIGINT UNSIGNED NULL COMMENT '冻结操作人ID', + removed_by BIGINT UNSIGNED NULL COMMENT '推送前逻辑删除操作人ID', + removed_by_name VARCHAR(64) NULL COMMENT '推送前逻辑删除操作人名称快照', + removed_at DATETIME(3) NULL COMMENT '推送前逻辑删除时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '推送前逻辑删除标记;冻结后不可修改', + active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS ( + CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END + ) STORED COMMENT '未删除明细唯一锁;删除后返回NULL释放分配资格', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', + updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) + COMMENT '更新时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_item_active_detail (account_id, active_inbound_detail_id), + KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id), + KEY idx_loading_item_account (account_id, created_at, id), + KEY idx_loading_item_inbound (account_id, inbound_id, deleted), + KEY idx_loading_item_order (account_id, order_id, deleted), + CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), + CONSTRAINT chk_loading_item_account CHECK (account_id > 0), + CONSTRAINT chk_loading_item_package CHECK (package_count > 0), + CONSTRAINT chk_loading_item_product_quantity CHECK (product_quantity IS NULL OR product_quantity > 0), + CONSTRAINT chk_loading_item_units CHECK (units_per_carton IS NULL OR units_per_carton > 0), + CONSTRAINT chk_loading_item_pallet CHECK (pallet_count IS NULL OR pallet_count >= 0), + CONSTRAINT chk_loading_item_weight CHECK ( + (gross_weight_kg IS NULL OR gross_weight_kg >= 0) + AND (net_weight_kg IS NULL OR net_weight_kg >= 0) + AND (dimensional_weight_kg IS NULL OR dimensional_weight_kg >= 0) + ), + CONSTRAINT chk_loading_item_volume CHECK (volume_cbm IS NULL OR volume_cbm >= 0), + CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1)) +) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层'; + +CREATE TABLE IF NOT EXISTS fl_container_document ( + id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)', + container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', + document_type VARCHAR(32) NOT NULL COMMENT 'CUSTOMS_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER', + file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键', + file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照', + file_url VARCHAR(1000) NULL COMMENT '文件访问地址快照', + uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID', + uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) + COMMENT '更新时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_container_document_type (container_booking_id, document_type), + KEY idx_container_document_file (file_id), + CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id) + REFERENCES fl_container_booking (id), + CONSTRAINT chk_container_document_type + CHECK (document_type IN ('CUSTOMS_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER')) +) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联'; + +CREATE TABLE IF NOT EXISTS fl_loading_plan_history ( + id BIGINT UNSIGNED NOT NULL COMMENT '装柜操作历史ID(雪花ID)', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID', + loading_plan_item_id BIGINT UNSIGNED NULL COMMENT '装柜计划明细ID', + operation_id VARCHAR(128) NOT NULL COMMENT '操作幂等ID', + action_type VARCHAR(32) NOT NULL + COMMENT 'CREATE_PLAN、ADD_ITEM、ADJUST_ITEM、REMOVE_ITEM、START_LOADING、COMPLETE_LOADING、PICK_ITEM', + from_status VARCHAR(24) NULL COMMENT '变更前状态', + to_status VARCHAR(24) NULL COMMENT '变更后状态', + before_snapshot JSON NULL COMMENT '变更前快照', + after_snapshot JSON NULL COMMENT '变更后快照', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + operation_reason VARCHAR(500) NULL COMMENT '操作原因', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_history_operation (operation_id), + KEY idx_loading_history_plan (loading_plan_id, created_at, id), + KEY idx_loading_history_item (loading_plan_item_id, created_at, id), + CONSTRAINT fk_loading_history_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), + CONSTRAINT fk_loading_history_item FOREIGN KEY (loading_plan_item_id) + REFERENCES fl_loading_plan_item (id) +) ENGINE=InnoDB COMMENT='智能装柜追加式不可变操作历史'; + +CREATE TABLE IF NOT EXISTS fl_loading_pick_task ( + id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID', + container_no VARCHAR(64) NOT NULL COMMENT '集装箱号快照', + task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING' COMMENT 'LOADING、COMPLETED', + started_by BIGINT UNSIGNED NOT NULL COMMENT '开始装柜人', + started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称', + started_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '开始时间', + completed_by BIGINT UNSIGNED NULL COMMENT '完成装柜人', + completed_by_name VARCHAR(64) NULL COMMENT '完成装柜人名称', + completed_at DATETIME(3) NULL COMMENT '完成时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id), + KEY idx_loading_pick_container (container_no, task_status, started_at), + CONSTRAINT fk_loading_pick_task_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), + CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING', 'COMPLETED')) +) ENGINE=InnoDB COMMENT='PDA装柜拣货任务'; + +CREATE TABLE IF NOT EXISTS fl_loading_pick_item ( + id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID', + pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID', + loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划明细ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '父入库单ID', + 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', + 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), + PRIMARY KEY (id), + UNIQUE KEY uk_loading_pick_item_plan_item (loading_plan_item_id), + KEY idx_loading_pick_item_task (pick_task_id, item_status, id), + CONSTRAINT fk_loading_pick_item_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT fk_loading_pick_item_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id), + CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0), + CONSTRAINT chk_loading_pick_item_count CHECK (picked_package_count <= planned_package_count), + CONSTRAINT chk_loading_pick_item_status CHECK (item_status IN ('PENDING', 'PICKING', 'PICKED')) +) ENGINE=InnoDB COMMENT='PDA装柜拣货明细'; + +CREATE TABLE IF NOT EXISTS fl_loading_pick_history ( + id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货操作历史ID', + pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID', + pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID', + operation_id VARCHAR(128) NOT NULL COMMENT '客户端幂等操作号', + request_hash CHAR(64) NOT NULL COMMENT '请求指纹', + picked_package_count INT UNSIGNED NOT 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), + PRIMARY KEY (id), + UNIQUE KEY uk_loading_pick_history_operation (operation_id), + KEY idx_loading_pick_history_item (pick_item_id, created_at), + CONSTRAINT fk_loading_pick_history_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id) +) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史'; + +DROP TRIGGER IF EXISTS trg_container_booking_before_update; +DELIMITER $$ +CREATE TRIGGER trg_container_booking_before_update +BEFORE UPDATE ON fl_container_booking +FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status + FROM fl_loading_plan WHERE container_booking_id = OLD.id; + IF current_plan_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改'; + END IF; + IF current_plan_status = 'LOADING' AND ( + NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) + OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) + OR NOT (OLD.warehouse_id <=> NEW.warehouse_id) + OR NOT (OLD.warehouse_name <=> NEW.warehouse_name) + OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no) + OR NOT (OLD.vessel_name <=> NEW.vessel_name) + OR NOT (OLD.container_type <=> NEW.container_type) + OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id) + OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name) + OR NOT (OLD.departure_port_code <=> NEW.departure_port_code) + OR NOT (OLD.departure_port_name <=> NEW.departure_port_name) + OR NOT (OLD.destination_port_code <=> NEW.destination_port_code) + OR NOT (OLD.destination_port_name <=> NEW.destination_port_name) + OR NOT (OLD.destination_warehouse_code <=> NEW.destination_warehouse_code) + OR NOT (OLD.destination_warehouse_name <=> NEW.destination_warehouse_name) + OR NOT (OLD.capacity_cbm <=> NEW.capacity_cbm) + OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg) + OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company) + OR NOT (OLD.loading_at <=> NEW.loading_at) + OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at) + OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) + OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at) + OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at) + OR NOT (OLD.estimated_warehouse_arrival_at <=> NEW.estimated_warehouse_arrival_at) + OR NOT (OLD.actual_warehouse_arrival_at <=> NEW.actual_warehouse_arrival_at) + OR NOT (OLD.created_by <=> NEW.created_by) + OR NOT (OLD.created_by_name <=> NEW.created_by_name) + OR NOT (OLD.created_at <=> NEW.created_at) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '装柜中仅允许修改集装箱号与航次'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_plan_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_plan_before_update +BEFORE UPDATE ON fl_loading_plan +FOR EACH ROW +BEGIN + IF OLD.plan_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成装柜计划全部只读'; + END IF; + IF NOT ( + (OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING')) + OR (OLD.plan_status = 'LOADING' + AND NEW.plan_status IN ('LOADING', 'COMPLETED')) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜状态只允许单向推进'; + END IF; + IF OLD.plan_status = 'LOADING' AND ( + NOT (OLD.id <=> NEW.id) OR NOT (OLD.plan_no <=> NEW.plan_no) + OR NOT (OLD.container_booking_id <=> NEW.container_booking_id) + OR NOT (OLD.push_idempotency_key <=> NEW.push_idempotency_key) + OR NOT (OLD.preload_pushed_by <=> NEW.preload_pushed_by) + OR NOT (OLD.preload_pushed_by_name <=> NEW.preload_pushed_by_name) + OR NOT (OLD.preload_pushed_at <=> NEW.preload_pushed_at) + OR NOT (OLD.remark <=> NEW.remark) + OR NOT (OLD.created_by <=> NEW.created_by) + OR NOT (OLD.created_by_name <=> NEW.created_by_name) + OR NOT (OLD.created_at <=> NEW.created_at) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '装柜中计划仅允许乐观锁触碰或完成装柜'; + END IF; + IF OLD.plan_status = NEW.plan_status + AND OLD.plan_status = 'LOADING' AND ( + NOT (OLD.preload_completed_by <=> NEW.preload_completed_by) + OR NOT (OLD.preload_completed_by_name <=> NEW.preload_completed_by_name) + OR NOT (OLD.preload_completed_at <=> NEW.preload_completed_at) + OR NOT (OLD.confirmed_by <=> NEW.confirmed_by) + OR NOT (OLD.confirmed_by_name <=> NEW.confirmed_by_name) + OR NOT (OLD.confirmed_at <=> NEW.confirmed_at) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同状态乐观锁触碰不得修改阶段审计字段'; + END IF; + IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN + IF NEW.push_idempotency_key IS NULL OR CHAR_LENGTH(TRIM(NEW.push_idempotency_key)) = 0 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '开始装柜必须提供幂等键'; + END IF; + SET NEW.preload_pushed_at = COALESCE(NEW.preload_pushed_at, CURRENT_TIMESTAMP(3)); + ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN + IF EXISTS (SELECT 1 FROM fl_loading_plan_item item + LEFT JOIN fl_loading_pick_item pick_item + ON pick_item.loading_plan_item_id = item.id + LEFT JOIN fl_loading_pick_task pick_task + ON pick_task.id = pick_item.pick_task_id + AND pick_task.loading_plan_id = OLD.id + WHERE item.loading_plan_id = OLD.id AND item.deleted = 0 + AND (pick_task.id IS NULL + OR pick_item.picked_package_count <> pick_item.planned_package_count)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仍有未完成拣货明细,不能完成装柜'; + END IF; + SET NEW.confirmed_at = COALESCE(NEW.confirmed_at, CURRENT_TIMESTAMP(3)); + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_plan_after_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_plan_after_update +AFTER UPDATE ON fl_loading_plan +FOR EACH ROW +BEGIN + IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN + UPDATE fl_loading_plan_item + SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3)), + frozen_by = COALESCE(frozen_by, NEW.updated_by), + updated_by = NEW.updated_by, + updated_by_name = NEW.updated_by_name, + version = version + 1 + WHERE loading_plan_id = NEW.id AND deleted = 0; + UPDATE fl_loading_pick_task + SET task_status = 'COMPLETED', completed_by = NEW.updated_by, + completed_by_name = NEW.updated_by_name, completed_at = CURRENT_TIMESTAMP(3), + version = version + 1 + WHERE loading_plan_id = NEW.id AND task_status = 'LOADING'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_item_before_insert; +DELIMITER $$ +CREATE TRIGGER trg_loading_item_before_insert +BEFORE INSERT ON fl_loading_plan_item +FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE id = NEW.loading_plan_id; + IF current_plan_status IS NULL OR current_plan_status NOT IN ('EDITING', 'LOADING') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仅编辑中或装柜中的计划允许新增明细'; + END IF; + IF NEW.deleted <> 0 OR NEW.frozen_at IS NOT NULL OR NEW.frozen_by IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '新增装柜明细必须为未删除且未冻结状态'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_item_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_item_before_update +BEFORE UPDATE ON fl_loading_plan_item +FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status + FROM fl_loading_plan WHERE id = OLD.loading_plan_id; + 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拣货记录的装柜明细不可修改或删除'; + END IF; + IF OLD.frozen_at IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结'; + END IF; + IF OLD.deleted = 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已删除装柜明细不可恢复或修改'; + END IF; + IF NEW.loading_plan_id <> OLD.loading_plan_id OR NEW.account_id <> OLD.account_id + OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜明细归属键不可修改,请删除后重新分配'; + END IF; + IF NEW.deleted = 1 THEN + IF NEW.removed_by IS NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除装柜明细必须记录操作人'; + END IF; + SET NEW.removed_at = COALESCE(NEW.removed_at, CURRENT_TIMESTAMP(3)); + ELSEIF NEW.removed_by IS NOT NULL OR NEW.removed_by_name IS NOT NULL OR NEW.removed_at IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除审计字段只能在逻辑删除时写入'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_pick_task_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_task_before_update +BEFORE UPDATE ON fl_loading_pick_task +FOR EACH ROW +BEGIN + IF OLD.task_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成PDA拣货任务不可修改'; + END IF; + IF NEW.loading_plan_id <> OLD.loading_plan_id OR NOT (NEW.container_no <=> OLD.container_no) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务归属不可修改'; + END IF; + IF NOT (OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING', 'COMPLETED')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态只允许完成'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_pick_item_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_item_before_update +BEFORE UPDATE ON fl_loading_pick_item +FOR EACH ROW +BEGIN + DECLARE current_task_status VARCHAR(16); + SELECT MAX(task_status) INTO current_task_status + FROM fl_loading_pick_task WHERE id = OLD.pick_task_id; + IF current_task_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后PDA拣货明细不可修改'; + END IF; + IF NEW.pick_task_id <> OLD.pick_task_id + OR NEW.loading_plan_item_id <> OLD.loading_plan_item_id + OR NEW.inbound_id <> OLD.inbound_id + OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货明细归属不可修改'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_pick_item_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_item_before_delete +BEFORE DELETE ON fl_loading_pick_item +FOR EACH ROW +BEGIN + DECLARE current_task_status VARCHAR(16); + SELECT MAX(task_status) INTO current_task_status + FROM fl_loading_pick_task WHERE id = OLD.pick_task_id; + IF current_task_status = 'COMPLETED' OR OLD.picked_package_count > 0 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已拣货或已完成的PDA明细不可删除'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_pick_history_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_history_before_update +BEFORE UPDATE ON fl_loading_pick_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货历史不可修改'; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_pick_history_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_history_before_delete +BEFORE DELETE ON fl_loading_pick_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货历史不可删除'; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_item_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_loading_item_before_delete +BEFORE DELETE ON fl_loading_plan_item +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜明细只允许推送前逻辑删除'; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_container_document_before_insert; +DELIMITER $$ +CREATE TRIGGER trg_container_document_before_insert +BEFORE INSERT ON fl_container_document +FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan + WHERE container_booking_id = NEW.container_booking_id; + IF current_plan_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_container_document_before_update; +DELIMITER $$ +CREATE TRIGGER trg_container_document_before_update +BEFORE UPDATE ON fl_container_document +FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan + WHERE container_booking_id = OLD.container_booking_id; + IF current_plan_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_container_document_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_container_document_before_delete +BEFORE DELETE ON fl_container_document +FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan + WHERE container_booking_id = OLD.container_booking_id; + IF current_plan_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可删除'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_history_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_history_before_update +BEFORE UPDATE ON fl_loading_plan_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜操作历史不可修改'; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_history_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_loading_history_before_delete +BEFORE DELETE ON fl_loading_plan_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜操作历史不可删除'; +END$$ +DELIMITER ; + +-- 三期智能装柜、预装清单和装柜清单平台权限;与一期平台根菜单幂等合并。 +USE baiyue_iam; +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, built_in, deleted) +VALUES + (5102090, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:container:list', + '货柜', '/first-leg/containers', 'GET', 40, 'ACTIVE', 1, 0), + (5102091, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:view', + '查看货柜', NULL, 'GET', 10, 'ACTIVE', 1, 0), + (5102092, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:create', + '建立货柜', NULL, 'POST', 20, 'ACTIVE', 1, 0), + (5102093, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:edit', + '编辑货柜', NULL, 'PUT', 30, 'ACTIVE', 1, 0), + (5102094, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:smart-loading:list', + '智能装柜', '/first-leg/smart-loading', 'GET', 50, 'ACTIVE', 1, 0), + (5102096, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:create', + '创建智能装柜计划', NULL, 'POST', 20, 'ACTIVE', 1, 0), + (5102097, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:assign', + '分配装柜明细', NULL, 'POST', 30, 'ACTIVE', 1, 0), + (5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete', + '删除装柜明细', NULL, 'DELETE', 40, 'ACTIVE', 1, 0), + (5102099, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:push', + '推送预装', NULL, 'POST', 50, 'ACTIVE', 1, 0), + (5102100, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:preload:list', + '预装清单', '/first-leg/preload-list', 'GET', 60, 'ACTIVE', 1, 0), + (5102102, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:view', + '查看预装详情', NULL, 'GET', 20, 'ACTIVE', 1, 0), + (5102103, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:edit', + '编辑预装运输字段', NULL, 'PUT', 30, 'ACTIVE', 1, 0), + (5102104, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:complete', + '完成预装', NULL, 'POST', 40, 'ACTIVE', 1, 0), + (5102105, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:confirm', + '确认预装', NULL, 'POST', 50, 'ACTIVE', 1, 0), + (5102106, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:loading-list:list', + '装柜清单', '/first-leg/loading-list', 'GET', 70, 'ACTIVE', 1, 0), + (5102108, 0, 'PLATFORM', 'BUTTON', 5102106, 'platform:firstleg:loading-list:view', + '查看装柜详情', NULL, 'GET', 20, 'ACTIVE', 1, 0) + ON DUPLICATE KEY UPDATE + permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), + permission_name = VALUES(permission_name), route_path = VALUES(route_path), + http_method = VALUES(http_method), sort_no = VALUES(sort_no), status = 'ACTIVE', + built_in = 1, deleted = 0; + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT role.id, permission.id, role.tenant_id +FROM iam_role role + JOIN iam_permission permission + ON 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') +WHERE role.tenant_id = 0 + AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR'); + +USE baiyue_firstleg; + +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_active_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; diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index d237a107b..a0caea8f3 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -67,6 +67,90 @@ paths: responses: '200': {description: 成功或业务失败,按响应体 code 判断} '403': {description: 无分配权限} + /api/platform/v1/containers: + get: + summary: 分页查询平台货柜 + description: 需要 platform:firstleg:container:list 权限;货柜为平台级数据,不传 tenant/account + parameters: + - {$ref: '#/components/parameters/Page'} + - {$ref: '#/components/parameters/Size'} + - {name: keyword, in: query, schema: {type: string, maxLength: 128}} + - {name: containerNo, in: query, schema: {type: string, maxLength: 64}} + - {name: cabinetNo, in: query, schema: {type: string, maxLength: 64}} + - {name: warehouseId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: status, in: query, schema: {type: string, enum: [EDITING, LOADING, COMPLETED]}} + responses: + '200': + description: 成功或业务失败,按响应体 code 判断 + content: + application/json: + schema: {$ref: '#/components/schemas/ContainerBookingPageEnvelope'} + '403': {description: 无货柜查询权限} + post: + summary: 新增平台货柜 + description: 需要 platform:firstleg:container:create 权限;新增后聚合状态为 EDITING + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ContainerSaveRequest'} + responses: + '200': {description: 成功或重复号、参数校验等业务失败} + '403': {description: 无新增按钮权限} + /api/platform/v1/containers/{id}: + get: + summary: 查询平台货柜详情 + description: 需要 platform:firstleg:container:view 权限 + parameters: + - {$ref: '#/components/parameters/ContainerBookingId'} + responses: + '200': {description: 成功或货柜不存在} + '403': {description: 无货柜查询权限} + put: + summary: 草稿阶段完整编辑货柜 + description: 需要 platform:firstleg:container:edit 权限;仅无装柜计划或计划为 EDITING 时允许 + parameters: + - {$ref: '#/components/parameters/ContainerBookingId'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ContainerFullUpdateRequest'} + responses: + '200': {description: 成功或状态、版本、重复号冲突} + '403': {description: 无货柜编辑权限} + /api/platform/v1/containers/{id}/transport: + patch: + summary: 修改集装箱号和航次 + description: 需要 platform:firstleg:container:edit 权限;LOADING 仅开放此白名单,COMPLETED 全部只读 + parameters: + - {$ref: '#/components/parameters/ContainerBookingId'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ContainerTransportUpdateRequest'} + responses: + '200': {description: 成功或状态、版本、重复号冲突} + '403': {description: 无货柜编辑权限} + /api/platform/v1/first-leg/smart-loading/plans/by-booking/{bookingId}: + get: + summary: 按货柜恢复智能装柜草稿 + description: 需要 platform:firstleg:smart-loading:list 权限;仅返回 EDITING 草稿 + parameters: + - {name: bookingId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': {description: 成功、草稿不存在或计划已进入预装流程} + '403': {description: 无智能装柜查询权限} + /api/platform/v1/first-leg/smart-loading/plans/{planId}: + get: + summary: 查询智能装柜草稿详情 + description: 需要 platform:firstleg:smart-loading:list 权限;返回草稿及当前未删除明细 + parameters: + - {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': {description: 成功、计划不存在或计划已进入预装流程} + '403': {description: 无智能装柜查询权限} /openapi/v1/inbound-measurements: post: summary: 提交一条第三方箱规测量结果和一张图片 @@ -258,7 +342,103 @@ components: name: size in: query schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20} + ContainerBookingId: + name: id + in: path + required: true + schema: {type: string, pattern: '^[1-9][0-9]*$'} schemas: + ContainerBookingPageEnvelope: + type: object + properties: + code: {type: string, example: '200'} + message: {type: string} + data: + type: object + properties: + records: {type: array, items: {$ref: '#/components/schemas/ContainerBooking'}} + page: {type: string, pattern: '^[1-9][0-9]*$'} + size: {type: string, pattern: '^[1-9][0-9]*$'} + total: {type: string, pattern: '^[0-9]+$'} + pages: {type: string, pattern: '^[0-9]+$'} + ContainerBooking: + type: object + required: [id, bookingNo, cabinetNo, warehouseId, warehouseName, containerType, planStatus, version] + properties: + id: {type: string, pattern: '^[1-9][0-9]*$'} + planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空} + bookingNo: {type: string, maxLength: 40} + containerNo: {type: string, maxLength: 64, nullable: true} + cabinetNo: {type: string, maxLength: 64} + warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} + warehouseName: {type: string, maxLength: 128} + billOfLadingNo: {type: string, maxLength: 128, nullable: true} + vesselName: {type: string, maxLength: 128, nullable: true} + voyageNo: {type: string, maxLength: 64, nullable: true} + containerType: {type: string, maxLength: 32} + shippingCompanyId: {type: string, nullable: true} + shippingCompanyName: {type: string, maxLength: 128, nullable: true} + departurePortCode: {type: string, maxLength: 32, nullable: true} + departurePortName: {type: string, maxLength: 128, nullable: true} + destinationPortCode: {type: string, maxLength: 32, nullable: true} + destinationPortName: {type: string, maxLength: 128, nullable: true} + destinationWarehouseCode: {type: string, maxLength: 64, nullable: true} + destinationWarehouseName: {type: string, maxLength: 128, nullable: true} + capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true} + maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true} + terminalTruckingCompany: {type: string, maxLength: 128, nullable: true} + loadingAt: {type: string, format: date-time, nullable: true} + estimatedSailingAt: {type: string, format: date-time, nullable: true} + actualSailingAt: {type: string, format: date-time, nullable: true} + estimatedPortArrivalAt: {type: string, format: date-time, nullable: true} + actualPortArrivalAt: {type: string, format: date-time, nullable: true} + estimatedWarehouseArrivalAt: {type: string, format: date-time, nullable: true} + actualWarehouseArrivalAt: {type: string, format: date-time, nullable: true} + planStatus: {type: string, enum: [EDITING, LOADING, COMPLETED]} + version: {type: integer, minimum: 0} + ContainerSaveRequest: + type: object + required: [cabinetNo, warehouseId, warehouseName, containerType] + properties: + containerNo: {type: string, maxLength: 64, nullable: true} + cabinetNo: {type: string, maxLength: 64} + warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} + warehouseName: {type: string, maxLength: 128} + billOfLadingNo: {type: string, maxLength: 128, nullable: true} + vesselName: {type: string, maxLength: 128, nullable: true} + voyageNo: {type: string, maxLength: 64, nullable: true} + containerType: {type: string, maxLength: 32} + shippingCompanyId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + shippingCompanyName: {type: string, maxLength: 128, nullable: true} + departurePortCode: {type: string, maxLength: 32, nullable: true} + departurePortName: {type: string, maxLength: 128, nullable: true} + destinationPortCode: {type: string, maxLength: 32, nullable: true} + destinationPortName: {type: string, maxLength: 128, nullable: true} + destinationWarehouseCode: {type: string, maxLength: 64, nullable: true} + destinationWarehouseName: {type: string, maxLength: 128, nullable: true} + capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true} + maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true} + terminalTruckingCompany: {type: string, maxLength: 128, nullable: true} + loadingAt: {type: string, format: date-time, nullable: true} + estimatedSailingAt: {type: string, format: date-time, nullable: true} + actualSailingAt: {type: string, format: date-time, nullable: true} + estimatedPortArrivalAt: {type: string, format: date-time, nullable: true} + actualPortArrivalAt: {type: string, format: date-time, nullable: true} + estimatedWarehouseArrivalAt: {type: string, format: date-time, nullable: true} + actualWarehouseArrivalAt: {type: string, format: date-time, nullable: true} + ContainerFullUpdateRequest: + type: object + required: [version, data] + properties: + version: {type: integer, minimum: 0} + data: {$ref: '#/components/schemas/ContainerSaveRequest'} + ContainerTransportUpdateRequest: + type: object + required: [version] + properties: + containerNo: {type: string, maxLength: 64, nullable: true} + voyageNo: {type: string, maxLength: 64, nullable: true} + version: {type: integer, minimum: 0} MerchantInboundBillingEnvelope: type: object properties: diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml new file mode 100644 index 000000000..2b8423152 --- /dev/null +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -0,0 +1,340 @@ +openapi: 3.0.3 +info: + title: 头程装柜与完成装柜清单接口 + version: 1.0.0 + description: 装柜计划仅允许 EDITING 到 LOADING 再到 COMPLETED 单向推进;EDITING 和 LOADING 均可维护未拣货明细,COMPLETED 后计划、明细与货柜运输信息永久只读,不提供回退或撤回接口。 +paths: + /api/platform/v1/first-leg/loading-plans: + get: + summary: 分页查询预装清单 + description: 仅返回预装柜中或预装柜完成的计划,计划为平台作业数据,客户账号仅作为明细筛选条件。 + x-permission: platform:firstleg:preload:list + parameters: + - {$ref: '#/components/parameters/Page'} + - {$ref: '#/components/parameters/Size'} + - {$ref: '#/components/parameters/Keyword'} + - name: status + in: query + description: 预装状态 + schema: {type: string, enum: [LOADING]} + - {$ref: '#/components/parameters/AccountId'} + - {$ref: '#/components/parameters/WarehouseId'} + - {$ref: '#/components/parameters/CreatedFrom'} + - {$ref: '#/components/parameters/CreatedTo'} + responses: + '200': {$ref: '#/components/responses/PreloadPageResponse'} + /api/platform/v1/first-leg/loading-plans/{planId}: + get: + summary: 查询预装清单详情 + description: 返回同一装柜计划的汇总、冻结明细和完整操作历史。 + x-permission: platform:firstleg:preload:view + parameters: [{$ref: '#/components/parameters/PlanId'}] + responses: + '200': {$ref: '#/components/responses/PreloadDetailResponse'} + /api/platform/v1/first-leg/loading-plans/{planId}/transport: + patch: + summary: 修改预装运输信息 + description: 仅 LOADING 状态允许修改集装箱号与航次;装柜中可以维护未拣货明细。 + x-permission: platform:firstleg:preload:edit + parameters: + - {$ref: '#/components/parameters/PlanId'} + - {$ref: '#/components/parameters/IdempotencyKey'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/TransportUpdateRequest'} + responses: + '200': {$ref: '#/components/responses/PreloadDetailResponse'} + /api/platform/v1/first-leg/loading-plans/{planId}/complete: + post: + summary: 完成预装 + description: 仅允许将 LOADING 单向推进为 COMPLETED;完成前必须完成全部 PDA 拣货。 + x-permission: platform:firstleg:preload:complete + parameters: + - {$ref: '#/components/parameters/PlanId'} + - {$ref: '#/components/parameters/IdempotencyKey'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/AdvanceRequest'} + responses: + '200': {$ref: '#/components/responses/PreloadDetailResponse'} + /api/platform/v1/first-leg/loading-lists: + get: + summary: 分页查询装柜清单 + 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'} + responses: + '200': {$ref: '#/components/responses/ConfirmedPageResponse'} + /api/platform/v1/first-leg/loading-lists/{planId}: + get: + summary: 查询装柜清单详情 + description: 返回已确认计划的汇总、冻结明细和操作历史,全部只读。 + x-permission: platform:firstleg:loading-list:view + parameters: [{$ref: '#/components/parameters/PlanId'}] + responses: + '200': {$ref: '#/components/responses/ConfirmedDetailResponse'} +components: + parameters: + PlanId: + name: planId + in: path + required: true + description: 装柜计划 ID + schema: {$ref: '#/components/schemas/LongId'} + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + description: 写操作幂等键,同一业务操作重试时必须保持不变 + schema: {type: string, minLength: 1, maxLength: 128} + Page: + name: page + in: query + description: 页码,从 1 开始 + schema: {type: integer, format: int64, minimum: 1, default: 1} + Size: + name: size + in: query + description: 每页条数 + schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20} + Keyword: + name: keyword + 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 + schema: {$ref: '#/components/schemas/LongId'} + CreatedFrom: + name: createdFrom + in: query + description: 创建时间起点,包含该时间 + schema: {type: string, format: date-time} + CreatedTo: + name: createdTo + in: query + description: 创建时间终点,包含该时间 + schema: {type: string, format: date-time} + responses: + PreloadPageResponse: + description: 预装清单分页查询成功或业务失败,按响应体 code 判断 + content: {application/json: {schema: {$ref: '#/components/schemas/PreloadPageEnvelope'}}} + PreloadDetailResponse: + description: 预装清单详情查询或操作成功,也可能返回业务失败 + content: {application/json: {schema: {$ref: '#/components/schemas/PreloadDetailEnvelope'}}} + ConfirmedPageResponse: + description: 装柜清单分页查询成功或业务失败,按响应体 code 判断 + content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmedPageEnvelope'}}} + ConfirmedDetailResponse: + description: 装柜清单详情查询或确认成功,也可能返回业务失败 + content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmedDetailEnvelope'}}} + schemas: + LongId: + type: string + pattern: '^[1-9][0-9]*$' + example: '1900000000000000001' + description: 长整型 ID,JSON 中使用字符串避免前端精度丢失 + NullableLongId: + allOf: [{$ref: '#/components/schemas/LongId'}] + nullable: true + ApiEnvelope: + type: object + required: [code, message, timestamp] + properties: + code: {type: string, description: 业务结果码,仅字符串 200 表示成功, example: '200'} + message: {type: string, description: 结果说明} + requestId: {type: string, nullable: true, description: 请求标识} + traceId: {type: string, nullable: true, description: 链路标识} + timestamp: {type: string, format: date-time, description: 服务端响应时间} + PreloadPageEnvelope: + allOf: + - {$ref: '#/components/schemas/ApiEnvelope'} + - type: object + properties: + data: + allOf: [{$ref: '#/components/schemas/PreloadPage'}] + nullable: true + PreloadDetailEnvelope: + allOf: + - {$ref: '#/components/schemas/ApiEnvelope'} + - type: object + properties: + data: + allOf: [{$ref: '#/components/schemas/PreloadDetail'}] + nullable: true + ConfirmedPageEnvelope: + allOf: + - {$ref: '#/components/schemas/ApiEnvelope'} + - type: object + properties: + data: + allOf: [{$ref: '#/components/schemas/ConfirmedPage'}] + nullable: true + ConfirmedDetailEnvelope: + allOf: + - {$ref: '#/components/schemas/ApiEnvelope'} + - type: object + properties: + data: + allOf: [{$ref: '#/components/schemas/ConfirmedDetail'}] + nullable: true + PreloadPage: + type: object + required: [records, page, size, total, pages] + properties: + records: {type: array, items: {$ref: '#/components/schemas/PreloadSummary'}} + page: {type: string, pattern: '^[1-9][0-9]*$', description: 当前页码} + size: {type: string, pattern: '^[1-9][0-9]*$', description: 每页条数} + total: {type: string, pattern: '^[0-9]+$', description: 总记录数} + pages: {type: string, pattern: '^[0-9]+$', description: 总页数} + ConfirmedPage: + type: object + required: [records, page, size, total, pages] + properties: + records: {type: array, items: {$ref: '#/components/schemas/ConfirmedSummary'}} + page: {type: string, pattern: '^[1-9][0-9]*$', description: 当前页码} + size: {type: string, pattern: '^[1-9][0-9]*$', description: 每页条数} + total: {type: string, pattern: '^[0-9]+$', description: 总记录数} + pages: {type: string, pattern: '^[0-9]+$', description: 总页数} + Customer: + type: object + required: [accountId, customerName] + properties: + accountId: {$ref: '#/components/schemas/LongId'} + customerName: {type: string, nullable: true, description: 客户名称} + PreloadSummary: + type: object + required: [planId, preloadNo, customers, status, skuCount, totalPackageCount, totalVolumeCbm, totalGrossWeightKg, createdAt, version] + properties: + planId: {$ref: '#/components/schemas/LongId'} + preloadNo: {type: string, description: 预装单号} + containerNo: {type: string, nullable: true, description: 集装箱号} + internalContainerNo: {type: string, nullable: true, description: 内部柜号} + customers: {type: array, description: 计划包含的客户, items: {$ref: '#/components/schemas/Customer'}} + warehouseId: {$ref: '#/components/schemas/NullableLongId'} + warehouseName: {type: string, nullable: true, description: 仓库名称} + vesselName: {type: string, nullable: true, description: 船名} + voyageNo: {type: string, nullable: true, description: 航次} + containerType: {type: string, nullable: true, description: 货柜类型} + status: {type: string, enum: [LOADING], description: 装柜状态} + skuCount: {type: string, pattern: '^[0-9]+$', description: SKU 种类数} + totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数} + totalVolumeCbm: {type: number, description: 总体积,单位立方米} + totalGrossWeightKg: {type: number, description: 总毛重,单位千克} + createdAt: {type: string, format: date-time, description: 创建时间} + version: {type: integer, format: int32, minimum: 0, description: 乐观锁版本} + ConfirmedSummary: + type: object + required: [planId, loadingListNo, status, totalPackageCount, totalGrossWeightKg, totalNetWeightKg, totalVolumeCbm, totalQuantity, createdAt, version] + properties: + planId: {$ref: '#/components/schemas/LongId'} + loadingListNo: {type: string, description: 装柜清单号} + warehouseId: {$ref: '#/components/schemas/NullableLongId'} + warehouseName: {type: string, nullable: true, description: 仓库名称} + containerNo: {type: string, nullable: true, description: 集装箱号} + internalContainerNo: {type: string, nullable: true, description: 内部柜号} + status: {type: string, enum: [COMPLETED], description: 装柜清单状态} + totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数} + totalGrossWeightKg: {type: number, description: 总毛重,单位千克} + totalNetWeightKg: {type: number, description: 总净重,单位千克} + totalVolumeCbm: {type: number, description: 总体积,单位立方米} + totalQuantity: {type: string, pattern: '^[0-9]+$', description: 产品总数量} + remark: {type: string, nullable: true, description: 备注} + createdBy: {$ref: '#/components/schemas/NullableLongId'} + createdByName: {type: string, nullable: true, description: 创建人姓名} + createdAt: {type: string, format: date-time, description: 创建时间} + version: {type: integer, format: int32, minimum: 0, description: 乐观锁版本} + LoadingItem: + type: object + required: [itemId, accountId, inboundId, inboundDetailId, packageCount, palletCount, grossWeightKg, netWeightKg, volumeCbm, dimensionalWeightKg, productQuantity, unitsPerPackage, loadSequence] + properties: + itemId: {$ref: '#/components/schemas/LongId'} + accountId: {$ref: '#/components/schemas/LongId'} + customerName: {type: string, nullable: true, description: 客户名称快照} + inboundId: {$ref: '#/components/schemas/LongId'} + inboundDetailId: {$ref: '#/components/schemas/LongId'} + inboundCode: {type: string, nullable: true, description: 入仓码} + skuCode: {type: string, nullable: true, description: SKU 编码快照} + destinationWarehouse: {type: string, nullable: true, description: 目的仓名称快照} + chineseName: {type: string, nullable: true, description: 中文品名快照} + englishName: {type: string, nullable: true, description: 英文品名快照} + goodsImageUrl: {type: string, nullable: true, description: 产品图片地址快照} + packageCount: {type: integer, format: int32, minimum: 0, description: 箱数} + palletCount: {type: integer, format: int32, minimum: 0, description: 托盘数} + grossWeightKg: {type: number, description: 毛重,单位千克} + netWeightKg: {type: number, description: 净重,单位千克} + volumeCbm: {type: number, description: 体积,单位立方米} + dimensionalWeightKg: {type: number, description: 材积重,单位千克} + productQuantity: {type: string, pattern: '^[0-9]+$', description: 产品数量} + unitsPerPackage: {type: integer, format: int32, minimum: 0, description: 每箱数量} + fbaTrackingNo: {type: string, nullable: true, description: FBA 跟踪编号} + shipmentNo: {type: string, nullable: true, description: 货件号} + shippingMark: {type: string, nullable: true, description: 唛头} + internalRemark: {type: string, nullable: true, description: 内部备注} + customerRemark: {type: string, nullable: true, description: 客户备注} + warehouseRemark: {type: string, nullable: true, description: 仓库备注} + loadSequence: {type: integer, format: int32, minimum: 0, description: 装柜顺序} + frozenAt: {type: string, format: date-time, nullable: true, description: 明细冻结时间} + OperationHistory: + type: object + required: [historyId, actionType, operatorId, occurredAt] + properties: + historyId: {$ref: '#/components/schemas/LongId'} + itemId: {$ref: '#/components/schemas/NullableLongId'} + actionType: {type: string, description: 操作类型} + fromStatus: {type: string, nullable: true, description: 操作前状态} + toStatus: {type: string, nullable: true, description: 操作后状态} + beforeSnapshot: {type: string, nullable: true, description: 操作前快照} + afterSnapshot: {type: string, nullable: true, description: 操作后快照} + operatorId: {$ref: '#/components/schemas/LongId'} + operatorName: {type: string, nullable: true, description: 操作人姓名} + operationReason: {type: string, nullable: true, description: 操作原因} + occurredAt: {type: string, format: date-time, description: 操作时间} + PreloadDetail: + type: object + required: [summary, items, histories] + properties: + summary: {$ref: '#/components/schemas/PreloadSummary'} + items: {type: array, description: 装柜中明细,可维护未拣货项, items: {$ref: '#/components/schemas/LoadingItem'}} + histories: {type: array, description: 计划操作历史, items: {$ref: '#/components/schemas/OperationHistory'}} + ConfirmedDetail: + type: object + required: [summary, items, histories] + properties: + summary: {$ref: '#/components/schemas/ConfirmedSummary'} + items: {type: array, description: 已完成且永久只读的装柜明细, items: {$ref: '#/components/schemas/LoadingItem'}} + histories: {type: array, description: 计划操作历史, items: {$ref: '#/components/schemas/OperationHistory'}} + TransportUpdateRequest: + type: object + required: [expectedVersion] + additionalProperties: false + properties: + containerNo: {type: string, maxLength: 64, nullable: true, description: 集装箱号} + voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次} + expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本} + operationReason: {type: string, maxLength: 500, nullable: true, description: 操作原因} + AdvanceRequest: + type: object + required: [expectedVersion] + additionalProperties: false + properties: + expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本} + operationReason: {type: string, maxLength: 500, nullable: true, description: 操作原因} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java new file mode 100644 index 000000000..b84db2745 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java @@ -0,0 +1,51 @@ +package com.baiyue.firstleg.controller; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.core.io.ClassPathResource; + +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("货柜基础资料接口契约") +class ContainerControllerContractTest { + @Test + @DisplayName("查询、新增和编辑按钮权限彼此独立") + void separatesButtonPermissions() { + assertPermission("page", "platform:firstleg:container:list"); + assertPermission("detail", "platform:firstleg:container:view"); + assertPermission("create", "platform:firstleg:container:create"); + assertPermission("updateDraft", "platform:firstleg:container:edit"); + assertPermission("updateTransport", "platform:firstleg:container:edit"); + } + + @Test + @DisplayName("OpenAPI声明平台级货柜路径和冻结规则") + void documentsContainerContract() throws Exception { + String openapi = new ClassPathResource("openapi/firstleg-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + String section = openapi.substring(openapi.indexOf(" /api/platform/v1/containers:"), + openapi.indexOf(" /openapi/v1/inbound-measurements:")); + + assertThat(section) + .contains("/api/platform/v1/containers/{id}/transport:") + .contains("platform:firstleg:container:list") + .contains("EDITING") + .contains("LOADING") + .contains("COMPLETED") + .doesNotContain("tenantId") + .doesNotContain("accountId"); + } + + private static void assertPermission(String methodName, String authority) { + Method method = Arrays.stream(PlatformContainerController.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(methodName)) + .findFirst().orElseThrow(); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('" + authority + "')"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java new file mode 100644 index 000000000..7cd2228db --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -0,0 +1,120 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; +import com.baiyue.firstleg.service.LoadingListService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; +import org.springframework.core.io.ClassPathResource; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("预装与装柜清单控制器契约") +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("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.class, + long.class, String.class, TransportUpdateRequest.class); + authority("complete", "platform:firstleg:preload:complete", AuthenticatedUser.class, + long.class, String.class, AdvanceRequest.class); + authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class, + ConfirmedQuery.class); + authority("confirmedDetail", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class); + } + + @Test + void writesAudited() throws Exception { + audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, String.class, + String.class, TransportUpdateRequest.class); + audit("complete", "PRELOAD_COMPLETE", long.class, long.class, String.class, + String.class, AdvanceRequest.class); + } + + @Test + void openApiComplete() throws Exception { + String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(yaml) + .contains("EDITING 到 LOADING 再到 COMPLETED") + .contains("Idempotency-Key") + .contains("expectedVersion") + .contains("createdFrom") + .contains("createdTo") + .contains("platform:firstleg:loading-list:view") + .contains("PreloadPageEnvelope") + .contains("ConfirmedPageEnvelope") + .contains("PreloadSummary") + .contains("ConfirmedSummary") + .contains("LoadingItem") + .contains("OperationHistory") + .contains("customers") + .contains("productQuantity") + .contains("beforeSnapshot") + .contains("containerNo") + .contains("voyageNo") + .doesNotContain("rollback") + .doesNotContain("delete:") + .doesNotContain("/items/{itemId}"); + } + + @Test + void yamlParses() throws Exception { + String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + Map document = stringMap(new Yaml().load(yaml)); + assertThat(document.get("openapi")).isEqualTo("3.0.3"); + assertThat((Map) document.get("paths")).hasSize(6); + Map components = stringMap(document.get("components")); + Map schemas = stringMap(components.get("schemas")); + assertThat(schemas).containsKeys( + "LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary", + "ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail", + "ConfirmedDetail", "TransportUpdateRequest", "AdvanceRequest"); + assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string"); + assertThat(properties(schemas, "PreloadPage")).containsKeys("records", "page", "size", "total", "pages"); + assertThat(properties(schemas, "PreloadSummary")).containsKeys("planId", "customers", "skuCount"); + assertThat(properties(schemas, "LoadingItem")).containsKeys( + "itemId", "accountId", "inboundId", "inboundDetailId", "skuCode", + "destinationWarehouse", "productQuantity", "frozenAt"); + assertThat(properties(schemas, "OperationHistory")).containsKeys( + "historyId", "itemId", "actionType", "fromStatus", "toStatus", + "beforeSnapshot", "afterSnapshot", "operatorId", "occurredAt"); + assertThat(properties(schemas, "TransportUpdateRequest")).containsOnlyKeys( + "containerNo", "voyageNo", "expectedVersion", "operationReason"); + } + + private static Map properties(Map schemas, String schemaName) { + return stringMap(stringMap(schemas.get(schemaName)).get("properties")); + } + + @SuppressWarnings("unchecked") + private static Map stringMap(Object value) { + return (Map) value; + } + + private static void authority(String name, String permission, Class... parameterTypes) throws Exception { + Method method = PlatformLoadingListController.class.getDeclaredMethod(name, parameterTypes); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('" + permission + "')"); + } + + private static void audit(String name, String event, Class... parameterTypes) throws Exception { + Method method = LoadingListService.class.getDeclaredMethod(name, parameterTypes); + BusinessAudit audit = method.getAnnotation(BusinessAudit.class); + assertThat(audit.event()).isEqualTo(event); + assertThat(audit.resourceType()).isEqualTo("LOADING_PLAN"); + assertThat(audit.resourceId()).isEqualTo("#planId"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java new file mode 100644 index 000000000..36d5b5f01 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java @@ -0,0 +1,23 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.dto.SmartLoadingDtos.*; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import java.lang.reflect.Method; +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformSmartLoadingControllerContractTest { + @Test void draftQueriesUseListPermission() throws Exception { + authority("draftByBooking","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class); + authority("detail","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class); + } + @Test void everyMutationDeclaresDedicatedPermission() throws Exception { + authority("assign","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignRequest.class); + authority("recommend","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,RecommendRequest.class); + authority("adjust","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,long.class,AdjustRequest.class); + authority("delete","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,DeleteRequest.class); + authority("push","platform:firstleg:smart-loading:push",AuthenticatedUser.class,long.class,String.class,PushRequest.class); + } + private void authority(String method,String permission,Class...types)throws Exception{Method m=PlatformSmartLoadingController.class.getDeclaredMethod(method,types);assertThat(m.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('"+permission+"')");} +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java new file mode 100644 index 000000000..edf2bbbf9 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java @@ -0,0 +1,46 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.mapper.ContainerBookingMapper; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("货柜数据库映射契约") +class ContainerBookingMapperContractTest { + @Test + @DisplayName("货柜映射平台级最终表且状态只从装柜计划读取") + void mapsFinalBookingContract() throws Exception { + assertThat(ContainerBookingEntity.class.getAnnotation(TableName.class).value()) + .isEqualTo("fl_container_booking"); + String select = String.join("\n", ContainerBookingMapper.class + .getDeclaredMethod("selectBooking", long.class).getAnnotation(Select.class).value()); + assertThat(select) + .contains("fl_loading_plan") + .contains("p.id AS plan_id") + .contains("plan_status") + .doesNotContain("tenant_id") + .doesNotContain("account_id"); + } + + @Test + @DisplayName("草稿完整更新和预装运输白名单都带版本与状态门禁") + void guardsUpdatesInSql() { + String allSql = Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods()) + .map(method -> method.getAnnotation(Update.class)) + .filter(annotation -> annotation != null) + .flatMap(annotation -> Arrays.stream(annotation.value())) + .reduce("", (left, right) -> left + "\n" + right); + assertThat(allSql) + .contains("capacity_cbm") + .contains("p.plan_status<>'EDITING'") + .contains("p.plan_status='COMPLETED'") + .contains("version=#{expectedVersion}"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java new file mode 100644 index 000000000..a4ee03ac2 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -0,0 +1,89 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.LoadingListQueryMapper; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("清单持久化SQL契约") +class LoadingListQueryMapperContractTest { + @Test + void annotationScriptsShouldParseAsMyBatisStatements() { + Configuration configuration = new Configuration(); + configuration.addMapper(LoadingListQueryMapper.class); + + assertThat(configuration.hasStatement( + LoadingListQueryMapper.class.getName() + ".selectPreloadPage")).isTrue(); + assertThat(configuration.hasStatement( + LoadingListQueryMapper.class.getName() + ".advance")).isTrue(); + } + + @Test + void bothListsShouldProjectTheSameLoadingPlanAggregate() throws Exception { + String preload = sql("selectPreloadPage", Select.class); + String confirmed = sql("selectConfirmedPage", Select.class); + + assertThat(preload) + .contains("FROM fl_loading_plan p") + .contains("JOIN fl_container_booking b") + .contains("LEFT JOIN fl_loading_plan_item i") + .contains("p.plan_status = 'LOADING'") + .doesNotContain("PRELOAD_PUSHED"); + assertThat(confirmed) + .contains("FROM fl_loading_plan p") + .contains("p.plan_status = 'COMPLETED'") + .doesNotContain("fl_loading_list"); + } + + @Test + void mutationsShouldUseStatusAndOptimisticVersionConditions() throws Exception { + String touch = sql("touchEditable", Update.class); + String transport = sql("updateTransport", Update.class); + String advance = sql("advance", Update.class); + + assertThat(touch) + .contains("plan_status = 'LOADING'") + .contains("version = #{expectedVersion}") + .contains("version = version + 1"); + assertThat(transport) + .contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}") + .doesNotContain("DELETE") + .doesNotContain("fl_loading_plan_item"); + assertThat(advance) + .contains("plan_status = #{fromStatus}") + .contains("plan_status = #{toStatus}") + .contains("version = #{expectedVersion}") + .doesNotContain("ROLLED_BACK"); + } + + @Test + void everyWriteShouldAppendAnImmutableHistoryOperation() throws Exception { + String insert = sql("insertHistory", Insert.class); + assertThat(insert) + .contains("INSERT INTO fl_loading_plan_history") + .contains("operation_id") + .contains("before_snapshot") + .contains("after_snapshot") + .contains("operator_id"); + } + + private static String sql(String methodName, Class annotationType) throws Exception { + Method method = java.util.Arrays.stream(LoadingListQueryMapper.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(methodName)) + .findFirst().orElseThrow(); + if (annotationType == Select.class) { + return String.join("\n", method.getAnnotation(Select.class).value()); + } + if (annotationType == Update.class) { + return String.join("\n", method.getAnnotation(Update.class).value()); + } + return String.join("\n", method.getAnnotation(Insert.class).value()); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java new file mode 100644 index 000000000..4dcf91f3b --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -0,0 +1,37 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class SmartLoadingCandidateMapperContractTest { + @Test + void selectsLatestApprovedBatch() throws Exception { + Method list = SmartLoadingCandidateMapper.class.getDeclaredMethod("candidates", long.class); + String sql = String.join(" ", list.getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("o.destination_warehouse_id destinationWarehouseId") + .contains("m.status='APPROVED'") + .contains("SELECT MAX(m2.id)") + .contains("m2.status='APPROVED'") + .contains("i.inbound_detail_id IS NOT NULL") + .contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC"); + } + + @Test + void detailUsesSameBatchRule() throws Exception { + Method detail = SmartLoadingCandidateMapper.class.getDeclaredMethod( + "candidate", long.class, long.class, long.class); + String sql = String.join(" ", detail.getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("o.destination_warehouse_id destinationWarehouseId") + .contains("m.status='APPROVED'") + .contains("SELECT MAX(m2.id)"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java new file mode 100644 index 000000000..3d278e8e2 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -0,0 +1,176 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("智能装柜SQL契约") +class SmartLoadingSqlContractTest { + private static final Pattern PERMISSION_PATTERN = Pattern.compile("'((?:platform:firstleg:)[^']+)'"); + private static final Set PHASE3_PERMISSIONS = Set.of( + "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"); + + @Test + @DisplayName("平台聚合与客户明细边界及原型快照字段完整") + void shouldProvidePlatformAggregatesAndAccountScopedSnapshots() throws Exception { + assertThat(migration()) + .contains("CREATE TABLE IF NOT EXISTS fl_container_booking") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_plan") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_item") + .contains("CREATE TABLE IF NOT EXISTS fl_container_document") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_history") + .contains("account_id BIGINT UNSIGNED NOT NULL") + .contains("customer_name VARCHAR(128) NOT NULL") + .contains("warehouse_id BIGINT UNSIGNED NOT NULL") + .contains("cabinet_no VARCHAR(64) NOT NULL") + .contains("bill_of_lading_no VARCHAR(128)") + .contains("destination_warehouse_name VARCHAR(128)") + .contains("product_name_zh VARCHAR(255)") + .contains("product_name_en VARCHAR(255)") + .contains("product_image_file_id BIGINT UNSIGNED") + .contains("pallet_count INT UNSIGNED") + .contains("net_weight_kg DECIMAL(18,3)") + .contains("dimensional_weight_kg DECIMAL(18,3)") + .contains("product_quantity INT UNSIGNED") + .contains("units_per_carton INT UNSIGNED") + .contains("fba_tracking_no VARCHAR(128)") + .contains("shipment_no VARCHAR(128)") + .contains("shipping_mark VARCHAR(500)") + .contains("internal_remark VARCHAR(1000)") + .contains("customer_remark VARCHAR(1000)") + .contains("warehouse_remark VARCHAR(1000)"); + } + + @Test + @DisplayName("活动明细唯一锁、乐观锁和双层幂等字段完整") + void shouldProvideConcurrencyAndIdempotencyBoundaries() throws Exception { + assertThat(migration()) + .contains("active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS") + .contains("CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END") + .contains("UNIQUE KEY uk_loading_item_active_detail (account_id, active_inbound_detail_id)") + .contains("UNIQUE KEY uk_container_booking_no (booking_no)") + .contains("UNIQUE KEY uk_container_booking_cabinet (cabinet_no)") + .contains("UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key)") + .contains("UNIQUE KEY uk_loading_history_operation (operation_id)") + .contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'"); + } + + @Test + @DisplayName("平台菜单按钮权限使用统一前缀并授权运营角色") + void shouldProvidePlatformPermissions() throws Exception { + assertThat(migration()) + .contains("platform:firstleg:container:view") + .contains("platform:firstleg:smart-loading:push") + .contains("platform:firstleg:preload:confirm") + .contains("platform:firstleg:loading-list:view") + .contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')") + .contains("智能装柜迁移失败:平台权限资源未完整创建") + .contains("智能装柜迁移失败:平台角色授权未完整写入"); + } + + @Test + @DisplayName("模块脚本和统一脚本均校验16项权限与两个角色32项授权") + void shouldAssertCompletePermissionGrants() throws Exception { + assertPermissionContract(migration()); + assertPermissionContract(unifiedMigration()); + assertThat(unifiedMigration()).contains(migration().trim()); + } + + @Test + @DisplayName("计划状态只允许编辑中、装柜中和已完成单向推进") + void shouldEnforceOneWayStatusAndPermanentFreeze() throws Exception { + assertThat(migration()) + .contains("CHECK (plan_status IN ('EDITING', 'LOADING', 'COMPLETED'))") + .contains("OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING')") + .contains("NEW.plan_status IN ('LOADING', 'COMPLETED')") + .contains("SET MESSAGE_TEXT = '装柜状态只允许单向推进'") + .contains("IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN") + .contains("ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN") + .contains("LEFT JOIN fl_loading_pick_item pick_item") + .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("装柜完成后的明细永久冻结") + .contains("装柜中仅允许修改集装箱号与航次") + .contains("装柜完成后货柜全部只读,不允许修改") + .doesNotContain("preload_status") + .doesNotContain("PRELOAD_PUSHED") + .doesNotContain("PRELOADING") + .doesNotContain("PRELOAD_COMPLETED") + .doesNotContain("CONFIRMED") + .doesNotContain("ROLLED_BACK") + .doesNotContain("WITHDRAWN"); + } + + @Test + @DisplayName("PDA三表、唯一索引、向上取整和不可变审计契约完整") + void shouldProvidePdaContract() throws Exception { + assertThat(migration()) + .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_task") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_item") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_history") + .contains("UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id)") + .contains("UNIQUE KEY uk_loading_pick_item_plan_item (loading_plan_item_id)") + .contains("UNIQUE KEY uk_loading_pick_history_operation (operation_id)") + .contains("上层拆分按业务规则向上取整") + .contains("CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0)") + .contains("trg_loading_pick_task_before_update") + .contains("trg_loading_pick_item_before_update") + .contains("trg_loading_pick_item_before_delete") + .contains("trg_loading_pick_history_before_update") + .contains("trg_loading_pick_history_before_delete") + .contains("PDA唯一索引缺失") + .contains(")) <> 16 THEN"); + } + + private static String migration() throws Exception { + return new ClassPathResource("db/phase3-smart-loading-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + } + + private static String unifiedMigration() throws Exception { + Path working = Path.of("").toAbsolutePath(); + Path file = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(file)) { + file = working.resolve("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize(); + } + return Files.readString(file, StandardCharsets.UTF_8); + } + + private static void assertPermissionContract(String sql) { + int message = sql.indexOf("智能装柜迁移失败:平台角色授权未完整写入"); + assertThat(message).isGreaterThan(0); + + int grantStart = sql.lastIndexOf("INSERT IGNORE INTO iam_role_permission", message); + int grantEnd = sql.indexOf(';', grantStart); + assertThat(permissions(sql.substring(grantStart, grantEnd))).isEqualTo(PHASE3_PERMISSIONS); + + int assertionStart = sql.lastIndexOf("IF (SELECT COUNT(DISTINCT CONCAT", message); + String assertion = sql.substring(assertionStart, message); + assertThat(permissions(assertion)).isEqualTo(PHASE3_PERMISSIONS); + assertThat(assertion).contains("<> 32 THEN"); + } + + private static Set permissions(String sql) { + Matcher matcher = PERMISSION_PATTERN.matcher(sql); + return matcher.results().map(result -> result.group(1)).collect(Collectors.toSet()); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java new file mode 100644 index 000000000..6979c2053 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -0,0 +1,148 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; +import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; +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.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.math.BigDecimal; +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.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("货柜基础资料服务") +@SpringJUnitConfig(ContainerService.class) +class ContainerServiceTest { + @MockBean private ContainerRepository repository; + @Resource private ContainerService service; + + @Test + @DisplayName("新增货柜保存最终数据库契约字段并生成平台预订单号") + void createsContainerBooking() { + ContainerBookingEntity created = service.create(9L, "admin", request("柜-001")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ContainerBookingEntity.class); + verify(repository).insert(captor.capture()); + ContainerBookingEntity saved = captor.getValue(); + assertThat(created).isSameAs(saved); + assertThat(saved.getBookingNo()).startsWith("CB"); + assertThat(saved.getCabinetNo()).isEqualTo("柜-001"); + assertThat(saved.getDeparturePortCode()).isEqualTo("CNSHA"); + assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000"); + assertThat(saved.getPlanStatus()).isEqualTo("EDITING"); + } + + @Test + @DisplayName("智能装柜草稿阶段允许完整编辑") + void updatesAllFieldsWhileEditing() { + ContainerBookingEntity current = booking("EDITING", 2); + when(repository.find(11L)).thenReturn(current); + when(repository.updateDraft(any(), org.mockito.ArgumentMatchers.eq(2), + org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1); + + service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW"))); + + assertThat(current.getCabinetNo()).isEqualTo("柜-NEW"); + verify(repository).updateDraft(current, 2, 9L, "admin"); + } + + @Test + @DisplayName("进入预装柜中后拒绝完整编辑") + void rejectsFullUpdateAfterPreloadPush() { + when(repository.find(11L)).thenReturn(booking("LOADING", 2)); + + assertThatThrownBy(() -> service.updateDraft( + 11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW")))) + .isInstanceOf(BizException.class) + .hasMessageContaining("只能修改集装箱号和航次"); + + verify(repository, never()).updateDraft(any(), any(Integer.class), + any(Long.class), any(String.class)); + } + + @Test + @DisplayName("预装柜中和预装柜完成只更新集装箱号与航次") + void updatesTransportWhitelistAfterPush() { + for (String status : new String[] {"LOADING"}) { + ContainerBookingEntity current = booking(status, 2); + when(repository.find(11L)).thenReturn(current); + when(repository.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin")) + .thenReturn(1); + + service.updateTransport(11L, 9L, "admin", + new TransportUpdateRequest("MSCU1234567", "V002", 2)); + } + + verify(repository, org.mockito.Mockito.times(1)) + .updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin"); + } + + @Test + @DisplayName("已确认货柜全部只读") + void rejectsConfirmedUpdate() { + when(repository.find(11L)).thenReturn(booking("COMPLETED", 2)); + + assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin", + new TransportUpdateRequest("MSCU1234567", "V002", 2))) + .isInstanceOf(BizException.class) + .hasMessageContaining("全部只读"); + + verify(repository, never()).updateTransport(any(Long.class), any(), any(), + any(Integer.class), any(Long.class), any()); + } + + @Test + @DisplayName("旧版本编辑请求触发乐观锁冲突") + void rejectsStaleVersion() { + when(repository.find(11L)).thenReturn(booking("EDITING", 3)); + + assertThatThrownBy(() -> service.updateDraft( + 11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW")))) + .isInstanceOf(BizException.class) + .hasMessageContaining("刷新后重试"); + } + + @Test + @DisplayName("新增时校验重复集装箱号和内部柜号") + void rejectsDuplicateNumbers() { + when(repository.containerNoExists("MSCU1234567", null)).thenReturn(true); + + assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("集装箱号已存在"); + + verify(repository, never()).insert(any()); + } + + private static ContainerBookingEntity booking(String status, int version) { + ContainerBookingEntity value = new ContainerBookingEntity(); + value.setId(11L); + value.setContainerNo("MSCU1234567"); + value.setCabinetNo("柜-001"); + value.setPlanStatus(status); + value.setVersion(version); + return value; + } + + private static SaveRequest request(String cabinetNo) { + return new SaveRequest("MSCU1234567", cabinetNo, 101L, "上海仓", "BOL-001", "柏悦轮", + "V001", "40HQ", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港", + "ONT8", "ONT8", new BigDecimal("68.0000"), new BigDecimal("26000.000"), "拖车公司A", + LocalDateTime.of(2026, 8, 10, 8, 0), LocalDateTime.of(2026, 8, 12, 8, 0), null, + LocalDateTime.of(2026, 8, 25, 8, 0), null, + LocalDateTime.of(2026, 8, 27, 8, 0), null); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java new file mode 100644 index 000000000..7c72b7501 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -0,0 +1,162 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow; +import com.baiyue.firstleg.repository.LoadingListRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("预装与装柜清单状态服务") +@SpringJUnitConfig(classes = {LoadingListService.class, LoadingListServiceTest.Config.class}) +class LoadingListServiceTest { + @MockBean + private LoadingListRepository repository; + @Resource + private LoadingListService service; + + @TestConfiguration + static class Config { + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + } + + @Test + void shouldRejectEditingStatusFromPreloadList() { + PreloadQuery query = new PreloadQuery(null, "EDITING", null, null, null, null, 1, 20); + + assertThatThrownBy(() -> service.preloadPage(query)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.BAD_REQUEST); + } + + @Test + void shouldOnlyUpdateTransportFieldsWithOptimisticLockAndHistory() { + AggregateRow before = row("LOADING", 3); + before.setContainerNo("OLD"); + before.setVoyageNo("001E"); + AggregateRow after = row("LOADING", 4); + after.setContainerNo("CSLU1234567"); + after.setVoyageNo("023E"); + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(before, after); + when(repository.updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator")) + .thenReturn(1); + when(repository.preloadDetail(after)).thenReturn( + new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); + + service.updateTransport(10L, 9L, "operator", "key-1", + new TransportUpdateRequest(" CSLU1234567 ", "023E", 3, "补录箱号")); + + verify(repository).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator"); + verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"), + eq("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L), + eq("operator"), eq("补录箱号")); + } + + @Test + void shouldRejectTransportUpdateAfterConfirmation() { + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(row("COMPLETED", 5)); + + assertThatThrownBy(() -> service.updateTransport(10L, 9L, "operator", "key-1", + new TransportUpdateRequest("CSLU1234567", "023E", 5, null))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + verify(repository, never()).updateTransport(eq(10L), eq(5), anyString(), anyString(), eq(9L), anyString()); + } + + @Test + void shouldAdvanceOnlyLoadingToCompleted() { + AggregateRow before = row("LOADING", 2); + AggregateRow after = row("COMPLETED", 3); + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(before, after); + when(repository.advance(10L, "LOADING", "COMPLETED", 2, 9L, "operator")) + .thenReturn(1); + when(repository.preloadDetail(after)).thenReturn( + new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); + + service.complete(10L, 9L, "operator", "complete-key", new AdvanceRequest(2, null)); + + verify(repository).advance(10L, "LOADING", "COMPLETED", 2, 9L, "operator"); + } + + @Test + void shouldReplaySameTransportRequestWithoutSecondMutation() { + AggregateRow before = row("LOADING", 3); + AggregateRow after = row("LOADING", 4); + after.setContainerNo("CSLU1234567"); + after.setVoyageNo("023E"); + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(before, after); + when(repository.updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator")) + .thenReturn(1); + when(repository.preloadDetail(after)).thenReturn( + new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); + ArgumentCaptor snapshot = ArgumentCaptor.forClass(String.class); + + TransportUpdateRequest request = new TransportUpdateRequest("CSLU1234567", "023E", 3, null); + service.updateTransport(10L, 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)); + HistoryRow replay = new HistoryRow(); + replay.setAfterSnapshot(snapshot.getValue()); + when(repository.history(anyString())).thenReturn(replay); + when(repository.aggregate(10L)).thenReturn(after); + + service.updateTransport(10L, 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); + when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn( + new PageResult<>(List.of(), 1, 20, 0, 0)); + + service.confirmedPage(query); + + verify(repository).confirmedPage(org.mockito.ArgumentMatchers.argThat(value -> + "plan".equals(value.keyword()))); + } + + private static AggregateRow row(String status, int version) { + AggregateRow row = new AggregateRow(); + row.setPlanId(10L); + row.setPlanNo("PL-10"); + row.setPlanStatus(status); + row.setVersion(version); + return row; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java index 29c1676bb..a916ec621 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java @@ -72,6 +72,7 @@ class MeasurementLocationOccupationServiceTest { org.mockito.ArgumentMatchers.eq(WAREHOUSE), org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT), org.mockito.ArgumentMatchers.eq(new BigDecimal("1.200000")), + org.mockito.ArgumentMatchers.eq(20), org.mockito.ArgumentMatchers.any()); } @@ -102,6 +103,7 @@ class MeasurementLocationOccupationServiceTest { org.mockito.ArgumentMatchers.eq(WAREHOUSE), org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT), org.mockito.ArgumentMatchers.eq(new BigDecimal("1.200000")), + org.mockito.ArgumentMatchers.eq(20), org.mockito.ArgumentMatchers.any()); } @@ -126,7 +128,7 @@ class MeasurementLocationOccupationServiceTest { assertThat(release.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT); assertThat(release.getValue().businessId()).isEqualTo(INBOUND); verify(allocations).replace(BUSINESS_TENANT, INBOUND, WAREHOUSE, PLATFORM_RESOURCE_TENANT, - new BigDecimal("1.200000"), batch("RESERVED")); + new BigDecimal("1.200000"), 20, batch("RESERVED")); } private static InboundProperties properties() { @@ -150,6 +152,7 @@ class MeasurementLocationOccupationServiceTest { value.setWarehouseId(WAREHOUSE); value.setInboundCode("RK-001"); value.setActualVolumeCbm(new BigDecimal("1.0000")); + value.setTotalPackageCount(20); return value; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java new file mode 100644 index 000000000..0a780ecab --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -0,0 +1,349 @@ +package com.baiyue.firstleg.service; + +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.OrderCargoClient; +import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignRequest; +import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; +import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest; +import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan; +import com.baiyue.firstleg.dto.SmartLoadingDtos.PushRequest; +import com.baiyue.firstleg.dto.SmartLoadingDtos.RecommendRequest; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.entity.LoadingPlanEntity; +import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; +import com.baiyue.firstleg.entity.LoadingPlanItemEntity; +import com.baiyue.firstleg.repository.SmartLoadingRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.FeignException; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(SmartLoadingServiceTest.Config.class) +class SmartLoadingServiceTest { + @MockBean private SmartLoadingRepository repository; + @MockBean private OrderCargoClient cargoClient; + @MockBean private LoadingPickService loadingPickService; + @Resource private SmartLoadingService service; + + @TestConfiguration + static class Config { + @Bean SmartLoadingService smartLoadingService() { return new SmartLoadingService(); } + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } + } + + @BeforeEach + void setUp() { + when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn( + ApiResponse.success(new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 5, + new BigDecimal("10"), new BigDecimal("2")))); + } + + @Test + void restoresDrafts() { + LoadingPlanEntity plan = plan("EDITING", 2); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.planByBooking(99L)).thenReturn(plan); + when(repository.plan(1L)).thenReturn(plan); + when(repository.items(1L)).thenReturn(List.of(item(8, 9, 20, 1))); + + assertThat(service.draftByBooking(99L).id()).isEqualTo(1L); + assertThat(service.detail(1L).bookingId()).isEqualTo(99L); + } + + @Test + void returnsExistingDraftOnCreate() { + LoadingPlanEntity plan = plan("EDITING", 2); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.planByBooking(99L)).thenReturn(plan); + when(repository.items(1L)).thenReturn(List.of()); + + Plan result = service.create(99L, "备注", 7L, "u"); + + assertThat(result.id()).isEqualTo(1L); + verify(repository, never()).insert(any(LoadingPlanEntity.class)); + } + + @Test + void rejectsExcessBoxes() { + LoadingPlanEntity plan = plan("EDITING", 0); + when(repository.plan(1)).thenReturn(plan); + when(repository.candidate(9, 10, 20)).thenReturn(candidate(5)); + when(repository.activeItem(9, 20)).thenReturn(item(7, 9, 20, 4)); + + BizException error = catchThrowableOfType(() -> service.assign(1, + new AssignRequest(9L, 10L, 20L, 2, 0, "assign-1"), 7, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); + verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any()); + } + + @Test + void snapshotsCargoFields() { + stubAssignment(); + when(cargoClient.detail(anyString(), eq(9L), eq(30L), eq(20L))).thenReturn(ApiResponse.success( + new OrderCargoClient.CargoBinding(20L, 30L, 1, "中文品名", 10, + new BigDecimal("20"), new BigDecimal("2"), "SKU-1", "English name", + 4, 100, new BigDecimal("10"), "货物备注"))); + doAnswer(invocation -> { + Object value = invocation.getArgument(0); + if (value instanceof LoadingPlanItemEntity item) item.setId(88L); + return null; + }).when(repository).insert(any(LoadingPlanItemEntity.class)); + + service.assign(1, new AssignRequest(9L, 10L, 20L, 5, 0, "assign-snapshot"), 7, "u"); + + ArgumentCaptor itemCaptor = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); + verify(repository).insert(itemCaptor.capture()); + LoadingPlanItemEntity saved = itemCaptor.getValue(); + assertThat(saved.getDestinationWarehouseId()).isEqualTo(200L); + assertThat(saved.getSkuCode()).isEqualTo("SKU-1"); + assertThat(saved.getProductNameEn()).isEqualTo("English name"); + assertThat(saved.getUnitsPerCarton()).isEqualTo(4); + assertThat(saved.getProductQuantity()).isEqualTo(20); + assertThat(saved.getNetWeightKg()).isEqualByComparingTo("5"); + assertThat(saved.getInternalRemark()).isEqualTo("货物备注"); + + ArgumentCaptor historyCaptor = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class); + verify(repository).insert(historyCaptor.capture()); + assertThat(historyCaptor.getValue().getLoadingPlanItemId()).isEqualTo(88L); + } + + @Test + void roundsQuantityWithoutLoss() { + stubAssignment(); + when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(ApiResponse.success( + 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"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); + verify(repository).insert(captor.capture()); + assertThat(captor.getValue().getProductQuantity()).isEqualTo(67); + } + + @Test + void replaysBeforeVersionCheck() { + AssignRequest request = new AssignRequest(9L, 10L, 20L, 2, 0, "assign-replay"); + LoadingPlanHistoryEntity replay = history(1, null, "ADD_ITEM", + sha256("ASSIGN|1|9|10|20|2|0")); + LoadingPlanEntity frozen = plan("LOADING", 9); + when(repository.operation("assign-replay")).thenReturn(replay); + when(repository.plan(1)).thenReturn(frozen); + when(repository.booking(99)).thenReturn(booking()); + when(repository.items(1)).thenReturn(List.of()); + + Plan result = service.assign(1, request, 7, "u"); + + assertThat(result.status()).isEqualTo("LOADING"); + verify(repository, never()).candidate(anyLong(), anyLong(), anyLong()); + verify(cargoClient, never()).detail(anyString(), anyLong(), anyLong(), anyLong()); + } + + @Test + void rejectsReusedOperation() { + AssignRequest request = new AssignRequest(9L, 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"))); + + BizException error = catchThrowableOfType(() -> service.assign(1, request, 7, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT); + } + + @Test + void replaysRecommendation() { + RecommendRequest request = new RecommendRequest(9L, 0, "recommend-replay"); + when(repository.operation("recommend-replay")).thenReturn(history(1, null, + "SMART_RECOMMEND", sha256("RECOMMEND|1|9|0"))); + when(repository.plan(1)).thenReturn(plan("LOADING", 8)); + when(repository.booking(99)).thenReturn(booking()); + when(repository.items(1)).thenReturn(List.of()); + + Plan result = service.recommend(1, request, 7, "u"); + + assertThat(result.status()).isEqualTo("LOADING"); + verify(repository, never()).candidates(anyLong()); + } + + @Test + void replaysDeleteBeforeVersionCheck() { + DeleteRequest request = new DeleteRequest(9L, 2, 0, "delete-replay"); + when(repository.operation("delete-replay")).thenReturn(history(1, 8L, + "REMOVE_ITEM", sha256("DELETE|1|8|9|2|0"))); + when(repository.plan(1)).thenReturn(plan("LOADING", 7)); + when(repository.booking(99)).thenReturn(booking()); + when(repository.items(1)).thenReturn(List.of()); + + Plan result = service.delete(1, 8, request, 7, "u"); + + assertThat(result.status()).isEqualTo("LOADING"); + verify(repository, never()).item(anyLong(), anyLong()); + verify(repository, never()).remove(anyLong(), anyLong(), anyInt(), anyLong(), any()); + } + + @Test + void deleteTouchesPlan() { + LoadingPlanEntity plan = plan("EDITING", 2); + LoadingPlanItemEntity item = item(8, 9, 20, 1); + when(repository.plan(1)).thenReturn(plan, plan("EDITING", 3)); + when(repository.item(1, 8)).thenReturn(item); + 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()); + + Plan result = service.delete(1, 8, new DeleteRequest(9L, 2, 0, "delete-1"), 7, "u"); + + assertThat(result.version()).isEqualTo(3); + verify(repository).touch(1, 2, 7, "u"); + } + + @Test + void preservesDownstreamCode() { + stubAssignment(); + when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn( + 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); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND); + } + + @Test + void mapsFeignFailure() { + stubAssignment(); + when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())) + .thenThrow(mock(FeignException.class)); + + BizException error = catchThrowableOfType(() -> service.assign(1, + new AssignRequest(9L, 10L, 20L, 2, 0, "assign-down"), 7, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.SERVICE_UNAVAILABLE); + } + + @Test + void rejectsCargoMismatch() { + stubAssignment(); + when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(ApiResponse.success( + 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); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT); + } + + @Test + void rejectsWrongAccountDelete() { + LoadingPlanEntity plan = plan("EDITING", 2); + when(repository.plan(1)).thenReturn(plan); + when(repository.item(1, 8)).thenReturn(item(8, 22, 20, 1)); + + BizException error = catchThrowableOfType(() -> service.delete(1, 8, + new DeleteRequest(9L, 2, 0, "delete-owner"), 7, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.FORBIDDEN); + verify(repository, never()).remove(anyLong(), anyLong(), anyInt(), anyLong(), any()); + } + + @Test + void pushesIdempotently() { + LoadingPlanEntity editing = plan("EDITING", 3); + LoadingPlanEntity pushed = plan("LOADING", 4); + pushed.setPushIdempotencyKey("key"); + when(repository.plan(1)).thenReturn(editing, pushed); + when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1))); + when(repository.booking(99)).thenReturn(booking()); + when(repository.push(1, "key", 3, 7, "u")).thenReturn(1); + + Plan result = service.pushPreload(1, "key", new PushRequest(3), 7, "u"); + + assertThat(result.status()).isEqualTo("LOADING"); + verify(repository).push(1, "key", 3, 7, "u"); + } + + private void stubAssignment() { + LoadingPlanEntity plan = plan("EDITING", 0); + 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.activeItem(9, 20)).thenReturn(null); + when(repository.touch(1, 0, 7, "u")).thenReturn(1); + } + + private static LoadingPlanHistoryEntity history(long planId, Long itemId, String action, String hash) { + LoadingPlanHistoryEntity value = new LoadingPlanHistoryEntity(); + value.setLoadingPlanId(planId); + value.setLoadingPlanItemId(itemId); + value.setActionType(action); + value.setAfterSnapshot("{\"requestHash\":\"" + hash + "\"}"); + return value; + } + + private static String sha256(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } + + private static LoadingPlanEntity plan(String status, int version) { + LoadingPlanEntity value = new LoadingPlanEntity(); + value.setId(1L); value.setContainerBookingId(99L); value.setPlanNo("LP1"); + value.setPlanStatus(status); value.setVersion(version); + return value; + } + + private static LoadingPlanItemEntity item(long id, long account, long detail, int boxes) { + LoadingPlanItemEntity value = new LoadingPlanItemEntity(); + value.setId(id); value.setLoadingPlanId(1L); value.setAccountId(account); + value.setInboundDetailId(detail); value.setPackageCount(boxes); + value.setVersion(0); value.setDeleted(false); + return value; + } + + private static Candidate candidate(int boxes) { + return new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L, 200L, + "FBA1", "FBA仓", "客户", "货物", boxes, new BigDecimal("2"), new BigDecimal("0.5")); + } + + private static ContainerBookingEntity booking() { + ContainerBookingEntity value = new ContainerBookingEntity(); + value.setId(99L); value.setWarehouseId(100L); value.setDestinationWarehouseCode("FBA1"); + value.setContainerNo("MSCU1234567"); + value.setCapacityCbm(new BigDecimal("60")); value.setMaxWeightKg(new BigDecimal("20000")); + return value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingStatusTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingStatusTest.java new file mode 100644 index 000000000..9b79ccb0a --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingStatusTest.java @@ -0,0 +1,19 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.domain.SmartLoadingStatus; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SmartLoadingStatusTest { + @Test + void statusCanOnlyAdvanceOneWay() { + assertThat(SmartLoadingStatus.EDITING.canAdvanceTo(SmartLoadingStatus.LOADING)).isTrue(); + assertThat(SmartLoadingStatus.LOADING.canAdvanceTo(SmartLoadingStatus.COMPLETED)).isTrue(); + assertThat(SmartLoadingStatus.COMPLETED.canAdvanceTo(SmartLoadingStatus.LOADING)).isFalse(); + assertThat(SmartLoadingStatus.EDITING.mutable()).isTrue(); + assertThat(SmartLoadingStatus.LOADING.mutable()).isTrue(); + assertThat(SmartLoadingStatus.LOADING.allowsTransportUpdate()).isTrue(); + assertThat(SmartLoadingStatus.COMPLETED.allowsTransportUpdate()).isFalse(); + } +} diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index c3002f435..abacfa7e6 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -73,7 +73,7 @@ spring: - id: firstleg-service uri: lb://firstleg-service predicates: - - Path=/api/merchant/v1/first-leg/**,/api/platform/v1/first-leg/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/** + - Path=/api/merchant/v1/first-leg/**,/api/platform/v1/first-leg/**,/api/platform/v1/containers/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/** # Swagger 文档聚合路由。 - id: auth-openapi uri: lb://auth-service diff --git a/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java b/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java new file mode 100644 index 000000000..defdfd285 --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java @@ -0,0 +1,18 @@ +package com.baiyue.gateway.config; + +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 FirstlegContainerRouteContractTest { + @Test + void routesContainers() throws Exception { + String yaml = new ClassPathResource("application.yml") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(yaml).contains("/api/platform/v1/containers/**"); + } +} diff --git a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java index 051fff45e..57fe17a98 100644 --- a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java +++ b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java @@ -46,7 +46,9 @@ public class InternalOrderCargoController { throw new BizException(ResultCode.NOT_FOUND, "订单货物明细不存在或不属于该订单"); } return ApiResponse.success(new CargoBinding(cargo.getId(), cargo.getOrderId(), cargo.getItemNo(), - cargo.getCargoName(), cargo.getPackageCount(), cargo.getGrossWeightKg(), cargo.getVolumeCbm())); + cargo.getCargoName(), cargo.getPackageCount(), cargo.getGrossWeightKg(), cargo.getVolumeCbm(), + cargo.getSkuNo(), cargo.getCargoNameEn(), cargo.getUnitsPerPackage(), cargo.getTotalQuantity(), + cargo.getNetWeightKg(), cargo.getCargoRemark())); } private void requireToken(String token) { @@ -58,6 +60,14 @@ public class InternalOrderCargoController { public record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, Integer originalPackageCount, java.math.BigDecimal originalGrossWeightKg, - java.math.BigDecimal originalVolumeCbm) { + java.math.BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn, + Integer unitsPerPackage, Integer totalQuantity, + java.math.BigDecimal netWeightKg, String cargoRemark) { + public CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, + Integer originalPackageCount, java.math.BigDecimal originalGrossWeightKg, + java.math.BigDecimal originalVolumeCbm) { + this(cargoId, orderId, itemNo, cargoName, originalPackageCount, + originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null); + } } } diff --git a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java index 9bbfa2589..11405e5a9 100644 --- a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java @@ -30,6 +30,12 @@ class InternalOrderCargoControllerTest { cargo.setItemNo(1); cargo.setCargoName("测试货物"); cargo.setPackageCount(50); + cargo.setSkuNo("SKU-31"); + cargo.setCargoNameEn("Test cargo"); + cargo.setUnitsPerPackage(4); + cargo.setTotalQuantity(200); + cargo.setNetWeightKg(new java.math.BigDecimal("18.500")); + cargo.setCargoRemark("内部货物备注"); when(orders.findCargo(11L, 21L, 31L)).thenReturn(cargo); var response = controller.detail("internal-token", 11L, 21L, 31L); @@ -37,6 +43,12 @@ class InternalOrderCargoControllerTest { assertThat(response.successful()).isTrue(); assertThat(response.data().cargoId()).isEqualTo(31L); assertThat(response.data().itemNo()).isEqualTo(1); + assertThat(response.data().skuNo()).isEqualTo("SKU-31"); + assertThat(response.data().cargoNameEn()).isEqualTo("Test cargo"); + assertThat(response.data().unitsPerPackage()).isEqualTo(4); + assertThat(response.data().totalQuantity()).isEqualTo(200); + assertThat(response.data().netWeightKg()).isEqualByComparingTo("18.500"); + assertThat(response.data().cargoRemark()).isEqualTo("内部货物备注"); verify(orders).findCargo(11L, 21L, 31L); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java index 341c3a76a..6a74a68fb 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java @@ -62,6 +62,16 @@ public class InternalPalletLocationController { return ApiResponse.success(service.release(request)); } + @PostMapping("/release-selected") + @Operation(summary = "Release selected occupied locations for partial picking", + description = "Explicit IDs release only those locations; otherwise the first N allocations are released") + public ApiResponse> releaseSelected( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody PalletAllocationDtos.SelectiveReleaseRequest request) { + verify(token); + return ApiResponse.success(service.releaseSelected(request)); + } + private void verify(String token) { byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8); if (!MessageDigest.isEqual(expectedToken, supplied)) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationDtos.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationDtos.java index b512b0e20..a8fecfde1 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationDtos.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationDtos.java @@ -138,6 +138,30 @@ public final class LocationDtos { ) { } + public record SelectiveReleaseCommand( + @Min(1) long tenantId, + @NotBlank @Size(max = 40) String sourceService, + @Min(1) long businessId, + @Min(1) long operatorId, + @NotBlank @Size(max = 500) String reason, + @Min(1) int totalPackageCount, + @Size(max = 200) List<@Min(1) Long> locationIds, + @Min(1) Integer releaseLocationCount + ) { + } + + public record SelectiveReleaseResult( + long reservationId, + long occupancyId, + int allocationNo, + long warehouseId, + long palletId, + long locationId, + String locationCode, + int releasedPackageCount + ) { + } + public record ActiveResource( long reservationId, String reservationNo, diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletAllocationDtos.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletAllocationDtos.java index 798417feb..28672f886 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletAllocationDtos.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletAllocationDtos.java @@ -52,6 +52,33 @@ public final class PalletAllocationDtos { ) { } + /** + * Releases occupied locations for a partial pick. When {@code locationIds} is empty, + * the first {@code releaseLocationCount} allocations are selected. + */ + public record SelectiveReleaseRequest( + @Min(1) long tenantId, + @NotBlank @Size(max = 40) String sourceService, + @Min(1) long businessId, + @Min(1) long operatorId, + @NotBlank @Size(max = 500) String reason, + @Min(1) int totalPackageCount, + @Size(max = 200) List<@Min(1) Long> locationIds, + @Min(1) Integer releaseLocationCount + ) { + } + + public record SelectiveReleaseResult( + long reservationId, + long occupancyId, + int allocationNo, + long palletId, + long locationId, + String locationCode, + int releasedPackageCount + ) { + } + public record AllocationResult( long reservationId, String reservationNo, diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationOccupancyMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationOccupancyMapper.java index 8c3e60595..8f66013a2 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationOccupancyMapper.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationOccupancyMapper.java @@ -7,6 +7,8 @@ import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; +import java.util.List; + /** 库位占用事实 Mapper。 */ @Mapper public interface LocationOccupancyMapper extends BaseMapper { @@ -26,4 +28,15 @@ public interface LocationOccupancyMapper extends BaseMapper lockOccupiedByBusiness(@Param("tenantId") long tenantId, + @Param("sourceService") String sourceService, + @Param("businessId") long businessId); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/LocationRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/LocationRepository.java index 36493b54a..17c3e0ae6 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/LocationRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/LocationRepository.java @@ -143,6 +143,11 @@ public class LocationRepository { .eq(LocationOccupancyEntity::getReservationId, reservationId)); } + public List lockOccupiedByBusiness(long tenantId, String sourceService, + long businessId) { + return occupancies.lockOccupiedByBusiness(tenantId, sourceService, businessId); + } + public void insertOccupancy(LocationOccupancyEntity entity) { occupancies.insert(entity); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java index c259024ce..48bd273ad 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java @@ -11,6 +11,8 @@ import com.baiyue.warehouse.dto.LocationDtos.ReleaseCommand; import com.baiyue.warehouse.dto.LocationDtos.ReservationResult; import com.baiyue.warehouse.dto.LocationDtos.ReservationTarget; import com.baiyue.warehouse.dto.LocationDtos.ReserveCommand; +import com.baiyue.warehouse.dto.LocationDtos.SelectiveReleaseCommand; +import com.baiyue.warehouse.dto.LocationDtos.SelectiveReleaseResult; import com.baiyue.warehouse.entity.LocationEntity; import com.baiyue.warehouse.entity.LocationOccupancyEntity; import com.baiyue.warehouse.entity.LocationReservationEntity; @@ -31,7 +33,6 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HashSet; import java.util.LinkedHashMap; -import java.util.Map; import java.util.stream.Collectors; import java.util.List; import java.util.Map; @@ -46,6 +47,8 @@ import java.util.Set; public class LocationResourceService { @Resource private LocationRepository repository; + @Resource + private PickingPackageAllocator packageAllocator; public List findAvailable(@Min(1) long tenantId, @Min(1) long warehouseId, @Min(1) @Max(200) int limit) { @@ -182,6 +185,66 @@ public class LocationResourceService { return List.copyOf(released); } + @Transactional + public List releaseSelected(@Valid SelectiveReleaseCommand command) { + List active = repository.lockOccupiedByBusiness( + command.tenantId(), command.sourceService(), command.businessId()); + if (active.isEmpty()) { + throw new BizException(ResultCode.CONFLICT, "No occupied locations are available for this inbound order"); + } + requireLimit(active.size()); + List packages = packageAllocator.allocate(command.totalPackageCount(), active.size()); + Set requestedIds = command.locationIds() == null + ? Set.of() : new HashSet<>(command.locationIds()); + if (requestedIds.size() != (command.locationIds() == null ? 0 : command.locationIds().size())) { + throw new BizException(ResultCode.BAD_REQUEST, "Selected location IDs must be unique"); + } + + List selectedIndexes = new ArrayList<>(); + if (!requestedIds.isEmpty()) { + for (int index = 0; index < active.size(); index++) { + if (requestedIds.contains(active.get(index).getLocationId())) { + selectedIndexes.add(index); + } + } + if (selectedIndexes.size() != requestedIds.size()) { + throw new BizException(ResultCode.FORBIDDEN, + "A selected location does not belong to the current inbound order"); + } + if (command.releaseLocationCount() != null + && command.releaseLocationCount() != selectedIndexes.size()) { + throw new BizException(ResultCode.BAD_REQUEST, + "releaseLocationCount must match the selected location count"); + } + } else { + if (command.releaseLocationCount() == null || command.releaseLocationCount() > active.size()) { + throw new BizException(ResultCode.BAD_REQUEST, + "releaseLocationCount must not exceed the active location count"); + } + for (int index = 0; index < command.releaseLocationCount(); index++) { + selectedIndexes.add(index); + } + } + + List released = new ArrayList<>(selectedIndexes.size()); + for (int index : selectedIndexes) { + LocationOccupancyEntity occupancy = active.get(index); + LocationEntity location = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(), + occupancy.getLocationId()); + if (location == null || repository.releaseOccupancy(occupancy, command.reason()) != 1 + || (!Boolean.TRUE.equals(occupancy.getSharedOverflow()) + && repository.changeStatus(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId(), + "OCCUPIED", "AVAILABLE", location.getVersion(), command.operatorId()) != 1)) { + throw new BizException(ResultCode.CONFLICT, + "Location occupancy changed concurrently while releasing selected locations"); + } + released.add(new SelectiveReleaseResult(occupancy.getReservationId(), occupancy.getId(), index + 1, + occupancy.getWarehouseId(), occupancy.getPalletId(), occupancy.getLocationId(), + occupancy.getLocationCode(), packages.get(index))); + } + return List.copyOf(released); + } + @Transactional(readOnly = true) public List findActiveResources(@Min(1) long tenantId, @NotBlank String sourceService, diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java index 6188b1413..98dcac790 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java @@ -129,6 +129,28 @@ public class PalletLocationAllocationService { return released; } + @Transactional + @BusinessAudit(event = "PALLET_LOCATION_RELEASE_SELECTED", resourceType = "INBOUND", + resourceId = "#request.businessId", description = "Release selected occupied locations for picking") + public List releaseSelected( + PalletAllocationDtos.SelectiveReleaseRequest request) { + List released = locationResourceService.releaseSelected( + new LocationDtos.SelectiveReleaseCommand(request.tenantId(), request.sourceService(), request.businessId(), + request.operatorId(), request.reason(), request.totalPackageCount(), request.locationIds(), + request.releaseLocationCount())); + List result = new ArrayList<>(released.size()); + for (LocationDtos.SelectiveReleaseResult resource : released) { + if (palletRepository.transitionStatus(request.tenantId(), resource.warehouseId(), resource.palletId(), + List.of("OCCUPIED"), "AVAILABLE") != 1) { + throw new BizException(ResultCode.CONFLICT, "Pallet changed concurrently during selected release"); + } + result.add(new PalletAllocationDtos.SelectiveReleaseResult(resource.reservationId(), + resource.occupancyId(), resource.allocationNo(), resource.palletId(), resource.locationId(), + resource.locationCode(), resource.releasedPackageCount())); + } + return List.copyOf(result); + } + private List active(long tenantId, String sourceService, long businessId) { return new ArrayList<>(locationResourceService.findActiveResources(tenantId, sourceService, businessId)); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PickingPackageAllocator.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PickingPackageAllocator.java new file mode 100644 index 000000000..0022119f8 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PickingPackageAllocator.java @@ -0,0 +1,27 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** Allocates cartons by location with a ceiling-sized leading share. */ +@Component +public class PickingPackageAllocator { + public List allocate(int totalPackageCount, int locationCount) { + if (totalPackageCount < 1 || locationCount < 1 || locationCount > 200) { + throw new BizException(ResultCode.BAD_REQUEST, "Package and location counts must be positive"); + } + int share = ((totalPackageCount - 1) / locationCount) + 1; + int remaining = totalPackageCount; + List result = new ArrayList<>(locationCount); + for (int index = 0; index < locationCount; index++) { + int allocated = Math.min(share, remaining); + result.add(allocated); + remaining -= allocated; + } + return List.copyOf(result); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java index 9310ba2e8..3ddf7690a 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.warehouse.dto.LocationDtos.ConfirmCommand; import com.baiyue.warehouse.dto.LocationDtos.OccupancyTarget; import com.baiyue.warehouse.dto.LocationDtos.ReservationTarget; import com.baiyue.warehouse.dto.LocationDtos.ReserveCommand; +import com.baiyue.warehouse.dto.LocationDtos.SelectiveReleaseCommand; import com.baiyue.warehouse.entity.LocationEntity; import com.baiyue.warehouse.entity.LocationOccupancyEntity; import com.baiyue.warehouse.entity.LocationReservationEntity; @@ -20,6 +21,7 @@ import java.time.LocalDateTime; import java.util.List; 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.anyInt; import static org.mockito.ArgumentMatchers.anyLong; @@ -29,7 +31,7 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@SpringJUnitConfig(LocationResourceService.class) +@SpringJUnitConfig(classes = {LocationResourceService.class, PickingPackageAllocator.class}) class LocationResourceServiceTest { @MockBean private LocationRepository repository; @Resource private LocationResourceService service; @@ -138,6 +140,79 @@ class LocationResourceServiceTest { verify(repository).insertOccupancy(any(LocationOccupancyEntity.class)); } + @Test + void shouldReleaseOnlyExplicitlySelectedLocations() { + List active = List.of( + occupancy(201L, 101L, 9L, "A1", 501L), + occupancy(202L, 102L, 10L, "A2", 502L), + occupancy(203L, 103L, 11L, "A3", 503L)); + when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(active); + when(repository.findOwned(10001L, 88L, 10L)).thenReturn(occupiedLocation(10L, "A2")); + when(repository.releaseOccupancy(active.get(1), "PDA partial pick")).thenReturn(1); + when(repository.changeStatus(10001L, 88L, 10L, "OCCUPIED", "AVAILABLE", 0, 7L)).thenReturn(1); + + var result = service.releaseSelected(new SelectiveReleaseCommand(10001L, "FIRSTLEG", 301L, 7L, + "PDA partial pick", 10, List.of(10L), null)); + + assertThat(result).singleElement().satisfies(item -> { + assertThat(item.allocationNo()).isEqualTo(2); + assertThat(item.locationId()).isEqualTo(10L); + assertThat(item.releasedPackageCount()).isEqualTo(4); + }); + verify(repository, never()).releaseOccupancy(active.get(0), "PDA partial pick"); + verify(repository, never()).releaseOccupancy(active.get(2), "PDA partial pick"); + } + + @Test + void shouldReleaseFirstNAllocationsWhenLocationsAreNotSpecified() { + List active = List.of( + occupancy(201L, 101L, 9L, "A1", 501L), + occupancy(202L, 102L, 10L, "A2", 502L), + occupancy(203L, 103L, 11L, "A3", 503L)); + when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(active); + for (int index = 0; index < 2; index++) { + LocationOccupancyEntity item = active.get(index); + when(repository.findOwned(10001L, 88L, item.getLocationId())) + .thenReturn(occupiedLocation(item.getLocationId(), item.getLocationCode())); + when(repository.releaseOccupancy(item, "PDA default pick")).thenReturn(1); + when(repository.changeStatus(10001L, 88L, item.getLocationId(), "OCCUPIED", "AVAILABLE", 0, 7L)) + .thenReturn(1); + } + + var result = service.releaseSelected(new SelectiveReleaseCommand(10001L, "FIRSTLEG", 301L, 7L, + "PDA default pick", 10, List.of(), 2)); + + assertThat(result).extracting(item -> item.allocationNo()).containsExactly(1, 2); + assertThat(result).extracting(item -> item.locationCode()).containsExactly("A1", "A2"); + assertThat(result).extracting(item -> item.releasedPackageCount()).containsExactly(4, 4); + verify(repository, never()).releaseOccupancy(active.get(2), "PDA default pick"); + } + + @Test + void shouldRejectLocationOwnedByAnotherInboundOrder() { + when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of( + occupancy(201L, 101L, 9L, "A1", 501L))); + + assertThatThrownBy(() -> service.releaseSelected(new SelectiveReleaseCommand( + 10001L, "FIRSTLEG", 301L, 7L, "PDA partial pick", 10, List.of(99L), null))) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class); + verify(repository, never()).releaseOccupancy(any(), any(String.class)); + } + + @Test + void shouldFailOnConcurrentOccupancyUpdate() { + LocationOccupancyEntity active = occupancy(201L, 101L, 9L, "A1", 501L); + when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(active)); + when(repository.findOwned(10001L, 88L, 9L)).thenReturn(occupiedLocation(9L, "A1")); + when(repository.releaseOccupancy(active, "PDA pick")).thenReturn(0); + + assertThatThrownBy(() -> service.releaseSelected(new SelectiveReleaseCommand( + 10001L, "FIRSTLEG", 301L, 7L, "PDA pick", 10, List.of(), 1))) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class); + verify(repository, never()).changeStatus(anyLong(), anyLong(), anyLong(), + any(String.class), any(String.class), anyInt(), anyLong()); + } + private static ReserveCommand reserveCommand() { return new ReserveCommand(10001L, 88L, "FIRSTLEG", 301L, "RK001", 7L, LocalDateTime.now().plusMinutes(30), List.of( @@ -176,6 +251,30 @@ class LocationResourceServiceTest { return location; } + private static LocationEntity occupiedLocation(long id, String code) { + LocationEntity location = location(id, code); + location.setStatus("OCCUPIED"); + return location; + } + + private static LocationOccupancyEntity occupancy(long id, long reservationId, long locationId, + String locationCode, long palletId) { + LocationOccupancyEntity occupancy = new LocationOccupancyEntity(); + occupancy.setId(id); + occupancy.setTenantId(10001L); + occupancy.setReservationId(reservationId); + occupancy.setSourceService("FIRSTLEG"); + occupancy.setBusinessId(301L); + occupancy.setWarehouseId(88L); + occupancy.setPalletId(palletId); + occupancy.setLocationId(locationId); + occupancy.setLocationCode(locationCode); + occupancy.setSharedOverflow(false); + occupancy.setOccupancyStatus("OCCUPIED"); + occupancy.setVersion(0); + return occupancy; + } + private static LocationReservationEntity reservation(String status) { LocationReservationEntity reservation = new LocationReservationEntity(); reservation.setId(101L); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java index 3775ec830..88503f0cb 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java @@ -104,6 +104,25 @@ class PalletLocationAllocationServiceTest { verify(pallets, never()).findAvailable(any(Long.class), any(Long.class), any(), any(), any(Integer.class)); } + @Test + void shouldReleaseOnlyPalletsReturnedBySelectedLocationRelease() { + var resource = new LocationDtos.SelectiveReleaseResult( + 102L, 202L, 2, 20L, 30L, 40L, "A-02", 4); + when(locations.releaseSelected(any())).thenReturn(List.of(resource)); + when(pallets.transitionStatus(10001L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE")) + .thenReturn(1); + + var result = service.releaseSelected(new PalletAllocationDtos.SelectiveReleaseRequest( + 10001L, "FIRSTLEG", 88L, 9001L, "PDA partial pick", 10, List.of(40L), null)); + + assertThat(result).singleElement().satisfies(item -> { + assertThat(item.allocationNo()).isEqualTo(2); + assertThat(item.locationId()).isEqualTo(40L); + assertThat(item.releasedPackageCount()).isEqualTo(4); + }); + verify(pallets).transitionStatus(10001L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE"); + } + private static PalletAllocationDtos.ReserveRequest reserve(long businessId) { return new PalletAllocationDtos.ReserveRequest(10001, 20, "FIRSTLEG", businessId, "RK-001", "measure-001", 9001, new BigDecimal("2"), new BigDecimal("2"), "木托盘", LocalDateTime.now().plusMinutes(30), true); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PickingPackageAllocatorTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PickingPackageAllocatorTest.java new file mode 100644 index 000000000..224f381dc --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PickingPackageAllocatorTest.java @@ -0,0 +1,24 @@ +package com.baiyue.warehouse.service; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PickingPackageAllocatorTest { + private final PickingPackageAllocator allocator = new PickingPackageAllocator(); + + @Test + void shouldUseCeilingShareAndCapFinalLocation() { + assertThat(allocator.allocate(10, 3)).containsExactly(4, 4, 2); + } + + @Test + void shouldHandleExactDivision() { + assertThat(allocator.allocate(12, 3)).containsExactly(4, 4, 4); + } + + @Test + void shouldNeverAllocateMoreThanRemainingPackages() { + assertThat(allocator.allocate(2, 3)).containsExactly(1, 1, 0); + } +}