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);
+ }
+}