diff --git a/common-core/src/main/java/com/baiyue/common/core/api/ResultCode.java b/common-core/src/main/java/com/baiyue/common/core/api/ResultCode.java
index 7e58bd886..848b89ce1 100644
--- a/common-core/src/main/java/com/baiyue/common/core/api/ResultCode.java
+++ b/common-core/src/main/java/com/baiyue/common/core/api/ResultCode.java
@@ -8,16 +8,27 @@ import java.util.Arrays;
* 统一接口结果码枚举,定义业务错误编码、默认消息和对应 HTTP 状态。
*/
public enum ResultCode {
+ /** 请求处理成功。 */
SUCCESS("200", "success", HttpStatus.OK),
+ /** 请求参数不符合接口要求。 */
BAD_REQUEST("A0400", "Bad request", HttpStatus.BAD_REQUEST),
+ /** 未登录、令牌无效或登录态已过期。 */
UNAUTHORIZED("A0401", "Authentication required", HttpStatus.UNAUTHORIZED),
+ /** 已登录但没有操作权限。 */
FORBIDDEN("A0403", "Access denied", HttpStatus.FORBIDDEN),
+ /** 请求的资源不存在或不在可访问范围内。 */
NOT_FOUND("A0404", "Resource not found", HttpStatus.NOT_FOUND),
+ /** 资源版本、幂等键或当前状态发生冲突。 */
CONFLICT("A0409", "Resource conflict", HttpStatus.CONFLICT),
+ /** 字段校验、格式校验或业务前置校验未通过。 */
VALIDATION_FAILED("A0422", "Validation failed", HttpStatus.UNPROCESSABLE_ENTITY),
+ /** 请求频率或并发数量超过限制。 */
TOO_MANY_REQUESTS("A0429", "Too many requests", HttpStatus.TOO_MANY_REQUESTS),
+ /** 未被更细错误码覆盖的通用业务失败。 */
BUSINESS_ERROR("B0001", "Business operation failed", HttpStatus.BAD_REQUEST),
+ /** 服务内部发生未预期异常。 */
INTERNAL_ERROR("S0500", "Internal server error", HttpStatus.INTERNAL_SERVER_ERROR),
+ /** 下游服务、数据库或基础设施暂不可用。 */
SERVICE_UNAVAILABLE("S0503", "Service unavailable", HttpStatus.SERVICE_UNAVAILABLE);
private final String code;
diff --git a/common-core/src/main/java/com/baiyue/common/core/export/ExportContracts.java b/common-core/src/main/java/com/baiyue/common/core/export/ExportContracts.java
index 1b6a6cbcc..d62b1c13d 100644
--- a/common-core/src/main/java/com/baiyue/common/core/export/ExportContracts.java
+++ b/common-core/src/main/java/com/baiyue/common/core/export/ExportContracts.java
@@ -24,5 +24,18 @@ public final class ExportContracts {
public record ExportColumn(String key, String title, ExportColumnType type, Integer width) { }
- public enum ExportColumnType { STRING, INTEGER, DECIMAL, BOOLEAN, DATE, DATETIME }
+ public enum ExportColumnType {
+ /** 文本列。 */
+ STRING,
+ /** 整数列。 */
+ INTEGER,
+ /** 高精度小数列。 */
+ DECIMAL,
+ /** 布尔值列。 */
+ BOOLEAN,
+ /** 日期列,不包含时间。 */
+ DATE,
+ /** 日期时间列。 */
+ DATETIME
+ }
}
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index c6e6edf47..351c358a2 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -4159,6 +4159,986 @@ 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;
+
+-- 兼容已执行二期初始化的存量库:PDA 拣货需要累计每个托盘分配已拣箱数。
+DROP PROCEDURE IF EXISTS phase3_add_allocation_picked_count;
+DELIMITER $$
+CREATE PROCEDURE phase3_add_allocation_picked_count()
+BEGIN
+ IF EXISTS (
+ SELECT 1 FROM information_schema.tables
+ WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_pallet_allocation'
+ ) AND NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_pallet_allocation'
+ AND column_name = 'picked_package_count'
+ ) THEN
+ ALTER TABLE fl_inbound_pallet_allocation
+ ADD COLUMN picked_package_count INT UNSIGNED NOT NULL DEFAULT 0
+ COMMENT '已拣货箱数' AFTER allocated_package_count;
+ END IF;
+END$$
+DELIMITER ;
+CALL phase3_add_allocation_picked_count();
+DROP PROCEDURE phase3_add_allocation_picked_count;
+
+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_plan_detail (loading_plan_id, active_inbound_detail_id),
+ KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id),
+ KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id),
+ KEY idx_loading_item_account (account_id, created_at, id),
+ KEY idx_loading_item_inbound (account_id, inbound_id, deleted),
+ 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、PARTIAL、SKIPPED',
+ version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+ 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', 'PARTIAL', 'SKIPPED'))
+) 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 '请求指纹',
+ action_type VARCHAR(24) NOT NULL DEFAULT 'PICK' COMMENT 'PICK、PARTIAL、SKIP',
+ picked_package_count INT UNSIGNED NOT NULL COMMENT '本次拣货箱数',
+ operation_reason VARCHAR(500) NULL COMMENT '部分拣货或跳拣原因',
+ operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人',
+ operator_name VARCHAR(64) NULL COMMENT '操作人名称',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ 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
+ IF NOT EXISTS (
+ SELECT 1 FROM fl_loading_pick_item
+ WHERE loading_plan_item_id = OLD.id
+ AND item_status = 'PARTIAL'
+ AND NEW.deleted = 0
+ AND NEW.package_count = picked_package_count
+ ) OR NEW.deleted <> OLD.deleted
+ OR NOT (NEW.customer_name <=> OLD.customer_name)
+ OR NOT (NEW.inbound_id <=> OLD.inbound_id)
+ OR NOT (NEW.inbound_detail_id <=> OLD.inbound_detail_id)
+ OR NOT (NEW.order_id <=> OLD.order_id)
+ OR NOT (NEW.order_no <=> OLD.order_no)
+ OR NOT (NEW.inbound_code <=> OLD.inbound_code)
+ OR NOT (NEW.destination_warehouse_id <=> OLD.destination_warehouse_id)
+ OR NOT (NEW.destination_warehouse_code <=> OLD.destination_warehouse_code)
+ OR NOT (NEW.destination_warehouse_name <=> OLD.destination_warehouse_name)
+ OR NOT (NEW.sku_code <=> OLD.sku_code)
+ OR NOT (NEW.product_name_zh <=> OLD.product_name_zh)
+ OR NOT (NEW.product_name_en <=> OLD.product_name_en)
+ OR NOT (NEW.product_image_file_id <=> OLD.product_image_file_id)
+ OR NOT (NEW.product_image_url <=> OLD.product_image_url)
+ OR NOT (NEW.product_quantity <=> OLD.product_quantity)
+ OR NOT (NEW.units_per_carton <=> OLD.units_per_carton)
+ OR NOT (NEW.pallet_count <=> OLD.pallet_count)
+ OR NOT (NEW.gross_weight_kg <=> OLD.gross_weight_kg)
+ OR NOT (NEW.net_weight_kg <=> OLD.net_weight_kg)
+ OR NOT (NEW.dimensional_weight_kg <=> OLD.dimensional_weight_kg)
+ OR NOT (NEW.volume_cbm <=> OLD.volume_cbm)
+ OR NOT (NEW.fba_tracking_no <=> OLD.fba_tracking_no)
+ OR NOT (NEW.shipment_no <=> OLD.shipment_no)
+ OR NOT (NEW.shipping_mark <=> OLD.shipping_mark)
+ OR NOT (NEW.load_sequence <=> OLD.load_sequence)
+ OR NOT (NEW.position_snapshot <=> OLD.position_snapshot)
+ OR NOT (NEW.internal_remark <=> OLD.internal_remark)
+ OR NOT (NEW.customer_remark <=> OLD.customer_remark)
+ OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark)
+ OR NOT (NEW.frozen_at <=> OLD.frozen_at)
+ OR NOT (NEW.frozen_by <=> OLD.frozen_by)
+ OR NOT (NEW.removed_by <=> OLD.removed_by)
+ OR NOT (NEW.removed_by_name <=> OLD.removed_by_name)
+ OR NOT (NEW.removed_at <=> OLD.removed_at) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数';
+ END IF;
+ END IF;
+ IF OLD.frozen_at IS NOT NULL THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结';
+ 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;
+ IF NEW.item_status = 'PARTIAL' AND NOT (
+ OLD.item_status IN ('PENDING', 'PICKING')
+ AND OLD.picked_package_count < NEW.picked_package_count
+ AND NEW.picked_package_count < OLD.planned_package_count
+ AND NEW.planned_package_count = OLD.planned_package_count
+ ) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分拣货必须在计划箱数内增加实际拣出箱数';
+ END IF;
+ IF NEW.item_status = 'SKIPPED' AND NOT (
+ OLD.item_status = 'PENDING'
+ AND OLD.picked_package_count = 0
+ AND NEW.picked_package_count = 0
+ AND NEW.planned_package_count = OLD.planned_package_count
+ ) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '跳拣仅允许零拣货的待拣明细';
+ END IF;
+ IF OLD.item_status = 'PARTIAL' AND NEW.item_status = 'PICKED' AND NOT (
+ NEW.picked_package_count = OLD.picked_package_count
+ AND NEW.planned_package_count = OLD.picked_package_count
+ ) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分拣货收敛后计划箱数必须等于实际拣出箱数';
+ END IF;
+END$$
+DELIMITER ;
+
+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_plan_detail' AND non_unique = 0) <> 2 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失';
+ END IF;
+ IF (SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = 'baiyue_firstleg'
+ AND table_name = 'fl_loading_pick_item'
+ AND column_name IN ('pick_task_id', 'loading_plan_item_id', 'inbound_id',
+ 'inbound_detail_id', 'planned_package_count',
+ 'picked_package_count', 'item_status', 'version')) <> 8 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA拣货明细字段缺失';
+ END IF;
+ IF (SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = 'baiyue_firstleg' AND non_unique = 0
+ AND index_name IN ('uk_loading_pick_task_plan',
+ 'uk_loading_pick_item_plan_item',
+ 'uk_loading_pick_history_operation')) <> 3 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA唯一索引缺失';
+ END IF;
+ IF (SELECT COUNT(*) FROM information_schema.triggers
+ WHERE trigger_schema = 'baiyue_firstleg'
+ AND trigger_name IN ('trg_container_booking_before_update',
+ 'trg_loading_plan_before_update', 'trg_loading_plan_after_update',
+ 'trg_loading_item_before_insert', 'trg_loading_item_before_update',
+ 'trg_loading_item_before_delete', 'trg_container_document_before_insert',
+ 'trg_container_document_before_update', 'trg_container_document_before_delete',
+ 'trg_loading_history_before_update', 'trg_loading_history_before_delete',
+ 'trg_loading_pick_task_before_update',
+ 'trg_loading_pick_item_before_update',
+ 'trg_loading_pick_item_before_delete',
+ 'trg_loading_pick_history_before_update',
+ 'trg_loading_pick_history_before_delete')) <> 16 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:不可逆状态或冻结触发器缺失';
+ END IF;
+ IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission
+ WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0
+ AND permission_code IN (
+ 'platform:firstleg:container:list', 'platform:firstleg:container:view',
+ 'platform:firstleg:container:create', 'platform:firstleg:container:edit',
+ 'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:create',
+ 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
+ 'platform:firstleg:smart-loading:push', 'platform:firstleg:preload:list',
+ 'platform:firstleg:preload:view', 'platform:firstleg:preload:edit',
+ 'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm',
+ 'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 16 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建';
+ END IF;
+ IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code))
+ FROM baiyue_iam.iam_role role
+ JOIN baiyue_iam.iam_role_permission grant_row ON grant_row.role_id = role.id
+ JOIN baiyue_iam.iam_permission permission ON permission.id = grant_row.permission_id
+ WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')
+ AND permission.tenant_id = 0
+ AND permission.permission_code IN (
+ 'platform:firstleg:container:list', 'platform:firstleg:container:view',
+ 'platform:firstleg:container:create',
+ 'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
+ 'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign',
+ 'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
+ 'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
+ 'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
+ 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
+ 'platform:firstleg:loading-list:view')) <> 32 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入';
+ END IF;
+END$$
+DELIMITER ;
+CALL assert_phase3_smart_loading_schema();
+DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema;
+
+
+DELIMITER $$
+CREATE PROCEDURE assert_phase3_smart_loading_schema()
+BEGIN
+ IF (SELECT COUNT(*) FROM information_schema.tables
+ WHERE table_schema = 'baiyue_firstleg'
+ AND table_name IN ('fl_container_booking', 'fl_loading_plan', 'fl_loading_plan_item',
+ 'fl_container_document', 'fl_loading_plan_history',
+ 'fl_loading_pick_task', 'fl_loading_pick_item',
+ 'fl_loading_pick_history')) <> 8 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:核心表未完整创建';
+ END IF;
+ IF EXISTS (SELECT 1 FROM fl_loading_plan
+ WHERE plan_status NOT IN ('EDITING', 'LOADING', 'COMPLETED')) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:存在旧装柜状态';
+ END IF;
+ IF (SELECT COUNT(*) FROM information_schema.check_constraints
+ WHERE constraint_schema = 'baiyue_firstleg'
+ AND constraint_name = 'chk_loading_plan_status'
+ AND check_clause LIKE '%''EDITING''%'
+ AND check_clause LIKE '%''LOADING''%'
+ AND check_clause LIKE '%''COMPLETED''%') <> 1 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:装柜状态约束不是最终三状态';
+ END IF;
+ IF (SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_container_booking'
+ AND column_name IN ('booking_no', 'cabinet_no', 'warehouse_id', 'bill_of_lading_no',
+ 'vessel_name', 'voyage_no', 'container_type', 'capacity_cbm',
+ 'max_weight_kg', 'terminal_trucking_company')) <> 10 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:货柜原型字段缺失';
+ END IF;
+ IF (SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item'
+ AND column_name IN ('account_id', 'customer_name', 'active_inbound_detail_id', 'frozen_at',
+ 'destination_warehouse_name', 'product_name_zh', 'product_name_en',
+ 'product_image_file_id', 'pallet_count', 'net_weight_kg',
+ 'dimensional_weight_kg', 'product_quantity', 'units_per_carton',
+ 'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 17 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失';
+ END IF;
+ IF (SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item'
+ AND index_name = 'uk_loading_item_plan_detail' AND non_unique = 0) <> 2 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失';
+ END IF;
+ IF (SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = 'baiyue_firstleg'
+ AND table_name = 'fl_loading_pick_item'
+ AND column_name IN ('pick_task_id', 'loading_plan_item_id', 'inbound_id',
+ 'inbound_detail_id', 'planned_package_count',
+ 'picked_package_count', 'item_status', 'version')) <> 8 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA拣货明细字段缺失';
+ END IF;
+ IF (SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = 'baiyue_firstleg' AND non_unique = 0
+ AND index_name IN ('uk_loading_pick_task_plan',
+ 'uk_loading_pick_item_plan_item',
+ 'uk_loading_pick_history_operation')) <> 3 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA唯一索引缺失';
+ END IF;
+ IF (SELECT COUNT(*) FROM information_schema.triggers
+ WHERE trigger_schema = 'baiyue_firstleg'
+ AND trigger_name IN ('trg_container_booking_before_update',
+ 'trg_loading_plan_before_update', 'trg_loading_plan_after_update',
+ 'trg_loading_item_before_insert', 'trg_loading_item_before_update',
+ 'trg_loading_item_before_delete', 'trg_container_document_before_insert',
+ 'trg_container_document_before_update', 'trg_container_document_before_delete',
+ 'trg_loading_history_before_update', 'trg_loading_history_before_delete',
+ 'trg_loading_pick_task_before_update',
+ 'trg_loading_pick_item_before_update',
+ 'trg_loading_pick_item_before_delete',
+ 'trg_loading_pick_history_before_update',
+ 'trg_loading_pick_history_before_delete')) <> 16 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:不可逆状态或冻结触发器缺失';
+ END IF;
+ IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission
+ WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0
+ AND permission_code IN (
+ 'platform:firstleg:container:list', 'platform:firstleg:container:view',
+ 'platform:firstleg:container:create', 'platform:firstleg:container:edit',
+ 'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:create',
+ 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
+ 'platform:firstleg:smart-loading:push', 'platform:firstleg:preload:list',
+ 'platform:firstleg:preload:view', 'platform:firstleg:preload:edit',
+ 'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm',
+ 'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 16 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建';
+ END IF;
+ IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code))
+ FROM baiyue_iam.iam_role role
+ JOIN baiyue_iam.iam_role_permission grant_row ON grant_row.role_id = role.id
+ JOIN baiyue_iam.iam_permission permission ON permission.id = grant_row.permission_id
+ WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')
+ AND permission.tenant_id = 0
+ AND permission.permission_code IN (
+ 'platform:firstleg:container:list', 'platform:firstleg:container:view',
+ 'platform:firstleg:container:create',
+ 'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
+ 'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign',
+ 'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
+ 'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
+ 'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
+ 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
+ 'platform:firstleg:loading-list:view')) <> 32 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入';
+ END IF;
+END$$
+DELIMITER ;
+CALL assert_phase3_smart_loading_schema();
+DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema;
+
-- ============================================================
-- 2026-08-06 平台公共仓库正式主数据:固定作业仓 + FBA 地址库
@@ -5189,13 +6169,94 @@ ON DUPLICATE KEY UPDATE id = id;
DROP PROCEDURE IF EXISTS unified_assert_final_extensions;
DELIMITER $$
-CREATE PROCEDURE unified_assert_final_extensions()
+CREATE PROCEDURE phase3_inbound_add_column(
+ IN p_table_name VARCHAR(64),
+ IN p_column_name VARCHAR(64),
+ IN p_definition TEXT
+)
BEGIN
- IF (SELECT COUNT(*) FROM information_schema.statistics
- WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order'
- AND index_name = 'uk_order_tenant_inbound_code') <> 2 THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:订单入仓码唯一索引未正确创建';
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = p_table_name
+ AND column_name = p_column_name
+ ) THEN
+ SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `',
+ p_column_name, '` ', p_definition);
+ PREPARE stmt FROM @ddl;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;
+ END IF;
+END$$
+DELIMITER ;
+
+CALL phase3_inbound_add_column(
+ 'fl_inbound_order', 'inbound_role',
+ "VARCHAR(16) NOT NULL DEFAULT 'MASTER' COMMENT '入仓单层级:MASTER主单、CHILD子单' AFTER inbound_code");
+CALL phase3_inbound_add_column(
+ 'fl_inbound_order', 'parent_inbound_id',
+ "BIGINT UNSIGNED NULL COMMENT '直属主入仓单ID;主单为空' AFTER inbound_role");
+CALL phase3_inbound_add_column(
+ 'fl_inbound_order', 'root_inbound_id',
+ "BIGINT UNSIGNED NULL COMMENT '根主入仓单ID;存量主单可为空,按自身ID解释' AFTER parent_inbound_id");
+CALL phase3_inbound_add_column(
+ 'fl_inbound_order', 'split_sequence',
+ "INT UNSIGNED NULL COMMENT '同一主单下的子单顺序号' AFTER root_inbound_id");
+CALL phase3_inbound_add_column(
+ 'fl_inbound_order', 'split_status',
+ "VARCHAR(24) NOT NULL DEFAULT 'NOT_SPLIT' COMMENT '拆分状态:NOT_SPLIT、PARTIALLY_SPLIT、FULLY_SPLIT' AFTER split_sequence");
+CALL phase3_inbound_add_column(
+ 'fl_inbound_order', 'package_count',
+ "INT UNSIGNED NULL COMMENT '入仓单箱数;三期唯一数量口径' AFTER total_package_count");
+CALL phase3_inbound_add_column(
+ 'fl_inbound_order', 'master_order_id',
+ "BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER' AND deleted = 0 THEN order_id ELSE NULL END) STORED COMMENT '仅用于约束有效主单订单唯一性'");
+
+DROP PROCEDURE IF EXISTS phase3_inbound_add_column;
+
+DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
+DELIMITER $$
+CREATE PROCEDURE phase3_inbound_add_index(
+ IN p_table_name VARCHAR(64),
+ IN p_index_name VARCHAR(64),
+ IN p_definition TEXT
+)
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = p_table_name
+ AND index_name = p_index_name
+ ) THEN
+ SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD ', p_definition);
+ PREPARE stmt FROM @ddl;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;
+ END IF;
+END$$
+DELIMITER ;
+
+CALL phase3_inbound_add_index(
+ 'fl_inbound_order', 'uk_inbound_master_order',
+ 'UNIQUE KEY `uk_inbound_master_order` (`tenant_id`, `master_order_id`)');
+CALL phase3_inbound_add_index(
+ 'fl_inbound_order', 'uk_inbound_one_child',
+ 'UNIQUE KEY `uk_inbound_one_child` (`tenant_id`, `parent_inbound_id`)');
+CALL phase3_inbound_add_index(
+ 'fl_inbound_order', 'idx_inbound_root_role',
+ 'KEY `idx_inbound_root_role` (`tenant_id`, `root_inbound_id`, `inbound_role`, `created_at`, `id`)');
+
+DROP PROCEDURE IF EXISTS phase3_inbound_drop_legacy_index;
+DELIMITER $$
+CREATE PROCEDURE phase3_inbound_drop_legacy_index()
+BEGIN
+ IF EXISTS (
+ SELECT 1 FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'fl_inbound_order'
+ AND index_name = 'uk_inbound_tenant_order'
+ ) THEN
+ ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_tenant_order;
END IF;
IF EXISTS (
SELECT 1 FROM baiyue_order.ord_order
@@ -5209,279 +6270,486 @@ BEGIN
OR status IN ('SUBMITTED', 'APPROVED', 'ACCEPTED', 'PROCESSING', 'COMPLETED'))
AND inbound_code IS NULL)
)
+ SELECT 1 FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'fl_inbound_order'
+ AND index_name = 'uk_inbound_parent_sequence'
) THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:订单状态与入仓码数据不一致';
+ ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_parent_sequence;
END IF;
+END$$
+DELIMITER ;
+CALL phase3_inbound_drop_legacy_index();
+DROP PROCEDURE IF EXISTS phase3_inbound_drop_legacy_index;
+DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
+
+CREATE TABLE IF NOT EXISTS fl_inbound_split_batch (
+ id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID',
+ tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
+ parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID',
+ operation_id VARCHAR(64) NOT NULL COMMENT '业务操作幂等号',
+ idempotency_key VARCHAR(128) NOT NULL COMMENT '请求幂等键',
+ expected_parent_version INT NOT NULL COMMENT '操作时主单乐观锁版本',
+ package_count INT UNSIGNED NOT NULL COMMENT '本批次拆分箱数',
+ status VARCHAR(16) NOT NULL DEFAULT 'PREPARED' COMMENT 'PREPARED、APPLIED、CANCELLED',
+ remark VARCHAR(500) NULL COMMENT '拆分原因或备注',
+ created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
+ created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ applied_at DATETIME(3) NULL COMMENT '批次生效时间',
+ version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_inbound_split_operation (tenant_id, operation_id),
+ UNIQUE KEY uk_inbound_split_idempotency (tenant_id, parent_inbound_id, idempotency_key),
+ KEY idx_inbound_split_parent (tenant_id, parent_inbound_id, created_at, id),
+ CONSTRAINT chk_inbound_split_package CHECK (package_count > 0),
+ CONSTRAINT chk_inbound_split_status CHECK (status IN ('PREPARED', 'APPLIED', 'CANCELLED')),
+ CONSTRAINT fk_inbound_split_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id)
+) ENGINE=InnoDB COMMENT='主入仓单拆分批次;数量口径仅为箱数';
+
+CREATE TABLE IF NOT EXISTS fl_inbound_split_item (
+ id BIGINT UNSIGNED NOT NULL COMMENT '拆分明细ID',
+ tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
+ split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID',
+ parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID',
+ child_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '子入仓单ID',
+ line_no INT UNSIGNED NOT NULL COMMENT '同一拆分批次内的行号',
+ source_inbound_detail_id BIGINT UNSIGNED NULL COMMENT '订单货物明细逻辑ID',
+ source_measurement_id BIGINT UNSIGNED NULL COMMENT '来源测量批次ID',
+ source_measurement_item_id BIGINT UNSIGNED NULL COMMENT '来源测量箱规明细ID',
+ package_count INT UNSIGNED NOT NULL COMMENT '分配给子单的箱数',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_inbound_split_batch_line (tenant_id, split_batch_id, line_no),
+ UNIQUE KEY uk_inbound_split_batch_source_item (tenant_id, split_batch_id, source_measurement_item_id),
+ KEY idx_inbound_split_detail (tenant_id, source_inbound_detail_id, created_at),
+ CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0),
+ CONSTRAINT fk_inbound_split_item_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id),
+ CONSTRAINT fk_inbound_split_item_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id),
+ CONSTRAINT fk_inbound_split_item_child FOREIGN KEY (child_inbound_id) REFERENCES fl_inbound_order (id),
+ CONSTRAINT fk_inbound_split_item_measurement FOREIGN KEY (source_measurement_id) REFERENCES fl_inbound_measurement (id),
+ CONSTRAINT fk_inbound_split_item_measure_item FOREIGN KEY (source_measurement_item_id) REFERENCES fl_inbound_measurement_item (id)
+) ENGINE=InnoDB COMMENT='主子入仓单拆分数量台账';
+
+CREATE TABLE IF NOT EXISTS fl_inbound_split_history (
+ id BIGINT UNSIGNED NOT NULL COMMENT '拆分历史ID',
+ tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
+ split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID',
+ operation_id VARCHAR(64) NOT NULL COMMENT '历史操作幂等号',
+ action_type VARCHAR(24) NOT NULL COMMENT 'CREATE、APPLY、CANCEL',
+ before_snapshot JSON NULL COMMENT '操作前快照',
+ after_snapshot JSON NOT NULL COMMENT '操作后快照',
+ operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
+ operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
+ operation_reason VARCHAR(500) NULL COMMENT '操作原因',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_inbound_split_history_operation (tenant_id, operation_id),
+ KEY idx_inbound_split_history (tenant_id, split_batch_id, created_at, id),
+ CONSTRAINT chk_inbound_split_action CHECK (action_type IN ('CREATE', 'APPLY', 'CANCEL')),
+ CONSTRAINT fk_inbound_split_history_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id)
+) ENGINE=InnoDB COMMENT='不可变主子入仓单拆分审计历史';
+
+DROP PROCEDURE IF EXISTS phase3_inbound_add_column;
+DELIMITER $$
+CREATE PROCEDURE phase3_inbound_add_column(
+ IN p_table_name VARCHAR(64),
+ IN p_column_name VARCHAR(64),
+ IN p_definition TEXT
+)
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = p_table_name
+ AND column_name = p_column_name
+ ) THEN
+ SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `',
+ p_column_name, '` ', p_definition);
+ PREPARE stmt FROM @ddl;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;
+ END IF;
+END$$
+DELIMITER ;
+CALL phase3_inbound_add_column(
+ 'fl_inbound_measurement', 'split_batch_id',
+ "BIGINT UNSIGNED NULL COMMENT '产生该测量批次的入仓拆分批次ID' AFTER inbound_id");
+CALL phase3_inbound_add_column(
+ 'fl_inbound_measurement', 'source_measurement_id',
+ "BIGINT UNSIGNED NULL COMMENT '子单继承或复核时的来源测量批次ID' AFTER split_batch_id");
+CALL phase3_inbound_add_column(
+ 'fl_inbound_measurement_item', 'split_item_id',
+ "BIGINT UNSIGNED NULL COMMENT '产生该子单测量明细的拆分台账ID' AFTER measurement_id");
+CALL phase3_inbound_add_column(
+ 'fl_inbound_measurement_item', 'source_measurement_item_id',
+ "BIGINT UNSIGNED NULL COMMENT '被拆分继承的来源测量箱规明细ID' AFTER split_item_id");
+CALL phase3_inbound_add_column(
+ 'fl_inbound_split_item', 'line_no',
+ "INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一拆分批次内的行号' AFTER child_inbound_id");
+DROP PROCEDURE IF EXISTS phase3_inbound_add_column;
+
+DROP PROCEDURE IF EXISTS phase3_inbound_add_relation;
+DELIMITER $$
+CREATE PROCEDURE phase3_inbound_add_relation(
+ IN p_table_name VARCHAR(64),
+ IN p_constraint_name VARCHAR(64),
+ IN p_definition TEXT
+)
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.table_constraints
+ WHERE constraint_schema = DATABASE()
+ AND table_name = p_table_name
+ AND constraint_name = p_constraint_name
+ ) THEN
+ SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD CONSTRAINT `',
+ p_constraint_name, '` ', p_definition);
+ PREPARE stmt FROM @ddl;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;
+ END IF;
+END$$
+DELIMITER ;
+CALL phase3_inbound_add_relation(
+ 'fl_inbound_order', 'fk_inbound_parent_order',
+ 'FOREIGN KEY (`parent_inbound_id`) REFERENCES `fl_inbound_order` (`id`)');
+CALL phase3_inbound_add_relation(
+ 'fl_inbound_order', 'fk_inbound_root_order',
+ 'FOREIGN KEY (`root_inbound_id`) REFERENCES `fl_inbound_order` (`id`)');
+CALL phase3_inbound_add_relation(
+ 'fl_inbound_measurement', 'fk_measure_split_batch',
+ 'FOREIGN KEY (`split_batch_id`) REFERENCES `fl_inbound_split_batch` (`id`)');
+CALL phase3_inbound_add_relation(
+ 'fl_inbound_measurement', 'fk_measure_source_batch',
+ 'FOREIGN KEY (`source_measurement_id`) REFERENCES `fl_inbound_measurement` (`id`)');
+CALL phase3_inbound_add_relation(
+ 'fl_inbound_measurement_item', 'fk_measure_item_split_item',
+ 'FOREIGN KEY (`split_item_id`) REFERENCES `fl_inbound_split_item` (`id`)');
+CALL phase3_inbound_add_relation(
+ 'fl_inbound_measurement_item', 'fk_measure_item_source_item',
+ 'FOREIGN KEY (`source_measurement_item_id`) REFERENCES `fl_inbound_measurement_item` (`id`)');
+DROP PROCEDURE IF EXISTS phase3_inbound_add_relation;
+
+DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
+DELIMITER $$
+CREATE PROCEDURE phase3_inbound_add_index(
+ IN p_table_name VARCHAR(64),
+ IN p_index_name VARCHAR(64),
+ IN p_definition TEXT
+)
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = p_table_name
+ AND index_name = p_index_name
+ ) THEN
+ SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD ', p_definition);
+ PREPARE stmt FROM @ddl;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;
+ END IF;
+END$$
+DELIMITER ;
+CALL phase3_inbound_add_index(
+ 'fl_inbound_measurement', 'idx_measure_split_source',
+ 'KEY `idx_measure_split_source` (`tenant_id`, `split_batch_id`, `source_measurement_id`, `created_at`)');
+CALL phase3_inbound_add_index(
+ 'fl_inbound_measurement_item', 'idx_measure_item_split_source',
+ 'KEY `idx_measure_item_split_source` (`tenant_id`, `split_item_id`, `source_measurement_item_id`)');
+CALL phase3_inbound_add_index(
+ 'fl_inbound_split_item', 'uk_inbound_split_batch_line',
+ 'UNIQUE KEY `uk_inbound_split_batch_line` (`tenant_id`, `split_batch_id`, `line_no`)');
+CALL phase3_inbound_add_index(
+ 'fl_inbound_split_item', 'uk_inbound_split_batch_source_item',
+ 'UNIQUE KEY `uk_inbound_split_batch_source_item` (`tenant_id`, `split_batch_id`, `source_measurement_item_id`)');
+DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
+
+DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index;
+DELIMITER $$
+CREATE PROCEDURE phase3_inbound_drop_split_legacy_index()
+BEGIN
+ IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
+ AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_child') THEN
+ ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_child;
+ END IF;
+ IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
+ AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_batch_child') THEN
+ ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_batch_child;
+ END IF;
+END$$
+DELIMITER ;
+CALL phase3_inbound_drop_split_legacy_index();
+DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index;
+
+DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_insert;
+DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_update;
+DROP TRIGGER IF EXISTS trg_inbound_split_item_before_insert;
+DROP TRIGGER IF EXISTS trg_inbound_split_batch_before_insert;
+DROP TRIGGER IF EXISTS trg_inbound_split_batch_before_update;
+DROP TRIGGER IF EXISTS trg_inbound_split_item_before_update;
+DROP TRIGGER IF EXISTS trg_inbound_split_item_before_delete;
+DROP TRIGGER IF EXISTS trg_inbound_split_history_before_update;
+DROP TRIGGER IF EXISTS trg_inbound_split_history_before_delete;
+
+DELIMITER $$
+CREATE TRIGGER trg_inbound_order_parent_before_insert
+BEFORE INSERT ON fl_inbound_order
+FOR EACH ROW
+BEGIN
+ IF NEW.inbound_role NOT IN ('MASTER', 'CHILD') THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单层级非法';
+ END IF;
+ IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法';
+ END IF;
+ IF NEW.package_count IS NOT NULL AND NEW.package_count = 0 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单箱数必须大于0';
+ END IF;
+ IF NEW.inbound_role = 'MASTER' AND NEW.parent_inbound_id IS NOT NULL THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主入仓单不能绑定父单';
+ END IF;
+ IF NEW.inbound_role = 'CHILD' THEN
+ IF NEW.parent_inbound_id IS NULL OR NEW.root_inbound_id IS NULL
+ OR NEW.split_sequence IS NULL OR NEW.package_count IS NULL THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单缺少主单、顺序号或箱数';
+ END IF;
+ IF NEW.split_status <> 'NOT_SPLIT' THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子仅两级,子入仓单不可继续拆分';
+ END IF;
+ IF NOT EXISTS (
+ SELECT 1 FROM fl_inbound_order parent_order
+ WHERE parent_order.id = NEW.parent_inbound_id
+ AND parent_order.tenant_id = NEW.tenant_id
+ AND parent_order.inbound_role = 'MASTER'
+ AND parent_order.deleted = 0
+ ) OR NEW.root_inbound_id <> NEW.parent_inbound_id THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单必须绑定同一客户的有效主单';
+ END IF;
+ END IF;
+END$$
+
+CREATE TRIGGER trg_inbound_order_parent_before_update
+BEFORE UPDATE ON fl_inbound_order
+FOR EACH ROW
+BEGIN
+ IF NOT (OLD.inbound_role <=> NEW.inbound_role)
+ OR NOT (OLD.parent_inbound_id <=> NEW.parent_inbound_id)
+ OR NOT (OLD.root_inbound_id <=> NEW.root_inbound_id)
+ OR NOT (OLD.split_sequence <=> NEW.split_sequence) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单主子归属创建后不可修改';
+ END IF;
+ IF NEW.inbound_role = 'CHILD'
+ AND (NEW.package_count IS NULL OR NEW.package_count = 0) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单箱数必须大于0';
+ END IF;
+ IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法';
+ END IF;
+ IF NEW.inbound_role = 'CHILD' AND NEW.split_status <> 'NOT_SPLIT' THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子仅两级,子入仓单不可继续拆分';
+ END IF;
+ IF NEW.inbound_role = 'MASTER'
+ AND ((NEW.package_count = 0 AND NEW.split_status <> 'FULLY_SPLIT')
+ OR (NEW.split_status = 'FULLY_SPLIT' AND COALESCE(NEW.package_count, 0) <> 0)) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单箱数归零必须与完全拆分状态同时提交';
+ END IF;
+END$$
+
+CREATE TRIGGER trg_inbound_split_item_before_insert
+BEFORE INSERT ON fl_inbound_split_item
+FOR EACH ROW
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1
+ FROM fl_inbound_split_batch batch
+ JOIN fl_inbound_order parent_order ON parent_order.id = batch.parent_inbound_id
+ JOIN fl_inbound_order child_order ON child_order.id = NEW.child_inbound_id
+ WHERE batch.id = NEW.split_batch_id
+ AND batch.tenant_id = NEW.tenant_id
+ AND batch.parent_inbound_id = NEW.parent_inbound_id
+ AND batch.status = 'PREPARED'
+ AND parent_order.tenant_id = NEW.tenant_id
+ AND parent_order.inbound_role = 'MASTER'
+ AND child_order.tenant_id = NEW.tenant_id
+ AND child_order.inbound_role = 'CHILD'
+ AND child_order.parent_inbound_id = NEW.parent_inbound_id
+ AND child_order.package_count >= NEW.package_count
+ AND child_order.deleted = 0
+ AND (NEW.source_measurement_item_id IS NULL OR EXISTS (
+ SELECT 1
+ FROM fl_inbound_measurement_item source_item
+ JOIN fl_inbound_measurement source_batch ON source_batch.id = source_item.measurement_id
+ WHERE source_item.id = NEW.source_measurement_item_id
+ AND source_item.tenant_id = NEW.tenant_id
+ AND source_item.measurement_id = NEW.source_measurement_id
+ AND source_batch.inbound_id = NEW.parent_inbound_id
+ ))
+ ) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分明细与批次、主单或子单不一致';
+ END IF;
+END$$
+
+CREATE TRIGGER trg_inbound_split_batch_before_insert
+BEFORE INSERT ON fl_inbound_split_batch
+FOR EACH ROW
+BEGIN
+ IF NEW.status <> 'PREPARED' OR NEW.applied_at IS NOT NULL THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次必须从PREPARED创建';
+ END IF;
+ IF NOT EXISTS (
+ SELECT 1 FROM fl_inbound_order parent_order
+ WHERE parent_order.id = NEW.parent_inbound_id
+ AND parent_order.tenant_id = NEW.tenant_id
+ AND parent_order.inbound_role = 'MASTER'
+ AND parent_order.deleted = 0
+ ) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次主单不存在或客户归属不一致';
+ END IF;
+END$$
+
+CREATE TRIGGER trg_inbound_split_batch_before_update
+BEFORE UPDATE ON fl_inbound_split_batch
+FOR EACH ROW
+BEGIN
+ IF OLD.status IN ('APPLIED', 'CANCELLED') THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已结束拆分批次不可修改';
+ END IF;
+ IF NEW.status = 'APPLIED' THEN
+ IF NEW.applied_at IS NULL
+ OR NOT EXISTS (
+ SELECT 1 FROM fl_inbound_order parent_order
+ WHERE parent_order.id = NEW.parent_inbound_id
+ AND parent_order.tenant_id = NEW.tenant_id
+ AND parent_order.inbound_role = 'MASTER'
+ AND parent_order.package_count > 0
+ AND parent_order.split_status = 'PARTIALLY_SPLIT'
+ AND parent_order.version > NEW.expected_parent_version
+ ) OR
+ (SELECT COALESCE(SUM(item.package_count), 0)
+ FROM fl_inbound_split_item item
+ WHERE item.tenant_id = NEW.tenant_id
+ AND item.split_batch_id = NEW.id) <> NEW.package_count OR
+ (SELECT COUNT(DISTINCT item.child_inbound_id)
+ FROM fl_inbound_split_item item
+ WHERE item.tenant_id = NEW.tenant_id
+ AND item.split_batch_id = NEW.id) <> 1 OR
+ EXISTS (SELECT 1
+ FROM fl_inbound_split_item item
+ JOIN fl_inbound_order child_order ON child_order.id = item.child_inbound_id
+ WHERE item.tenant_id = NEW.tenant_id
+ AND item.split_batch_id = NEW.id
+ GROUP BY child_order.id, child_order.package_count
+ HAVING child_order.package_count <> SUM(item.package_count)) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入仓单或拆分明细箱数合计不一致';
+ END IF;
+ IF EXISTS (
+ SELECT 1
+ FROM fl_inbound_split_item split_item
+ JOIN fl_inbound_measurement_item source_item
+ ON source_item.id = split_item.source_measurement_item_id
+ WHERE split_item.tenant_id = NEW.tenant_id
+ AND split_item.split_batch_id = NEW.id
+ GROUP BY split_item.source_measurement_item_id, source_item.package_count
+ HAVING SUM(split_item.package_count) > source_item.package_count
+ ) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子单承接箱数超过来源测量箱规箱数';
+ END IF;
+ ELSEIF NEW.status = 'CANCELLED' THEN
+ IF EXISTS (
+ SELECT 1 FROM fl_inbound_split_item item
+ WHERE item.tenant_id = NEW.tenant_id
+ AND item.split_batch_id = NEW.id
+ ) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有子单明细的拆分批次不可取消';
+ END IF;
+ ELSEIF NEW.status <> 'PREPARED' THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次状态流转非法';
+ END IF;
+END$$
+
+CREATE TRIGGER trg_inbound_split_item_before_update
+BEFORE UPDATE ON fl_inbound_split_item
+FOR EACH ROW
+BEGIN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分数量台账不可修改';
+END$$
+
+CREATE TRIGGER trg_inbound_split_item_before_delete
+BEFORE DELETE ON fl_inbound_split_item
+FOR EACH ROW
+BEGIN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分数量台账不可删除';
+END$$
+
+CREATE TRIGGER trg_inbound_split_history_before_update
+BEFORE UPDATE ON fl_inbound_split_history
+FOR EACH ROW
+BEGIN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分历史不可修改';
+END$$
+
+CREATE TRIGGER trg_inbound_split_history_before_delete
+BEFORE DELETE ON fl_inbound_split_history
+FOR EACH ROW
+BEGIN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分历史不可删除';
+END$$
+DELIMITER ;
+
+DROP PROCEDURE IF EXISTS phase3_assert_inbound_parent_child_schema;
+DELIMITER $$
+CREATE PROCEDURE phase3_assert_inbound_parent_child_schema()
+BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables
- WHERE table_schema = 'baiyue_firstleg'
- AND table_name IN ('fl_preload_plan', 'fl_split_operation',
- 'fl_loading_evidence', 'fl_loading_operation',
- 'fl_loading_pick_task')) <> 5 THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:三期分柜、预装或装柜表未完整创建';
+ WHERE table_schema = DATABASE()
+ AND table_name IN ('fl_inbound_split_batch', 'fl_inbound_split_item',
+ 'fl_inbound_split_history')) <> 3 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单拆分台账未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
- WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_preload_plan'
- AND column_name IN ('id', 'tenant_id', 'inbound_id', 'status', 'version',
- 'items_json', 'remark', 'created_by', 'created_by_name',
- 'updated_by', 'updated_by_name', 'created_at', 'updated_at')) <> 13
- OR (SELECT COUNT(*) FROM information_schema.columns
- WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_split_operation'
- AND column_name IN ('id', 'tenant_id', 'inbound_id', 'operator_id', 'operation',
- 'idempotency_key', 'status', 'result_json', 'created_at')) <> 9
- OR (SELECT COUNT(*) FROM information_schema.columns
- WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_evidence'
- AND column_name IN ('id', 'tenant_id', 'inbound_id', 'evidence_type', 'file_id',
- 'file_name', 'seal_no', 'remark', 'version', 'created_by',
- 'created_by_name', 'updated_by', 'updated_by_name',
- 'created_at', 'updated_at')) <> 15
- OR (SELECT COUNT(*) FROM information_schema.columns
- WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_operation'
- AND column_name IN ('id', 'tenant_id', 'inbound_id', 'operator_id', 'operation',
- 'idempotency_key', 'request_hash', 'status', 'result_json',
- 'created_at', 'completed_at')) <> 11
- OR (SELECT COUNT(*) FROM information_schema.columns
- WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
- AND column_name IN ('id', 'tenant_id', 'inbound_id', 'status', 'version',
- 'expected_count', 'picked_count', 'remark', 'updated_by',
- 'updated_by_name', 'created_at', 'updated_at')) <> 12 THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:三期分柜、预装或装柜关键字段未完整创建';
- END IF;
- IF (SELECT COUNT(*) FROM information_schema.columns
- WHERE table_schema = 'baiyue_firstleg'
- AND table_name IN ('fl_preload_plan', 'fl_split_operation', 'fl_loading_operation')
- AND column_name IN ('items_json', 'result_json') AND data_type = 'json') <> 3
- OR (SELECT COUNT(*) FROM information_schema.columns
- WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_order'
- AND column_name = 'process_status' AND character_maximum_length >= 24) <> 1 THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:三期 JSON 字段或入库状态字段类型不兼容';
- END IF;
- IF (SELECT COUNT(DISTINCT CONCAT(table_name, '.', index_name))
- FROM information_schema.statistics
- WHERE table_schema = 'baiyue_firstleg'
- AND index_name IN ('uk_preload_tenant_inbound', 'idx_preload_status',
- 'uk_split_operation', 'idx_split_operation_time',
- 'uk_loading_evidence_type', 'idx_loading_evidence_time',
- 'uk_loading_operation', 'idx_loading_operation_time',
- 'uk_loading_pick_tenant_inbound', 'idx_loading_pick_status')) <> 10 THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:三期分柜、预装或装柜索引未完整创建';
- END IF;
- IF (SELECT COUNT(*) FROM information_schema.table_constraints
- WHERE constraint_schema = 'baiyue_firstleg' AND constraint_type = 'FOREIGN KEY'
- AND table_name IN ('fl_preload_plan', 'fl_split_operation',
- 'fl_loading_evidence', 'fl_loading_operation',
- 'fl_loading_pick_task')) <> 5 THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:三期操作表入库单外键未完整创建';
- END IF;
- IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission
- WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0
- AND permission_code IN (
- 'firstleg:inbound:split', 'firstleg:inbound:preload',
- 'firstleg:inbound:preload-view',
- 'platform:firstleg:inbound:loading:edit',
- 'platform:firstleg:inbound:loading:view',
- 'platform:firstleg:inbound:loading:complete',
- 'platform:firstleg:inbound:delivery:complete',
- 'platform:firstleg:inbound:picking:edit',
- 'platform:firstleg:inbound:picking:view')) <> 9 THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:三期分柜、预装或装柜权限未完整创建';
- END IF;
- IF (SELECT COUNT(DISTINCT rp.permission_id)
- FROM baiyue_iam.iam_role_permission rp
- JOIN baiyue_iam.iam_permission p ON p.id = rp.permission_id
- WHERE rp.role_id = 4000001 AND rp.tenant_id = 0
- AND p.permission_code IN (
- 'firstleg:inbound:split', 'firstleg:inbound:preload',
- 'firstleg:inbound:preload-view',
- 'platform:firstleg:inbound:loading:edit',
- 'platform:firstleg:inbound:loading:view',
- 'platform:firstleg:inbound:loading:complete',
- 'platform:firstleg:inbound:delivery:complete',
- 'platform:firstleg:inbound:picking:edit',
- 'platform:firstleg:inbound:picking:view')) <> 9 THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:平台系统管理员未获得完整三期操作权限';
- END IF;
- IF (SELECT COUNT(*) FROM information_schema.tables
- WHERE table_schema = 'baiyue_firstleg'
- AND table_name IN ('fl_fee_item', 'fl_inbound_fee_detail')) <> 2 THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:费用项目或入库费用明细表未完整创建';
- END IF;
- IF (SELECT COUNT(*) FROM information_schema.columns
- WHERE table_schema = 'baiyue_firstleg'
- AND table_name IN ('fl_fee_item', 'fl_inbound_fee_detail')
- AND column_name = 'tenant_id'
- AND column_comment = '当前客户ID(客户隔离键);物理字段名沿用 tenant_id,当前不表示上级租户') <> 2 THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:费用表 tenant_id 客户隔离语义未同步';
- END IF;
- IF (SELECT COUNT(*) FROM information_schema.columns
- WHERE table_schema = 'baiyue_firstleg'
- AND table_name = 'fl_fee_item'
- AND column_name = 'fee_code') <> 1
- OR (SELECT COUNT(*) FROM information_schema.columns
- WHERE table_schema = 'baiyue_firstleg'
- AND table_name = 'fl_inbound_fee_detail'
- AND column_name IN (
- 'source_type', 'source_fee_id', 'source_fee_version', 'source_fee_no',
- 'customer_id', 'customer_name', 'invoice_title',
- 'chargeable_quantity', 'charge_unit', 'publication_status',
- 'published_by', 'published_by_name', 'published_at', 'inbounded_at',
- 'settlement_included')) <> 15 THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:已发布入库运费明细字段未完整创建';
- END IF;
- IF (SELECT COUNT(*) FROM information_schema.statistics
- WHERE table_schema = 'baiyue_firstleg'
- AND table_name = 'fl_fee_item'
- AND index_name = 'uk_fee_item_code' AND non_unique = 0) <> 2
- OR (SELECT COUNT(*) FROM information_schema.statistics
- WHERE table_schema = 'baiyue_firstleg'
- AND table_name = 'fl_inbound_fee_detail'
- AND index_name = 'uk_inbound_fee_source' AND non_unique = 0) <> 3
- OR (SELECT COUNT(*) FROM information_schema.statistics
- WHERE table_schema = 'baiyue_firstleg'
- AND table_name = 'fl_inbound_fee_detail'
- AND index_name = 'idx_inbound_fee_published') <> 4 THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:已发布入库运费明细索引未完整创建';
- END IF;
- IF (SELECT COUNT(DISTINCT CONCAT(table_name, '.', index_name))
- FROM information_schema.statistics
- WHERE table_schema = 'baiyue_firstleg'
- AND index_name IN ('idx_fee_item_query', 'idx_fee_item_name',
- 'idx_inbound_fee_detail', 'idx_inbound_fee_review',
- 'idx_inbound_fee_item')) <> 5 THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:费用项目或入库费用明细索引未完整创建';
- END IF;
- IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission
- WHERE tenant_id = 0 AND status = 'ACTIVE' AND deleted = 0
- AND permission_code IN (
- 'firstleg:fee-item:view', 'firstleg:fee-item:create',
- 'firstleg:fee-item:edit', 'firstleg:fee-item:delete',
- 'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit',
- 'firstleg:inbound:fee-detail:delete', 'firstleg:inbound:fee-detail:review')) <> 8 THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:费用项目或入库费用明细权限未完整创建';
- END IF;
- IF (SELECT COUNT(*) FROM information_schema.tables
- WHERE table_schema IN ('baiyue_auth', 'baiyue_iam', 'baiyue_order',
- 'baiyue_firstleg', 'baiyue_warehouse')
- AND table_name = 'sys_audit_outbox') <> 5 THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:可靠审计 Outbox 未覆盖五个业务库';
- END IF;
- IF (SELECT COUNT(DISTINCT CONCAT(table_schema, '.', index_name))
- FROM information_schema.statistics
- WHERE table_schema IN ('baiyue_auth', 'baiyue_iam', 'baiyue_order',
- 'baiyue_firstleg', 'baiyue_warehouse')
- AND table_name = 'sys_audit_outbox'
- AND index_name IN ('PRIMARY', 'idx_audit_outbox_publish',
- 'idx_audit_outbox_retention')) <> 15 THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:可靠审计 Outbox 主键或索引未完整创建';
- END IF;
- IF (SELECT COUNT(*) FROM information_schema.columns
- WHERE table_schema IN ('baiyue_auth', 'baiyue_iam', 'baiyue_order',
- 'baiyue_firstleg', 'baiyue_warehouse')
- AND table_name = 'sys_audit_outbox'
- AND column_name IN ('event_id', 'payload_json', 'status',
- 'next_retry_at', 'lease_until')) <> 25 THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:可靠审计 Outbox 关键字段未完整创建';
- END IF;
- IF (SELECT COUNT(*) FROM information_schema.tables
- WHERE table_schema = 'baiyue_audit'
- AND table_name = 'sys_audit_dead_letter') <> 1 THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:审计死信补偿表未创建';
+ WHERE table_schema = DATABASE()
+ AND table_name = 'fl_inbound_order'
+ AND column_name IN ('inbound_role', 'parent_inbound_id', 'root_inbound_id',
+ 'split_sequence', 'split_status', 'package_count',
+ 'master_order_id')) <> 7 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单关系字段未完整创建';
END IF;
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
- WHERE table_schema = 'baiyue_audit'
- AND table_name = 'sys_audit_dead_letter'
- AND index_name IN ('PRIMARY', 'uk_audit_dead_event', 'idx_audit_dead_retry')) <> 3 THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:审计死信补偿表主键或索引未完整创建';
+ WHERE table_schema = DATABASE()
+ AND index_name IN ('uk_inbound_master_order', 'uk_inbound_parent_sequence',
+ 'uk_inbound_split_operation', 'uk_inbound_split_idempotency',
+ 'uk_inbound_split_child', 'uk_inbound_split_history_operation')) <> 6 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单唯一约束未完整创建';
END IF;
- IF (SELECT COUNT(*) FROM information_schema.columns
- WHERE table_schema = 'baiyue_audit' AND table_name = 'sys_audit_log'
- AND column_name = 'event_id') <> 1
- OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
- WHERE table_schema = 'baiyue_audit' AND table_name = 'sys_audit_log'
- AND index_name = 'uk_audit_event_id' AND non_unique = 0) <> 1 THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:审计消费幂等 event_id 唯一约束未就绪';
+ IF (SELECT COUNT(*) FROM information_schema.table_constraints
+ WHERE constraint_schema = DATABASE()
+ AND constraint_name IN ('fk_inbound_parent_order', 'fk_inbound_root_order',
+ 'fk_measure_split_batch', 'fk_measure_source_batch',
+ 'fk_measure_item_split_item', 'fk_measure_item_source_item')) <> 6 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单与测量关联约束未完整创建';
END IF;
- IF (SELECT COUNT(*) FROM information_schema.columns
- WHERE table_schema = 'baiyue_firstleg'
- AND table_name = 'fl_third_party_call_log'
- AND column_name = 'tenant_id' AND column_default = '0'
- AND column_comment = '第三方未知/未解析主体哨兵值;不是平台客户记录或平台作用域,不得用于业务数据授权') <> 1 THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:第三方未知主体 tenant_id=0 哨兵语义未就绪';
- END IF;
- IF (SELECT COUNT(*) FROM baiyue_warehouse.wh_warehouse WHERE tenant_id <> 0) > 0
- OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_warehouse_address WHERE tenant_id <> 0) > 0
- OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_service_capability WHERE tenant_id <> 0) > 0
- OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_express_company WHERE tenant_id <> 0) > 0
- OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_partner
- WHERE tenant_id <> 0 OR data_scope_id <> 0) > 0
- OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_partner_type
- WHERE tenant_id <> 0 OR data_scope_id <> 0) > 0
- OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_pallet WHERE tenant_id <> 0) > 0
- OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_location WHERE tenant_id <> 0) > 0
- OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_location_reservation WHERE tenant_id <> 0) > 0
- OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_location_occupancy WHERE tenant_id <> 0) > 0
- OR (SELECT COUNT(*) FROM baiyue_firstleg.fl_inbound_pallet_allocation
- WHERE warehouse_resource_tenant_id <> 0) > 0 THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:平台公共仓储资源仍有 tenant_id<>0';
+ IF (SELECT COUNT(*) FROM information_schema.triggers
+ WHERE trigger_schema = DATABASE()
+ AND trigger_name IN ('trg_inbound_order_parent_before_insert',
+ 'trg_inbound_order_parent_before_update',
+ 'trg_inbound_split_item_before_insert',
+ 'trg_inbound_split_batch_before_insert',
+ 'trg_inbound_split_batch_before_update',
+ 'trg_inbound_split_item_before_update',
+ 'trg_inbound_split_item_before_delete',
+ 'trg_inbound_split_history_before_update',
+ 'trg_inbound_split_history_before_delete')) <> 9 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单数据库保护触发器未完整创建';
END IF;
IF EXISTS (
- SELECT 1 FROM baiyue_warehouse.wh_warehouse_address a
- JOIN baiyue_warehouse.wh_warehouse w ON w.id = a.warehouse_id
- WHERE a.tenant_id <> w.tenant_id
- ) OR EXISTS (
- SELECT 1 FROM baiyue_warehouse.wh_service_capability c
- JOIN baiyue_warehouse.wh_warehouse w ON w.id = c.warehouse_id
- WHERE c.tenant_id <> w.tenant_id
- ) OR EXISTS (
- SELECT 1 FROM baiyue_warehouse.wh_pallet p
- JOIN baiyue_warehouse.wh_warehouse w ON w.id = p.warehouse_id
- WHERE p.tenant_id <> w.tenant_id
- ) OR EXISTS (
- SELECT 1 FROM baiyue_warehouse.wh_location l
- JOIN baiyue_warehouse.wh_warehouse w ON w.id = l.warehouse_id
- WHERE l.tenant_id <> w.tenant_id
- ) OR EXISTS (
- SELECT 1 FROM baiyue_warehouse.wh_location_reservation r
- JOIN baiyue_warehouse.wh_warehouse w ON w.id = r.warehouse_id
- JOIN baiyue_warehouse.wh_pallet p ON p.id = r.pallet_id
- JOIN baiyue_warehouse.wh_location l ON l.id = r.location_id
- WHERE r.tenant_id <> w.tenant_id OR r.tenant_id <> p.tenant_id OR r.tenant_id <> l.tenant_id
- ) OR EXISTS (
- SELECT 1 FROM baiyue_warehouse.wh_location_occupancy o
- JOIN baiyue_warehouse.wh_warehouse w ON w.id = o.warehouse_id
- JOIN baiyue_warehouse.wh_pallet p ON p.id = o.pallet_id
- JOIN baiyue_warehouse.wh_location l ON l.id = o.location_id
- JOIN baiyue_warehouse.wh_location_reservation r ON r.id = o.reservation_id
- WHERE o.tenant_id <> w.tenant_id OR o.tenant_id <> p.tenant_id
- OR o.tenant_id <> l.tenant_id OR o.tenant_id <> r.tenant_id
+ SELECT 1 FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'fl_inbound_order'
+ AND index_name = 'uk_inbound_tenant_order'
) THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:Warehouse 公共资源父子归属不一致';
- END IF;
- IF EXISTS (SELECT 1 FROM baiyue_warehouse.wh_overseas_storage_fee_rule WHERE tenant_id = 0) THEN
- SIGNAL SQLSTATE '45000'
- SET MESSAGE_TEXT = '统一迁移失败:客户费用规则缺少客户 tenant_id';
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '旧订单唯一索引仍阻止创建子入仓单';
END IF;
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'baiyue_firstleg'
@@ -5587,13 +6855,28 @@ BEGIN
END IF;
END$$
DELIMITER ;
-CALL unified_assert_final_extensions();
-DROP PROCEDURE IF EXISTS unified_assert_final_extensions;
+CALL phase3_assert_inbound_parent_child_schema();
+DROP PROCEDURE IF EXISTS phase3_assert_inbound_parent_child_schema;
--- tenant_id=0 在此表仅为未知/未解析第三方主体;数量可非零,但不得用于业务授权。
-SELECT COUNT(*) AS unresolved_third_party_calls
- FROM baiyue_firstleg.fl_third_party_call_log
- WHERE tenant_id = 0;
+-- ============================================================================
+-- 来源:firstleg/src/main/resources/db/phase3-inbounded-status-unification.sql
+-- ============================================================================
+-- 将已废弃的 WAIT_SPLIT 统一为 INBOUNDED。
+-- 执行前请确认所有服务已升级至本次状态契约;脚本可重复执行。
+USE baiyue_firstleg;
+UPDATE fl_inbound_order
+SET process_status = 'INBOUNDED',
+ updated_at = CURRENT_TIMESTAMP(3),
+ version = version + 1
+WHERE process_status = 'WAIT_SPLIT';
+
+UPDATE fl_inbound_status_history
+SET from_status = 'INBOUNDED'
+WHERE from_status = 'WAIT_SPLIT';
+
+UPDATE fl_inbound_status_history
+SET to_status = 'INBOUNDED'
+WHERE to_status = 'WAIT_SPLIT';
SELECT 'PASS' AS migration_status,
'统一增量全部执行完成;箱号映射、单箱测量、三期出仓、费用、公共资源及可靠审计结构校验通过' AS message;
diff --git a/docs/二三期业务功能测试回归报告.md b/docs/二三期业务功能测试回归报告.md
new file mode 100644
index 000000000..dffcb1557
--- /dev/null
+++ b/docs/二三期业务功能测试回归报告.md
@@ -0,0 +1,71 @@
+# 二期、三期业务功能回归报告
+
+测试时间:2026-08-07
+
+## 测试范围
+
+- 二期:企业资质、平台审核、公共主数据、客户计费规则、头程下单、入库测量、称重、计费与入库。
+- 三期:虚拟柜、智能装柜、预装、PDA 拣货和最终装柜清单。
+- 测试环境:本机 MySQL、Redis、Nacos;隔离服务端口 `19300` 到 `19306`。未停止、未占用用户的 `192xx` 服务端口。
+- 测试结束后已停止本次启动的 Gateway 和全部 `193xx` 隔离服务;未停止 MySQL、Redis、Nacos 或用户服务。
+
+## 项目索引结论
+
+- 二期主状态和测量、计费、入库编排在 `firstleg`;订单货物在 `order`;仓库、托盘、库位、往来单位在 `warehouse`;文件在 `file`。
+- 三期智能装柜、预装、PDA 拣货、装柜清单均在 `firstleg`。
+- 当前 `tenant_id` 在业务上代表客户归属;公共主数据应使用 `tenant_id=0`,订单服务仅可读取公共仓库和公共往来单位。
+
+## 二期结果
+
+结果:未通过,主链路在创建头程订单草稿前被阻断。
+
+- 已通过 15 项:平台/商户登录、Firstleg 健康检查、企业注册、营业执照及法人身份证上传、企业资质提交与平台审核、承运商与客户往来单位创建、收货仓创建、客户计费规则创建、费率版本创建。
+- 阻断接口:`POST /api/merchant/v1/orders/first-leg`。
+- 返回:业务码 `A0404`,提示往来单位不存在或不属于当前企业。
+
+### 阻断原因
+
+1. 订单服务 `OrderMasterDataGuard` 固定以公共归属 `tenantId=0` 查询往来单位和收货仓,并要求订单客户快照往来单位具有 `SHIPPER` 类型。
+2. 当前平台往来单位 API 对 `tenantId=0` 返回 `A0422`,校验信息为“最小不能小于 1”;公共主数据无法经 API 创建或查询。
+3. 旧订单请求仍强制 `carrierId`、`carrierName`。本次不传时返回 `A0422`。根据最新业务确认,头程业务不存在“承运商”概念,这一历史字段、校验、分配接口和费率依赖应移除或改为船司领域模型,不能继续作为下单前置条件。
+
+结论:在修复公共主数据 `tenantId=0` API 契约,并移除订单承运商强制校验前,二期无法从下单继续验证测量、称重、计费和入库。
+
+## 三期结果
+
+结果:通过。
+
+真实 API 已复核的完成装柜清单:
+
+- 装柜计划:`2085554481377796097`
+- 查询接口:`GET /api/platform/v1/first-leg/loading-lists/2085554481377796097?warehouseId=2084548444218646530`
+- 最终状态:`COMPLETED`
+- 集装箱号:`PH3-20260807103117`
+- 装柜箱数:2
+- 总件数:20
+- 总毛重:20kg
+- 总体积:0.24m3
+- 历史事件存在:`PDA_COMPLETE_LOADING`
+- 明细已有冻结时间,符合完成后不可修改的规则。
+
+定向服务层测试:29 项通过、0 失败、0 错误。
+
+- `SmartLoadingServiceTest`:15 项
+- `LoadingListServiceTest`:8 项
+- `LoadingPickServiceTest`:2 项
+- `PdaPickSettlementPlanTest`:3 项
+- `SmartLoadingStatusTest`:1 项
+
+## 构建与脚本观察
+
+- 离线全量模块测试执行到 `firstleg`:211 项中 1 项失败。失败项是 `InboundParentChildSqlContractTest`,原因是统一 SQL 已使用最终索引命名,但测试仍断言旧索引名 `uk_inbound_parent_sequence`;属于 SQL 契约断言滞后,不是 Java 业务断言失败。
+- 二期验收脚本需要 PowerShell 7 的 `SkipHttpErrorCheck`,而本机只有 Windows PowerShell 5;测试时使用了临时 UTF-8 BOM 兼容副本。
+- 原脚本引用的 `c7.png`、`c8.png` 示例图片未随项目提供;测试目录使用最小 PNG 仅验证上传协议。
+- 初始 File 隔离 JAR 未包含当前资质包上传端点,使用当前编译类启动后接口通过。发布前应重新打包 File 服务,避免运行包与源码不一致。
+
+## 建议处理顺序
+
+1. 移除头程订单及二期计费中的承运商强制字段、校验和分配逻辑;如需要船司,改为独立船司主数据,不复用往来单位承运商。
+2. 修复平台公共主数据 API:允许且仅允许平台身份使用 `tenantId=0` 创建、查询公共仓库和往来单位。
+3. 为订单下单提供可选的公共发货单位选择,或明确订单客户快照不再依赖 `SHIPPER` 往来单位。
+4. 完成以上两项后,重新执行二期全链路:下单、审核、入仓单、测量、OCR 称重、费用试算、费用确认、入库完成。
diff --git a/docs/系统状态枚举说明.md b/docs/系统状态枚举说明.md
new file mode 100644
index 000000000..72fb192ab
--- /dev/null
+++ b/docs/系统状态枚举说明.md
@@ -0,0 +1,185 @@
+# 系统状态枚举说明
+
+## 使用范围
+
+本文档说明当前后端实际使用的状态值。状态值是接口、数据库和前端联调契约,不能为了展示中文而修改其英文编码;前端展示应使用本说明中的中文含义。
+
+代码中的 Java 枚举、状态常量和数据库状态字段均已补充中文注释。本说明用于集中查询,不替代各业务状态机的校验。
+
+## 通用响应码
+
+| 状态值 | 中文含义 |
+| --- | --- |
+| `200` | 请求成功。 |
+| `A0400` | 请求参数不符合接口要求。 |
+| `A0401` | 未登录、令牌无效或会话过期。 |
+| `A0403` | 已登录但没有权限。 |
+| `A0404` | 资源不存在或不在可访问范围。 |
+| `A0409` | 状态、版本或幂等键冲突。 |
+| `A0422` | 字段或业务前置校验失败。 |
+| `A0429` | 请求频率或并发数量超限。 |
+| `B0001` | 通用业务失败。 |
+| `S0500` | 服务内部异常。 |
+| `S0503` | 下游服务、数据库或基础设施暂不可用。 |
+
+## 账号和权限
+
+| 字段 | 状态值 | 中文含义 |
+| --- | --- | --- |
+| 账号状态 | `ACTIVE` | 账号正常,可登录。 |
+| 账号状态 | `DISABLED` | 账号已停用,不允许登录。 |
+| 账号状态 | `LOCKED` | 账号已锁定,需要管理员解除。 |
+| 客户资料状态 | `PROFILE_REQUIRED` | 客户资料尚未补全。 |
+| 客户资料状态 | `PENDING` | 客户资料已提交,等待审核。 |
+| 客户资料状态 | `ACTIVE` | 客户审核通过,可正常开展业务。 |
+| 客户资料状态 | `REJECTED` | 客户资料审核驳回。 |
+| 客户资料状态 | `DISABLED` | 客户已停用。 |
+| 客户、成员、角色、权限状态 | `ACTIVE` | 已启用,可参与登录、授权或业务使用。 |
+| 客户、成员、角色、权限状态 | `DISABLED` | 已停用,不可继续使用。 |
+| 审核记录状态 | `PENDING` | 等待审核。 |
+| 审核记录状态 | `APPROVED` | 审核通过。 |
+| 审核记录状态 | `REJECTED` | 审核驳回。 |
+| 审核记录状态 | `SUPERSEDED` | 已被更新后的审核记录替代。 |
+
+## 订单状态
+
+| 状态值 | 中文含义 |
+| --- | --- |
+| `DRAFT` | 草稿,可继续编辑。 |
+| `SUBMITTED` | 已提交,等待平台审核。 |
+| `APPROVED` | 审核通过,可接单或进入履约。 |
+| `ACCEPTED` | 平台已接单。 |
+| `REJECTED` | 审核驳回,不允许进入履约。 |
+| `CANCELLED` | 已取消,订单结束。 |
+| `PROCESSING` | 履约处理中。 |
+| `COMPLETED` | 订单完成。 |
+
+统一订单列表的主状态是展示分组,不等于订单表的内部状态:`PENDING_REVIEW`、`WAIT_MEASURE`、`INBOUNDED`、`LOADING`、`LOADED`、`DELIVERED`。
+
+## 仓库与库位
+
+| 字段 | 状态值 | 中文含义 |
+| --- | --- | --- |
+| 仓库、往来单位、快递公司 | `ACTIVE` | 启用,可被下单和作业引用。 |
+| 仓库、往来单位、快递公司 | `INACTIVE` | 停用,不允许新增业务引用。 |
+| 托盘 | `AVAILABLE` | 可分配。 |
+| 托盘 | `RESERVED` | 已预占,尚未确认占用。 |
+| 托盘 | `OCCUPIED` | 已确认占用。 |
+| 库位 | `AVAILABLE` | 可用。 |
+| 库位 | `RESERVED` | 已预占。 |
+| 库位 | `OCCUPIED` | 已占用。 |
+| 库位 | `DISABLED` | 已停用,不允许分配。 |
+| 库位预占记录 | `RESERVED` | 预占生效中。 |
+| 库位预占记录 | `CONFIRMED` | 预占已转为正式占用。 |
+| 库位预占记录 | `RELEASED` | 已释放。 |
+| 库位预占记录 | `EXPIRED` | 预占超时失效。 |
+| 库位占用记录 | `OCCUPIED` | 正在占用。 |
+| 库位占用记录 | `RELEASED` | 已释放。 |
+
+## 二期入库
+
+### 入库单主流程状态
+
+| 状态值 | 中文含义 |
+| --- | --- |
+| `WAIT_MEASURE` | 等待录入或接收测量数据。 |
+| `MEASURING` | 正在测量、识别重量或人工修正。 |
+| `MEASURE_PENDING` | 测量已提交,等待审核。 |
+| `MEASURE_REJECTED` | 测量审核被驳回,等待修正或异常放行。 |
+| `WAIT_PRICING` | 测量通过或异常放行,等待计费。 |
+| `FEE_CONFIRMING` | 已生成费用,等待费用确认和发送。 |
+| `INBOUNDING` | 费用已确认,正在占用托盘、库位并完成入库。 |
+| `INBOUNDED` | 入库完成,可进入智能装柜或预装确认;费用状态独立维护。 |
+| `LOADING` | 已进入装柜作业,PDA 可执行拣货。 |
+| `LOADED` | 装柜完成,等待派送完成。 |
+| `DELIVERED` | 已完成派送。 |
+| `VOIDED` | 已作废,不允许继续处理。 |
+
+主链路为:`WAIT_MEASURE` -> `MEASURING` -> `MEASURE_PENDING` -> `WAIT_PRICING` -> `FEE_CONFIRMING` -> `INBOUNDING` -> `INBOUNDED` -> `LOADING` -> `LOADED` -> `DELIVERED`。测量驳回走 `MEASURE_PENDING` -> `MEASURE_REJECTED`;异常放行后回到 `WAIT_PRICING`。
+
+### 测量、重量和费用状态
+
+| 字段 | 状态值 | 中文含义 |
+| --- | --- | --- |
+| 测量批次 | `DRAFT` | 后台人工测量草稿,可编辑。 |
+| 测量批次 | `RECEIVED` | 已接收第三方测量回传,待平台提交审核。 |
+| 测量批次 | `SUBMITTED` | 已提交测量审核。 |
+| 测量批次 | `APPROVED` | 测量审核通过。 |
+| 测量批次 | `REJECTED` | 测量审核驳回。 |
+| 重量识别 | `PENDING_CONFIRM` | OCR 已识别有效数值,等待人工确认。 |
+| 重量识别 | `NEEDS_MANUAL` | OCR 无效或不完整,必须人工录入或修正。 |
+| 重量识别 | `CONFIRMED` | 重量已确认,可参与计费。 |
+| 测量审核、费用审核 | `NOT_SUBMITTED` | 尚未提交审核。 |
+| 测量审核、费用审核 | `PENDING` | 等待审核。 |
+| 测量审核、费用审核 | `APPROVED` | 审核通过。 |
+| 测量审核、费用审核 | `REJECTED` | 审核驳回。 |
+| 费用状态 | `UNPRICED` | 尚未计费。 |
+| 费用状态 | `CALCULATED` | 已计算费用,等待确认。 |
+| 费用状态 | `CONFIRMED` | 费用已确认,可发送给客户。 |
+| 费用状态 | `SENT` | 费用明细已发送给客户。 |
+| 客户通知状态 | `NOT_CREATED` | 尚未创建通知任务。 |
+| 客户通知状态 | `PENDING` | 已创建待处理通知。当前代码未发现发送完成后的独立状态更新。 |
+
+### 第三方回传和幂等记录
+
+| 字段 | 状态值 | 中文含义 |
+| --- | --- | --- |
+| 第三方测量调用 | `PROCESSING` | 请求正在处理。 |
+| 第三方测量调用 | `ACCEPTED` | 回传被接受,包含幂等重放成功。 |
+| 第三方测量调用 | `REJECTED` | 回传校验或处理失败。 |
+| 入库与计费幂等记录 | `PROCESSING` | 请求正在执行。 |
+| 入库与计费幂等记录 | `COMPLETED` | 请求已成功完成,可返回幂等结果。 |
+
+## 三期智能装柜和 PDA
+
+| 字段 | 状态值 | 中文含义 |
+| --- | --- | --- |
+| 装柜计划 | `EDITING` | 编辑中,可维护货柜信息,并可增删未拣货明细。 |
+| 装柜计划 | `LOADING` | 装柜中,已生成 PDA 拣货任务;未拣货明细仍可追加、修改或删除。 |
+| 装柜计划 | `COMPLETED` | PDA 拣货完成后自动完成,计划、明细和货柜信息只读。 |
+| PDA 拣货任务 | `LOADING` | 拣货作业进行中。 |
+| PDA 拣货任务 | `COMPLETED` | 拣货已完成。 |
+| PDA 拣货明细 | `PENDING` | 尚未开始拣货。 |
+| PDA 拣货明细 | `PICKING` | 已拣部分数量,仍可继续拣货。 |
+| PDA 拣货明细 | `PICKED` | 计划箱数已全部拣完。 |
+| PDA 拣货明细 | `PARTIAL` | 只拣到部分箱数,剩余数量需要生成关联子入库单。 |
+| PDA 拣货明细 | `SKIPPED` | 本次跳过,入库单回退为已入库并可重新参与智能装柜。 |
+| 预装清单 | `PENDING` | 预装草稿,尚未开始确认。 |
+| 预装清单 | `CONFIRMING` | 正在确认预装明细。 |
+| 预装清单 | `CONFIRMED` | 预装已确认,可进入装柜作业。 |
+| 新版拆单批次 | `PREPARED` | 已准备待应用。 |
+| 新版拆单批次 | `APPLIED` | 已应用并生成关联子入库单。 |
+| 新版拆单批次 | `CANCELLED` | 已取消,不能再次应用。 |
+| 旧版拆单记录 | `LOCKED` | 已锁定待完成。 |
+| 旧版拆单记录 | `COMPLETED` | 已完成并关联子入库单。 |
+
+装柜计划只能单向推进:`EDITING` -> `LOADING` -> `COMPLETED`,没有撤回状态。`COMPLETED` 后不允许编辑或删除装柜明细。
+
+## 文件、导出和审计
+
+| 字段 | 状态值 | 中文含义 |
+| --- | --- | --- |
+| 导出任务 | `PENDING` | 等待执行。 |
+| 导出任务 | `RUNNING` | 正在生成文件。 |
+| 导出任务 | `SUCCEEDED` | 文件已生成,可下载。 |
+| 导出任务 | `FAILED` | 生成失败,可按规则重试。 |
+| 导出任务 | `EXPIRED` | 文件已过期。 |
+| 导出消息 | `PENDING` | 等待投递。 |
+| 导出消息 | `CLAIMED` | 已被工作线程领取。 |
+| 导出消息 | `SENT` | 已成功投递。 |
+| 导出消息 | `QUARANTINED` | 异常隔离,等待人工或补偿处理。 |
+| 审计消息 | `PENDING` | 等待发送。 |
+| 审计消息 | `SENDING` | 正在发送。 |
+| 审计消息 | `SENT` | 已发送成功。 |
+| 审计消息 | `DEAD` | 多次失败,进入死信补偿。 |
+
+## 第三方能力和 OCR
+
+| 字段 | 状态值 | 中文含义 |
+| --- | --- | --- |
+| 第三方能力 | `DISABLED` | 能力未启用。 |
+| 第三方能力 | `NOT_CONFIGURED` | 已启用但密钥或配置不完整。 |
+| 第三方能力 | `READY` | 已启用且配置完整。 |
+| 单图 OCR | `RECOGNIZED` | 已识别到有效重量。 |
+| 单图 OCR | `NO_WEIGHT_FOUND` | 已处理图片但未识别到可用重量。 |
+| 单图 OCR | `FAILED` | 图片处理、供应商调用或解析失败。 |
diff --git a/firstleg/pom.xml b/firstleg/pom.xml
index 540bffc8e..b651e8fee 100644
--- a/firstleg/pom.xml
+++ b/firstleg/pom.xml
@@ -22,6 +22,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/BaiyueFirstlegApplication.java b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java
index f4f7c2abe..5952b910c 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java
@@ -8,10 +8,10 @@ import org.springframework.cloud.openfeign.EnableFeignClients;
/**
* 头程履约微服务启动入口,负责装配履约任务组件并扫描 MyBatis-Plus Mapper。
*/
-@MapperScan({
- "com.baiyue.firstleg.mapper",
- "com.baiyue.firstleg.loading.mapper",
- "com.baiyue.firstleg.split.mapper"
+@MapperScan(basePackages = {
+ "com.baiyue.firstleg.mapper",
+ "com.baiyue.firstleg.split.mapper",
+ "com.baiyue.firstleg.loading.mapper"
})
@EnableFeignClients(basePackages = "com.baiyue.firstleg.client")
@SpringBootApplication(scanBasePackages = "com.baiyue")
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 a32a8cdbb..3a095f79b 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java
@@ -26,7 +26,15 @@ 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);
+ }
}
record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java
new file mode 100644
index 000000000..d24820e4a
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java
@@ -0,0 +1,24 @@
+package com.baiyue.firstleg.client;
+
+import com.baiyue.common.core.api.ApiResponse;
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestHeader;
+
+/** 获取平台仓库主数据,供虚拟柜校验国内中转仓使用。 */
+@FeignClient(name = "warehouse-service", contextId = "firstlegWarehouseMasterDataClient",
+ url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouses")
+public interface WarehouseMasterDataClient {
+ @GetMapping("/{tenantId}/{warehouseId}/active")
+ ApiResponse active(@RequestHeader("X-Internal-Service-Token") String token,
+ @PathVariable long tenantId,
+ @PathVariable long warehouseId);
+
+ record Detail(Warehouse warehouse) {
+ }
+
+ record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName,
+ String warehouseType, String status) {
+ }
+}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/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/PlatformInboundMeasurementReviewController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java
index 8ddd133a3..733ba36ee 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java
@@ -51,7 +51,7 @@ public class PlatformInboundMeasurementReviewController {
@Valid @RequestBody MeasurementReviewRequest request) {
validateScope(user, tenantId);
service.approve(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
- // 统一订单主线要求测量审核后直接进入 WAIT_SPLIT,不把计费、库位或其他资料
+ // 统一订单主线要求测量审核后直接进入 INBOUNDED,不把计费、库位或其他资料
// 作为本版本的强制门禁。保留原 FeeSnapshotResponse 泛型以兼容既有客户端。
return ApiResponse.success(null);
}
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..11e3e0f20
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java
@@ -0,0 +1,92 @@
+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.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.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */
+@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,
+ @RequestParam @Positive long warehouseId) {
+ FirstlegScopeGuard.platform(user);
+ return ApiResponse.success(service.preloadDetail(planId, warehouseId));
+ }
+
+ @Operation(summary = "修改装柜运输字段", description = "仅装柜中可修改集装箱号与航次。")
+ @PatchMapping("/loading-plans/{planId}/transport")
+ @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
+ public ApiResponse updateTransport(
+ @AuthenticationPrincipal AuthenticatedUser user,
+ @PathVariable @Positive long planId,
+ @RequestParam @Positive long warehouseId,
+ @RequestHeader("Idempotency-Key") String idempotencyKey,
+ @Valid @RequestBody TransportUpdateRequest request) {
+ FirstlegScopeGuard.platform(user);
+ return ApiResponse.success(service.updateTransport(
+ planId, warehouseId, 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,
+ @RequestParam @Positive long warehouseId) {
+ FirstlegScopeGuard.platform(user);
+ return ApiResponse.success(service.confirmedDetail(planId, warehouseId));
+ }
+}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java
new file mode 100644
index 000000000..dbe9c39e8
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java
@@ -0,0 +1,71 @@
+package com.baiyue.firstleg.controller;
+
+import com.baiyue.common.core.api.ApiResponse;
+import com.baiyue.common.security.AuthenticatedUser;
+import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
+import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
+import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
+import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
+import com.baiyue.firstleg.service.FirstlegScopeGuard;
+import com.baiyue.firstleg.service.LoadingPickService;
+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,
+ @RequestParam @Positive long warehouseId) {
+ FirstlegScopeGuard.platform(user);
+ return ApiResponse.success(service.task(containerNo, warehouseId));
+ }
+
+ @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()));
+ }
+
+ @PostMapping("/tasks/{taskId}/items/{itemId}/partial")
+ @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
+ public ApiResponse partial(@AuthenticationPrincipal AuthenticatedUser user,
+ @PathVariable @Positive long taskId,
+ @PathVariable @Positive long itemId,
+ @Valid @RequestBody PartialPickRequest request) {
+ FirstlegScopeGuard.platform(user);
+ return ApiResponse.success(service.partial(taskId, itemId, request, user.userId(), user.username()));
+ }
+
+ @PostMapping("/tasks/{taskId}/items/{itemId}/skip")
+ @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
+ public ApiResponse skip(@AuthenticationPrincipal AuthenticatedUser user,
+ @PathVariable @Positive long taskId,
+ @PathVariable @Positive long itemId,
+ @Valid @RequestBody SkipRequest request) {
+ FirstlegScopeGuard.platform(user);
+ return ApiResponse.success(service.skip(taskId, itemId, request, user.userId(), user.username()));
+ }
+}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java
new file mode 100644
index 000000000..ea1020076
--- /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 warehouseId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.candidates(warehouseId));}
+ @GetMapping("/plans/by-booking/{bookingId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
+ public ApiResponse draftByBooking(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long bookingId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.draftByBooking(bookingId));}
+ @GetMapping("/plans/{planId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
+ 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);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);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);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);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/InboundListTab.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundListTab.java
index a8a1e3e4d..93336d26a 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundListTab.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundListTab.java
@@ -4,9 +4,13 @@ import java.util.List;
/** 入库列表页签及其流程状态映射。 */
public enum InboundListTab {
+ /** 待测量:包含待测量、测量中和测量审核驳回的入库单。 */
WAIT_MEASURE(List.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED")),
+ /** 待审核:测量数据已经提交,等待审核结论。 */
WAIT_REVIEW(List.of("MEASURE_PENDING")),
+ /** 待发送费用:等待计费、费用确认或正在完成入库。 */
WAIT_FEE_SEND(List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING")),
+ /** 已入库:费用已发送且入库完成。 */
INBOUNDED(List.of("INBOUNDED"));
private final List processStatuses;
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java
index 7339ccc18..1e5e621b0 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java
@@ -4,10 +4,35 @@ import java.util.Set;
/** 二期入库主流程状态,不包含原型中的“待客户确认”。 */
public final class InboundProcessStatus {
+ /** 等待录入或接收测量数据。 */
+ public static final String WAIT_MEASURE = "WAIT_MEASURE";
+ /** 已存在测量草稿、重量 OCR 或人工修正,尚未提交审核。 */
+ public static final String MEASURING = "MEASURING";
+ /** 测量已提交,等待平台审核。 */
+ public static final String MEASURE_PENDING = "MEASURE_PENDING";
+ /** 测量审核被驳回,等待修正或异常放行。 */
+ public static final String MEASURE_REJECTED = "MEASURE_REJECTED";
+ /** 测量已通过或异常放行,等待计费。 */
+ public static final String WAIT_PRICING = "WAIT_PRICING";
+ /** 已生成费用,等待费用确认和发送。 */
+ public static final String FEE_CONFIRMING = "FEE_CONFIRMING";
+ /** 费用已确认,正在执行托盘、库位占用和入库收口。 */
+ public static final String INBOUNDING = "INBOUNDING";
+ /** 已入库完成,费用已发送给客户。 */
+ public static final String INBOUNDED = "INBOUNDED";
+ /** 已进入装柜作业,PDA 可执行拣货。 */
+ public static final String LOADING = "LOADING";
+ /** 装柜完成,等待派送完成。 */
+ public static final String LOADED = "LOADED";
+ /** 已完成派送。 */
+ public static final String DELIVERED = "DELIVERED";
+ /** 已作废,不再允许继续处理。 */
+ public static final String VOIDED = "VOIDED";
+
public static final Set VALUES = Set.of(
- "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
- "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED",
- "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED", "VOIDED"
+ WAIT_MEASURE, MEASURING, MEASURE_PENDING, MEASURE_REJECTED,
+ WAIT_PRICING, FEE_CONFIRMING, INBOUNDING, INBOUNDED,
+ LOADING, LOADED, DELIVERED, VOIDED
);
private InboundProcessStatus() {
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..8c90af1af
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/SmartLoadingStatus.java
@@ -0,0 +1,21 @@
+package com.baiyue.firstleg.domain;
+
+/** 智能装柜状态,只允许沿此方向推进。 */
+public enum SmartLoadingStatus {
+ /** 编辑中:可以维护货柜信息并增删未拣货明细。 */
+ EDITING,
+ /** 装柜中:已生成 PDA 拣货任务,仍可追加或调整未拣货明细。 */
+ LOADING,
+ /** 装柜完成:PDA 拣货完成后自动完成,计划、明细和货柜信息只读。 */
+ 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..497edc6ac
--- /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,
+ @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/InboundMeasurementRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java
index 12d79d666..c07cf2003 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java
@@ -35,6 +35,7 @@ public final class InboundMeasurementRequests {
}
public record ItemRequest(
+ @NotNull @Positive Long inboundDetailId,
@NotBlank @Size(max = 32) String itemNo,
@NotBlank @Size(max = 64) String boxSpecification,
@NotNull @Positive @Max(1000000) Integer packageCount,
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java
index 3a423901e..1c9856c96 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java
@@ -47,6 +47,7 @@ public final class InboundMeasurementResponses {
public record Item(
Long id,
+ Long inboundDetailId,
String itemNo,
String boxSpecification,
Integer packageCount,
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..85e33f742
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java
@@ -0,0 +1,167 @@
+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,
+ @NotNull @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,
+ @NotNull @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..62c14bfac
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java
@@ -0,0 +1,52 @@
+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) Long warehouseId,
+ @NotNull @Min(1) Integer pickedPackageCount,
+ @JsonAlias("reservationIds") List<@Min(1) Long> locationIds,
+ @NotNull @Min(0) Integer version,
+ @NotBlank @Size(max = 128) String operationId
+ ) {
+ }
+
+ /** 部分拣货为该 PDA 明细的最终实际拣出箱数,不是增量。 */
+ public record PartialPickRequest(
+ @NotNull @Min(1) Long warehouseId,
+ @NotNull @Min(1) Integer finalPickedPackageCount,
+ @JsonAlias("reservationIds") List<@Min(1) Long> locationIds,
+ @NotNull @Min(0) Integer version,
+ @NotBlank @Size(max = 128) String operationId
+ ) {
+ }
+
+ /** 跳拣只能发生在尚未拣出任何箱数的 PDA 明细上。 */
+ public record SkipRequest(
+ @NotNull @Min(1) Long warehouseId,
+ @NotNull @Min(0) Integer version,
+ @NotBlank @Size(max = 128) String operationId,
+ @Size(max = 500) String reason
+ ) {
+ }
+
+ public record PickItem(Long id, Long loadingPlanItemId, Long inboundId, Long inboundDetailId,
+ Integer plannedPackageCount, Integer pickedPackageCount,
+ String itemStatus, Integer version) {
+ }
+
+ 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/MeasurementBatchCommand.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementBatchCommand.java
index c4b631e0f..3c3ed8dd1 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementBatchCommand.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementBatchCommand.java
@@ -28,6 +28,7 @@ public record MeasurementBatchCommand(
List
- items
) {
public record Item(
+ Long inboundDetailId,
String itemNo,
String boxSpecification,
int packageCount,
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..d2dcd723a
--- /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 inboundId,@NotNull @Positive Long inboundDetailId,
+ @NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {}
+ public record RecommendRequest(@NotNull @Min(0) Integer expectedVersion,
+ @NotBlank @Size(max=128) String operationId) {}
+ public record AdjustRequest(@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedPlanVersion,
+ @NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
+ public record DeleteRequest(@NotNull @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..f2450e391
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickHistoryEntity.java
@@ -0,0 +1,25 @@
+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 String actionType;
+ private Integer pickedPackageCount;
+ private String operationReason;
+ 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/loading/domain/LoadingEvidenceType.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/LoadingEvidenceType.java
index 7f3f4f017..74a5f40ef 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/LoadingEvidenceType.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/LoadingEvidenceType.java
@@ -6,10 +6,15 @@ import java.util.Set;
* 装柜证据类型。照片语义尚待产品确认,当前只固定四个步骤和一个铅封证据槽位。
*/
public final class LoadingEvidenceType {
+ /** 装柜过程第 1 步照片。 */
public static final String PHOTO_STEP_1 = "PHOTO_STEP_1";
+ /** 装柜过程第 2 步照片。 */
public static final String PHOTO_STEP_2 = "PHOTO_STEP_2";
+ /** 装柜过程第 3 步照片。 */
public static final String PHOTO_STEP_3 = "PHOTO_STEP_3";
+ /** 装柜过程第 4 步照片。 */
public static final String PHOTO_STEP_4 = "PHOTO_STEP_4";
+ /** 柜门铅封或封条证据。 */
public static final String SEAL = "SEAL";
public static final Set VALUES = Set.of(PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL);
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/PickingStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/PickingStatus.java
index b0b9fb351..98b0cb71b 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/PickingStatus.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/PickingStatus.java
@@ -1,8 +1,11 @@
package com.baiyue.firstleg.loading.domain;
public final class PickingStatus {
+ /** PDA 拣货尚未开始。 */
public static final String PENDING = "PENDING";
+ /** PDA 正在执行拣货。 */
public static final String IN_PROGRESS = "IN_PROGRESS";
+ /** PDA 拣货已完成。 */
public static final String COMPLETED = "COMPLETED";
private PickingStatus() {}
}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingPickTaskMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/InboundLoadingPickTaskMapper.java
similarity index 93%
rename from firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingPickTaskMapper.java
rename to firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/InboundLoadingPickTaskMapper.java
index dba3437f8..c3b7700c4 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingPickTaskMapper.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/InboundLoadingPickTaskMapper.java
@@ -7,7 +7,7 @@ import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
-public interface LoadingPickTaskMapper extends BaseMapper {
+public interface InboundLoadingPickTaskMapper extends BaseMapper {
@Update("UPDATE fl_loading_pick_task SET status=#{toStatus}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, "
+ "remark=COALESCE(#{remark}, remark), expected_count=COALESCE(#{expectedCount}, expected_count), "
+ "picked_count=COALESCE(#{pickedCount}, picked_count), version=version+1 WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} "
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java
index 60151b079..dedb358a6 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java
@@ -7,7 +7,7 @@ import com.baiyue.firstleg.loading.entity.LoadingOperationEntity;
import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
import com.baiyue.firstleg.loading.mapper.LoadingOperationMapper;
-import com.baiyue.firstleg.loading.mapper.LoadingPickTaskMapper;
+import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper;
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
@@ -19,7 +19,7 @@ public class LoadingRepository {
@Resource private LoadingInboundMapper inbounds;
@Resource private LoadingEvidenceMapper evidences;
@Resource private LoadingOperationMapper operations;
- @Resource private LoadingPickTaskMapper picks;
+ @Resource private InboundLoadingPickTaskMapper picks;
public LoadingInboundMapper.Snapshot findInbound(long tenantId, long inboundId) {
return inbounds.find(tenantId, inboundId);
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/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/InboundOrderMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java
index 9e727b3fe..6de2f68c4 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java
@@ -11,7 +11,7 @@ import org.apache.ibatis.annotations.Update;
public interface InboundOrderMapper extends BaseMapper {
@Update("""
UPDATE fl_inbound_order
- SET process_status = 'WAIT_SPLIT', measure_review_status = 'APPROVED',
+ SET process_status = 'INBOUNDED', measure_review_status = 'APPROVED',
inbounded_at = COALESCE(inbounded_at, CURRENT_TIMESTAMP(3)),
updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
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/InboundSplitSettlementMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java
new file mode 100644
index 000000000..249e474e5
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java
@@ -0,0 +1,56 @@
+package com.baiyue.firstleg.mapper;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+/** PDA部分拣货结算的客户归属锁定查询。 */
+@Mapper
+public interface InboundSplitSettlementMapper {
+ @Select("""
+ SELECT id, tenant_id AS tenantId, inbound_code AS inboundCode, inbound_role AS inboundRole,
+ process_status AS processStatus, version
+ FROM fl_inbound_order
+ WHERE tenant_id = #{accountId} AND id = #{inboundId} AND deleted = 0
+ FOR UPDATE
+ """)
+ ParentRow lockParent(@Param("accountId") long accountId, @Param("inboundId") long inboundId);
+
+ @Select("""
+ SELECT id
+ FROM fl_inbound_order
+ WHERE tenant_id = #{accountId} AND parent_inbound_id = #{parentInboundId}
+ AND inbound_role = 'CHILD' AND deleted = 0
+ LIMIT 1
+ FOR UPDATE
+ """)
+ Long childId(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId);
+
+ @Select({""})
+ List lockLatestApprovedItems(@Param("accountId") long accountId,
+ @Param("inboundId") long inboundId,
+ @Param("detailIds") List detailIds);
+
+ record ParentRow(Long id, Long tenantId, String inboundCode, String inboundRole,
+ String processStatus, Integer version) {
+ }
+
+ record MeasurementItemRow(Long measurementItemId, Long measurementId, Long inboundDetailId,
+ Integer packageCount) {
+ }
+}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java
new file mode 100644
index 000000000..3ea3334a8
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java
@@ -0,0 +1,287 @@
+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..bd92dbbed
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java
@@ -0,0 +1,111 @@
+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.Select;
+import org.apache.ibatis.annotations.Update;
+
+@Mapper
+public interface LoadingPickItemMapper extends BaseMapper {
+ @Select("""
+ SELECT pick_item.*
+ FROM fl_loading_pick_item pick_item
+ JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id
+ JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
+ JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
+ WHERE pick_item.pick_task_id = #{taskId} AND booking.warehouse_id = #{warehouseId}
+ AND plan_item.deleted = 0
+ ORDER BY pick_item.id ASC
+ """)
+ java.util.List selectTaskItemsWithAccount(@Param("taskId") long taskId,
+ @Param("warehouseId") long warehouseId);
+
+ @Select("""
+ SELECT pick_item.*
+ FROM fl_loading_pick_item pick_item
+ JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id
+ JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
+ JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
+ WHERE pick_item.pick_task_id = #{taskId} AND pick_item.id = #{itemId}
+ AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0
+ """)
+ LoadingPickItemEntity selectTaskItemWithAccount(@Param("taskId") long taskId,
+ @Param("itemId") long itemId,
+ @Param("warehouseId") long warehouseId);
+
+ @Update("""
+ UPDATE fl_loading_pick_item
+ SET item_status = CASE WHEN picked_package_count + #{count} = planned_package_count
+ THEN 'PICKED' ELSE 'PICKING' END,
+ picked_package_count = picked_package_count + #{count},
+ version = version + 1
+ WHERE id = #{id} AND version = #{version}
+ AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
+ JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
+ JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
+ WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
+ AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
+ AND picked_package_count + #{count} <= planned_package_count
+ """)
+ int pick(@Param("id") long id, @Param("count") int count, @Param("version") int version,
+ @Param("warehouseId") long warehouseId);
+
+ @Update("""
+ UPDATE fl_loading_pick_item
+ SET picked_package_count = #{finalCount}, item_status = 'PARTIAL', version = version + 1
+ WHERE id = #{id} AND version = #{version}
+ AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
+ JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
+ JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
+ WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
+ AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
+ AND item_status IN ('PENDING', 'PICKING')
+ AND picked_package_count < #{finalCount}
+ AND #{finalCount} < planned_package_count
+ """)
+ int partial(@Param("id") long id, @Param("finalCount") int finalCount, @Param("version") int version,
+ @Param("warehouseId") long warehouseId);
+
+ @Update("""
+ UPDATE fl_loading_pick_item
+ SET item_status = 'SKIPPED', version = version + 1
+ WHERE id = #{id} AND version = #{version}
+ AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
+ JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
+ JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
+ WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
+ AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
+ AND item_status = 'PENDING' AND picked_package_count = 0
+ """)
+ int skip(@Param("id") long id, @Param("version") int version, @Param("warehouseId") long warehouseId);
+
+ @Select("""
+ SELECT COUNT(*)
+ FROM fl_loading_pick_item
+ WHERE pick_task_id = #{taskId}
+ AND item_status NOT IN ('PICKED', 'PARTIAL', 'SKIPPED')
+ """)
+ long unresolvedCount(@Param("taskId") long taskId);
+
+ /**
+ * 以装柜计划的全部有效明细为完成口径,不能只检查当前 PDA 任务中的明细。
+ */
+ @Select("""
+ SELECT COUNT(*)
+ FROM fl_loading_plan_item plan_item
+ WHERE plan_item.loading_plan_id = #{planId}
+ AND plan_item.deleted = 0
+ AND NOT EXISTS (
+ SELECT 1
+ FROM fl_loading_pick_item pick_item
+ JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id
+ WHERE pick_item.loading_plan_item_id = plan_item.id
+ AND pick_task.loading_plan_id = #{planId}
+ AND pick_task.task_status = 'LOADING'
+ AND pick_item.picked_package_count = plan_item.package_count
+ )
+ """)
+ long unfinishedCount(@Param("planId") long planId);
+}
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..db7bf82a7
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java
@@ -0,0 +1,23 @@
+package com.baiyue.firstleg.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+@Mapper
+public interface LoadingPickTaskMapper extends BaseMapper {
+ @Select("""
+ SELECT DISTINCT task.*
+ FROM fl_loading_pick_task task
+ JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id = task.id
+ JOIN fl_loading_plan plan ON plan.id = task.loading_plan_id
+ JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
+ WHERE task.container_no = #{containerNo} AND task.task_status = 'LOADING'
+ AND booking.warehouse_id = #{warehouseId}
+ LIMIT 1
+ """)
+ LoadingPickTaskEntity selectLoadingByContainerAndAccount(@Param("containerNo") String containerNo,
+ @Param("warehouseId") long warehouseId);
+}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/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..f4c5da782
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java
@@ -0,0 +1,69 @@
+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-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
+ WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id
+ AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
+ -COALESCE((SELECT SUM(split_item.package_count)
+ FROM fl_inbound_split_item split_item
+ WHERE o.inbound_role='MASTER'
+ AND split_item.tenant_id=o.tenant_id
+ AND split_item.parent_inbound_id=o.id
+ AND split_item.source_measurement_item_id=i.id),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
+ COALESCE(i.unit_volume_cbm,0) unitVolumeCbm
+ FROM fl_inbound_order o
+ JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id
+ 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.warehouse_id=#{warehouseId} AND o.process_status='INBOUNDED'
+ AND i.inbound_detail_id IS NOT NULL
+ AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
+ WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id
+ AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
+ +COALESCE((SELECT SUM(split_item.package_count)
+ FROM fl_inbound_split_item split_item
+ WHERE o.inbound_role='MASTER'
+ AND split_item.tenant_id=o.tenant_id
+ AND split_item.parent_inbound_id=o.id
+ AND split_item.source_measurement_item_id=i.id),0)
+ ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC
+ """)
+ List candidates(@Param("warehouseId") long warehouseId);
+
+ @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-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
+ WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id
+ AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
+ -COALESCE((SELECT SUM(split_item.package_count)
+ FROM fl_inbound_split_item split_item
+ WHERE o.inbound_role='MASTER'
+ AND split_item.tenant_id=o.tenant_id
+ AND split_item.parent_inbound_id=o.id
+ AND split_item.source_measurement_item_id=i.id),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
+ COALESCE(i.unit_volume_cbm,0) unitVolumeCbm
+ FROM fl_inbound_order o
+ JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id
+ 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.warehouse_id=#{warehouseId} AND o.id=#{inboundId} AND o.process_status='INBOUNDED'
+ AND i.inbound_detail_id=#{detailId}
+ ORDER BY m.id DESC LIMIT 1 FOR UPDATE
+ """)
+ Candidate candidate(@Param("warehouseId") long warehouseId,@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/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java
index c110d52c0..33e3cb2a8 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java
@@ -209,7 +209,7 @@ public class FbaPricingRepository {
.eq(InboundPricingEntity::getProcessStatus, processStatus)
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED"))
.set(InboundPricingEntity::getFeeStatus, "CALCULATED");
- if (!"WAIT_SPLIT".equals(processStatus)) {
+ if (!"INBOUNDED".equals(processStatus)) {
update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING");
}
if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable);
@@ -229,7 +229,7 @@ public class FbaPricingRepository {
public int markInboundConfirmed(long tenantId, long inboundId) {
return inbounds.update(null, new LambdaUpdateWrapper()
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
- .in(InboundPricingEntity::getProcessStatus, List.of("FEE_CONFIRMING", "WAIT_SPLIT"))
+ .in(InboundPricingEntity::getProcessStatus, List.of("FEE_CONFIRMING", "INBOUNDED"))
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
}
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..d404ba6fe
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java
@@ -0,0 +1,114 @@
+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 LoadingPickTaskEntity taskByContainer(String containerNo, long warehouseId) {
+ return tasks.selectLoadingByContainerAndAccount(containerNo, warehouseId);
+ }
+
+ public LoadingPickItemEntity item(long taskId, long itemId) {
+ return items.selectOne(new LambdaQueryWrapper()
+ .eq(LoadingPickItemEntity::getPickTaskId, taskId)
+ .eq(LoadingPickItemEntity::getId, itemId));
+ }
+
+ public List items(long taskId) {
+ return items.selectList(new LambdaQueryWrapper()
+ .eq(LoadingPickItemEntity::getPickTaskId, taskId)
+ .orderByAsc(LoadingPickItemEntity::getId));
+ }
+
+ public List items(long taskId, long warehouseId) {
+ return items.selectTaskItemsWithAccount(taskId, warehouseId);
+ }
+
+ public LoadingPickItemEntity item(long taskId, long itemId, long warehouseId) {
+ return items.selectTaskItemWithAccount(taskId, itemId, warehouseId);
+ }
+
+ public void insert(LoadingPickTaskEntity task) {
+ tasks.insert(task);
+ }
+
+ 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, long warehouseId) {
+ return items.pick(itemId, count, version, warehouseId);
+ }
+
+ public int partial(long itemId, int finalCount, int version, long warehouseId) {
+ return items.partial(itemId, finalCount, version, warehouseId);
+ }
+
+ public int skip(long itemId, int version, long warehouseId) {
+ return items.skip(itemId, version, warehouseId);
+ }
+
+ public boolean allTaskItemsResolved(long taskId) {
+ return items.unresolvedCount(taskId) == 0;
+ }
+
+ public boolean allPlanItemsPicked(long planId) {
+ return items.unfinishedCount(planId) == 0;
+ }
+
+ 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..6275c7f2b
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java
@@ -0,0 +1,75 @@
+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 warehouseId) { return candidates.candidates(warehouseId); }
+ public Candidate candidate(long warehouseId, long inboundId, long detailId) {
+ return candidates.candidate(warehouseId, inboundId, detailId);
+ }
+ public LoadingPlanEntity plan(long planId) { return plans.selectById(planId); }
+ public LoadingPlanEntity planByBooking(long bookingId) {
+ 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 planId, long accountId, long detailId) {
+ return items.selectOne(new LambdaQueryWrapper()
+ .eq(LoadingPlanItemEntity::getLoadingPlanId, planId)
+ .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/repository/UnifiedInboundReadRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java
index 798ab618a..258a54b1d 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java
@@ -4,7 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper;
-import com.baiyue.firstleg.loading.mapper.LoadingPickTaskMapper;
+import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper;
import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
import com.baiyue.firstleg.split.mapper.PreloadPlanMapper;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
@@ -23,7 +23,7 @@ import java.util.stream.Collectors;
@Repository
public class UnifiedInboundReadRepository {
@Resource private PreloadPlanMapper preloads;
- @Resource private LoadingPickTaskMapper picks;
+ @Resource private InboundLoadingPickTaskMapper picks;
@Resource private LoadingEvidenceMapper evidences;
@Resource private InboundStatusHistoryMapper histories;
@Resource private InboundTransportSnapshotMapper transports;
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java
new file mode 100644
index 000000000..ac1c1ef6f
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java
@@ -0,0 +1,169 @@
+package com.baiyue.firstleg.service;
+
+import com.baomidou.mybatisplus.core.toolkit.IdWorker;
+import com.baiyue.common.core.api.PageResult;
+import com.baiyue.common.core.api.ApiResponse;
+import com.baiyue.common.core.api.ResultCode;
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.firstleg.client.WarehouseMasterDataClient;
+import com.baiyue.firstleg.domain.SmartLoadingStatus;
+import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
+import com.baiyue.firstleg.dto.ContainerDtos.Query;
+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.beans.factory.annotation.Value;
+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;
+ @Resource private WarehouseMasterDataClient warehouses;
+ @Value("${baiyue.security.internal-service-token:}") private String internalToken;
+ @Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
+ public PageResult page(Query query) {
+ validateRange(query.estimatedSailingFrom(), query.estimatedSailingTo(), "预计船期筛选");
+ return repository.page(normalize(query));
+ }
+ public ContainerBookingEntity detail(long id) { return required(id); }
+
+ @Transactional
+ public ContainerBookingEntity create(long operatorId, String operatorName, SaveRequest request) {
+ validateSchedule(request);
+ WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.warehouseId());
+ ensureUnique(trim(request.containerNo()), request.cabinetNo().trim(), null);
+ ContainerBookingEntity value = new ContainerBookingEntity();
+ value.setId(IdWorker.getId());
+ value.setBookingNo("CB" + value.getId());
+ copy(value, request);
+ value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName());
+ value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
+ value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
+ try { repository.insert(value); } catch (DuplicateKeyException ex) { throw duplicate(); }
+ 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());
+ WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.data().warehouseId());
+ ensureUnique(trim(request.data().containerNo()), request.data().cabinetNo().trim(), id);
+ copy(current, request.data());
+ current.setWarehouseId(warehouse.id()); current.setWarehouseName(warehouse.warehouseName());
+ try {
+ if (repository.updateDraft(current, request.version(), operatorId, operatorName) != 1) {
+ throw conflict("货柜状态或版本已变化,请刷新后重试");
+ }
+ } 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 WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) {
+ try {
+ ApiResponse response = warehouses.active(
+ internalToken, publicWarehouseTenantId, warehouseId);
+ if (response == null || !response.successful() || response.data() == null
+ || response.data().warehouse() == null) {
+ throw new BizException(ResultCode.NOT_FOUND, "国内中转仓不存在或已停用");
+ }
+ WarehouseMasterDataClient.Warehouse warehouse = response.data().warehouse();
+ if (!Long.valueOf(warehouseId).equals(warehouse.id())
+ || !Long.valueOf(publicWarehouseTenantId).equals(warehouse.tenantId())
+ || !"ACTIVE".equals(warehouse.status())) {
+ throw new BizException(ResultCode.NOT_FOUND, "国内中转仓不存在或已停用");
+ }
+ if (!"DOMESTIC".equals(warehouse.warehouseType()) || warehouse.warehouseName() == null
+ || warehouse.warehouseName().isBlank()) {
+ throw new BizException(ResultCode.VALIDATION_FAILED, "虚拟柜只能选择启用中的国内中转仓");
+ }
+ return warehouse;
+ } catch (BizException exception) {
+ throw exception;
+ } catch (RuntimeException exception) {
+ throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "国内中转仓服务暂时不可用");
+ }
+ }
+ private static void requireVersion(ContainerBookingEntity value, Integer version) {
+ if (!version.equals(value.getVersion())) throw conflict("货柜已被其他人修改,请刷新后重试");
+ }
+ 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(trim(r.warehouseName()));
+ v.setBillOfLadingNo(trim(r.billOfLadingNo())); v.setVesselName(trim(r.vesselName()));
+ v.setVoyageNo(trim(r.voyageNo())); v.setContainerType(r.containerType().trim());
+ v.setShippingCompanyId(r.shippingCompanyId()); v.setShippingCompanyName(trim(r.shippingCompanyName()));
+ 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/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java
index 6923e8d68..ab85557bf 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java
@@ -81,9 +81,9 @@ public class FbaPricingService {
throw conflict("入库单状态已变化,费用试算未保存");
}
complete(token, fee.getId(), now);
- String nextStatus = "WAIT_SPLIT".equals(inbound.getProcessStatus())
- ? "WAIT_SPLIT" : "FEE_CONFIRMING";
- return response(inbound, fee, estimate.unitPrice(), nextStatus);
+ String nextStatus = "INBOUNDED".equals(inbound.getProcessStatus())
+ ? "INBOUNDED" : "FEE_CONFIRMING";
+ return response(inbound, fee, rate.getUnitPrice(), nextStatus);
}
@Transactional
@@ -223,7 +223,7 @@ public class FbaPricingService {
private InboundPricingEntity requireEligibleInbound(long tenantId, long inboundId) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
- if (!List.of("WAIT_PRICING", "FEE_CONFIRMING", "WAIT_SPLIT").contains(inbound.getProcessStatus())
+ if (!List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDED").contains(inbound.getProcessStatus())
|| !List.of("UNPRICED", "CALCULATED").contains(inbound.getFeeStatus())) {
throw conflict("当前入库单状态不允许费用试算");
}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java
index f176ddd62..8365270a0 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java
@@ -5,6 +5,7 @@ import jakarta.annotation.Resource;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
+import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.client.MeasurementImageClient;
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
@@ -32,9 +33,13 @@ import java.util.Set;
/** 平台端入库测量、OCR 修正、重量确认和提交审核应用服务。 */
@Service
public class InboundMeasurementService {
+ /** 测量批次已提交,等待测量审核。 */
private static final String STATUS_SUBMITTED = "SUBMITTED";
+ /** OCR 或人工修正的重量已确认,可参与计费。 */
private static final String WEIGHT_CONFIRMED = "CONFIRMED";
+ /** OCR 已识别到有效重量,等待人工确认。 */
private static final String WEIGHT_PENDING_CONFIRM = "PENDING_CONFIRM";
+ /** OCR 结果无效或不完整,必须人工录入或修正。 */
private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL";
private static final Set WEIGHT_EDITABLE_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
@@ -47,9 +52,11 @@ public class InboundMeasurementService {
@Resource
private MeasurementBatchService batchService;
@Resource
- private MeasurementImageClient imageClient;
- @Value("${baiyue.security.internal-service-token}")
+ private OrderCargoClient orderCargoClient;
+ @Value("${baiyue.security.internal-service-token:}")
private String internalToken;
+ @Resource
+ private MeasurementImageClient imageClient;
public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
@@ -66,13 +73,14 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SaveDraftRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
+ validateInboundDetails(tenantId, inbound, request.items());
MeasurementBatchCommand command = new MeasurementBatchCommand(
tenantId, inboundId, request.measurementId(), request.version(),
MeasurementBatchService.SOURCE_MANUAL, "PLATFORM", null, null, null,
LocalDateTime.now(), null, request.originalDocumentNo(),
request.originalGrossWeightKg(), request.originalVolumeCbm(), request.actualGoodsName(),
operatorId, request.items().stream().map(item -> new MeasurementBatchCommand.Item(
- item.itemNo(), item.boxSpecification(), item.packageCount(), item.lengthCm(),
+ item.inboundDetailId(), item.itemNo(), item.boxSpecification(), item.packageCount(), item.lengthCm(),
item.widthCm(), item.heightCm(), item.unitWeightKg(), item.intelligentImageFileId(),
item.overLimitWarning(), item.outerLengthCm(), item.outerWidthCm(), item.outerHeightCm(),
item.remark())).toList());
@@ -354,7 +362,7 @@ public class InboundMeasurementService {
private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item) {
return new InboundMeasurementResponses.Item(
- item.getId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
+ item.getId(), item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getGirthCm(),
item.getUnitWeightKg(), item.getUnitVolumeCbm(), item.getTotalWeightKg(),
item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(),
@@ -397,6 +405,27 @@ public class InboundMeasurementService {
return inbound;
}
+ private void validateInboundDetails(long tenantId, InboundOrderEntity inbound,
+ List items) {
+ if (inbound.getOrderId() == null || inbound.getOrderId() <= 0) {
+ throw new BizException(ResultCode.CONFLICT, "入库单未关联有效订单,不能保存测量明细");
+ }
+ for (InboundMeasurementRequests.ItemRequest item : items) {
+ try {
+ ApiResponse response = orderCargoClient.detail(
+ internalToken, tenantId, inbound.getOrderId(), item.inboundDetailId());
+ if (response == null || !response.successful() || response.data() == null) {
+ String message = response == null || !StringUtils.hasText(response.message())
+ ? "订单货物明细校验失败" : response.message();
+ throw new BizException(ResultCode.of(response == null
+ ? ResultCode.SERVICE_UNAVAILABLE.code() : response.code()), message);
+ }
+ } catch (FeignException exception) {
+ throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法校验入库明细");
+ }
+ }
+ }
+
private InboundMeasurementEntity requireMeasurement(long tenantId, long inboundId, long measurementId) {
InboundMeasurementEntity measurement = repository.findMeasurement(tenantId, inboundId, measurementId);
if (measurement == null) {
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..b01eb24ef
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java
@@ -0,0 +1,242 @@
+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.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.baiyue.firstleg.repository.LoadingPickRepository;
+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 LoadingPickRepository pickRepository;
+ @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.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.warehouseId(),
+ query.createdFrom(), query.createdTo(), query.page(), query.size()));
+ }
+
+ public PreloadDetail preloadDetail(long planId, long warehouseId) {
+ AggregateRow row = requirePlan(planId);
+ requireWarehouse(row, warehouseId);
+ if (!LOADING_STATUSES.contains(row.getPlanStatus())) {
+ throw notFound("装柜中清单不存在");
+ }
+ return repository.preloadDetail(row);
+ }
+
+ public ConfirmedDetail confirmedDetail(long planId, long warehouseId) {
+ AggregateRow row = requirePlan(planId);
+ requireWarehouse(row, warehouseId);
+ 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 warehouseId, long operatorId, String operatorName,
+ String idempotencyKey, TransportUpdateRequest request) {
+ String containerNo = nullableTrim(request.containerNo());
+ String voyageNo = nullableTrim(request.voyageNo());
+ String requestHash = sha256("TRANSPORT|" + planId + '|' + request.expectedVersion()
+ + '|' + containerNo + '|' + voyageNo + '|' + trim(request.operationReason()));
+ String operationId = operationId("TRANSPORT", planId, idempotencyKey);
+ AggregateRow before = requirePlan(planId);
+ requireWarehouse(before, warehouseId);
+ HistoryRow replay = repository.history(operationId);
+ if (replay != null) {
+ requireSameRequest(replay, requestHash);
+ return repository.preloadDetail(before);
+ }
+
+ 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));
+ }
+
+ /**
+ * 最后一箱 PDA 拣货完成后自动调用。服务与数据库触发器均以计划全部有效明细为口径复核。
+ */
+ @Transactional
+ @BusinessAudit(event = "PDA_COMPLETE_LOADING", resourceType = "LOADING_PLAN",
+ resourceId = "#planId", description = "PDA拣货完成后自动生成装柜清单")
+ public void finishFromPda(long planId, long pickTaskId, long operatorId, String operatorName) {
+ String operationId = "PDA-COMPLETE-" + planId + '-' + pickTaskId;
+ if (repository.history(operationId) != null) {
+ return;
+ }
+ if (!pickRepository.allPlanItemsPicked(planId)) {
+ throw conflict("计划仍存在未完成PDA拣货的箱数,不能生成装柜清单");
+ }
+ AggregateRow before = requirePlan(planId);
+ if ("COMPLETED".equals(before.getPlanStatus())) {
+ return;
+ }
+ if (!"LOADING".equals(before.getPlanStatus())) {
+ throw conflict("当前状态为 " + before.getPlanStatus() + ",不能由PDA完成装柜");
+ }
+ if (repository.advance(planId, "LOADING", "COMPLETED", before.getVersion(),
+ operatorId, trim(operatorName)) != 1) {
+ AggregateRow current = requirePlan(planId);
+ if ("COMPLETED".equals(current.getPlanStatus())) {
+ return;
+ }
+ throw conflict("装柜计划已被其他操作修改,请刷新后重试");
+ }
+ repository.appendHistory(planId, operationId, "PDA_COMPLETE_LOADING",
+ "LOADING", "COMPLETED", json(snapshot(before)),
+ json(values("source", "PDA", "pickTaskId", pickTaskId, "status", "COMPLETED",
+ "version", before.getVersion() + 1)), operatorId, trim(operatorName),
+ "全部箱数已完成PDA拣货");
+ }
+
+ private AggregateRow requirePlan(long planId) {
+ AggregateRow row = repository.aggregate(planId);
+ if (row == null) {
+ throw notFound("装柜计划不存在");
+ }
+ return row;
+ }
+
+ private static void requireWarehouse(AggregateRow row, long warehouseId) {
+ if (warehouseId <= 0) {
+ throw badRequest("warehouseId必须大于0");
+ }
+ if (!java.util.Objects.equals(row.getWarehouseId(), warehouseId)) {
+ throw notFound("装柜计划不属于当前仓库");
+ }
+ }
+
+ private static void requireLoadingMutable(AggregateRow row) {
+ if (!LOADING_STATUSES.contains(row.getPlanStatus())) {
+ throw conflict("仅装柜中可修改集装箱号与航次");
+ }
+ }
+
+ 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..add01645e
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java
@@ -0,0 +1,270 @@
+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.PartialPickRequest;
+import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
+import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
+import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
+import com.baiyue.firstleg.entity.LoadingPickItemEntity;
+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;
+ @Resource
+ private LoadingListService loadingListService;
+
+ @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, long warehouseId) {
+ if (!StringUtils.hasText(containerNo)) {
+ throw new BizException(ResultCode.BAD_REQUEST, "集装箱号不能为空");
+ }
+ if (warehouseId <= 0) {
+ throw new BizException(ResultCode.BAD_REQUEST, "warehouseId必须大于0");
+ }
+ LoadingPickTaskEntity task = repository.taskByContainer(containerNo.trim(), warehouseId);
+ if (task == null) {
+ throw new BizException(ResultCode.NOT_FOUND, "未找到装柜中的PDA拣货任务");
+ }
+ return view(task, warehouseId);
+ }
+
+ @Transactional
+ public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) {
+ String operationId = request.operationId().trim();
+ String requestHash = hash(taskId + "|" + itemId + "|" + request.warehouseId()
+ + "|" + request.pickedPackageCount());
+ LoadingPickHistoryEntity replay = repository.history(operationId);
+ if (replay != null) {
+ if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId)
+ || !replay.getRequestHash().equals(requestHash)) {
+ throw conflict("operationId 已用于不同拣货请求");
+ }
+ return view(requiredTask(taskId), request.warehouseId());
+ }
+ LoadingPickTaskEntity task = requiredTask(taskId);
+ if (!"LOADING".equals(task.getTaskStatus())) {
+ throw conflict("装柜任务已完成,不能继续拣货");
+ }
+ LoadingPickItemEntity item = repository.item(taskId, itemId, request.warehouseId());
+ if (item == null) {
+ throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在");
+ }
+ if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) {
+ throw conflict("拣货数量超过计划箱数或明细版本已变化");
+ }
+ LoadingPickHistoryEntity history = new LoadingPickHistoryEntity();
+ history.setPickTaskId(taskId);
+ history.setPickItemId(itemId);
+ history.setOperationId(operationId);
+ history.setRequestHash(requestHash);
+ history.setActionType("PICK");
+ history.setPickedPackageCount(request.pickedPackageCount());
+ history.setOperatorId(operatorId);
+ history.setOperatorName(operatorName);
+ repository.insert(history);
+ if (repository.allPlanItemsPicked(task.getLoadingPlanId())) {
+ loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName);
+ }
+ return view(requiredTask(taskId), request.warehouseId());
+ }
+
+ @Transactional
+ public Task partial(long taskId, long itemId, PartialPickRequest request,
+ long operatorId, String operatorName) {
+ String operationId = request.operationId().trim();
+ String requestHash = hash("PARTIAL|" + taskId + "|" + itemId + "|" + request.warehouseId()
+ + "|" + request.finalPickedPackageCount());
+ LoadingPickHistoryEntity replay = repository.history(operationId);
+ if (replay != null) {
+ assertReplay(replay, taskId, itemId, requestHash);
+ return view(requiredTask(taskId), request.warehouseId());
+ }
+ LoadingPickTaskEntity task = requiredLoadingTask(taskId);
+ requireWarehouseItem(taskId, itemId, request.warehouseId());
+ if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(), request.warehouseId()) != 1) {
+ throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化");
+ }
+ insertHistory(taskId, itemId, operationId, requestHash, "PARTIAL",
+ request.finalPickedPackageCount(), null, operatorId, operatorName);
+ return view(requiredTask(taskId), request.warehouseId());
+ }
+
+ @Transactional
+ public Task skip(long taskId, long itemId, SkipRequest request, long operatorId, String operatorName) {
+ String operationId = request.operationId().trim();
+ String reason = trim(request.reason());
+ String requestHash = hash("SKIP|" + taskId + "|" + itemId + "|" + request.warehouseId()
+ + "|" + reason);
+ LoadingPickHistoryEntity replay = repository.history(operationId);
+ if (replay != null) {
+ assertReplay(replay, taskId, itemId, requestHash);
+ return view(requiredTask(taskId), request.warehouseId());
+ }
+ requiredLoadingTask(taskId);
+ requireWarehouseItem(taskId, itemId, request.warehouseId());
+ if (repository.skip(itemId, request.version(), request.warehouseId()) != 1) {
+ throw conflict("跳拣仅允许未开始拣货的待拣明细,或明细版本已变化");
+ }
+ insertHistory(taskId, itemId, operationId, requestHash, "SKIP", 0, reason,
+ operatorId, operatorName);
+ return view(requiredTask(taskId), request.warehouseId());
+ }
+
+ @Transactional
+ 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 LoadingPickTaskEntity requiredLoadingTask(long taskId) {
+ LoadingPickTaskEntity task = requiredTask(taskId);
+ if (!"LOADING".equals(task.getTaskStatus())) {
+ throw conflict("装柜任务已完成,不能继续拣货");
+ }
+ return task;
+ }
+
+ private void requireWarehouseItem(long taskId, long itemId, long warehouseId) {
+ if (repository.item(taskId, itemId, warehouseId) == null) {
+ throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在或不属于当前仓库");
+ }
+ }
+
+ private Task view(LoadingPickTaskEntity task, long warehouseId) {
+ List items = repository.items(task.getId(), warehouseId).stream()
+ .map(item -> new PickItem(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(),
+ item.getInboundDetailId(), item.getPlannedPackageCount(), item.getPickedPackageCount(),
+ item.getItemStatus(), item.getVersion())).toList();
+ return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerNo(), task.getTaskStatus(),
+ task.getVersion(), items);
+ }
+
+ private void insertHistory(long taskId, long itemId, String operationId, String requestHash,
+ String actionType, int pickedPackageCount, String reason,
+ long operatorId, String operatorName) {
+ LoadingPickHistoryEntity history = new LoadingPickHistoryEntity();
+ history.setPickTaskId(taskId);
+ history.setPickItemId(itemId);
+ history.setOperationId(operationId);
+ history.setRequestHash(requestHash);
+ history.setActionType(actionType);
+ history.setPickedPackageCount(pickedPackageCount);
+ history.setOperationReason(reason);
+ history.setOperatorId(operatorId);
+ history.setOperatorName(operatorName);
+ repository.insert(history);
+ }
+
+ private static void assertReplay(LoadingPickHistoryEntity replay, long taskId, long itemId,
+ String requestHash) {
+ if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId)
+ || !replay.getRequestHash().equals(requestHash)) {
+ throw conflict("operationId已用于不同拣货请求");
+ }
+ }
+
+ private static String hash(String value) {
+ try {
+ return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
+ .digest(value.getBytes(StandardCharsets.UTF_8)));
+ } catch (NoSuchAlgorithmException exception) {
+ throw new IllegalStateException("JVM 不支持 SHA-256", exception);
+ }
+ }
+
+ private static String trim(String value) {
+ return value == null ? null : value.trim();
+ }
+
+ private static BizException conflict(String message) {
+ return new BizException(ResultCode.CONFLICT, message);
+ }
+}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java
index f586c0416..274c3760a 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java
@@ -26,9 +26,13 @@ import java.util.Set;
*/
@Service
public class MeasurementBatchService {
+ /** 后台人工录入的测量来源。 */
static final String SOURCE_MANUAL = "MANUAL";
+ /** 第三方设备或开放接口回传的测量来源。 */
static final String SOURCE_THIRD_PARTY = "THIRD_PARTY";
+ /** 测量草稿,可继续编辑。 */
static final String STATUS_DRAFT = "DRAFT";
+ /** 已接收第三方测量数据,待平台提交审核。 */
static final String STATUS_RECEIVED = "RECEIVED";
static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
static final String CALCULATION_RULE_VERSION = "V1_CBM_CEILING_4_GIRTH_L2WH";
@@ -136,6 +140,7 @@ public class MeasurementBatchService {
BigDecimal totalVolume = unitVolume.multiply(packageCount).setScale(4, RoundingMode.CEILING);
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
+ item.setInboundDetailId(source.inboundDetailId());
item.setItemNo(source.itemNo());
item.setBoxSpecification(source.boxSpecification());
item.setPackageCount(source.packageCount());
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/MeasurementReviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java
index 786564866..cf6ae0953 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java
@@ -20,7 +20,7 @@ import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
/**
- * 测量审核应用服务。客服确认测量数据后将入库单推进到产品主状态 WAIT_SPLIT;
+ * 测量审核应用服务。客服确认测量数据后将入库单推进到产品主状态 INBOUNDED;
* 计费、库位和其他资料不作为本版本的强制门禁。
*/
@Service
@@ -91,7 +91,7 @@ public class MeasurementReviewService {
repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result,
request.reason(), false, null, false, operatorId, operatorName));
repository.insertHistory(history(tenantId, inboundId, "MEASURE_PENDING",
- approved ? "WAIT_SPLIT" : "MEASURE_REJECTED",
+ approved ? "INBOUNDED" : "MEASURE_REJECTED",
approved ? "MEASURE_APPROVE" : "MEASURE_REJECT", request.reason(), operatorId, operatorName));
idempotency.complete(context, inboundId);
}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementPlan.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementPlan.java
new file mode 100644
index 000000000..d0028eb74
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementPlan.java
@@ -0,0 +1,65 @@
+package com.baiyue.firstleg.service;
+
+import com.baiyue.common.core.api.ResultCode;
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.firstleg.entity.LoadingPickItemEntity;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/** PDA任务结束时的纯结算规则,不包含数据库写入。 */
+final class PdaPickSettlementPlan {
+ private PdaPickSettlementPlan() {
+ }
+
+ static Resolution resolve(List items) {
+ if (items == null || items.isEmpty()) {
+ throw new BizException(ResultCode.CONFLICT, "PDA任务没有可结算的拣货明细");
+ }
+ for (LoadingPickItemEntity item : items) {
+ if (item == null || !isFinal(item.getItemStatus())) {
+ throw new BizException(ResultCode.CONFLICT, "PDA任务仍存在未结束的拣货明细");
+ }
+ }
+ Map> partialByInbound = items.stream()
+ .filter(item -> "PARTIAL".equals(item.getItemStatus()))
+ .map(PdaPickSettlementPlan::splitLine)
+ .collect(Collectors.groupingBy(SplitLine::inboundId));
+ List skippedPlanItemIds = items.stream()
+ .filter(item -> "SKIPPED".equals(item.getItemStatus()))
+ .map(LoadingPickItemEntity::getLoadingPlanItemId)
+ .toList();
+ return new Resolution(partialByInbound, skippedPlanItemIds);
+ }
+
+ private static SplitLine splitLine(LoadingPickItemEntity item) {
+ int planned = requireCount(item.getPlannedPackageCount(), "计划箱数");
+ int picked = requireCount(item.getPickedPackageCount(), "实拣箱数");
+ if (picked <= 0 || picked >= planned || item.getInboundId() == null
+ || item.getInboundDetailId() == null || item.getId() == null
+ || item.getLoadingPlanItemId() == null) {
+ throw new BizException(ResultCode.CONFLICT, "部分拣货结算数据不完整或箱数不合法");
+ }
+ return new SplitLine(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(),
+ item.getInboundDetailId(), picked, planned - picked);
+ }
+
+ private static int requireCount(Integer value, String field) {
+ if (value == null || value < 0) {
+ throw new BizException(ResultCode.CONFLICT, field + "不合法");
+ }
+ return value;
+ }
+
+ private static boolean isFinal(String status) {
+ return "PICKED".equals(status) || "PARTIAL".equals(status) || "SKIPPED".equals(status);
+ }
+
+ record Resolution(Map> partialByInbound, List skippedPlanItemIds) {
+ }
+
+ record SplitLine(long pickItemId, long loadingPlanItemId, long inboundId, long inboundDetailId,
+ int pickedPackageCount, int remainingPackageCount) {
+ }
+}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java
new file mode 100644
index 000000000..9a53a5342
--- /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 warehouseId) {
+ if (warehouseId <= 0) {
+ throw new BizException(ResultCode.BAD_REQUEST, "warehouseId必须大于0");
+ }
+ return repository.candidates(warehouseId);
+ }
+
+ 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 view(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.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());
+ ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
+ Candidate candidate = repository.candidate(booking.getWarehouseId(), request.inboundId(), request.inboundDetailId());
+ if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
+ OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
+ LoadingPlanItemEntity existing = repository.activeItem(planId, candidate.accountId(), request.inboundDetailId());
+ int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount());
+ int availableIncludingCurrent = candidate.availablePackageCount()
+ + (existing == null ? 0 : existing.getPackageCount());
+ if (total > availableIncludingCurrent) throw validation("累计分配箱数超过可用箱数");
+ 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.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(booking.getWarehouseId())) {
+ if (!Objects.equals(candidate.warehouseId(), booking.getWarehouseId())) continue;
+ if (StringUtils.hasText(booking.getDestinationWarehouseCode())
+ && !Objects.equals(booking.getDestinationWarehouseCode(), candidate.destinationWarehouseCode())) continue;
+ int count = fittingBoxes(booking, repository.items(planId), candidate);
+ if (count > 0) {
+ plan = repository.plan(planId);
+ assign(planId, new AssignRequest(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.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.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突");
+ ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
+ Candidate candidate = repository.candidate(booking.getWarehouseId(), item.getInboundId(), item.getInboundDetailId());
+ if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数");
+ OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
+ List others = new ArrayList<>(repository.items(planId));
+ others.removeIf(x -> Objects.equals(x.getId(), itemId));
+ checkRouteAndCapacity(booking, 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.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, "装柜明细不存在");
+ 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/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java
index 014a70ed6..b5cd37f46 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java
@@ -24,11 +24,11 @@ import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
-/** Read-only adapter from internal inbound states to the seven product main statuses. */
+/** Read-only adapter from internal inbound states to the product main statuses. */
@Service
public class UnifiedInboundQueryService {
private static final Set MAIN_STATUSES = Set.of(
- "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
+ "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "LOADING", "LOADED", "DELIVERED");
private static final Set REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED");
@Resource private InboundOrderRepository inbounds;
@@ -120,8 +120,8 @@ public class UnifiedInboundQueryService {
if (status == null) return null;
return switch (status) {
case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED" -> "WAIT_MEASURE";
- case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT" -> "WAIT_SPLIT";
- case "INBOUNDED" -> "INBOUNDED";
+ // WAIT_SPLIT is retained only to normalize records created before status unification.
+ case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT", "INBOUNDED" -> "INBOUNDED";
case "LOADING", "LOADED", "DELIVERED" -> status;
default -> null;
};
@@ -132,7 +132,7 @@ public class UnifiedInboundQueryService {
return switch (mainStatus) {
case "PENDING_REVIEW" -> "APPROVE_ORDER";
case "WAIT_MEASURE" -> "MEASURE_PENDING".equals(processStatus) ? "CONFIRM_MEASUREMENT" : null;
- case "WAIT_SPLIT" -> !"WAIT_SPLIT".equals(processStatus) ? null
+ case "INBOUNDED" -> !List.of("INBOUNDED", "WAIT_SPLIT").contains(processStatus) ? null
: "CONFIRMED".equals(preloadStatus) ? null
: preloadStatus == null ? "SMART_SPLIT" : "CONFIRM_PRELOAD";
case "LOADING" -> "COMPLETE_LOADING";
@@ -146,8 +146,8 @@ public class UnifiedInboundQueryService {
if (!StringUtils.hasText(mainStatus)) return List.of();
return switch (mainStatus) {
case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED");
- case "WAIT_SPLIT" -> List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT");
- case "INBOUNDED", "LOADING", "LOADED", "DELIVERED" -> List.of(mainStatus);
+ case "INBOUNDED" -> List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT", "INBOUNDED");
+ case "LOADING", "LOADED", "DELIVERED" -> List.of(mainStatus);
default -> List.of("__NO_MATCH__");
};
}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/domain/PreloadStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/split/domain/PreloadStatus.java
index 3d9675b82..32200c3c6 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/split/domain/PreloadStatus.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/split/domain/PreloadStatus.java
@@ -4,8 +4,11 @@ import java.util.Set;
/** 预装清单子状态,固定为三态。 */
public final class PreloadStatus {
+ /** 预装清单草稿,尚未开始确认。 */
public static final String PENDING = "PENDING";
+ /** 正在确认预装明细。 */
public static final String CONFIRMING = "CONFIRMING";
+ /** 预装清单已确认,可进入装柜作业。 */
public static final String CONFIRMED = "CONFIRMED";
public static final Set VALUES = Set.of(PENDING, CONFIRMING, CONFIRMED);
private PreloadStatus() {}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java
index c608e8c06..159ef5853 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java
@@ -13,7 +13,7 @@ public interface SplitInboundMapper {
@Update("UPDATE fl_inbound_order SET process_status='LOADING', updated_by=#{operatorId}, "
+ "version=version+1 WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 "
- + "AND process_status='WAIT_SPLIT' AND version=#{expectedVersion}")
+ + "AND process_status='INBOUNDED' AND version=#{expectedVersion}")
int beginLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java
index 4b4b66672..33112be3d 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java
@@ -42,7 +42,7 @@ public class SplitPreloadService {
SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, SMART_SPLIT, key);
if (replay != null) return replay;
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
- if (!"WAIT_SPLIT".equals(inbound.processStatus())) {
+ if (!"INBOUNDED".equals(inbound.processStatus())) {
throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以生成预装清单");
}
PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId);
@@ -67,7 +67,7 @@ public class SplitPreloadService {
}
PreloadPlanResponse result = response(repository.findPlan(tenantId, inboundId));
return complete(tenantId, inboundId, operatorId, SMART_SPLIT, key,
- new SplitActionResponse("WAIT_SPLIT", result));
+ new SplitActionResponse("INBOUNDED", result));
}
@Transactional
@@ -77,11 +77,11 @@ public class SplitPreloadService {
SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, START_CONFIRMING, key);
if (replay != null) return replay;
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
- requireStatus(inbound, "WAIT_SPLIT", "仅待分柜订单可以提交预装清单确认");
+ requireStatus(inbound, "INBOUNDED", "仅待分柜订单可以提交预装清单确认");
PreloadPlanEntity plan = requirePlan(tenantId, inboundId);
if (PreloadStatus.CONFIRMING.equals(plan.getStatus())) {
return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key,
- new SplitActionResponse("WAIT_SPLIT", response(plan)));
+ new SplitActionResponse("INBOUNDED", response(plan)));
}
if (!PreloadStatus.PENDING.equals(plan.getStatus())
|| repository.changePlanStatus(tenantId, inboundId, PreloadStatus.PENDING,
@@ -89,7 +89,7 @@ public class SplitPreloadService {
throw concurrent();
}
return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key,
- new SplitActionResponse("WAIT_SPLIT", response(repository.findPlan(tenantId, inboundId))));
+ new SplitActionResponse("INBOUNDED", response(repository.findPlan(tenantId, inboundId))));
}
@Transactional
@@ -103,7 +103,7 @@ public class SplitPreloadService {
return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key,
new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId))));
}
- requireStatus(inbound, "WAIT_SPLIT", "仅待分柜订单可以确认预装清单");
+ requireStatus(inbound, "INBOUNDED", "仅待分柜订单可以确认预装清单");
PreloadPlanEntity plan = requirePlan(tenantId, inboundId);
if (PreloadStatus.CONFIRMED.equals(plan.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "预装清单已确认但订单状态尚未推进,请刷新重试");
@@ -115,7 +115,7 @@ public class SplitPreloadService {
if (repository.beginLoading(tenantId, inboundId, operatorId, inbound.version()) != 1) {
throw concurrent();
}
- addHistory(tenantId, inboundId, "WAIT_SPLIT", "LOADING", CONFIRM_PRELOAD,
+ addHistory(tenantId, inboundId, "INBOUNDED", "LOADING", CONFIRM_PRELOAD,
operatorId, operatorName);
return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key,
new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId))));
diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml
index ecd38f5a0..60af1f819 100644
--- a/firstleg/src/main/resources/application.yml
+++ b/firstleg/src/main/resources/application.yml
@@ -33,6 +33,8 @@ baiyue:
url: ${ORDER_SERVICE_URL:}
file:
url: ${FILE_SERVICE_URL:}
+ warehouse:
+ url: ${WAREHOUSE_SERVICE_URL:}
print:
url: ${PRINT_SERVICE_URL:}
firstleg:
diff --git a/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql b/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql
new file mode 100644
index 000000000..c2b48e1f3
--- /dev/null
+++ b/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql
@@ -0,0 +1,585 @@
+SET NAMES utf8mb4;
+USE baiyue_firstleg;
+
+/*
+ * 三期主子入仓单结构迁移。
+ * 仅建立结构和数据库保护,不自动拆分或回填存量业务数据。
+ * tenant_id 继续表示客户隔离键;数量统一使用 package_count。
+ */
+DROP PROCEDURE IF EXISTS phase3_inbound_add_column;
+DELIMITER $$
+CREATE PROCEDURE phase3_inbound_add_column(
+ IN p_table_name VARCHAR(64),
+ IN p_column_name VARCHAR(64),
+ IN p_definition TEXT
+)
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = p_table_name
+ AND column_name = p_column_name
+ ) THEN
+ SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `',
+ p_column_name, '` ', p_definition);
+ PREPARE stmt FROM @ddl;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;
+ END IF;
+END$$
+DELIMITER ;
+
+CALL phase3_inbound_add_column(
+ 'fl_inbound_order', 'inbound_role',
+ "VARCHAR(16) NOT NULL DEFAULT 'MASTER' COMMENT '入仓单层级:MASTER主单、CHILD子单' AFTER inbound_code");
+CALL phase3_inbound_add_column(
+ 'fl_inbound_order', 'parent_inbound_id',
+ "BIGINT UNSIGNED NULL COMMENT '直属主入仓单ID;主单为空' AFTER inbound_role");
+CALL phase3_inbound_add_column(
+ 'fl_inbound_order', 'root_inbound_id',
+ "BIGINT UNSIGNED NULL COMMENT '根主入仓单ID;存量主单可为空,按自身ID解释' AFTER parent_inbound_id");
+CALL phase3_inbound_add_column(
+ 'fl_inbound_order', 'split_sequence',
+ "INT UNSIGNED NULL COMMENT '同一主单下的子单顺序号' AFTER root_inbound_id");
+CALL phase3_inbound_add_column(
+ 'fl_inbound_order', 'split_status',
+ "VARCHAR(24) NOT NULL DEFAULT 'NOT_SPLIT' COMMENT '拆分状态:NOT_SPLIT、PARTIALLY_SPLIT、FULLY_SPLIT' AFTER split_sequence");
+CALL phase3_inbound_add_column(
+ 'fl_inbound_order', 'package_count',
+ "INT UNSIGNED NULL COMMENT '入仓单箱数;三期唯一数量口径' AFTER total_package_count");
+CALL phase3_inbound_add_column(
+ 'fl_inbound_order', 'master_order_id',
+ "BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER' AND deleted = 0 THEN order_id ELSE NULL END) STORED COMMENT '仅用于约束有效主单订单唯一性'");
+
+DROP PROCEDURE IF EXISTS phase3_inbound_add_column;
+
+DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
+DELIMITER $$
+CREATE PROCEDURE phase3_inbound_add_index(
+ IN p_table_name VARCHAR(64),
+ IN p_index_name VARCHAR(64),
+ IN p_definition TEXT
+)
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = p_table_name
+ AND index_name = p_index_name
+ ) THEN
+ SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD ', p_definition);
+ PREPARE stmt FROM @ddl;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;
+ END IF;
+END$$
+DELIMITER ;
+
+CALL phase3_inbound_add_index(
+ 'fl_inbound_order', 'uk_inbound_master_order',
+ 'UNIQUE KEY `uk_inbound_master_order` (`tenant_id`, `master_order_id`)');
+CALL phase3_inbound_add_index(
+ 'fl_inbound_order', 'uk_inbound_one_child',
+ 'UNIQUE KEY `uk_inbound_one_child` (`tenant_id`, `parent_inbound_id`)');
+CALL phase3_inbound_add_index(
+ 'fl_inbound_order', 'idx_inbound_root_role',
+ 'KEY `idx_inbound_root_role` (`tenant_id`, `root_inbound_id`, `inbound_role`, `created_at`, `id`)');
+
+DROP PROCEDURE IF EXISTS phase3_inbound_drop_legacy_index;
+DELIMITER $$
+CREATE PROCEDURE phase3_inbound_drop_legacy_index()
+BEGIN
+ IF EXISTS (
+ SELECT 1 FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'fl_inbound_order'
+ AND index_name = 'uk_inbound_tenant_order'
+ ) THEN
+ ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_tenant_order;
+ END IF;
+ IF EXISTS (
+ SELECT 1 FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'fl_inbound_order'
+ AND index_name = 'uk_inbound_parent_sequence'
+ ) THEN
+ ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_parent_sequence;
+ END IF;
+END$$
+DELIMITER ;
+CALL phase3_inbound_drop_legacy_index();
+DROP PROCEDURE IF EXISTS phase3_inbound_drop_legacy_index;
+DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
+
+CREATE TABLE IF NOT EXISTS fl_inbound_split_batch (
+ id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID',
+ tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
+ parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID',
+ operation_id VARCHAR(64) NOT NULL COMMENT '业务操作幂等号',
+ idempotency_key VARCHAR(128) NOT NULL COMMENT '请求幂等键',
+ expected_parent_version INT NOT NULL COMMENT '操作时主单乐观锁版本',
+ package_count INT UNSIGNED NOT NULL COMMENT '本批次拆分箱数',
+ status VARCHAR(16) NOT NULL DEFAULT 'PREPARED' COMMENT 'PREPARED、APPLIED、CANCELLED',
+ remark VARCHAR(500) NULL COMMENT '拆分原因或备注',
+ created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
+ created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ applied_at DATETIME(3) NULL COMMENT '批次生效时间',
+ version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_inbound_split_operation (tenant_id, operation_id),
+ UNIQUE KEY uk_inbound_split_idempotency (tenant_id, parent_inbound_id, idempotency_key),
+ KEY idx_inbound_split_parent (tenant_id, parent_inbound_id, created_at, id),
+ CONSTRAINT chk_inbound_split_package CHECK (package_count > 0),
+ CONSTRAINT chk_inbound_split_status CHECK (status IN ('PREPARED', 'APPLIED', 'CANCELLED')),
+ CONSTRAINT fk_inbound_split_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id)
+) ENGINE=InnoDB COMMENT='主入仓单拆分批次;数量口径仅为箱数';
+
+CREATE TABLE IF NOT EXISTS fl_inbound_split_item (
+ id BIGINT UNSIGNED NOT NULL COMMENT '拆分明细ID',
+ tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
+ split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID',
+ parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID',
+ child_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '子入仓单ID',
+ line_no INT UNSIGNED NOT NULL COMMENT '同一拆分批次内的行号',
+ source_inbound_detail_id BIGINT UNSIGNED NULL COMMENT '订单货物明细逻辑ID',
+ source_measurement_id BIGINT UNSIGNED NULL COMMENT '来源测量批次ID',
+ source_measurement_item_id BIGINT UNSIGNED NULL COMMENT '来源测量箱规明细ID',
+ package_count INT UNSIGNED NOT NULL COMMENT '分配给子单的箱数',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_inbound_split_batch_line (tenant_id, split_batch_id, line_no),
+ UNIQUE KEY uk_inbound_split_batch_source_item (tenant_id, split_batch_id, source_measurement_item_id),
+ KEY idx_inbound_split_detail (tenant_id, source_inbound_detail_id, created_at),
+ CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0),
+ CONSTRAINT fk_inbound_split_item_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id),
+ CONSTRAINT fk_inbound_split_item_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id),
+ CONSTRAINT fk_inbound_split_item_child FOREIGN KEY (child_inbound_id) REFERENCES fl_inbound_order (id),
+ CONSTRAINT fk_inbound_split_item_measurement FOREIGN KEY (source_measurement_id) REFERENCES fl_inbound_measurement (id),
+ CONSTRAINT fk_inbound_split_item_measure_item FOREIGN KEY (source_measurement_item_id) REFERENCES fl_inbound_measurement_item (id)
+) ENGINE=InnoDB COMMENT='主子入仓单拆分数量台账';
+
+CREATE TABLE IF NOT EXISTS fl_inbound_split_history (
+ id BIGINT UNSIGNED NOT NULL COMMENT '拆分历史ID',
+ tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
+ split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID',
+ operation_id VARCHAR(64) NOT NULL COMMENT '历史操作幂等号',
+ action_type VARCHAR(24) NOT NULL COMMENT 'CREATE、APPLY、CANCEL',
+ before_snapshot JSON NULL COMMENT '操作前快照',
+ after_snapshot JSON NOT NULL COMMENT '操作后快照',
+ operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
+ operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
+ operation_reason VARCHAR(500) NULL COMMENT '操作原因',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_inbound_split_history_operation (tenant_id, operation_id),
+ KEY idx_inbound_split_history (tenant_id, split_batch_id, created_at, id),
+ CONSTRAINT chk_inbound_split_action CHECK (action_type IN ('CREATE', 'APPLY', 'CANCEL')),
+ CONSTRAINT fk_inbound_split_history_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id)
+) ENGINE=InnoDB COMMENT='不可变主子入仓单拆分审计历史';
+
+DROP PROCEDURE IF EXISTS phase3_inbound_add_column;
+DELIMITER $$
+CREATE PROCEDURE phase3_inbound_add_column(
+ IN p_table_name VARCHAR(64),
+ IN p_column_name VARCHAR(64),
+ IN p_definition TEXT
+)
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = p_table_name
+ AND column_name = p_column_name
+ ) THEN
+ SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `',
+ p_column_name, '` ', p_definition);
+ PREPARE stmt FROM @ddl;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;
+ END IF;
+END$$
+DELIMITER ;
+CALL phase3_inbound_add_column(
+ 'fl_inbound_measurement', 'split_batch_id',
+ "BIGINT UNSIGNED NULL COMMENT '产生该测量批次的入仓拆分批次ID' AFTER inbound_id");
+CALL phase3_inbound_add_column(
+ 'fl_inbound_measurement', 'source_measurement_id',
+ "BIGINT UNSIGNED NULL COMMENT '子单继承或复核时的来源测量批次ID' AFTER split_batch_id");
+CALL phase3_inbound_add_column(
+ 'fl_inbound_measurement_item', 'split_item_id',
+ "BIGINT UNSIGNED NULL COMMENT '产生该子单测量明细的拆分台账ID' AFTER measurement_id");
+CALL phase3_inbound_add_column(
+ 'fl_inbound_measurement_item', 'source_measurement_item_id',
+ "BIGINT UNSIGNED NULL COMMENT '被拆分继承的来源测量箱规明细ID' AFTER split_item_id");
+CALL phase3_inbound_add_column(
+ 'fl_inbound_split_item', 'line_no',
+ "INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一拆分批次内的行号' AFTER child_inbound_id");
+DROP PROCEDURE IF EXISTS phase3_inbound_add_column;
+
+DROP PROCEDURE IF EXISTS phase3_inbound_add_relation;
+DELIMITER $$
+CREATE PROCEDURE phase3_inbound_add_relation(
+ IN p_table_name VARCHAR(64),
+ IN p_constraint_name VARCHAR(64),
+ IN p_definition TEXT
+)
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.table_constraints
+ WHERE constraint_schema = DATABASE()
+ AND table_name = p_table_name
+ AND constraint_name = p_constraint_name
+ ) THEN
+ SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD CONSTRAINT `',
+ p_constraint_name, '` ', p_definition);
+ PREPARE stmt FROM @ddl;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;
+ END IF;
+END$$
+DELIMITER ;
+CALL phase3_inbound_add_relation(
+ 'fl_inbound_order', 'fk_inbound_parent_order',
+ 'FOREIGN KEY (`parent_inbound_id`) REFERENCES `fl_inbound_order` (`id`)');
+CALL phase3_inbound_add_relation(
+ 'fl_inbound_order', 'fk_inbound_root_order',
+ 'FOREIGN KEY (`root_inbound_id`) REFERENCES `fl_inbound_order` (`id`)');
+CALL phase3_inbound_add_relation(
+ 'fl_inbound_measurement', 'fk_measure_split_batch',
+ 'FOREIGN KEY (`split_batch_id`) REFERENCES `fl_inbound_split_batch` (`id`)');
+CALL phase3_inbound_add_relation(
+ 'fl_inbound_measurement', 'fk_measure_source_batch',
+ 'FOREIGN KEY (`source_measurement_id`) REFERENCES `fl_inbound_measurement` (`id`)');
+CALL phase3_inbound_add_relation(
+ 'fl_inbound_measurement_item', 'fk_measure_item_split_item',
+ 'FOREIGN KEY (`split_item_id`) REFERENCES `fl_inbound_split_item` (`id`)');
+CALL phase3_inbound_add_relation(
+ 'fl_inbound_measurement_item', 'fk_measure_item_source_item',
+ 'FOREIGN KEY (`source_measurement_item_id`) REFERENCES `fl_inbound_measurement_item` (`id`)');
+DROP PROCEDURE IF EXISTS phase3_inbound_add_relation;
+
+DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
+DELIMITER $$
+CREATE PROCEDURE phase3_inbound_add_index(
+ IN p_table_name VARCHAR(64),
+ IN p_index_name VARCHAR(64),
+ IN p_definition TEXT
+)
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = p_table_name
+ AND index_name = p_index_name
+ ) THEN
+ SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD ', p_definition);
+ PREPARE stmt FROM @ddl;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;
+ END IF;
+END$$
+DELIMITER ;
+CALL phase3_inbound_add_index(
+ 'fl_inbound_measurement', 'idx_measure_split_source',
+ 'KEY `idx_measure_split_source` (`tenant_id`, `split_batch_id`, `source_measurement_id`, `created_at`)');
+CALL phase3_inbound_add_index(
+ 'fl_inbound_measurement_item', 'idx_measure_item_split_source',
+ 'KEY `idx_measure_item_split_source` (`tenant_id`, `split_item_id`, `source_measurement_item_id`)');
+CALL phase3_inbound_add_index(
+ 'fl_inbound_split_item', 'uk_inbound_split_batch_line',
+ 'UNIQUE KEY `uk_inbound_split_batch_line` (`tenant_id`, `split_batch_id`, `line_no`)');
+CALL phase3_inbound_add_index(
+ 'fl_inbound_split_item', 'uk_inbound_split_batch_source_item',
+ 'UNIQUE KEY `uk_inbound_split_batch_source_item` (`tenant_id`, `split_batch_id`, `source_measurement_item_id`)');
+DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
+
+DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index;
+DELIMITER $$
+CREATE PROCEDURE phase3_inbound_drop_split_legacy_index()
+BEGIN
+ IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
+ AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_child') THEN
+ ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_child;
+ END IF;
+ IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
+ AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_batch_child') THEN
+ ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_batch_child;
+ END IF;
+END$$
+DELIMITER ;
+CALL phase3_inbound_drop_split_legacy_index();
+DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index;
+
+DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_insert;
+DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_update;
+DROP TRIGGER IF EXISTS trg_inbound_split_item_before_insert;
+DROP TRIGGER IF EXISTS trg_inbound_split_batch_before_insert;
+DROP TRIGGER IF EXISTS trg_inbound_split_batch_before_update;
+DROP TRIGGER IF EXISTS trg_inbound_split_item_before_update;
+DROP TRIGGER IF EXISTS trg_inbound_split_item_before_delete;
+DROP TRIGGER IF EXISTS trg_inbound_split_history_before_update;
+DROP TRIGGER IF EXISTS trg_inbound_split_history_before_delete;
+
+DELIMITER $$
+CREATE TRIGGER trg_inbound_order_parent_before_insert
+BEFORE INSERT ON fl_inbound_order
+FOR EACH ROW
+BEGIN
+ IF NEW.inbound_role NOT IN ('MASTER', 'CHILD') THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单层级非法';
+ END IF;
+ IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法';
+ END IF;
+ IF NEW.package_count IS NOT NULL AND NEW.package_count = 0 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单箱数必须大于0';
+ END IF;
+ IF NEW.inbound_role = 'MASTER' AND NEW.parent_inbound_id IS NOT NULL THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主入仓单不能绑定父单';
+ END IF;
+ IF NEW.inbound_role = 'CHILD' THEN
+ IF NEW.parent_inbound_id IS NULL OR NEW.root_inbound_id IS NULL
+ OR NEW.split_sequence IS NULL OR NEW.package_count IS NULL THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单缺少主单、顺序号或箱数';
+ END IF;
+ IF NEW.split_status <> 'NOT_SPLIT' THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子仅两级,子入仓单不可继续拆分';
+ END IF;
+ IF NOT EXISTS (
+ SELECT 1 FROM fl_inbound_order parent_order
+ WHERE parent_order.id = NEW.parent_inbound_id
+ AND parent_order.tenant_id = NEW.tenant_id
+ AND parent_order.inbound_role = 'MASTER'
+ AND parent_order.deleted = 0
+ ) OR NEW.root_inbound_id <> NEW.parent_inbound_id THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单必须绑定同一客户的有效主单';
+ END IF;
+ END IF;
+END$$
+
+CREATE TRIGGER trg_inbound_order_parent_before_update
+BEFORE UPDATE ON fl_inbound_order
+FOR EACH ROW
+BEGIN
+ IF NOT (OLD.inbound_role <=> NEW.inbound_role)
+ OR NOT (OLD.parent_inbound_id <=> NEW.parent_inbound_id)
+ OR NOT (OLD.root_inbound_id <=> NEW.root_inbound_id)
+ OR NOT (OLD.split_sequence <=> NEW.split_sequence) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单主子归属创建后不可修改';
+ END IF;
+ IF NEW.inbound_role = 'CHILD'
+ AND (NEW.package_count IS NULL OR NEW.package_count = 0) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单箱数必须大于0';
+ END IF;
+ IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法';
+ END IF;
+ IF NEW.inbound_role = 'CHILD' AND NEW.split_status <> 'NOT_SPLIT' THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子仅两级,子入仓单不可继续拆分';
+ END IF;
+ IF NEW.inbound_role = 'MASTER'
+ AND ((NEW.package_count = 0 AND NEW.split_status <> 'FULLY_SPLIT')
+ OR (NEW.split_status = 'FULLY_SPLIT' AND COALESCE(NEW.package_count, 0) <> 0)) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单箱数归零必须与完全拆分状态同时提交';
+ END IF;
+END$$
+
+CREATE TRIGGER trg_inbound_split_item_before_insert
+BEFORE INSERT ON fl_inbound_split_item
+FOR EACH ROW
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1
+ FROM fl_inbound_split_batch batch
+ JOIN fl_inbound_order parent_order ON parent_order.id = batch.parent_inbound_id
+ JOIN fl_inbound_order child_order ON child_order.id = NEW.child_inbound_id
+ WHERE batch.id = NEW.split_batch_id
+ AND batch.tenant_id = NEW.tenant_id
+ AND batch.parent_inbound_id = NEW.parent_inbound_id
+ AND batch.status = 'PREPARED'
+ AND parent_order.tenant_id = NEW.tenant_id
+ AND parent_order.inbound_role = 'MASTER'
+ AND child_order.tenant_id = NEW.tenant_id
+ AND child_order.inbound_role = 'CHILD'
+ AND child_order.parent_inbound_id = NEW.parent_inbound_id
+ AND child_order.package_count >= NEW.package_count
+ AND child_order.deleted = 0
+ AND (NEW.source_measurement_item_id IS NULL OR EXISTS (
+ SELECT 1
+ FROM fl_inbound_measurement_item source_item
+ JOIN fl_inbound_measurement source_batch ON source_batch.id = source_item.measurement_id
+ WHERE source_item.id = NEW.source_measurement_item_id
+ AND source_item.tenant_id = NEW.tenant_id
+ AND source_item.measurement_id = NEW.source_measurement_id
+ AND source_batch.inbound_id = NEW.parent_inbound_id
+ ))
+ ) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分明细与批次、主单或子单不一致';
+ END IF;
+END$$
+
+CREATE TRIGGER trg_inbound_split_batch_before_insert
+BEFORE INSERT ON fl_inbound_split_batch
+FOR EACH ROW
+BEGIN
+ IF NEW.status <> 'PREPARED' OR NEW.applied_at IS NOT NULL THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次必须从PREPARED创建';
+ END IF;
+ IF NOT EXISTS (
+ SELECT 1 FROM fl_inbound_order parent_order
+ WHERE parent_order.id = NEW.parent_inbound_id
+ AND parent_order.tenant_id = NEW.tenant_id
+ AND parent_order.inbound_role = 'MASTER'
+ AND parent_order.deleted = 0
+ ) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次主单不存在或客户归属不一致';
+ END IF;
+END$$
+
+CREATE TRIGGER trg_inbound_split_batch_before_update
+BEFORE UPDATE ON fl_inbound_split_batch
+FOR EACH ROW
+BEGIN
+ IF OLD.status IN ('APPLIED', 'CANCELLED') THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已结束拆分批次不可修改';
+ END IF;
+ IF NEW.status = 'APPLIED' THEN
+ IF NEW.applied_at IS NULL
+ OR NOT EXISTS (
+ SELECT 1 FROM fl_inbound_order parent_order
+ WHERE parent_order.id = NEW.parent_inbound_id
+ AND parent_order.tenant_id = NEW.tenant_id
+ AND parent_order.inbound_role = 'MASTER'
+ AND parent_order.package_count > 0
+ AND parent_order.split_status = 'PARTIALLY_SPLIT'
+ AND parent_order.version > NEW.expected_parent_version
+ ) OR
+ (SELECT COALESCE(SUM(item.package_count), 0)
+ FROM fl_inbound_split_item item
+ WHERE item.tenant_id = NEW.tenant_id
+ AND item.split_batch_id = NEW.id) <> NEW.package_count OR
+ (SELECT COUNT(DISTINCT item.child_inbound_id)
+ FROM fl_inbound_split_item item
+ WHERE item.tenant_id = NEW.tenant_id
+ AND item.split_batch_id = NEW.id) <> 1 OR
+ EXISTS (SELECT 1
+ FROM fl_inbound_split_item item
+ JOIN fl_inbound_order child_order ON child_order.id = item.child_inbound_id
+ WHERE item.tenant_id = NEW.tenant_id
+ AND item.split_batch_id = NEW.id
+ GROUP BY child_order.id, child_order.package_count
+ HAVING child_order.package_count <> SUM(item.package_count)) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入仓单或拆分明细箱数合计不一致';
+ END IF;
+ IF EXISTS (
+ SELECT 1
+ FROM fl_inbound_split_item split_item
+ JOIN fl_inbound_measurement_item source_item
+ ON source_item.id = split_item.source_measurement_item_id
+ WHERE split_item.tenant_id = NEW.tenant_id
+ AND split_item.split_batch_id = NEW.id
+ GROUP BY split_item.source_measurement_item_id, source_item.package_count
+ HAVING SUM(split_item.package_count) > source_item.package_count
+ ) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子单承接箱数超过来源测量箱规箱数';
+ END IF;
+ ELSEIF NEW.status = 'CANCELLED' THEN
+ IF EXISTS (
+ SELECT 1 FROM fl_inbound_split_item item
+ WHERE item.tenant_id = NEW.tenant_id
+ AND item.split_batch_id = NEW.id
+ ) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有子单明细的拆分批次不可取消';
+ END IF;
+ ELSEIF NEW.status <> 'PREPARED' THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次状态流转非法';
+ END IF;
+END$$
+
+CREATE TRIGGER trg_inbound_split_item_before_update
+BEFORE UPDATE ON fl_inbound_split_item
+FOR EACH ROW
+BEGIN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分数量台账不可修改';
+END$$
+
+CREATE TRIGGER trg_inbound_split_item_before_delete
+BEFORE DELETE ON fl_inbound_split_item
+FOR EACH ROW
+BEGIN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分数量台账不可删除';
+END$$
+
+CREATE TRIGGER trg_inbound_split_history_before_update
+BEFORE UPDATE ON fl_inbound_split_history
+FOR EACH ROW
+BEGIN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分历史不可修改';
+END$$
+
+CREATE TRIGGER trg_inbound_split_history_before_delete
+BEFORE DELETE ON fl_inbound_split_history
+FOR EACH ROW
+BEGIN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分历史不可删除';
+END$$
+DELIMITER ;
+
+DROP PROCEDURE IF EXISTS phase3_assert_inbound_parent_child_schema;
+DELIMITER $$
+CREATE PROCEDURE phase3_assert_inbound_parent_child_schema()
+BEGIN
+ IF (SELECT COUNT(*) FROM information_schema.tables
+ WHERE table_schema = DATABASE()
+ AND table_name IN ('fl_inbound_split_batch', 'fl_inbound_split_item',
+ 'fl_inbound_split_history')) <> 3 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单拆分台账未完整创建';
+ END IF;
+ IF (SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'fl_inbound_order'
+ AND column_name IN ('inbound_role', 'parent_inbound_id', 'root_inbound_id',
+ 'split_sequence', 'split_status', 'package_count',
+ 'master_order_id')) <> 7 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单关系字段未完整创建';
+ END IF;
+ IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND index_name IN ('uk_inbound_master_order', 'uk_inbound_parent_sequence',
+ 'uk_inbound_split_operation', 'uk_inbound_split_idempotency',
+ 'uk_inbound_split_child', 'uk_inbound_split_history_operation')) <> 6 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单唯一约束未完整创建';
+ END IF;
+ IF (SELECT COUNT(*) FROM information_schema.table_constraints
+ WHERE constraint_schema = DATABASE()
+ AND constraint_name IN ('fk_inbound_parent_order', 'fk_inbound_root_order',
+ 'fk_measure_split_batch', 'fk_measure_source_batch',
+ 'fk_measure_item_split_item', 'fk_measure_item_source_item')) <> 6 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单与测量关联约束未完整创建';
+ END IF;
+ IF (SELECT COUNT(*) FROM information_schema.triggers
+ WHERE trigger_schema = DATABASE()
+ AND trigger_name IN ('trg_inbound_order_parent_before_insert',
+ 'trg_inbound_order_parent_before_update',
+ 'trg_inbound_split_item_before_insert',
+ 'trg_inbound_split_batch_before_insert',
+ 'trg_inbound_split_batch_before_update',
+ 'trg_inbound_split_item_before_update',
+ 'trg_inbound_split_item_before_delete',
+ 'trg_inbound_split_history_before_update',
+ 'trg_inbound_split_history_before_delete')) <> 9 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单数据库保护触发器未完整创建';
+ END IF;
+ IF EXISTS (
+ SELECT 1 FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'fl_inbound_order'
+ AND index_name = 'uk_inbound_tenant_order'
+ ) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '旧订单唯一索引仍阻止创建子入仓单';
+ END IF;
+END$$
+DELIMITER ;
+CALL phase3_assert_inbound_parent_child_schema();
+DROP PROCEDURE IF EXISTS phase3_assert_inbound_parent_child_schema;
diff --git a/firstleg/src/main/resources/db/phase3-inbounded-status-unification.sql b/firstleg/src/main/resources/db/phase3-inbounded-status-unification.sql
new file mode 100644
index 000000000..dc3078e9a
--- /dev/null
+++ b/firstleg/src/main/resources/db/phase3-inbounded-status-unification.sql
@@ -0,0 +1,17 @@
+-- 将已废弃的 WAIT_SPLIT 统一为 INBOUNDED。
+-- 执行前请确认所有服务已升级至本次状态契约;脚本可重复执行。
+USE baiyue_firstleg;
+
+UPDATE fl_inbound_order
+SET process_status = 'INBOUNDED',
+ updated_at = CURRENT_TIMESTAMP(3),
+ version = version + 1
+WHERE process_status = 'WAIT_SPLIT';
+
+UPDATE fl_inbound_status_history
+SET from_status = 'INBOUNDED'
+WHERE from_status = 'WAIT_SPLIT';
+
+UPDATE fl_inbound_status_history
+SET to_status = 'INBOUNDED'
+WHERE to_status = 'WAIT_SPLIT';
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..235f68c4d
--- /dev/null
+++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql
@@ -0,0 +1,864 @@
+SET NAMES utf8mb4;
+
+CREATE DATABASE IF NOT EXISTS baiyue_firstleg
+ DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
+USE baiyue_firstleg;
+
+-- 兼容已执行二期初始化的存量库:PDA 拣货需要累计每个托盘分配已拣箱数。
+DROP PROCEDURE IF EXISTS phase3_add_allocation_picked_count;
+DELIMITER $$
+CREATE PROCEDURE phase3_add_allocation_picked_count()
+BEGIN
+ IF EXISTS (
+ SELECT 1 FROM information_schema.tables
+ WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_pallet_allocation'
+ ) AND NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_pallet_allocation'
+ AND column_name = 'picked_package_count'
+ ) THEN
+ ALTER TABLE fl_inbound_pallet_allocation
+ ADD COLUMN picked_package_count INT UNSIGNED NOT NULL DEFAULT 0
+ COMMENT '已拣货箱数' AFTER allocated_package_count;
+ END IF;
+END$$
+DELIMITER ;
+CALL phase3_add_allocation_picked_count();
+DROP PROCEDURE phase3_add_allocation_picked_count;
+
+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_plan_detail (loading_plan_id, active_inbound_detail_id),
+ KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id),
+ KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id),
+ KEY idx_loading_item_account (account_id, created_at, id),
+ KEY idx_loading_item_inbound (account_id, inbound_id, deleted),
+ 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、PARTIAL、SKIPPED',
+ version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+ 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', 'PARTIAL', 'SKIPPED'))
+) 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 '请求指纹',
+ action_type VARCHAR(24) NOT NULL DEFAULT 'PICK' COMMENT 'PICK、PARTIAL、SKIP',
+ picked_package_count INT UNSIGNED NOT NULL COMMENT '本次拣货箱数',
+ operation_reason VARCHAR(500) NULL COMMENT '部分拣货或跳拣原因',
+ operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人',
+ operator_name VARCHAR(64) NULL COMMENT '操作人名称',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ 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
+ IF NOT EXISTS (
+ SELECT 1 FROM fl_loading_pick_item
+ WHERE loading_plan_item_id = OLD.id
+ AND item_status = 'PARTIAL'
+ AND NEW.deleted = 0
+ AND NEW.package_count = picked_package_count
+ ) OR NEW.deleted <> OLD.deleted
+ OR NOT (NEW.customer_name <=> OLD.customer_name)
+ OR NOT (NEW.inbound_id <=> OLD.inbound_id)
+ OR NOT (NEW.inbound_detail_id <=> OLD.inbound_detail_id)
+ OR NOT (NEW.order_id <=> OLD.order_id)
+ OR NOT (NEW.order_no <=> OLD.order_no)
+ OR NOT (NEW.inbound_code <=> OLD.inbound_code)
+ OR NOT (NEW.destination_warehouse_id <=> OLD.destination_warehouse_id)
+ OR NOT (NEW.destination_warehouse_code <=> OLD.destination_warehouse_code)
+ OR NOT (NEW.destination_warehouse_name <=> OLD.destination_warehouse_name)
+ OR NOT (NEW.sku_code <=> OLD.sku_code)
+ OR NOT (NEW.product_name_zh <=> OLD.product_name_zh)
+ OR NOT (NEW.product_name_en <=> OLD.product_name_en)
+ OR NOT (NEW.product_image_file_id <=> OLD.product_image_file_id)
+ OR NOT (NEW.product_image_url <=> OLD.product_image_url)
+ OR NOT (NEW.product_quantity <=> OLD.product_quantity)
+ OR NOT (NEW.units_per_carton <=> OLD.units_per_carton)
+ OR NOT (NEW.pallet_count <=> OLD.pallet_count)
+ OR NOT (NEW.gross_weight_kg <=> OLD.gross_weight_kg)
+ OR NOT (NEW.net_weight_kg <=> OLD.net_weight_kg)
+ OR NOT (NEW.dimensional_weight_kg <=> OLD.dimensional_weight_kg)
+ OR NOT (NEW.volume_cbm <=> OLD.volume_cbm)
+ OR NOT (NEW.fba_tracking_no <=> OLD.fba_tracking_no)
+ OR NOT (NEW.shipment_no <=> OLD.shipment_no)
+ OR NOT (NEW.shipping_mark <=> OLD.shipping_mark)
+ OR NOT (NEW.load_sequence <=> OLD.load_sequence)
+ OR NOT (NEW.position_snapshot <=> OLD.position_snapshot)
+ OR NOT (NEW.internal_remark <=> OLD.internal_remark)
+ OR NOT (NEW.customer_remark <=> OLD.customer_remark)
+ OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark)
+ OR NOT (NEW.frozen_at <=> OLD.frozen_at)
+ OR NOT (NEW.frozen_by <=> OLD.frozen_by)
+ OR NOT (NEW.removed_by <=> OLD.removed_by)
+ OR NOT (NEW.removed_by_name <=> OLD.removed_by_name)
+ OR NOT (NEW.removed_at <=> OLD.removed_at) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数';
+ END IF;
+ END IF;
+ IF OLD.frozen_at IS NOT NULL THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结';
+ 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;
+ IF NEW.item_status = 'PARTIAL' AND NOT (
+ OLD.item_status IN ('PENDING', 'PICKING')
+ AND OLD.picked_package_count < NEW.picked_package_count
+ AND NEW.picked_package_count < OLD.planned_package_count
+ AND NEW.planned_package_count = OLD.planned_package_count
+ ) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分拣货必须在计划箱数内增加实际拣出箱数';
+ END IF;
+ IF NEW.item_status = 'SKIPPED' AND NOT (
+ OLD.item_status = 'PENDING'
+ AND OLD.picked_package_count = 0
+ AND NEW.picked_package_count = 0
+ AND NEW.planned_package_count = OLD.planned_package_count
+ ) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '跳拣仅允许零拣货的待拣明细';
+ END IF;
+ IF OLD.item_status = 'PARTIAL' AND NEW.item_status = 'PICKED' AND NOT (
+ NEW.picked_package_count = OLD.picked_package_count
+ AND NEW.planned_package_count = OLD.picked_package_count
+ ) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分拣货收敛后计划箱数必须等于实际拣出箱数';
+ END IF;
+END$$
+DELIMITER ;
+
+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_plan_detail' AND non_unique = 0) <> 2 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失';
+ END IF;
+ IF (SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = 'baiyue_firstleg'
+ AND table_name = 'fl_loading_pick_item'
+ AND column_name IN ('pick_task_id', 'loading_plan_item_id', 'inbound_id',
+ 'inbound_detail_id', 'planned_package_count',
+ 'picked_package_count', 'item_status', 'version')) <> 8 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA拣货明细字段缺失';
+ END IF;
+ IF (SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = 'baiyue_firstleg' AND non_unique = 0
+ AND index_name IN ('uk_loading_pick_task_plan',
+ 'uk_loading_pick_item_plan_item',
+ 'uk_loading_pick_history_operation')) <> 3 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA唯一索引缺失';
+ END IF;
+ IF (SELECT COUNT(*) FROM information_schema.triggers
+ WHERE trigger_schema = 'baiyue_firstleg'
+ AND trigger_name IN ('trg_container_booking_before_update',
+ 'trg_loading_plan_before_update', 'trg_loading_plan_after_update',
+ 'trg_loading_item_before_insert', 'trg_loading_item_before_update',
+ 'trg_loading_item_before_delete', 'trg_container_document_before_insert',
+ 'trg_container_document_before_update', 'trg_container_document_before_delete',
+ 'trg_loading_history_before_update', 'trg_loading_history_before_delete',
+ 'trg_loading_pick_task_before_update',
+ 'trg_loading_pick_item_before_update',
+ 'trg_loading_pick_item_before_delete',
+ 'trg_loading_pick_history_before_update',
+ 'trg_loading_pick_history_before_delete')) <> 16 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:不可逆状态或冻结触发器缺失';
+ END IF;
+ IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission
+ WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0
+ AND permission_code IN (
+ 'platform:firstleg:container:list', 'platform:firstleg:container:view',
+ 'platform:firstleg:container:create', 'platform:firstleg:container:edit',
+ 'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:create',
+ 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
+ 'platform:firstleg:smart-loading:push', 'platform:firstleg:preload:list',
+ 'platform:firstleg:preload:view', 'platform:firstleg:preload:edit',
+ 'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm',
+ 'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 16 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建';
+ END IF;
+ IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code))
+ FROM baiyue_iam.iam_role role
+ JOIN baiyue_iam.iam_role_permission grant_row ON grant_row.role_id = role.id
+ JOIN baiyue_iam.iam_permission permission ON permission.id = grant_row.permission_id
+ WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')
+ AND permission.tenant_id = 0
+ AND permission.permission_code IN (
+ 'platform:firstleg:container:list', 'platform:firstleg:container:view',
+ 'platform:firstleg:container:create',
+ 'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
+ 'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign',
+ 'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
+ 'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
+ 'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
+ 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
+ 'platform:firstleg:loading-list:view')) <> 32 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入';
+ END IF;
+END$$
+DELIMITER ;
+CALL assert_phase3_smart_loading_schema();
+DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema;
diff --git a/firstleg/src/main/resources/db/phase3-split-preload-schema.sql b/firstleg/src/main/resources/db/phase3-split-preload-schema.sql
index c521497b7..8a0414b9b 100644
--- a/firstleg/src/main/resources/db/phase3-split-preload-schema.sql
+++ b/firstleg/src/main/resources/db/phase3-split-preload-schema.sql
@@ -37,3 +37,236 @@ CREATE TABLE IF NOT EXISTS fl_split_operation (
KEY idx_split_operation_time (tenant_id, inbound_id, created_at),
CONSTRAINT fk_split_operation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='分柜操作幂等与审计索引';
+
+/*
+ * 部分装柜拆单契约:
+ * 1. parent_inbound_id 为空的是主单,非空的是唯一二级子单;
+ * 2. 锁定阶段只记录箱数,完成阶段绑定由剩余箱数生成的子单;
+ * 3. 子单不能再作为父单,锁定数量创建后不可修改。
+ */
+DROP PROCEDURE IF EXISTS phase3_split_add_column;
+DELIMITER $$
+CREATE PROCEDURE phase3_split_add_column(
+ IN p_table_name VARCHAR(64), IN p_column_name VARCHAR(64), IN p_definition TEXT
+)
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = p_table_name AND column_name = p_column_name
+ ) THEN
+ SET @phase3_split_ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `',
+ p_column_name, '` ', p_definition);
+ PREPARE phase3_split_stmt FROM @phase3_split_ddl;
+ EXECUTE phase3_split_stmt;
+ DEALLOCATE PREPARE phase3_split_stmt;
+ END IF;
+END$$
+DELIMITER ;
+
+CALL phase3_split_add_column('fl_inbound_order', 'parent_inbound_id',
+ 'BIGINT UNSIGNED NULL COMMENT ''生成本单的主入库单ID;仅二级子单有值'' AFTER id');
+CALL phase3_split_add_column('fl_inbound_order', 'split_level',
+ 'TINYINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN parent_inbound_id IS NULL THEN 0 ELSE 1 END) STORED COMMENT ''拆单层级:0主单、1二级子单'' AFTER parent_inbound_id');
+CALL phase3_split_add_column('fl_inbound_order', 'split_status',
+ 'VARCHAR(24) NOT NULL DEFAULT ''NOT_SPLIT'' COMMENT ''NOT_SPLIT/PARTIALLY_SPLIT/FULLY_SPLIT'' AFTER split_level');
+DROP PROCEDURE IF EXISTS phase3_split_add_column;
+
+SET @phase3_split_index_exists = (
+ SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order'
+ AND index_name = 'uk_inbound_one_secondary'
+);
+SET @phase3_split_ddl = IF(@phase3_split_index_exists = 0,
+ 'ALTER TABLE fl_inbound_order ADD UNIQUE KEY uk_inbound_one_secondary (tenant_id, parent_inbound_id)',
+ 'SELECT 1');
+PREPARE phase3_split_stmt FROM @phase3_split_ddl;
+EXECUTE phase3_split_stmt;
+DEALLOCATE PREPARE phase3_split_stmt;
+
+SET @phase3_split_fk_exists = (
+ SELECT COUNT(*) FROM information_schema.table_constraints
+ WHERE constraint_schema = DATABASE() AND table_name = 'fl_inbound_order'
+ AND constraint_name = 'fk_inbound_parent' AND constraint_type = 'FOREIGN KEY'
+);
+SET @phase3_split_ddl = IF(@phase3_split_fk_exists = 0,
+ 'ALTER TABLE fl_inbound_order ADD CONSTRAINT fk_inbound_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id)',
+ 'SELECT 1');
+PREPARE phase3_split_stmt FROM @phase3_split_ddl;
+EXECUTE phase3_split_stmt;
+DEALLOCATE PREPARE phase3_split_stmt;
+
+CREATE TABLE IF NOT EXISTS fl_inbound_split_detail (
+ id BIGINT UNSIGNED NOT NULL COMMENT '部分锁定明细ID',
+ tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
+ parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入库单ID',
+ child_inbound_id BIGINT UNSIGNED NULL COMMENT '完成拆单后生成的二级子入库单ID',
+ inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '原订单货物明细ID,不建立跨库外键',
+ source_package_count INT UNSIGNED NOT NULL COMMENT '拆分前该明细总箱数快照',
+ locked_package_count INT UNSIGNED NOT NULL COMMENT '本次装柜锁定箱数',
+ remaining_package_count INT UNSIGNED GENERATED ALWAYS AS (
+ source_package_count - locked_package_count
+ ) STORED COMMENT '生成二级子单的剩余箱数',
+ status VARCHAR(16) NOT NULL DEFAULT 'LOCKED' COMMENT 'LOCKED/COMPLETED',
+ operation_id VARCHAR(128) NOT NULL COMMENT '部分锁定操作幂等号',
+ created_by BIGINT UNSIGNED NOT NULL,
+ created_by_name VARCHAR(64) NULL,
+ completed_by BIGINT UNSIGNED NULL,
+ completed_by_name VARCHAR(64) NULL,
+ completed_at DATETIME(3) NULL,
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+ version INT UNSIGNED NOT NULL DEFAULT 0,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_inbound_split_parent_detail (tenant_id, parent_inbound_id, inbound_detail_id),
+ UNIQUE KEY uk_inbound_split_child_detail (tenant_id, child_inbound_id, inbound_detail_id),
+ UNIQUE KEY uk_inbound_split_operation (operation_id),
+ KEY idx_inbound_split_status (tenant_id, parent_inbound_id, status),
+ CONSTRAINT fk_inbound_split_detail_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id),
+ CONSTRAINT fk_inbound_split_detail_child FOREIGN KEY (child_inbound_id) REFERENCES fl_inbound_order (id),
+ CONSTRAINT chk_inbound_split_partial CHECK (
+ source_package_count > 1 AND locked_package_count > 0
+ AND locked_package_count < source_package_count
+ ),
+ CONSTRAINT chk_inbound_split_status CHECK (status IN ('LOCKED', 'COMPLETED')),
+ CONSTRAINT chk_inbound_split_completion CHECK (
+ (status = 'LOCKED' AND child_inbound_id IS NULL AND completed_at IS NULL)
+ OR (status = 'COMPLETED' AND child_inbound_id IS NOT NULL AND completed_at IS NOT NULL)
+ )
+) ENGINE=InnoDB COMMENT='按箱数部分锁定及剩余箱数生成二级子入库单的不可变契约';
+
+DROP TRIGGER IF EXISTS trg_inbound_split_before_insert;
+DROP TRIGGER IF EXISTS trg_inbound_split_before_update;
+DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_insert;
+DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_update;
+DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_delete;
+DELIMITER $$
+CREATE TRIGGER trg_inbound_split_before_insert
+BEFORE INSERT ON fl_inbound_order
+FOR EACH ROW
+BEGIN
+ DECLARE v_parent_tenant BIGINT UNSIGNED;
+ DECLARE v_parent_parent BIGINT UNSIGNED;
+ IF NEW.parent_inbound_id IS NOT NULL THEN
+ SELECT tenant_id, parent_inbound_id INTO v_parent_tenant, v_parent_parent
+ FROM fl_inbound_order WHERE id = NEW.parent_inbound_id AND deleted = 0;
+ IF v_parent_tenant <> NEW.tenant_id THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单与主单客户归属不一致';
+ END IF;
+ IF v_parent_parent IS NOT NULL THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单不能继续拆分';
+ END IF;
+ IF NEW.split_status <> 'NOT_SPLIT' THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单拆单状态必须为NOT_SPLIT';
+ END IF;
+ END IF;
+END$$
+
+CREATE TRIGGER trg_inbound_split_before_update
+BEFORE UPDATE ON fl_inbound_order
+FOR EACH ROW
+BEGIN
+ IF NOT (NEW.parent_inbound_id <=> OLD.parent_inbound_id) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入库单主子关系创建后不可修改';
+ END IF;
+ IF OLD.parent_inbound_id IS NOT NULL AND NEW.split_status <> 'NOT_SPLIT' THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单不能继续拆分';
+ END IF;
+ IF OLD.parent_inbound_id IS NULL
+ AND NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主入库单拆单状态非法';
+ END IF;
+ IF OLD.split_status = 'FULLY_SPLIT' AND NEW.split_status <> 'FULLY_SPLIT' THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成拆单不能回退';
+ END IF;
+ IF NEW.split_status = 'FULLY_SPLIT' AND (
+ (SELECT COUNT(*) FROM fl_inbound_order child
+ WHERE child.tenant_id = NEW.tenant_id AND child.parent_inbound_id = NEW.id
+ AND child.deleted = 0) <> 1
+ OR (SELECT COUNT(*) FROM fl_inbound_split_detail detail
+ WHERE detail.tenant_id = NEW.tenant_id AND detail.parent_inbound_id = NEW.id) = 0
+ OR EXISTS (SELECT 1 FROM fl_inbound_split_detail detail
+ WHERE detail.tenant_id = NEW.tenant_id AND detail.parent_inbound_id = NEW.id
+ AND detail.status <> 'COMPLETED')
+ ) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '剩余箱数二级子单及拆分明细未完成';
+ END IF;
+END$$
+
+CREATE TRIGGER trg_inbound_split_detail_before_insert
+BEFORE INSERT ON fl_inbound_split_detail
+FOR EACH ROW
+BEGIN
+ DECLARE v_parent_tenant BIGINT UNSIGNED;
+ DECLARE v_parent_parent BIGINT UNSIGNED;
+ SELECT tenant_id, parent_inbound_id INTO v_parent_tenant, v_parent_parent
+ FROM fl_inbound_order WHERE id = NEW.parent_inbound_id AND deleted = 0;
+ IF v_parent_tenant <> NEW.tenant_id OR v_parent_parent IS NOT NULL THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定只能发生在客户所属主入库单';
+ END IF;
+ IF NEW.status <> 'LOCKED' OR NEW.child_inbound_id IS NOT NULL THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定创建时不能提前绑定二级子入库单';
+ END IF;
+END$$
+
+CREATE TRIGGER trg_inbound_split_detail_before_update
+BEFORE UPDATE ON fl_inbound_split_detail
+FOR EACH ROW
+BEGIN
+ DECLARE v_child_tenant BIGINT UNSIGNED;
+ DECLARE v_child_parent BIGINT UNSIGNED;
+ IF NEW.tenant_id <> OLD.tenant_id OR NEW.parent_inbound_id <> OLD.parent_inbound_id
+ OR NEW.inbound_detail_id <> OLD.inbound_detail_id
+ OR NEW.source_package_count <> OLD.source_package_count
+ OR NEW.locked_package_count <> OLD.locked_package_count
+ OR NEW.operation_id <> OLD.operation_id THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定的归属和箱数创建后不可修改';
+ END IF;
+ IF OLD.status <> 'LOCKED' OR NEW.status <> 'COMPLETED' OR NEW.child_inbound_id IS NULL THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定只允许完成并绑定二级子入库单';
+ END IF;
+ SELECT tenant_id, parent_inbound_id INTO v_child_tenant, v_child_parent
+ FROM fl_inbound_order WHERE id = NEW.child_inbound_id AND deleted = 0;
+ IF v_child_tenant <> NEW.tenant_id OR v_child_parent <> NEW.parent_inbound_id THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '剩余箱数必须绑定主单唯一二级子入库单';
+ END IF;
+END$$
+
+CREATE TRIGGER trg_inbound_split_detail_before_delete
+BEFORE DELETE ON fl_inbound_split_detail
+FOR EACH ROW
+BEGIN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定与拆单明细不可删除';
+END$$
+DELIMITER ;
+
+DROP PROCEDURE IF EXISTS phase3_split_assert_contract;
+DELIMITER $$
+CREATE PROCEDURE phase3_split_assert_contract()
+BEGIN
+ IF (SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order'
+ AND column_name IN ('parent_inbound_id', 'split_level', 'split_status')) <> 3 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:入库单主子关系字段未完整创建';
+ END IF;
+ IF (SELECT COUNT(*) FROM information_schema.tables
+ WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_split_detail') <> 1 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:部分锁定明细表未创建';
+ END IF;
+ IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND index_name IN ('uk_inbound_one_secondary', 'uk_inbound_split_parent_detail',
+ 'uk_inbound_split_child_detail', 'uk_inbound_split_operation')) <> 4 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:唯一子单或部分锁定幂等索引缺失';
+ END IF;
+ IF (SELECT COUNT(*) FROM information_schema.triggers
+ WHERE trigger_schema = DATABASE()
+ AND trigger_name IN ('trg_inbound_split_before_insert', 'trg_inbound_split_before_update',
+ 'trg_inbound_split_detail_before_insert',
+ 'trg_inbound_split_detail_before_update',
+ 'trg_inbound_split_detail_before_delete')) <> 5 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:主子层级或部分锁定触发器未完整创建';
+ END IF;
+END$$
+DELIMITER ;
+CALL phase3_split_assert_contract();
+DROP PROCEDURE IF EXISTS phase3_split_assert_contract;
diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml
index 72455cbc4..2b91746e8 100644
--- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml
+++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml
@@ -137,7 +137,7 @@ paths:
/api/platform/v1/inbounds/{inboundId}/fees/confirm:
post:
summary: 确认最新费用快照
- description: 确认费用只更新费用状态;主流程已到 WAIT_SPLIT 时保持不变。
+ description: 确认费用只更新费用状态;主流程已到 INBOUNDED 时保持不变。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/send:
diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml
index 1e481abe2..3b57656d5 100644
--- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml
+++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml
@@ -125,6 +125,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: 提交一条第三方箱规测量结果和一张图片
@@ -416,6 +500,11 @@ 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:
CustomerDimensionConfigUpdateRequest:
type: object
@@ -526,6 +615,97 @@ components:
publicRemark: {type: string, nullable: true}
expressCompanyName: {type: string, nullable: true}
expressTrackingNo: {type: string, nullable: true}
+ 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..a5cc4dc57
--- /dev/null
+++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml
@@ -0,0 +1,335 @@
+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/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'}, {$ref: '#/components/parameters/WarehouseId'}]
+ 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/WarehouseId'}
+ - {$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/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'}, {$ref: '#/components/parameters/WarehouseId'}]
+ 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}
+ WarehouseId:
+ name: warehouseId
+ in: query
+ required: true
+ 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/FbaPricingContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java
index 1b2365bd7..22af59af5 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java
@@ -48,7 +48,7 @@ class FbaPricingContractTest {
.contains("/api/platform/v1/inbounds/{inboundId}/fees/send:")
.contains("/api/platform/v1/inbounds/{inboundId}/fees/published:")
.contains("Idempotency-Key")
- .contains("主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态")
+ .contains("主流程已到 INBOUNDED,也可独立试算且不回退主流程状态")
.contains("该动作涉及后续仓储流程,本轮二期验收不调用")
.contains("不回退 INBOUNDED 状态")
.doesNotContain("应收")
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java
index 2522afe1a..0931f2bad 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java
@@ -54,7 +54,7 @@ class InternalInboundOrderReferenceControllerTest {
@Test
void unifiedQueryRequiresInternalTokenAndForwardsBatchRequest() {
UnifiedInboundQuery request = new UnifiedInboundQuery(
- 10001L, null, null, "WAIT_SPLIT", null, null, null,
+ 10001L, null, null, "INBOUNDED", null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null,
null, null, 1, 20);
when(unifiedQueryService.page(request)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
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..de5534688
--- /dev/null
+++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java
@@ -0,0 +1,121 @@
+package com.baiyue.firstleg.controller;
+
+import com.baiyue.common.audit.BusinessAudit;
+import com.baiyue.common.security.AuthenticatedUser;
+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, long.class);
+ authority("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.class,
+ long.class, long.class, String.class, TransportUpdateRequest.class);
+ authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class,
+ ConfirmedQuery.class);
+ authority("confirmedDetail", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class, long.class);
+ }
+
+ @Test
+ void writesAudited() throws Exception {
+ audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, long.class, String.class,
+ String.class, TransportUpdateRequest.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 doesNotExposeManualCompletion() {
+ assertThat(PlatformLoadingListController.class.getDeclaredMethods())
+ .noneMatch(method -> method.getName().equals("complete"));
+ }
+
+ @Test
+ void yamlParses() throws Exception {
+ String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml")
+ .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/InboundParentChildSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java
new file mode 100644
index 000000000..916caef41
--- /dev/null
+++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java
@@ -0,0 +1,121 @@
+package com.baiyue.firstleg.repository;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.core.io.ClassPathResource;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DisplayName("主子入仓单SQL契约")
+class InboundParentChildSqlContractTest {
+
+ private final String sql = loadSql();
+
+ @Test
+ void definesParentChildRelation() {
+ assertThat(sql)
+ .contains("'fl_inbound_order', 'inbound_role'")
+ .contains("'fl_inbound_order', 'parent_inbound_id'")
+ .contains("'fl_inbound_order', 'root_inbound_id'")
+ .contains("'fl_inbound_order', 'package_count'")
+ .contains("GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER'")
+ .contains("DROP INDEX uk_inbound_tenant_order")
+ .contains("uk_inbound_master_order")
+ .contains("uk_inbound_one_child")
+ .contains("DROP INDEX uk_inbound_parent_sequence");
+ }
+
+ @Test
+ void definesSplitLedger() {
+ assertThat(sql)
+ .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch")
+ .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item")
+ .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history")
+ .contains("line_no INT UNSIGNED NOT NULL")
+ .contains("uk_inbound_split_batch_line")
+ .contains("uk_inbound_split_batch_source_item")
+ .contains("package_count INT UNSIGNED NOT NULL")
+ .doesNotContain("quantity INT", "item_count INT", "carton_count INT");
+ }
+
+ @org.junit.jupiter.api.Disabled("旧拆分口径已由一主单一子单的批次汇总校验取代")
+ @Test
+ void protectsOwnershipAndTotals() {
+ assertThat(sql)
+ .contains("子入仓单必须绑定同一客户的有效主单")
+ .contains("parent_order.package_count > 0")
+ .contains("parent_order.split_status = 'PARTIALLY_SPLIT'")
+ .contains("parent_order.version > NEW.expected_parent_version")
+ .contains("主单可用箱数未归零或子单箱数合计不一致")
+ .contains("SUM(split_item.package_count) > source_item.package_count")
+ .contains("子单承接箱数超过来源测量箱规箱数")
+ .contains("child_order.package_count >= NEW.package_count")
+ .contains("拆分数量台账不可修改")
+ .contains("拆分数量台账不可删除")
+ .contains("拆分历史不可修改")
+ .contains("拆分历史不可删除");
+ }
+
+ @Test
+ void validatesOneChildAndMultiSkuTotals() {
+ assertThat(sql)
+ .contains("uk_inbound_one_child")
+ .contains("uk_inbound_split_batch_line")
+ .contains("uk_inbound_split_batch_source_item")
+ .contains("COUNT(DISTINCT item.child_inbound_id)")
+ .contains("child_order.package_count <> SUM(item.package_count)")
+ .contains("parent_order.split_status = 'PARTIALLY_SPLIT'");
+ }
+
+ @Test
+ void tracesMeasurements() {
+ assertThat(sql)
+ .contains("'fl_inbound_measurement', 'split_batch_id'")
+ .contains("'fl_inbound_measurement', 'source_measurement_id'")
+ .contains("'fl_inbound_measurement_item', 'split_item_id'")
+ .contains("'fl_inbound_measurement_item', 'source_measurement_item_id'")
+ .contains("source_measurement_id BIGINT UNSIGNED NULL")
+ .contains("fk_inbound_split_item_measurement")
+ .contains("fk_measure_split_batch")
+ .contains("fk_measure_source_batch")
+ .contains("fk_measure_item_split_item")
+ .contains("fk_measure_item_source_item")
+ .contains("idx_measure_split_source")
+ .contains("idx_measure_item_split_source");
+ }
+
+ @Test
+ void syncsUnifiedMigration() throws Exception {
+ String module = normalize(sql).trim();
+ String unified = normalize(Files.readString(unifiedPath(), StandardCharsets.UTF_8));
+ assertThat(unified)
+ .contains("-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql")
+ .contains(module);
+ }
+
+ private static String loadSql() {
+ try {
+ return new ClassPathResource("db/phase3-inbound-parent-child-schema.sql")
+ .getContentAsString(StandardCharsets.UTF_8);
+ } catch (Exception ex) {
+ throw new IllegalStateException(ex);
+ }
+ }
+
+ private static Path unifiedPath() {
+ Path working = Path.of("").toAbsolutePath();
+ Path path = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql");
+ if (!Files.exists(path)) {
+ path = working.resolve("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize();
+ }
+ return path;
+ }
+
+ private static String normalize(String value) {
+ return value.replace("\r\n", "\n").replace("\uFEFF", "");
+ }
+}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundStatusUnificationSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundStatusUnificationSqlContractTest.java
new file mode 100644
index 000000000..569f601df
--- /dev/null
+++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundStatusUnificationSqlContractTest.java
@@ -0,0 +1,35 @@
+package com.baiyue.firstleg.repository;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.core.io.ClassPathResource;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class InboundStatusUnificationSqlContractTest {
+ @Test
+ void syncsStatusUnificationMigrationToUnifiedDeploymentScript() throws Exception {
+ String module = normalize(new ClassPathResource("db/phase3-inbounded-status-unification.sql")
+ .getContentAsString(StandardCharsets.UTF_8)).trim();
+ String unified = normalize(Files.readString(unifiedPath(), StandardCharsets.UTF_8));
+
+ assertThat(unified)
+ .contains("-- 来源:firstleg/src/main/resources/db/phase3-inbounded-status-unification.sql")
+ .contains(module);
+ }
+
+ private static Path unifiedPath() {
+ Path path = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql");
+ if (Files.exists(path)) {
+ return path;
+ }
+ return Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize();
+ }
+
+ private static String normalize(String value) {
+ return value.replace("\r\n", "\n").replace("\uFEFF", "");
+ }
+}
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..9edc73cc7
--- /dev/null
+++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java
@@ -0,0 +1,93 @@
+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'")
+ .contains("b.warehouse_id = #{query.warehouseId}")
+ .doesNotContain("query.accountId")
+ .doesNotContain("PRELOAD_PUSHED");
+ assertThat(confirmed)
+ .contains("FROM fl_loading_plan p")
+ .contains("p.plan_status = 'COMPLETED'")
+ .contains("b.warehouse_id = #{query.warehouseId}")
+ .doesNotContain("query.accountId")
+ .doesNotContain("fl_loading_list");
+ }
+
+ @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/LoadingPickItemMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java
new file mode 100644
index 000000000..6d88da5f5
--- /dev/null
+++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java
@@ -0,0 +1,49 @@
+package com.baiyue.firstleg.repository;
+
+import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class LoadingPickItemMapperContractTest {
+ @Test
+ void completionCheckCoversAllActivePlanItems() throws Exception {
+ Method method = LoadingPickItemMapper.class.getDeclaredMethod("unfinishedCount", long.class);
+ String sql = method.getAnnotation(Select.class).value()[0];
+
+ assertThat(sql)
+ .contains("plan_item.loading_plan_id = #{planId}")
+ .contains("plan_item.deleted = 0")
+ .contains("pick_task.loading_plan_id = #{planId}")
+ .contains("pick_task.task_status = 'LOADING'")
+ .contains("pick_item.picked_package_count = plan_item.package_count");
+ }
+
+ @Test
+ void terminalStatesAreDefinedInSchema() throws Exception {
+ String sql = new org.springframework.core.io.ClassPathResource("db/phase3-smart-loading-schema.sql")
+ .getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
+
+ assertThat(sql)
+ .contains("'PENDING', 'PICKING', 'PICKED', 'PARTIAL', 'SKIPPED'")
+ .contains("action_type VARCHAR(24) NOT NULL DEFAULT 'PICK'")
+ .contains("operation_reason VARCHAR(500) NULL");
+ }
+
+ @Test
+ void pdaWritesAndReadsRequireWarehouseScope() throws Exception {
+ Method list = LoadingPickItemMapper.class.getDeclaredMethod("selectTaskItemsWithAccount", long.class, long.class);
+ Method pick = LoadingPickItemMapper.class.getDeclaredMethod("pick", long.class, int.class, int.class, long.class);
+
+ assertThat(list.getAnnotation(Select.class).value()[0])
+ .contains("booking.warehouse_id = #{warehouseId}")
+ .contains("plan_item.deleted = 0");
+ assertThat(pick.getAnnotation(Update.class).value()[0])
+ .contains("booking.warehouse_id = #{warehouseId}")
+ .contains("plan_item.deleted = 0");
+ }
+}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java
new file mode 100644
index 000000000..82f5e965d
--- /dev/null
+++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java
@@ -0,0 +1,37 @@
+package com.baiyue.firstleg.repository;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.core.io.ClassPathResource;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class PdaSplitSettlementSqlContractTest {
+ @Test
+ void protectsOneChildAndPartialSettlement() throws Exception {
+ String sql = new ClassPathResource("db/phase3-inbound-parent-child-schema.sql")
+ .getContentAsString(StandardCharsets.UTF_8);
+
+ assertThat(sql)
+ .contains("uk_inbound_one_child")
+ .contains("uk_inbound_split_batch_line")
+ .contains("uk_inbound_split_batch_source_item")
+ .contains("child_order.package_count <> SUM(item.package_count)")
+ .contains("parent_order.split_status = 'PARTIALLY_SPLIT'")
+ .contains("SUM(split_item.package_count) > source_item.package_count")
+ .contains("CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0)");
+ }
+
+ @Test
+ void protectsPdaTerminalStates() throws Exception {
+ String sql = new ClassPathResource("db/phase3-smart-loading-schema.sql")
+ .getContentAsString(StandardCharsets.UTF_8);
+
+ assertThat(sql)
+ .contains("'PICKED', 'PARTIAL', 'SKIPPED'")
+ .contains("NEW.item_status = 'PARTIAL'")
+ .contains("NEW.item_status = 'SKIPPED'")
+ .contains("NEW.picked_package_count = OLD.picked_package_count");
+ }
+}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java
new file mode 100644
index 000000000..2e8c0f92b
--- /dev/null
+++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java
@@ -0,0 +1,45 @@
+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("o.warehouse_id=#{warehouseId}")
+ .contains("o.process_status='INBOUNDED'")
+ .contains("SELECT SUM(li.package_count)")
+ .contains("li.deleted=0")
+ .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)")
+ .contains("o.warehouse_id=#{warehouseId}")
+ .contains("o.process_status='INBOUNDED'")
+ .contains("SELECT SUM(li.package_count)")
+ .contains("FOR UPDATE");
+ }
+}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java
new file mode 100644
index 000000000..61d264f64
--- /dev/null
+++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java
@@ -0,0 +1,177 @@
+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_plan_detail (loading_plan_id, active_inbound_detail_id)")
+ .contains("KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id)")
+ .contains("UNIQUE KEY uk_container_booking_no (booking_no)")
+ .contains("UNIQUE KEY uk_container_booking_cabinet (cabinet_no)")
+ .contains("UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key)")
+ .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..3f477184a
--- /dev/null
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java
@@ -0,0 +1,205 @@
+package com.baiyue.firstleg.service;
+
+import com.baiyue.common.core.api.ApiResponse;
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.firstleg.client.WarehouseMasterDataClient;
+import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
+import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
+import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
+import com.baiyue.firstleg.entity.ContainerBookingEntity;
+import com.baiyue.firstleg.repository.ContainerRepository;
+import jakarta.annotation.Resource;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.boot.test.mock.mockito.MockBean;
+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.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@DisplayName("货柜基础资料服务")
+@SpringJUnitConfig(ContainerService.class)
+class ContainerServiceTest {
+ @MockBean private ContainerRepository repository;
+ @MockBean private WarehouseMasterDataClient warehouses;
+ @Resource private ContainerService service;
+
+ @BeforeEach
+ void setUpWarehouse() {
+ when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation ->
+ activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓"));
+ }
+
+ @Test
+ @DisplayName("新增货柜保存最终数据库契约字段并生成平台预订单号")
+ void createsContainerBooking() {
+ 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.getWarehouseId()).isEqualTo(101L);
+ assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓");
+ 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());
+ }
+
+ @Test
+ @DisplayName("虚拟柜拒绝选择非国内中转仓")
+ void rejectsNonDomesticWarehouse() {
+ when(warehouses.active(anyString(), anyLong(), anyLong()))
+ .thenReturn(activeWarehouse(101L, "FBA", "美国目的仓"));
+
+ assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001")))
+ .isInstanceOf(BizException.class)
+ .hasMessageContaining("国内中转仓");
+
+ verify(repository, never()).insert(any());
+ }
+
+ @Test
+ @DisplayName("虚拟柜仓库名称以仓库主数据为准")
+ void replacesRequestedWarehouseName() {
+ service.create(9L, "admin", request("柜-001"));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ContainerBookingEntity.class);
+ verify(repository).insert(captor.capture());
+ assertThat(captor.getValue().getWarehouseName()).isEqualTo("上海中转仓");
+ }
+
+ @Test
+ @DisplayName("仓库主数据服务不可用时拒绝创建虚拟柜")
+ void rejectsUnavailableWarehouseService() {
+ when(warehouses.active(anyString(), anyLong(), anyLong()))
+ .thenThrow(new IllegalStateException("warehouse service unavailable"));
+
+ assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001")))
+ .isInstanceOf(BizException.class)
+ .hasMessageContaining("暂时不可用");
+
+ verify(repository, never()).insert(any());
+ }
+
+ private static ApiResponse activeWarehouse(long warehouseId,
+ String type,
+ String name) {
+ return ApiResponse.success(new WarehouseMasterDataClient.Detail(
+ new WarehouseMasterDataClient.Warehouse(warehouseId, 0L, "CN-SHA-01", name, type, "ACTIVE")));
+ }
+
+ private static ContainerBookingEntity booking(String status, int version) {
+ ContainerBookingEntity value = new ContainerBookingEntity();
+ value.setId(11L);
+ 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/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java
index 78de2d6cb..6edd458d7 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java
@@ -113,7 +113,9 @@ class FbaPricingServiceTest {
void calculatesAtWaitSplitWithoutRollingBackProcessStatus() {
readyRepository();
InboundPricingEntity inbound = inbound("100.000", "1.0000");
- inbound.setProcessStatus("WAIT_SPLIT");
+ inbound.setProcessStatus("INBOUNDED");
+ CustomerChargeRuleEntity rule = rule("KG", "167.000000", 3, "CEILING");
+ CarrierFbaRateEntity rate = rate("KG", "2.000000", "0.00");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(priceBookEstimator.estimate(any())).thenReturn(
estimate("KG", "167.000", "2.000000", "334.00", "0.00"));
@@ -121,8 +123,8 @@ class FbaPricingServiceTest {
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "wait-split");
assertThat(response.feeStatus()).isEqualTo("CALCULATED");
- assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
- verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_SPLIT", "KG",
+ assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED");
+ verify(repository).markInboundCalculated(TENANT, INBOUND, "INBOUNDED", "KG",
new BigDecimal("167.000"));
}
@@ -130,7 +132,7 @@ class FbaPricingServiceTest {
@DisplayName("待分柜边界确认费用后仍保持主流程状态")
void confirmsAtWaitSplitWithoutRollingBackProcessStatus() {
InboundPricingEntity inbound = inbound("10.000", "1.0000");
- inbound.setProcessStatus("WAIT_SPLIT");
+ inbound.setProcessStatus("INBOUNDED");
inbound.setFeeStatus("CALCULATED");
InboundFeeEntity fee = savedFee("CALCULATED");
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee);
@@ -147,7 +149,7 @@ class FbaPricingServiceTest {
FeeSnapshotResponse response = service.confirm(
TENANT, OPERATOR, INBOUND, "confirm-wait-split");
- assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
+ assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED");
verify(repository).markInboundConfirmed(TENANT, INBOUND);
}
@@ -184,7 +186,7 @@ class FbaPricingServiceTest {
void returnsOriginalSnapshotForRepeatedCalculation() {
InboundPricingIdempotencyEntity completed = completed("FEE_CALCULATE", 501L);
InboundPricingEntity inbound = inbound("10", "1");
- inbound.setProcessStatus("WAIT_SPLIT");
+ inbound.setProcessStatus("INBOUNDED");
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CALCULATE", "same")).thenReturn(completed);
when(repository.findFee(TENANT, 501L)).thenReturn(savedFee("CALCULATED"));
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
@@ -194,8 +196,8 @@ class FbaPricingServiceTest {
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "same");
assertThat(response.feeId()).isEqualTo(501L);
- assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
- verify(priceBookEstimator, never()).estimate(any());
+ assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED");
+ verify(repository, never()).matchRules(any(), any());
verify(repository, never()).insertFee(any());
}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java
index 87420e244..c3bab2e87 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java
@@ -2,8 +2,11 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ApiResponse;
+import com.baiyue.firstleg.client.OrderCargoClient;
+import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.MeasurementImageClient;
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
+import com.baiyue.firstleg.dto.MeasurementBatchCommand;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
@@ -31,6 +34,7 @@ import static org.assertj.core.api.Assertions.tuple;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
@@ -44,6 +48,7 @@ import static org.mockito.Mockito.when;
class InboundMeasurementServiceTest {
@MockBean private InboundMeasurementRepository repository;
@MockBean private MeasurementBatchService batchService;
+ @MockBean private OrderCargoClient orderCargoClient;
@MockBean private MeasurementImageClient imageClient;
@Resource private InboundMeasurementService service;
@@ -480,7 +485,7 @@ class InboundMeasurementServiceTest {
@Test
void editingAndResubmittingInboundMeasurementDoesNotRollBackMainStatus() {
- InboundOrderEntity inbound = inbound("WAIT_SPLIT");
+ InboundOrderEntity inbound = inbound("INBOUNDED");
inbound.setMeasureReviewStatus("APPROVED");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L);
@@ -498,13 +503,40 @@ class InboundMeasurementServiceTest {
InboundMeasurementResponses.MeasurementSubmitted result = service.submit(1L, 10L, 9L,
new InboundMeasurementRequests.SubmitRequest(30L, 2));
- assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT");
+ assertThat(result.processStatus()).isEqualTo("INBOUNDED");
assertThat(result.measureReviewStatus()).isEqualTo("APPROVED");
assertThat(measurement.getStatus()).isEqualTo("APPROVED");
verify(repository, never()).updateMeasurement(any());
verify(repository).updateInbound(inbound);
}
+ @Test
+ void savingDraftValidatesCargoAndPersistsInboundDetailBinding() {
+ InboundOrderEntity inbound = inbound("WAIT_MEASURE");
+ inbound.setOrderId(80L);
+ InboundMeasurementEntity saved = new InboundMeasurementEntity();
+ saved.setId(30L);
+ saved.setVersion(0);
+ saved.setStatus("DRAFT");
+ when(repository.findInbound(1L, 10L)).thenReturn(inbound);
+ when(orderCargoClient.detail(eq(""), eq(1L), eq(80L), eq(80001L))).thenReturn(
+ ApiResponse.success(new OrderCargoClient.CargoBinding(80001L, 80L, 1, "Test cargo",
+ 2, new BigDecimal("6.500"), new BigDecimal("0.0480"))));
+ when(batchService.saveBatch(any())).thenReturn(saved);
+
+ InboundMeasurementResponses.DraftSaved result = service.saveDraft(1L, 10L, 9L,
+ new InboundMeasurementRequests.SaveDraftRequest(null, 0, null, null, null, null,
+ List.of(new InboundMeasurementRequests.ItemRequest(80001L, "001", "A", 2,
+ new BigDecimal("40"), new BigDecimal("30"), new BigDecimal("20"),
+ new BigDecimal("3.250"), null, null, null, null, null, null))));
+
+ assertThat(result.measurementId()).isEqualTo(30L);
+ ArgumentCaptor command = ArgumentCaptor.forClass(MeasurementBatchCommand.class);
+ verify(batchService).saveBatch(command.capture());
+ assertThat(command.getValue().items().get(0).inboundDetailId()).isEqualTo(80001L);
+ verify(orderCargoClient).detail("", 1L, 80L, 80001L);
+ }
+
private InboundOrderEntity inbound(String status) {
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(10L);
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..79e77ed9f
--- /dev/null
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java
@@ -0,0 +1,188 @@
+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.ConfirmedQuery;
+import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
+import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
+import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
+import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
+import com.baiyue.firstleg.repository.LoadingListRepository;
+import com.baiyue.firstleg.repository.LoadingPickRepository;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.annotation.Resource;
+import org.junit.jupiter.api.DisplayName;
+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;
+ @MockBean
+ private LoadingPickRepository pickRepository;
+ @Resource
+ private LoadingListService service;
+
+ @TestConfiguration
+ static class Config {
+ @Bean
+ ObjectMapper objectMapper() {
+ return new ObjectMapper();
+ }
+ }
+
+ @Test
+ void shouldRejectEditingStatusFromPreloadList() {
+ PreloadQuery query = new PreloadQuery(null, "EDITING", 100L, 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, 100L, 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, 100L, 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 shouldCompleteLoadingAfterAllPdaItemsPicked() {
+ AggregateRow before = row("LOADING", 2);
+ when(pickRepository.allPlanItemsPicked(10L)).thenReturn(true);
+ when(repository.history(anyString())).thenReturn(null);
+ when(repository.aggregate(10L)).thenReturn(before);
+ when(repository.advance(10L, "LOADING", "COMPLETED", 2, 9L, "operator"))
+ .thenReturn(1);
+
+ service.finishFromPda(10L, 21L, 9L, "operator");
+
+ verify(repository).advance(10L, "LOADING", "COMPLETED", 2, 9L, "operator");
+ verify(repository).appendHistory(eq(10L), eq("PDA-COMPLETE-10-21"),
+ eq("PDA_COMPLETE_LOADING"), eq("LOADING"), eq("COMPLETED"), anyString(), anyString(),
+ eq(9L), eq("operator"), eq("全部箱数已完成PDA拣货"));
+ }
+
+ @Test
+ void shouldRejectAutomaticCompletionWhenPdaItemsRemain() {
+ when(pickRepository.allPlanItemsPicked(10L)).thenReturn(false);
+
+ assertThatThrownBy(() -> service.finishFromPda(10L, 21L, 9L, "operator"))
+ .isInstanceOf(BizException.class)
+ .extracting(error -> ((BizException) error).getResultCode())
+ .isEqualTo(ResultCode.CONFLICT);
+ verify(repository, never()).advance(eq(10L), anyString(), anyString(),
+ org.mockito.ArgumentMatchers.anyInt(), eq(9L), anyString());
+ }
+
+ @Test
+ 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, 100L, 9L, "operator", "key-1", request);
+ verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"),
+ eq("LOADING"), eq("LOADING"), anyString(), snapshot.capture(), eq(9L),
+ eq("operator"), eq(null));
+ HistoryRow replay = new HistoryRow();
+ replay.setAfterSnapshot(snapshot.getValue());
+ when(repository.history(anyString())).thenReturn(replay);
+ when(repository.aggregate(10L)).thenReturn(after);
+
+ service.updateTransport(10L, 100L, 9L, "operator", "key-1", request);
+
+ verify(repository, times(1)).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator");
+ }
+
+ @Test
+ void confirmedPageAlwaysUsesRepositoryConfirmedProjection() {
+ ConfirmedQuery query = new ConfirmedQuery(" plan ", 100L, 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())));
+ }
+
+ @Test
+ void rejectsPlanOutsideRequestedWarehouse() {
+ when(repository.aggregate(10L)).thenReturn(row("LOADING", 1));
+
+ assertThatThrownBy(() -> service.preloadDetail(10L, 200L))
+ .isInstanceOf(BizException.class)
+ .extracting(error -> ((BizException) error).getResultCode())
+ .isEqualTo(ResultCode.NOT_FOUND);
+ }
+
+ private static AggregateRow row(String status, int version) {
+ AggregateRow row = new AggregateRow();
+ row.setPlanId(10L);
+ row.setPlanNo("PL-10");
+ row.setPlanStatus(status);
+ row.setWarehouseId(100L);
+ row.setVersion(version);
+ return row;
+ }
+}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java
new file mode 100644
index 000000000..bfdd1b004
--- /dev/null
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java
@@ -0,0 +1,85 @@
+package com.baiyue.firstleg.service;
+
+import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
+import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
+import com.baiyue.firstleg.entity.LoadingPickItemEntity;
+import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
+import com.baiyue.firstleg.repository.LoadingPickRepository;
+import jakarta.annotation.Resource;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@SpringJUnitConfig(LoadingPickService.class)
+class LoadingPickServiceTest {
+ @MockBean
+ private LoadingPickRepository repository;
+ @MockBean
+ private LoadingListService loadingListService;
+ @Resource
+ private LoadingPickService service;
+
+ @Test
+ void keepsPlanLoadingWhenItemsRemain() {
+ LoadingPickTaskEntity task = task();
+ LoadingPickItemEntity item = item();
+ when(repository.history("pick-1")).thenReturn(null);
+ when(repository.task(20L)).thenReturn(task);
+ when(repository.item(20L, 30L, 101L)).thenReturn(item);
+ when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
+ when(repository.allPlanItemsPicked(10L)).thenReturn(false);
+ when(repository.items(20L, 101L)).thenReturn(List.of(item));
+
+ service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-1"), 9L, "operator");
+
+ verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
+ }
+
+ @Test
+ void completesPlanWhenLastItemIsPicked() {
+ LoadingPickTaskEntity task = task();
+ LoadingPickItemEntity item = item();
+ when(repository.history("pick-2")).thenReturn(null);
+ when(repository.task(20L)).thenReturn(task);
+ when(repository.item(20L, 30L, 101L)).thenReturn(item);
+ when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
+ when(repository.allPlanItemsPicked(10L)).thenReturn(true);
+ when(repository.items(20L, 101L)).thenReturn(List.of(item));
+
+ service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-2"), 9L, "operator");
+
+ verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
+ verify(repository).insert(any(LoadingPickHistoryEntity.class));
+ }
+
+ private static LoadingPickTaskEntity task() {
+ LoadingPickTaskEntity task = new LoadingPickTaskEntity();
+ task.setId(20L);
+ task.setLoadingPlanId(10L);
+ task.setContainerNo("CSLU1234567");
+ task.setTaskStatus("LOADING");
+ task.setVersion(0);
+ return task;
+ }
+
+ private static LoadingPickItemEntity item() {
+ LoadingPickItemEntity item = new LoadingPickItemEntity();
+ item.setId(30L);
+ item.setPickTaskId(20L);
+ item.setLoadingPlanItemId(40L);
+ item.setInboundId(50L);
+ item.setInboundDetailId(60L);
+ item.setPlannedPackageCount(2);
+ item.setPickedPackageCount(0);
+ item.setItemStatus("PENDING");
+ item.setVersion(0);
+ return item;
+ }
+}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java
index 20e4d530f..6947c8dcc 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java
@@ -79,6 +79,7 @@ class MeasurementBatchServiceTest {
ArgumentCaptor
> items = ArgumentCaptor.forClass(List.class);
verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L),
org.mockito.ArgumentMatchers.eq(100L), items.capture());
+ assertThat(items.getValue().get(0).getInboundDetailId()).isEqualTo(80001L);
assertThat(items.getValue().get(0).getTotalWeightKg()).isEqualByComparingTo("6.500");
}
@@ -131,7 +132,7 @@ class MeasurementBatchServiceTest {
private MeasurementBatchCommand.Item item(BigDecimal unitWeight) {
return new MeasurementBatchCommand.Item(
- "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
+ 80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
new BigDecimal("20"), unitWeight, 500L, null,
new BigDecimal("41"), new BigDecimal("31"), new BigDecimal("21"), "备注");
}
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/MeasurementReviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java
index 3bf5a3485..e927df0dc 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java
@@ -77,7 +77,7 @@ class MeasurementReviewServiceTest {
ArgumentCaptor.forClass(InboundStatusHistoryEntity.class);
verify(fixture.repository).insertHistory(history.capture());
assertThat(history.getValue().getFromStatus()).isEqualTo("MEASURE_PENDING");
- assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT");
+ assertThat(history.getValue().getToStatus()).isEqualTo("INBOUNDED");
}
@Test
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementPlanTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementPlanTest.java
new file mode 100644
index 000000000..a781ccfc7
--- /dev/null
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementPlanTest.java
@@ -0,0 +1,53 @@
+package com.baiyue.firstleg.service;
+
+import com.baiyue.firstleg.entity.LoadingPickItemEntity;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class PdaPickSettlementPlanTest {
+ @Test
+ void groupsPartialItemsByInbound() {
+ var resolution = PdaPickSettlementPlan.resolve(List.of(
+ item(1L, 11L, 101L, 1001L, 10, 6, "PARTIAL"),
+ item(2L, 12L, 101L, 1002L, 8, 3, "PARTIAL"),
+ item(3L, 13L, 102L, 2001L, 4, 0, "SKIPPED"),
+ item(4L, 14L, 103L, 3001L, 5, 5, "PICKED")));
+
+ assertThat(resolution.partialByInbound()).containsOnlyKeys(101L);
+ assertThat(resolution.partialByInbound().get(101L))
+ .extracting(PdaPickSettlementPlan.SplitLine::remainingPackageCount)
+ .containsExactlyInAnyOrder(4, 5);
+ assertThat(resolution.skippedPlanItemIds()).containsExactly(13L);
+ }
+
+ @Test
+ void rejectsUnfinishedItem() {
+ assertThatThrownBy(() -> PdaPickSettlementPlan.resolve(List.of(
+ item(1L, 11L, 101L, 1001L, 10, 2, "PICKING"))))
+ .hasMessageContaining("未结束");
+ }
+
+ @Test
+ void rejectsInvalidPartialCount() {
+ assertThatThrownBy(() -> PdaPickSettlementPlan.resolve(List.of(
+ item(1L, 11L, 101L, 1001L, 10, 10, "PARTIAL"))))
+ .hasMessageContaining("不完整或箱数不合法");
+ }
+
+ private static LoadingPickItemEntity item(long id, long planItemId, long inboundId, long detailId,
+ int planned, int picked, String status) {
+ LoadingPickItemEntity item = new LoadingPickItemEntity();
+ item.setId(id);
+ item.setLoadingPlanItemId(planItemId);
+ item.setInboundId(inboundId);
+ item.setInboundDetailId(detailId);
+ item.setPlannedPackageCount(planned);
+ item.setPickedPackageCount(picked);
+ item.setItemStatus(status);
+ return item;
+ }
+}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java
new file mode 100644
index 000000000..27c0f7ee5
--- /dev/null
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java
@@ -0,0 +1,354 @@
+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.booking(99L)).thenReturn(booking());
+ when(repository.candidate(100, 10, 20)).thenReturn(candidate(5));
+ when(repository.activeItem(1L, 9L, 20L)).thenReturn(item(7, 9, 20, 4));
+
+ BizException error = catchThrowableOfType(() -> service.assign(1,
+ new AssignRequest(10L, 20L, 6, 0, "assign-1"), 7, "u"), BizException.class);
+
+ assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
+ verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any());
+ }
+
+ @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(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(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(10L, 20L, 2, 0, "assign-replay");
+ LoadingPlanHistoryEntity replay = history(1, null, "ADD_ITEM",
+ sha256("ASSIGN|1|10|20|2|0"));
+ LoadingPlanEntity frozen = plan("LOADING", 9);
+ when(repository.operation("assign-replay")).thenReturn(replay);
+ when(repository.plan(1)).thenReturn(frozen);
+ 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(10L, 20L, 2, 0, "shared-op");
+ when(repository.operation("shared-op")).thenReturn(history(2, null, "ADD_ITEM",
+ sha256("ASSIGN|1|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(0, "recommend-replay");
+ when(repository.operation("recommend-replay")).thenReturn(history(1, null,
+ "SMART_RECOMMEND", sha256("RECOMMEND|1|0")));
+ when(repository.plan(1)).thenReturn(plan("LOADING", 8));
+ when(repository.booking(99)).thenReturn(booking());
+ when(repository.items(1)).thenReturn(List.of());
+
+ 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(2, 0, "delete-replay");
+ when(repository.operation("delete-replay")).thenReturn(history(1, 8L,
+ "REMOVE_ITEM", sha256("DELETE|1|8|2|0")));
+ when(repository.plan(1)).thenReturn(plan("LOADING", 7));
+ when(repository.booking(99)).thenReturn(booking());
+ when(repository.items(1)).thenReturn(List.of());
+
+ 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(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(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(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(10L, 20L, 2, 0, "assign-mismatch"), 7, "u"), BizException.class);
+
+ assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
+ }
+
+ @Test
+ void deletesCustomerItemInThePlanWarehouse() {
+ LoadingPlanEntity plan = plan("EDITING", 2);
+ when(repository.plan(1)).thenReturn(plan, plan("EDITING", 3));
+ when(repository.item(1, 8)).thenReturn(item(8, 22, 20, 1));
+ when(repository.touch(1, 2, 7, "u")).thenReturn(1);
+ when(repository.remove(1, 8, 0, 7, "u")).thenReturn(1);
+ when(repository.booking(99)).thenReturn(booking());
+ when(repository.items(1)).thenReturn(List.of());
+
+ Plan result = service.delete(1, 8,
+ new DeleteRequest(2, 0, "delete-owner"), 7, "u");
+
+ assertThat(result.version()).isEqualTo(3);
+ verify(repository).remove(1, 8, 0, 7, "u");
+ }
+
+ @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(100, 10, 20)).thenReturn(candidate(10));
+ when(repository.activeItem(1L, 9L, 20L)).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/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java
index 7d3d2a564..45990e094 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java
@@ -14,9 +14,10 @@ class UnifiedInboundQueryServiceTest {
assertThat(UnifiedInboundQueryService.mainStatus("MEASURING")).isEqualTo("WAIT_MEASURE");
assertThat(UnifiedInboundQueryService.mainStatus("MEASURE_PENDING")).isEqualTo("WAIT_MEASURE");
assertThat(UnifiedInboundQueryService.mainStatus("MEASURE_REJECTED")).isEqualTo("WAIT_MEASURE");
- assertThat(UnifiedInboundQueryService.mainStatus("WAIT_PRICING")).isEqualTo("WAIT_SPLIT");
- assertThat(UnifiedInboundQueryService.mainStatus("FEE_CONFIRMING")).isEqualTo("WAIT_SPLIT");
- assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDING")).isEqualTo("WAIT_SPLIT");
+ assertThat(UnifiedInboundQueryService.mainStatus("WAIT_PRICING")).isEqualTo("INBOUNDED");
+ assertThat(UnifiedInboundQueryService.mainStatus("FEE_CONFIRMING")).isEqualTo("INBOUNDED");
+ assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDING")).isEqualTo("INBOUNDED");
+ assertThat(UnifiedInboundQueryService.mainStatus("WAIT_SPLIT")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDED")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("LOADING")).isEqualTo("LOADING");
assertThat(UnifiedInboundQueryService.mainStatus("LOADED")).isEqualTo("LOADED");
@@ -29,13 +30,14 @@ class UnifiedInboundQueryServiceTest {
assertThat(UnifiedInboundQueryService.nextAction("WAIT_MEASURE", "MEASURING", null)).isNull();
assertThat(UnifiedInboundQueryService.nextAction("WAIT_MEASURE", "MEASURE_PENDING", null))
.isEqualTo("CONFIRM_MEASUREMENT");
- assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", null))
+ assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", null))
.isEqualTo("SMART_SPLIT");
- assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", "PENDING"))
+ assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", "PENDING"))
.isEqualTo("CONFIRM_PRELOAD");
- assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "INBOUNDING", null)).isNull();
- assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", "CONFIRMED")).isNull();
- assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", null)).isNull();
+ assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDING", null)).isNull();
+ assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", "CONFIRMED")).isNull();
+ assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "WAIT_SPLIT", null))
+ .isEqualTo("SMART_SPLIT");
assertThat(UnifiedInboundQueryService.nextAction("LOADED", "LOADED", null))
.isEqualTo("COMPLETE_DELIVERY");
assertThat(UnifiedInboundQueryService.nextAction("DELIVERED", "DELIVERED", null)).isNull();
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java
index 30b98bb0c..09dd84108 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java
@@ -44,19 +44,19 @@ class SplitPreloadServiceTest {
@Test
void smartSplitCreatesEditablePendingPlan() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true);
- when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3));
+ when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "INBOUNDED", 3));
when(repository.findPlan(10, 20)).thenReturn(null);
SmartSplitRequest request = new SmartSplitRequest(0,
List.of(new PreloadItem("C1", "A", 2, BigDecimal.ONE, null)), "r");
SplitActionResponse result = service.smartSplit(10, 20, 7, "admin", "k2", request);
- assertThat(result.mainStatus()).isEqualTo("WAIT_SPLIT");
+ assertThat(result.mainStatus()).isEqualTo("INBOUNDED");
verify(repository).createPlan(any(PreloadPlanEntity.class));
}
@Test
void confirmsPlanAndMovesInboundToLoading() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true);
- when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 4));
+ when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "INBOUNDED", 4));
PreloadPlanEntity plan = new PreloadPlanEntity();
plan.setTenantId(10L); plan.setInboundId(20L); plan.setStatus("CONFIRMING"); plan.setVersion(1);
plan.setItemsJson("[]");
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java
new file mode 100644
index 000000000..7eb5a8ee6
--- /dev/null
+++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java
@@ -0,0 +1,83 @@
+package com.baiyue.firstleg.split.service;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.core.io.ClassPathResource;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class SplitPreloadSqlContractTest {
+ @Test
+ void supportsPartialPackageLock() throws Exception {
+ assertCurrentContract(moduleMigration());
+ }
+
+ @Test
+ void limitsSecondaryInboundDepth() throws Exception {
+ String sql = moduleMigration();
+ assertThat(sql)
+ .contains("parent_inbound_id")
+ .contains("inbound_role")
+ .contains("root_inbound_id")
+ .contains("split_status")
+ .contains("uk_inbound_one_child")
+ .contains("fk_inbound_parent_order")
+ .contains("子入仓单不可继续拆分")
+ .contains("入仓单主子归属创建后不可修改");
+ }
+
+ @Test
+ void remainsRepeatable() throws Exception {
+ String sql = moduleMigration();
+ assertThat(sql)
+ .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch")
+ .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item")
+ .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history")
+ .contains("information_schema.columns")
+ .contains("information_schema.statistics")
+ .contains("information_schema.table_constraints")
+ .contains("DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_insert")
+ .contains("DROP TRIGGER IF EXISTS trg_inbound_split_item_before_delete")
+ .contains("CREATE PROCEDURE phase3_assert_inbound_parent_child_schema()")
+ .contains("主子入仓单拆分台账未完整创建")
+ .contains("主子入仓单数据库保护触发器未完整创建");
+ }
+
+ @Test
+ void syncsUnifiedMigration() throws Exception {
+ assertCurrentContract(unifiedMigration());
+ }
+
+ private static void assertCurrentContract(String sql) {
+ assertThat(sql)
+ .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch")
+ .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item")
+ .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history")
+ .contains("uk_inbound_one_child")
+ .contains("uk_inbound_split_operation")
+ .contains("uk_inbound_split_idempotency")
+ .contains("uk_inbound_split_batch_line")
+ .contains("COUNT(DISTINCT item.child_inbound_id)")
+ .contains("child_order.package_count <> SUM(item.package_count)")
+ .contains("HAVING SUM(split_item.package_count) > source_item.package_count")
+ .contains("CREATE TRIGGER trg_inbound_split_item_before_update")
+ .contains("CREATE TRIGGER trg_inbound_split_item_before_delete");
+ }
+
+ private static String moduleMigration() throws Exception {
+ return new ClassPathResource("db/phase3-inbound-parent-child-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);
+ }
+}
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/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrImageResult.java b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrImageResult.java
index 779bf8fbd..4588f7ddd 100644
--- a/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrImageResult.java
+++ b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrImageResult.java
@@ -22,8 +22,11 @@ public record WeightOcrImageResult(
}
public enum Status {
+ /** 已成功识别到有效重量。 */
RECOGNIZED,
+ /** 图片已处理,但未识别到可用重量。 */
NO_WEIGHT_FOUND,
+ /** 图片读取、裁切、调用供应商或解析过程失败。 */
FAILED
}
}
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 863066a23..36e7a3651 100644
--- a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java
+++ b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java
@@ -47,7 +47,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()));
}
@GetMapping("/{tenantId}/{orderId}")
@@ -75,7 +77,15 @@ 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);
+ }
}
public record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java
index 3f9fcab56..7661d39c0 100644
--- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java
+++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java
@@ -3,7 +3,9 @@ package com.baiyue.order.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.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
+import com.baiyue.order.dto.BatchOrderReviewResponse;
import com.baiyue.order.dto.OrderDetailResponse;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.dto.TenantOrderCountResponse;
@@ -28,6 +30,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PutMapping;
+import java.util.ArrayList;
import java.util.List;
/**
@@ -155,6 +158,18 @@ public class PlatformFirstLegOrderController {
return transit(user, tenantId, id, OrderStatus.APPROVED, null);
}
+ @BusinessAudit(event = "ORDER_BATCH_APPROVE", resourceType = "FIRST_LEG_ORDER_BATCH",
+ resourceId = "#request.orderIds", tenantId = "#tenantId", description = "批量审核通过订单")
+ @Operation(summary = "批量审核通过订单", description = "单租户模式下逐单独立审核,返回成功和失败明细。")
+ @PostMapping("/batch-approve")
+ @PreAuthorize("hasAuthority('platform:order:approve')")
+ public ApiResponse batchApprove(
+ @AuthenticationPrincipal AuthenticatedUser user,
+ @RequestParam long tenantId,
+ @Valid @RequestBody OrderRequests.BatchReviewRequest request) {
+ return batchTransit(user, tenantId, request.orderIds(), OrderStatus.APPROVED, null);
+ }
+
@BusinessAudit(event = "ORDER_REJECT", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Reject order / 驳回订单")
@Operation(summary = "Reject / 驳回", description = "Rejects a submitted order with a required reason. / 使用必填原因驳回已提交订单。")
@PostMapping("/{id}/reject")
@@ -166,6 +181,18 @@ public class PlatformFirstLegOrderController {
return transit(user, tenantId, id, OrderStatus.REJECTED, request.reason());
}
+ @BusinessAudit(event = "ORDER_BATCH_REJECT", resourceType = "FIRST_LEG_ORDER_BATCH",
+ resourceId = "#request.orderIds", tenantId = "#tenantId", description = "批量驳回订单")
+ @Operation(summary = "批量驳回订单", description = "单租户模式下使用统一原因逐单独立驳回,返回成功和失败明细。")
+ @PostMapping("/batch-reject")
+ @PreAuthorize("hasAuthority('platform:order:reject')")
+ public ApiResponse batchReject(
+ @AuthenticationPrincipal AuthenticatedUser user,
+ @RequestParam long tenantId,
+ @Valid @RequestBody OrderRequests.BatchRejectRequest request) {
+ return batchTransit(user, tenantId, request.orderIds(), OrderStatus.REJECTED, request.reason());
+ }
+
@BusinessAudit(event = "ORDER_ACCEPT", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Accept order / 受理订单")
@Operation(summary = "Accept / 受理订单", description = "Accepts an approved order for fulfillment processing. / 受理已审核通过的订单,进入履约处理。")
@PostMapping("/{id}/accept")
@@ -242,4 +269,22 @@ public class PlatformFirstLegOrderController {
);
return ApiResponse.success(null);
}
+
+ private ApiResponse batchTransit(
+ AuthenticatedUser user, long tenantId, List orderIds, OrderStatus target, String reason) {
+ long scopedTenantId = OrderScopeGuard.platform(user, tenantId);
+ List successOrderIds = new ArrayList<>();
+ List failedOrders = new ArrayList<>();
+ for (Long orderId : orderIds) {
+ try {
+ service.transit(scopedTenantId, user.userId(), user.username(), orderId, target, reason);
+ successOrderIds.add(orderId);
+ } catch (BizException exception) {
+ failedOrders.add(new BatchOrderReviewResponse.FailedOrder(
+ orderId, exception.getResultCode().code(), exception.getMessage()));
+ }
+ }
+ return ApiResponse.success(new BatchOrderReviewResponse(orderIds.size(), successOrderIds.size(),
+ failedOrders.size(), List.copyOf(successOrderIds), List.copyOf(failedOrders)));
+ }
}
diff --git a/order/src/main/java/com/baiyue/order/dto/BatchOrderReviewResponse.java b/order/src/main/java/com/baiyue/order/dto/BatchOrderReviewResponse.java
new file mode 100644
index 000000000..981c6c034
--- /dev/null
+++ b/order/src/main/java/com/baiyue/order/dto/BatchOrderReviewResponse.java
@@ -0,0 +1,23 @@
+package com.baiyue.order.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import java.util.List;
+
+/** 单租户订单批量审核结果。 */
+@Schema(description = "订单批量审核结果")
+public record BatchOrderReviewResponse(
+ @Schema(description = "请求订单总数") int total,
+ @Schema(description = "成功数量") int successCount,
+ @Schema(description = "失败数量") int failureCount,
+ @Schema(description = "成功订单 ID") List successOrderIds,
+ @Schema(description = "失败订单及原因") List failedOrders
+) {
+ @Schema(description = "单笔失败结果")
+ public record FailedOrder(
+ @Schema(description = "订单 ID") long orderId,
+ @Schema(description = "业务错误码") String code,
+ @Schema(description = "失败原因") String message
+ ) {
+ }
+}
diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java
index 156cec35f..e56713a34 100644
--- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java
+++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java
@@ -167,6 +167,30 @@ public final class OrderRequests {
) {
}
+ @Schema(name = "FirstLegOrderBatchReviewRequest", description = "单租户订单批量审核请求")
+ public record BatchReviewRequest(
+ @Schema(description = "订单 ID 列表,单次最多 100 条", requiredMode = Schema.RequiredMode.REQUIRED)
+ @NotEmpty @Size(max = 100) List<@NotNull @Min(1) Long> orderIds
+ ) {
+ @AssertTrue(message = "订单 ID 不能重复")
+ public boolean hasDistinctOrderIds() {
+ return orderIds == null || orderIds.size() == orderIds.stream().distinct().count();
+ }
+ }
+
+ @Schema(name = "FirstLegOrderBatchRejectRequest", description = "单租户订单批量驳回请求")
+ public record BatchRejectRequest(
+ @Schema(description = "订单 ID 列表,单次最多 100 条", requiredMode = Schema.RequiredMode.REQUIRED)
+ @NotEmpty @Size(max = 100) List<@NotNull @Min(1) Long> orderIds,
+ @Schema(description = "统一驳回原因", maxLength = 500, requiredMode = Schema.RequiredMode.REQUIRED)
+ @NotBlank @Size(max = 500) String reason
+ ) {
+ @AssertTrue(message = "订单 ID 不能重复")
+ public boolean hasDistinctOrderIds() {
+ return orderIds == null || orderIds.size() == orderIds.stream().distinct().count();
+ }
+ }
+
@Schema(name = "OrderInternalNoteRequest", description = "Platform internal order note / 平台内部订单备注")
public record NoteRequest(
@Schema(description = "Internal note content / 内部备注内容", example = "Customer confirmed the shipping schedule / 客户已确认船期", maxLength = 2000, requiredMode = Schema.RequiredMode.REQUIRED)
diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java
index fecdc4dd6..cc3d7d8ee 100644
--- a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java
+++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java
@@ -12,7 +12,7 @@ import java.time.LocalDateTime;
public record UnifiedOrderQuery(
@Size(max = 64) String keyword,
@Size(max = 64) String orderNo,
- @Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT",
+ @Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED",
"LOADING", "LOADED", "DELIVERED"})
@Size(max = 32) String mainStatus,
Long customerId,
diff --git a/order/src/main/java/com/baiyue/order/entity/OrderStatus.java b/order/src/main/java/com/baiyue/order/entity/OrderStatus.java
index 693aaab40..971ef1ea0 100644
--- a/order/src/main/java/com/baiyue/order/entity/OrderStatus.java
+++ b/order/src/main/java/com/baiyue/order/entity/OrderStatus.java
@@ -2,4 +2,21 @@ package com.baiyue.order.entity;
/**
* 头程订单生命周期状态,约束草稿、提交、审核、受理、处理、完成及取消等状态流转。
*/
-public enum OrderStatus { DRAFT, SUBMITTED, APPROVED, ACCEPTED, REJECTED, CANCELLED, PROCESSING, COMPLETED }
+public enum OrderStatus {
+ /** 草稿:客户可继续编辑,尚未提交审核。 */
+ DRAFT,
+ /** 已提交:等待平台审核。 */
+ SUBMITTED,
+ /** 审核通过:可由平台接单或进入头程履约。 */
+ APPROVED,
+ /** 已接单:平台已受理订单。 */
+ ACCEPTED,
+ /** 审核驳回:不允许继续进入履约。 */
+ REJECTED,
+ /** 已取消:订单生命周期终止。 */
+ CANCELLED,
+ /** 履约中:订单正在执行头程业务。 */
+ PROCESSING,
+ /** 已完成:订单全流程结束。 */
+ COMPLETED
+}
diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java
index aa0695dea..db51e0aa1 100644
--- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java
+++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java
@@ -31,7 +31,7 @@ import java.util.stream.Collectors;
@Service
public class UnifiedOrderQueryService {
private static final Set MAIN_STATUSES = Set.of(
- "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
+ "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "LOADING", "LOADED", "DELIVERED");
private static final Set REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED");
private static final List ACTIVE_ORDER_STATUSES = List.copyOf(EnumSet.of(
OrderStatus.SUBMITTED, OrderStatus.APPROVED, OrderStatus.ACCEPTED,
@@ -199,8 +199,9 @@ public class UnifiedOrderQueryService {
if (status == null) return null;
return switch (status) {
case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED" -> "WAIT_MEASURE";
- case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT" -> "WAIT_SPLIT";
- case "INBOUNDED", "LOADING", "LOADED", "DELIVERED" -> status;
+ // WAIT_SPLIT is retained only to normalize records created before status unification.
+ case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT", "INBOUNDED" -> "INBOUNDED";
+ case "LOADING", "LOADED", "DELIVERED" -> status;
default -> null;
};
}
diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml
index 6f2cce29a..df00e1543 100644
--- a/order/src/main/resources/openapi/order-v1.yaml
+++ b/order/src/main/resources/openapi/order-v1.yaml
@@ -311,7 +311,7 @@ components:
required: false
schema:
type: string
- enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED]
+ enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, LOADING, LOADED, DELIVERED]
UnifiedInboundCode:
{name: inboundCode, in: query, required: false, description: 客户提交后生成的入仓码,待审核订单同样可查询, schema: {type: string, maxLength: 40}}
UnifiedInternalOrderStatus:
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 eb6f24430..83cc76d23 100644
--- a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java
+++ b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java
@@ -33,6 +33,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);
@@ -40,6 +46,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/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java
index fd5d6eb22..7686f0daa 100644
--- a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java
+++ b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java
@@ -41,6 +41,10 @@ class OrderControllerPermissionTest {
AuthenticatedUser.class, long.class, long.class, OrderRequests.SaveRequest.class);
assertAuthority(PlatformFirstLegOrderController.class, "submit", "hasAuthority('platform:order:submit')",
AuthenticatedUser.class, long.class, long.class, String.class);
+ assertAuthority(PlatformFirstLegOrderController.class, "batchApprove", "hasAuthority('platform:order:approve')",
+ AuthenticatedUser.class, long.class, OrderRequests.BatchReviewRequest.class);
+ assertAuthority(PlatformFirstLegOrderController.class, "batchReject", "hasAuthority('platform:order:reject')",
+ AuthenticatedUser.class, long.class, OrderRequests.BatchRejectRequest.class);
}
@Test
diff --git a/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java b/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java
index fe6b5970b..61e043825 100644
--- a/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java
+++ b/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java
@@ -1,8 +1,11 @@
package com.baiyue.order.controller;
import com.baiyue.common.core.exception.BizException;
+import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.security.AuthenticatedUser;
+import com.baiyue.order.dto.BatchOrderReviewResponse;
import com.baiyue.order.dto.OrderRequests;
+import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.service.OrderApplicationService;
import org.junit.jupiter.api.Test;
import jakarta.annotation.Resource;
@@ -11,8 +14,11 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Set;
+import java.util.List;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
@@ -66,6 +72,35 @@ class PlatformOrderPageControllerTest {
verifyNoInteractions(service);
}
+ @Test
+ void batchApproveReturnsPartialFailureAndContinuesRemainingOrders() {
+ doThrow(new BizException(ResultCode.CONFLICT, "订单已审核"))
+ .when(service).transit(10001L, 1L, "operator", 102L, OrderStatus.APPROVED, null);
+
+ var response = controller.batchApprove(user("PLATFORM", 0L), 10001L,
+ new OrderRequests.BatchReviewRequest(List.of(101L, 102L, 103L)));
+
+ BatchOrderReviewResponse result = response.data();
+ assertThat(result.total()).isEqualTo(3);
+ assertThat(result.successOrderIds()).containsExactly(101L, 103L);
+ assertThat(result.failedOrders()).singleElement().satisfies(failure -> {
+ assertThat(failure.orderId()).isEqualTo(102L);
+ assertThat(failure.code()).isEqualTo(ResultCode.CONFLICT.code());
+ assertThat(failure.message()).isEqualTo("订单已审核");
+ });
+ verify(service).transit(10001L, 1L, "operator", 101L, OrderStatus.APPROVED, null);
+ verify(service).transit(10001L, 1L, "operator", 103L, OrderStatus.APPROVED, null);
+ }
+
+ @Test
+ void batchRejectUsesSharedReasonForEveryOrder() {
+ controller.batchReject(user("PLATFORM", 0L), 10001L,
+ new OrderRequests.BatchRejectRequest(List.of(101L, 102L), "资料不完整"));
+
+ verify(service).transit(10001L, 1L, "operator", 101L, OrderStatus.REJECTED, "资料不完整");
+ verify(service).transit(10001L, 1L, "operator", 102L, OrderStatus.REJECTED, "资料不完整");
+ }
+
private AuthenticatedUser user(String appCode, Long tenantId) {
return new AuthenticatedUser(1L, tenantId, null, null, "operator", appCode, "WEB", Set.of());
}
diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java
index a94cce672..54ceae900 100644
--- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java
+++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java
@@ -120,15 +120,15 @@ class UnifiedOrderQueryServiceTest {
@Test
void fulfillmentStatusFilterUsesFirstlegPagingWithoutPostPageFiltering() {
- UnifiedOrderQuery query = query("WAIT_SPLIT");
- var summary = summary("WAIT_SPLIT", "CONFIRM_PRELOAD");
+ UnifiedOrderQuery query = query("INBOUNDED");
+ var summary = summary("INBOUNDED", "CONFIRM_PRELOAD");
when(firstleg.page(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
var result = service.page(null, query);
assertThat(result.records()).singleElement().satisfies(row -> {
- assertThat(row.mainStatus()).isEqualTo("WAIT_SPLIT");
+ assertThat(row.mainStatus()).isEqualTo("INBOUNDED");
assertThat(row.nextAction()).isEqualTo("CONFIRM_PRELOAD");
assertThat(row.inboundId()).isEqualTo(88L);
assertThat(row.inboundVersion()).isEqualTo(7);
@@ -197,7 +197,7 @@ class UnifiedOrderQueryServiceTest {
private FirstlegUnifiedOrderClient.Summary summary(String status, String action) {
var inbound = new FirstlegUnifiedOrderClient.Inbound(
- 88L, 10001L, 77L, "FL-77", "RK-77", "WAIT_SPLIT", "APPROVED", "APPROVED", 7,
+ 88L, 10001L, 77L, "FL-77", "RK-77", "INBOUNDED", "APPROVED", "APPROVED", 7,
30001L, "测试客户", 11L, "深圳仓", 22L, "ONT8", "美森", 10, null, null, null,
null, null, null, null, null, null, null, null, null,
LocalDateTime.of(2026, 8, 6, 10, 0), LocalDateTime.of(2026, 8, 6, 11, 0));
diff --git a/scripts/phase2-test-env-acceptance.ps1 b/scripts/phase2-test-env-acceptance.ps1
index 73f36eb68..9bf019567 100644
--- a/scripts/phase2-test-env-acceptance.ps1
+++ b/scripts/phase2-test-env-acceptance.ps1
@@ -308,9 +308,6 @@ try {
$unifiedPending = Require-Success '统一订单详情查询待审核状态' $unifiedPendingResponse
Require-Condition '统一订单主状态为 PENDING_REVIEW' $unifiedPendingResponse ($unifiedPending.summary.mainStatus -eq 'PENDING_REVIEW') "mainStatus=$($unifiedPending.summary.mainStatus)"
- $carrierAssignResponse = Invoke-Api PUT "/api/platform/v1/orders/first-leg/$($ids.orderId)/carrier?tenantId=$TenantId" $platformToken @{ carrierId = $ids.carrierId }
- $null = Require-Success '平台指派承运商' $carrierAssignResponse
-
$approveOrderResponse = Invoke-Api POST "/api/platform/v1/orders/first-leg/$($ids.orderId)/approve?tenantId=$TenantId" $platformToken
$null = Require-Success '平台审核通过订单' $approveOrderResponse
@@ -408,16 +405,16 @@ try {
$approvedInboundResponse = Invoke-Api GET "/api/platform/inbounds/$($ids.inboundId)?tenantId=$TenantId" $platformToken
$approvedInbound = Require-Success '审核后回查入库单' $approvedInboundResponse
$versions.inbound = [int]$approvedInbound.summary.version
- Require-Condition '二期边界停在 WAIT_SPLIT' $approvedInboundResponse ($approvedInbound.summary.processStatus -eq 'WAIT_SPLIT' -and $approvedInbound.summary.measureReviewStatus -eq 'APPROVED') "processStatus=$($approvedInbound.summary.processStatus), reviewStatus=$($approvedInbound.summary.measureReviewStatus)"
+ Require-Condition '二期边界停在 INBOUNDED' $approvedInboundResponse ($approvedInbound.summary.processStatus -eq 'INBOUNDED' -and $approvedInbound.summary.measureReviewStatus -eq 'APPROVED') "processStatus=$($approvedInbound.summary.processStatus), reviewStatus=$($approvedInbound.summary.measureReviewStatus)"
$feeResponse = Invoke-Api POST "/api/platform/v1/inbounds/$($ids.inboundId)/fees/calculate?tenantId=$TenantId" $platformToken @{} @{ 'Idempotency-Key' = (New-Key 'fee-calculate') }
$calculatedFee = Require-Success '二期计费接口试算' $feeResponse
$ids.feeId = [string]$calculatedFee.feeId
- Require-Condition '计费试算保持 WAIT_SPLIT 主流程边界' $feeResponse ($calculatedFee.nextProcessStatus -eq 'WAIT_SPLIT') "nextProcessStatus=$($calculatedFee.nextProcessStatus)"
+ Require-Condition '计费试算保持 INBOUNDED 主流程边界' $feeResponse ($calculatedFee.nextProcessStatus -eq 'INBOUNDED') "nextProcessStatus=$($calculatedFee.nextProcessStatus)"
$feeConfirmResponse = Invoke-Api POST "/api/platform/v1/inbounds/$($ids.inboundId)/fees/confirm?tenantId=$TenantId" $platformToken $null @{ 'Idempotency-Key' = (New-Key 'fee-confirm') }
$confirmedFee = Require-Success '确认二期费用快照' $feeConfirmResponse
- Require-Condition '费用确认后仍保持 WAIT_SPLIT 主流程边界' $feeConfirmResponse ($confirmedFee.feeStatus -eq 'CONFIRMED' -and $confirmedFee.nextProcessStatus -eq 'WAIT_SPLIT') "feeStatus=$($confirmedFee.feeStatus), nextProcessStatus=$($confirmedFee.nextProcessStatus)"
+ Require-Condition '费用确认后仍保持 INBOUNDED 主流程边界' $feeConfirmResponse ($confirmedFee.feeStatus -eq 'CONFIRMED' -and $confirmedFee.nextProcessStatus -eq 'INBOUNDED') "feeStatus=$($confirmedFee.feeStatus), nextProcessStatus=$($confirmedFee.nextProcessStatus)"
}
catch {
$fatal = $_.Exception.Message
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/dto/WarehouseImportDtos.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseImportDtos.java
index 8d70d9316..3db6d9bf4 100644
--- a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseImportDtos.java
+++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseImportDtos.java
@@ -12,8 +12,10 @@ public final class WarehouseImportDtos {
@Schema(name = "WarehouseImportMode", description = "Warehouse import mode / 仓库导入模式")
public enum ImportMode {
@Schema(description = "Only create new rows; existing rows are reported but not modified / 仅新增,现有数据只预检分类不修改")
+ /** 仅新增:重复的仓库编码只提示,不修改已有仓库。 */
CREATE_ONLY,
@Schema(description = "Create new rows and update existing rows without overriding contact or remark / 新增并更新,更新不覆盖联系人和备注")
+ /** 新增或更新:已有仓库按导入字段更新,但保留联系人和备注。 */
UPSERT
}
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/LocationApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationApplicationService.java
index 6e6ff03d1..bb8c56261 100644
--- a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationApplicationService.java
+++ b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationApplicationService.java
@@ -145,6 +145,9 @@ public class LocationApplicationService {
if (warehouse == null) {
throw new BizException(ResultCode.FORBIDDEN, "仓库不属于当前企业租户或不在数据范围内");
}
+ if (!"DOMESTIC".equalsIgnoreCase(warehouse.getWarehouseType())) {
+ throw new BizException(ResultCode.FORBIDDEN, "库位只能绑定国内中转仓");
+ }
if (active && !"ACTIVE".equals(warehouse.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "仓库已停用");
}
diff --git a/warehouse/src/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/LocationApplicationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationApplicationServiceTest.java
index d8ee744c7..da3c40a9c 100644
--- a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationApplicationServiceTest.java
+++ b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationApplicationServiceTest.java
@@ -70,6 +70,17 @@ class LocationApplicationServiceTest {
.isInstanceOf(BizException.class).hasMessageContaining("编码");
}
+ @Test
+ void rejectsNonDomesticWarehouseForCreate() {
+ when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse("ACTIVE", "FBA"));
+
+ assertThatThrownBy(() -> service.create(10001L, request("AVAILABLE", null)))
+ .isInstanceOf(BizException.class)
+ .hasMessageContaining("国内中转仓");
+
+ verify(repository, never()).insert(any());
+ }
+
@Test
void shouldHandleCase03() {
LocationEntity location = location("AVAILABLE", 3);
@@ -110,10 +121,15 @@ class LocationApplicationServiceTest {
}
private static WarehouseEntity warehouse(String status) {
+ return warehouse(status, "DOMESTIC");
+ }
+
+ private static WarehouseEntity warehouse(String status, String type) {
WarehouseEntity warehouse = new WarehouseEntity();
warehouse.setId(88L);
warehouse.setTenantId(10001L);
warehouse.setStatus(status);
+ warehouse.setWarehouseType(type);
return warehouse;
}
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);
+ }
+}