提交
This commit is contained in:
@@ -3761,6 +3761,786 @@ CREATE TABLE IF NOT EXISTS sys_audit_dead_letter (
|
||||
KEY idx_audit_dead_retry(status,next_retry_at,lease_until)
|
||||
) ENGINE=InnoDB COMMENT='审计消息死信补偿表';
|
||||
|
||||
-- ============================================================
|
||||
-- 三期智能装柜、预装冻结与装柜清单
|
||||
-- 来源:firstleg/src/main/resources/db/phase3-smart-loading-schema.sql
|
||||
-- ============================================================
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS baiyue_firstleg
|
||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||
USE baiyue_firstleg;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
|
||||
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
|
||||
container_no VARCHAR(64) NULL COMMENT '集装箱号;确认前允许修订',
|
||||
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
|
||||
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '装柜仓库ID;平台公共资源',
|
||||
warehouse_name VARCHAR(128) NOT NULL COMMENT '装柜仓库名称快照',
|
||||
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
|
||||
vessel_name VARCHAR(128) NULL COMMENT '船名快照',
|
||||
voyage_no VARCHAR(64) NULL COMMENT '航次;确认前允许修订',
|
||||
container_type VARCHAR(32) NOT NULL COMMENT '箱型,例如20GP、40GP、40HQ',
|
||||
shipping_company_id BIGINT UNSIGNED NULL COMMENT '船司ID快照',
|
||||
shipping_company_name VARCHAR(128) NULL COMMENT '船司名称快照',
|
||||
departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照',
|
||||
departure_port_name VARCHAR(128) NULL COMMENT '起运港名称快照',
|
||||
destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照',
|
||||
destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照',
|
||||
destination_warehouse_code VARCHAR(64) NULL COMMENT '目的仓编码快照',
|
||||
destination_warehouse_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
|
||||
capacity_cbm DECIMAL(18,4) NULL COMMENT '货柜容量m3',
|
||||
max_weight_kg DECIMAL(18,3) NULL COMMENT '货柜最大承重kg',
|
||||
terminal_trucking_company VARCHAR(128) NULL COMMENT '码头拖车公司',
|
||||
loading_at DATETIME(3) NULL COMMENT '计划或实际装柜时间',
|
||||
estimated_sailing_at DATETIME(3) NULL COMMENT '预计开船时间',
|
||||
actual_sailing_at DATETIME(3) NULL COMMENT '实际开船时间',
|
||||
estimated_port_arrival_at DATETIME(3) NULL COMMENT '预计到港时间',
|
||||
actual_port_arrival_at DATETIME(3) NULL COMMENT '实际到港时间',
|
||||
estimated_warehouse_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间',
|
||||
actual_warehouse_arrival_at DATETIME(3) NULL COMMENT '实际到仓时间',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
|
||||
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
|
||||
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
|
||||
COMMENT '更新时间',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_container_booking_no (booking_no),
|
||||
UNIQUE KEY uk_container_booking_cabinet (cabinet_no),
|
||||
KEY idx_container_booking_container (container_no),
|
||||
KEY idx_container_booking_warehouse (warehouse_id, updated_at, id),
|
||||
KEY idx_container_booking_voyage (voyage_no, updated_at),
|
||||
CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0),
|
||||
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0)
|
||||
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_plan (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID(雪花ID)',
|
||||
plan_no VARCHAR(40) NOT NULL COMMENT '平台唯一装柜计划号',
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
|
||||
plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING'
|
||||
COMMENT '唯一装柜状态:EDITING、LOADING、COMPLETED',
|
||||
push_idempotency_key VARCHAR(128) NULL COMMENT '推送预装幂等键',
|
||||
preload_pushed_by BIGINT UNSIGNED NULL COMMENT '推送预装操作人ID',
|
||||
preload_pushed_by_name VARCHAR(64) NULL COMMENT '推送预装操作人名称快照',
|
||||
preload_pushed_at DATETIME(3) NULL COMMENT '开始装柜并进入LOADING的时间',
|
||||
preload_completed_by BIGINT UNSIGNED NULL COMMENT '预装完成操作人ID',
|
||||
preload_completed_by_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照',
|
||||
preload_completed_at DATETIME(3) NULL COMMENT '预装完成时间',
|
||||
confirmed_by BIGINT UNSIGNED NULL COMMENT '确认操作人ID',
|
||||
confirmed_by_name VARCHAR(64) NULL COMMENT '确认操作人名称快照',
|
||||
confirmed_at DATETIME(3) NULL COMMENT '确认时间',
|
||||
remark VARCHAR(500) NULL COMMENT '平台装柜计划备注',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
|
||||
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
|
||||
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
|
||||
COMMENT '更新时间',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_loading_plan_no (plan_no),
|
||||
UNIQUE KEY uk_loading_plan_booking (container_booking_id),
|
||||
UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key),
|
||||
KEY idx_loading_plan_status (plan_status, updated_at, id),
|
||||
CONSTRAINT fk_loading_plan_booking FOREIGN KEY (container_booking_id)
|
||||
REFERENCES fl_container_booking (id),
|
||||
CONSTRAINT chk_loading_plan_status
|
||||
CHECK (plan_status IN ('EDITING', 'LOADING', 'COMPLETED'))
|
||||
) ENGINE=InnoDB COMMENT='平台智能装柜计划与唯一预装状态源';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划明细ID(雪花ID)',
|
||||
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID',
|
||||
account_id BIGINT UNSIGNED NOT NULL COMMENT '客户账号ID;装柜业务唯一客户归属字段',
|
||||
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
|
||||
inbound_id BIGINT UNSIGNED NOT NULL COMMENT 'Firstleg入库单ID',
|
||||
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物或入库货物明细ID,不建立跨库外键',
|
||||
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID快照,不建立跨库外键',
|
||||
order_no VARCHAR(32) NOT NULL COMMENT '订单号快照',
|
||||
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
|
||||
destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '目的仓ID快照',
|
||||
destination_warehouse_code VARCHAR(64) NULL COMMENT '目的仓编码快照',
|
||||
destination_warehouse_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
|
||||
sku_code VARCHAR(128) NULL COMMENT 'SKU编码快照',
|
||||
product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照',
|
||||
product_name_en VARCHAR(255) NULL COMMENT '英文品名快照',
|
||||
product_image_file_id BIGINT UNSIGNED NULL COMMENT '产品图片File对象ID',
|
||||
product_image_url VARCHAR(1000) NULL COMMENT '产品图片访问地址快照',
|
||||
package_count INT UNSIGNED NOT NULL COMMENT '本次装柜箱数',
|
||||
product_quantity INT UNSIGNED NULL COMMENT '产品总数量',
|
||||
units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数',
|
||||
pallet_count INT UNSIGNED NULL COMMENT '托盘数',
|
||||
gross_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜毛重kg',
|
||||
net_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜净重kg',
|
||||
dimensional_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜材积重kg',
|
||||
volume_cbm DECIMAL(18,4) NULL COMMENT '本次装柜体积m3',
|
||||
fba_tracking_no VARCHAR(128) NULL COMMENT 'FBA追踪号快照',
|
||||
shipment_no VARCHAR(128) NULL COMMENT '货件编号快照',
|
||||
shipping_mark VARCHAR(500) NULL COMMENT '唛头快照',
|
||||
load_sequence INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '装柜顺序',
|
||||
position_snapshot JSON NULL COMMENT '智能装柜位置与摆放结果快照',
|
||||
internal_remark VARCHAR(1000) NULL COMMENT '平台内部备注',
|
||||
customer_remark VARCHAR(1000) NULL COMMENT '客户备注快照',
|
||||
warehouse_remark VARCHAR(1000) NULL COMMENT '仓库备注快照',
|
||||
frozen_at DATETIME(3) NULL COMMENT '装柜完成后的永久冻结时间',
|
||||
frozen_by BIGINT UNSIGNED NULL COMMENT '冻结操作人ID',
|
||||
removed_by BIGINT UNSIGNED NULL COMMENT '推送前逻辑删除操作人ID',
|
||||
removed_by_name VARCHAR(64) NULL COMMENT '推送前逻辑删除操作人名称快照',
|
||||
removed_at DATETIME(3) NULL COMMENT '推送前逻辑删除时间',
|
||||
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '推送前逻辑删除标记;冻结后不可修改',
|
||||
active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS (
|
||||
CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END
|
||||
) STORED COMMENT '未删除明细唯一锁;删除后返回NULL释放分配资格',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
|
||||
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
|
||||
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
|
||||
COMMENT '更新时间',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_loading_item_active_detail (account_id, active_inbound_detail_id),
|
||||
KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id),
|
||||
KEY idx_loading_item_account (account_id, created_at, id),
|
||||
KEY idx_loading_item_inbound (account_id, inbound_id, deleted),
|
||||
KEY idx_loading_item_order (account_id, order_id, deleted),
|
||||
CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
|
||||
CONSTRAINT chk_loading_item_account CHECK (account_id > 0),
|
||||
CONSTRAINT chk_loading_item_package CHECK (package_count > 0),
|
||||
CONSTRAINT chk_loading_item_product_quantity CHECK (product_quantity IS NULL OR product_quantity > 0),
|
||||
CONSTRAINT chk_loading_item_units CHECK (units_per_carton IS NULL OR units_per_carton > 0),
|
||||
CONSTRAINT chk_loading_item_pallet CHECK (pallet_count IS NULL OR pallet_count >= 0),
|
||||
CONSTRAINT chk_loading_item_weight CHECK (
|
||||
(gross_weight_kg IS NULL OR gross_weight_kg >= 0)
|
||||
AND (net_weight_kg IS NULL OR net_weight_kg >= 0)
|
||||
AND (dimensional_weight_kg IS NULL OR dimensional_weight_kg >= 0)
|
||||
),
|
||||
CONSTRAINT chk_loading_item_volume CHECK (volume_cbm IS NULL OR volume_cbm >= 0),
|
||||
CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1))
|
||||
) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_container_document (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
|
||||
document_type VARCHAR(32) NOT NULL COMMENT 'CUSTOMS_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER',
|
||||
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键',
|
||||
file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照',
|
||||
file_url VARCHAR(1000) NULL COMMENT '文件访问地址快照',
|
||||
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID',
|
||||
uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
|
||||
COMMENT '更新时间',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_container_document_type (container_booking_id, document_type),
|
||||
KEY idx_container_document_file (file_id),
|
||||
CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id)
|
||||
REFERENCES fl_container_booking (id),
|
||||
CONSTRAINT chk_container_document_type
|
||||
CHECK (document_type IN ('CUSTOMS_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER'))
|
||||
) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_plan_history (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '装柜操作历史ID(雪花ID)',
|
||||
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID',
|
||||
loading_plan_item_id BIGINT UNSIGNED NULL COMMENT '装柜计划明细ID',
|
||||
operation_id VARCHAR(128) NOT NULL COMMENT '操作幂等ID',
|
||||
action_type VARCHAR(32) NOT NULL
|
||||
COMMENT 'CREATE_PLAN、ADD_ITEM、ADJUST_ITEM、REMOVE_ITEM、START_LOADING、COMPLETE_LOADING、PICK_ITEM',
|
||||
from_status VARCHAR(24) NULL COMMENT '变更前状态',
|
||||
to_status VARCHAR(24) NULL COMMENT '变更后状态',
|
||||
before_snapshot JSON NULL COMMENT '变更前快照',
|
||||
after_snapshot JSON NULL COMMENT '变更后快照',
|
||||
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
|
||||
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
|
||||
operation_reason VARCHAR(500) NULL COMMENT '操作原因',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_loading_history_operation (operation_id),
|
||||
KEY idx_loading_history_plan (loading_plan_id, created_at, id),
|
||||
KEY idx_loading_history_item (loading_plan_item_id, created_at, id),
|
||||
CONSTRAINT fk_loading_history_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
|
||||
CONSTRAINT fk_loading_history_item FOREIGN KEY (loading_plan_item_id)
|
||||
REFERENCES fl_loading_plan_item (id)
|
||||
) ENGINE=InnoDB COMMENT='智能装柜追加式不可变操作历史';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
|
||||
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID',
|
||||
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号快照',
|
||||
task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING' COMMENT 'LOADING、COMPLETED',
|
||||
started_by BIGINT UNSIGNED NOT NULL COMMENT '开始装柜人',
|
||||
started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称',
|
||||
started_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '开始时间',
|
||||
completed_by BIGINT UNSIGNED NULL COMMENT '完成装柜人',
|
||||
completed_by_name VARCHAR(64) NULL COMMENT '完成装柜人名称',
|
||||
completed_at DATETIME(3) NULL COMMENT '完成时间',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id),
|
||||
KEY idx_loading_pick_container (container_no, task_status, started_at),
|
||||
CONSTRAINT fk_loading_pick_task_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
|
||||
CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING', 'COMPLETED'))
|
||||
) ENGINE=InnoDB COMMENT='PDA装柜拣货任务';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
|
||||
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
|
||||
loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划明细ID',
|
||||
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '父入库单ID',
|
||||
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID',
|
||||
planned_package_count INT UNSIGNED NOT NULL COMMENT '计划拣货箱数;上层拆分按业务规则向上取整',
|
||||
picked_package_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已拣货箱数',
|
||||
item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PICKING、PICKED',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_loading_pick_item_plan_item (loading_plan_item_id),
|
||||
KEY idx_loading_pick_item_task (pick_task_id, item_status, id),
|
||||
CONSTRAINT fk_loading_pick_item_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
|
||||
CONSTRAINT fk_loading_pick_item_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id),
|
||||
CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0),
|
||||
CONSTRAINT chk_loading_pick_item_count CHECK (picked_package_count <= planned_package_count),
|
||||
CONSTRAINT chk_loading_pick_item_status CHECK (item_status IN ('PENDING', 'PICKING', 'PICKED'))
|
||||
) ENGINE=InnoDB COMMENT='PDA装柜拣货明细';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_pick_history (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货操作历史ID',
|
||||
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
|
||||
pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
|
||||
operation_id VARCHAR(128) NOT NULL COMMENT '客户端幂等操作号',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '请求指纹',
|
||||
picked_package_count INT UNSIGNED NOT NULL COMMENT '本次拣货箱数',
|
||||
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人',
|
||||
operator_name VARCHAR(64) NULL COMMENT '操作人名称',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_loading_pick_history_operation (operation_id),
|
||||
KEY idx_loading_pick_history_item (pick_item_id, created_at),
|
||||
CONSTRAINT fk_loading_pick_history_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
|
||||
CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id)
|
||||
) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史';
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_container_booking_before_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_container_booking_before_update
|
||||
BEFORE UPDATE ON fl_container_booking
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
DECLARE current_plan_status VARCHAR(24);
|
||||
SELECT MAX(plan_status) INTO current_plan_status
|
||||
FROM fl_loading_plan WHERE container_booking_id = OLD.id;
|
||||
IF current_plan_status = 'COMPLETED' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改';
|
||||
END IF;
|
||||
IF current_plan_status = 'LOADING' AND (
|
||||
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
|
||||
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
|
||||
OR NOT (OLD.warehouse_id <=> NEW.warehouse_id)
|
||||
OR NOT (OLD.warehouse_name <=> NEW.warehouse_name)
|
||||
OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no)
|
||||
OR NOT (OLD.vessel_name <=> NEW.vessel_name)
|
||||
OR NOT (OLD.container_type <=> NEW.container_type)
|
||||
OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id)
|
||||
OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name)
|
||||
OR NOT (OLD.departure_port_code <=> NEW.departure_port_code)
|
||||
OR NOT (OLD.departure_port_name <=> NEW.departure_port_name)
|
||||
OR NOT (OLD.destination_port_code <=> NEW.destination_port_code)
|
||||
OR NOT (OLD.destination_port_name <=> NEW.destination_port_name)
|
||||
OR NOT (OLD.destination_warehouse_code <=> NEW.destination_warehouse_code)
|
||||
OR NOT (OLD.destination_warehouse_name <=> NEW.destination_warehouse_name)
|
||||
OR NOT (OLD.capacity_cbm <=> NEW.capacity_cbm)
|
||||
OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg)
|
||||
OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company)
|
||||
OR NOT (OLD.loading_at <=> NEW.loading_at)
|
||||
OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at)
|
||||
OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at)
|
||||
OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at)
|
||||
OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at)
|
||||
OR NOT (OLD.estimated_warehouse_arrival_at <=> NEW.estimated_warehouse_arrival_at)
|
||||
OR NOT (OLD.actual_warehouse_arrival_at <=> NEW.actual_warehouse_arrival_at)
|
||||
OR NOT (OLD.created_by <=> NEW.created_by)
|
||||
OR NOT (OLD.created_by_name <=> NEW.created_by_name)
|
||||
OR NOT (OLD.created_at <=> NEW.created_at)
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '装柜中仅允许修改集装箱号与航次';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_plan_before_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_plan_before_update
|
||||
BEFORE UPDATE ON fl_loading_plan
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
IF OLD.plan_status = 'COMPLETED' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成装柜计划全部只读';
|
||||
END IF;
|
||||
IF NOT (
|
||||
(OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING'))
|
||||
OR (OLD.plan_status = 'LOADING'
|
||||
AND NEW.plan_status IN ('LOADING', 'COMPLETED'))
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜状态只允许单向推进';
|
||||
END IF;
|
||||
IF OLD.plan_status = 'LOADING' AND (
|
||||
NOT (OLD.id <=> NEW.id) OR NOT (OLD.plan_no <=> NEW.plan_no)
|
||||
OR NOT (OLD.container_booking_id <=> NEW.container_booking_id)
|
||||
OR NOT (OLD.push_idempotency_key <=> NEW.push_idempotency_key)
|
||||
OR NOT (OLD.preload_pushed_by <=> NEW.preload_pushed_by)
|
||||
OR NOT (OLD.preload_pushed_by_name <=> NEW.preload_pushed_by_name)
|
||||
OR NOT (OLD.preload_pushed_at <=> NEW.preload_pushed_at)
|
||||
OR NOT (OLD.remark <=> NEW.remark)
|
||||
OR NOT (OLD.created_by <=> NEW.created_by)
|
||||
OR NOT (OLD.created_by_name <=> NEW.created_by_name)
|
||||
OR NOT (OLD.created_at <=> NEW.created_at)
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '装柜中计划仅允许乐观锁触碰或完成装柜';
|
||||
END IF;
|
||||
IF OLD.plan_status = NEW.plan_status
|
||||
AND OLD.plan_status = 'LOADING' AND (
|
||||
NOT (OLD.preload_completed_by <=> NEW.preload_completed_by)
|
||||
OR NOT (OLD.preload_completed_by_name <=> NEW.preload_completed_by_name)
|
||||
OR NOT (OLD.preload_completed_at <=> NEW.preload_completed_at)
|
||||
OR NOT (OLD.confirmed_by <=> NEW.confirmed_by)
|
||||
OR NOT (OLD.confirmed_by_name <=> NEW.confirmed_by_name)
|
||||
OR NOT (OLD.confirmed_at <=> NEW.confirmed_at)
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同状态乐观锁触碰不得修改阶段审计字段';
|
||||
END IF;
|
||||
IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN
|
||||
IF NEW.push_idempotency_key IS NULL OR CHAR_LENGTH(TRIM(NEW.push_idempotency_key)) = 0 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '开始装柜必须提供幂等键';
|
||||
END IF;
|
||||
SET NEW.preload_pushed_at = COALESCE(NEW.preload_pushed_at, CURRENT_TIMESTAMP(3));
|
||||
ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN
|
||||
IF EXISTS (SELECT 1 FROM fl_loading_plan_item item
|
||||
LEFT JOIN fl_loading_pick_item pick_item
|
||||
ON pick_item.loading_plan_item_id = item.id
|
||||
LEFT JOIN fl_loading_pick_task pick_task
|
||||
ON pick_task.id = pick_item.pick_task_id
|
||||
AND pick_task.loading_plan_id = OLD.id
|
||||
WHERE item.loading_plan_id = OLD.id AND item.deleted = 0
|
||||
AND (pick_task.id IS NULL
|
||||
OR pick_item.picked_package_count <> pick_item.planned_package_count)) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仍有未完成拣货明细,不能完成装柜';
|
||||
END IF;
|
||||
SET NEW.confirmed_at = COALESCE(NEW.confirmed_at, CURRENT_TIMESTAMP(3));
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_plan_after_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_plan_after_update
|
||||
AFTER UPDATE ON fl_loading_plan
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN
|
||||
UPDATE fl_loading_plan_item
|
||||
SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3)),
|
||||
frozen_by = COALESCE(frozen_by, NEW.updated_by),
|
||||
updated_by = NEW.updated_by,
|
||||
updated_by_name = NEW.updated_by_name,
|
||||
version = version + 1
|
||||
WHERE loading_plan_id = NEW.id AND deleted = 0;
|
||||
UPDATE fl_loading_pick_task
|
||||
SET task_status = 'COMPLETED', completed_by = NEW.updated_by,
|
||||
completed_by_name = NEW.updated_by_name, completed_at = CURRENT_TIMESTAMP(3),
|
||||
version = version + 1
|
||||
WHERE loading_plan_id = NEW.id AND task_status = 'LOADING';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_item_before_insert;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_item_before_insert
|
||||
BEFORE INSERT ON fl_loading_plan_item
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
DECLARE current_plan_status VARCHAR(24);
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE id = NEW.loading_plan_id;
|
||||
IF current_plan_status IS NULL OR current_plan_status NOT IN ('EDITING', 'LOADING') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仅编辑中或装柜中的计划允许新增明细';
|
||||
END IF;
|
||||
IF NEW.deleted <> 0 OR NEW.frozen_at IS NOT NULL OR NEW.frozen_by IS NOT NULL THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '新增装柜明细必须为未删除且未冻结状态';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_item_before_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_item_before_update
|
||||
BEFORE UPDATE ON fl_loading_plan_item
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
DECLARE current_plan_status VARCHAR(24);
|
||||
SELECT MAX(plan_status) INTO current_plan_status
|
||||
FROM fl_loading_plan WHERE id = OLD.loading_plan_id;
|
||||
IF current_plan_status = 'LOADING' AND EXISTS (
|
||||
SELECT 1 FROM fl_loading_pick_item
|
||||
WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细不可修改或删除';
|
||||
END IF;
|
||||
IF OLD.frozen_at IS NOT NULL THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结';
|
||||
END IF;
|
||||
IF OLD.deleted = 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已删除装柜明细不可恢复或修改';
|
||||
END IF;
|
||||
IF NEW.loading_plan_id <> OLD.loading_plan_id OR NEW.account_id <> OLD.account_id
|
||||
OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜明细归属键不可修改,请删除后重新分配';
|
||||
END IF;
|
||||
IF NEW.deleted = 1 THEN
|
||||
IF NEW.removed_by IS NULL THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除装柜明细必须记录操作人';
|
||||
END IF;
|
||||
SET NEW.removed_at = COALESCE(NEW.removed_at, CURRENT_TIMESTAMP(3));
|
||||
ELSEIF NEW.removed_by IS NOT NULL OR NEW.removed_by_name IS NOT NULL OR NEW.removed_at IS NOT NULL THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除审计字段只能在逻辑删除时写入';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_pick_task_before_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_pick_task_before_update
|
||||
BEFORE UPDATE ON fl_loading_pick_task
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
IF OLD.task_status = 'COMPLETED' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成PDA拣货任务不可修改';
|
||||
END IF;
|
||||
IF NEW.loading_plan_id <> OLD.loading_plan_id OR NOT (NEW.container_no <=> OLD.container_no) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务归属不可修改';
|
||||
END IF;
|
||||
IF NOT (OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING', 'COMPLETED')) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态只允许完成';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_pick_item_before_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_pick_item_before_update
|
||||
BEFORE UPDATE ON fl_loading_pick_item
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
DECLARE current_task_status VARCHAR(16);
|
||||
SELECT MAX(task_status) INTO current_task_status
|
||||
FROM fl_loading_pick_task WHERE id = OLD.pick_task_id;
|
||||
IF current_task_status = 'COMPLETED' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后PDA拣货明细不可修改';
|
||||
END IF;
|
||||
IF NEW.pick_task_id <> OLD.pick_task_id
|
||||
OR NEW.loading_plan_item_id <> OLD.loading_plan_item_id
|
||||
OR NEW.inbound_id <> OLD.inbound_id
|
||||
OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货明细归属不可修改';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_pick_item_before_delete;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_pick_item_before_delete
|
||||
BEFORE DELETE ON fl_loading_pick_item
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
DECLARE current_task_status VARCHAR(16);
|
||||
SELECT MAX(task_status) INTO current_task_status
|
||||
FROM fl_loading_pick_task WHERE id = OLD.pick_task_id;
|
||||
IF current_task_status = 'COMPLETED' OR OLD.picked_package_count > 0 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已拣货或已完成的PDA明细不可删除';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_pick_history_before_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_pick_history_before_update
|
||||
BEFORE UPDATE ON fl_loading_pick_history
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货历史不可修改';
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_pick_history_before_delete;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_pick_history_before_delete
|
||||
BEFORE DELETE ON fl_loading_pick_history
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货历史不可删除';
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_item_before_delete;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_item_before_delete
|
||||
BEFORE DELETE ON fl_loading_plan_item
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜明细只允许推送前逻辑删除';
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_container_document_before_insert;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_container_document_before_insert
|
||||
BEFORE INSERT ON fl_container_document
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
DECLARE current_plan_status VARCHAR(24);
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
|
||||
WHERE container_booking_id = NEW.container_booking_id;
|
||||
IF current_plan_status = 'COMPLETED' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_container_document_before_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_container_document_before_update
|
||||
BEFORE UPDATE ON fl_container_document
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
DECLARE current_plan_status VARCHAR(24);
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
|
||||
WHERE container_booking_id = OLD.container_booking_id;
|
||||
IF current_plan_status = 'COMPLETED' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_container_document_before_delete;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_container_document_before_delete
|
||||
BEFORE DELETE ON fl_container_document
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
DECLARE current_plan_status VARCHAR(24);
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
|
||||
WHERE container_booking_id = OLD.container_booking_id;
|
||||
IF current_plan_status = 'COMPLETED' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可删除';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_history_before_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_history_before_update
|
||||
BEFORE UPDATE ON fl_loading_plan_history
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜操作历史不可修改';
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_history_before_delete;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_history_before_delete
|
||||
BEFORE DELETE ON fl_loading_plan_history
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜操作历史不可删除';
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
-- 三期智能装柜、预装清单和装柜清单平台权限;与一期平台根菜单幂等合并。
|
||||
USE baiyue_iam;
|
||||
INSERT INTO iam_permission
|
||||
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
|
||||
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
|
||||
VALUES
|
||||
(5102090, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:container:list',
|
||||
'货柜', '/first-leg/containers', 'GET', 40, 'ACTIVE', 1, 0),
|
||||
(5102091, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:view',
|
||||
'查看货柜', NULL, 'GET', 10, 'ACTIVE', 1, 0),
|
||||
(5102092, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:create',
|
||||
'建立货柜', NULL, 'POST', 20, 'ACTIVE', 1, 0),
|
||||
(5102093, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:edit',
|
||||
'编辑货柜', NULL, 'PUT', 30, 'ACTIVE', 1, 0),
|
||||
(5102094, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:smart-loading:list',
|
||||
'智能装柜', '/first-leg/smart-loading', 'GET', 50, 'ACTIVE', 1, 0),
|
||||
(5102096, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:create',
|
||||
'创建智能装柜计划', NULL, 'POST', 20, 'ACTIVE', 1, 0),
|
||||
(5102097, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:assign',
|
||||
'分配装柜明细', NULL, 'POST', 30, 'ACTIVE', 1, 0),
|
||||
(5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete',
|
||||
'删除装柜明细', NULL, 'DELETE', 40, 'ACTIVE', 1, 0),
|
||||
(5102099, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:push',
|
||||
'推送预装', NULL, 'POST', 50, 'ACTIVE', 1, 0),
|
||||
(5102100, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:preload:list',
|
||||
'预装清单', '/first-leg/preload-list', 'GET', 60, 'ACTIVE', 1, 0),
|
||||
(5102102, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:view',
|
||||
'查看预装详情', NULL, 'GET', 20, 'ACTIVE', 1, 0),
|
||||
(5102103, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:edit',
|
||||
'编辑预装运输字段', NULL, 'PUT', 30, 'ACTIVE', 1, 0),
|
||||
(5102104, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:complete',
|
||||
'完成预装', NULL, 'POST', 40, 'ACTIVE', 1, 0),
|
||||
(5102105, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:confirm',
|
||||
'确认预装', NULL, 'POST', 50, 'ACTIVE', 1, 0),
|
||||
(5102106, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:loading-list:list',
|
||||
'装柜清单', '/first-leg/loading-list', 'GET', 70, 'ACTIVE', 1, 0),
|
||||
(5102108, 0, 'PLATFORM', 'BUTTON', 5102106, 'platform:firstleg:loading-list:view',
|
||||
'查看装柜详情', NULL, 'GET', 20, 'ACTIVE', 1, 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
|
||||
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
|
||||
http_method = VALUES(http_method), sort_no = VALUES(sort_no), status = 'ACTIVE',
|
||||
built_in = 1, deleted = 0;
|
||||
|
||||
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||
SELECT role.id, permission.id, role.tenant_id
|
||||
FROM iam_role role
|
||||
JOIN iam_permission permission
|
||||
ON permission.tenant_id = 0
|
||||
AND permission.permission_code IN (
|
||||
'platform:firstleg:container:list', 'platform:firstleg:container:view',
|
||||
'platform:firstleg:container:create',
|
||||
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
|
||||
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign',
|
||||
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
|
||||
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
|
||||
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
|
||||
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
|
||||
'platform:firstleg:loading-list:view')
|
||||
WHERE role.tenant_id = 0
|
||||
AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR');
|
||||
|
||||
USE baiyue_firstleg;
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_phase3_smart_loading_schema()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name IN ('fl_container_booking', 'fl_loading_plan', 'fl_loading_plan_item',
|
||||
'fl_container_document', 'fl_loading_plan_history',
|
||||
'fl_loading_pick_task', 'fl_loading_pick_item',
|
||||
'fl_loading_pick_history')) <> 8 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:核心表未完整创建';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM fl_loading_plan
|
||||
WHERE plan_status NOT IN ('EDITING', 'LOADING', 'COMPLETED')) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:存在旧装柜状态';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.check_constraints
|
||||
WHERE constraint_schema = 'baiyue_firstleg'
|
||||
AND constraint_name = 'chk_loading_plan_status'
|
||||
AND check_clause LIKE '%''EDITING''%'
|
||||
AND check_clause LIKE '%''LOADING''%'
|
||||
AND check_clause LIKE '%''COMPLETED''%') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:装柜状态约束不是最终三状态';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_container_booking'
|
||||
AND column_name IN ('booking_no', 'cabinet_no', 'warehouse_id', 'bill_of_lading_no',
|
||||
'vessel_name', 'voyage_no', 'container_type', 'capacity_cbm',
|
||||
'max_weight_kg', 'terminal_trucking_company')) <> 10 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:货柜原型字段缺失';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item'
|
||||
AND column_name IN ('account_id', 'customer_name', 'active_inbound_detail_id', 'frozen_at',
|
||||
'destination_warehouse_name', 'product_name_zh', 'product_name_en',
|
||||
'product_image_file_id', 'pallet_count', 'net_weight_kg',
|
||||
'dimensional_weight_kg', 'product_quantity', 'units_per_carton',
|
||||
'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 17 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item'
|
||||
AND index_name = 'uk_loading_item_active_detail' AND non_unique = 0) <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:唯一活动明细锁缺失';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name = 'fl_loading_pick_item'
|
||||
AND column_name IN ('pick_task_id', 'loading_plan_item_id', 'inbound_id',
|
||||
'inbound_detail_id', 'planned_package_count',
|
||||
'picked_package_count', 'item_status', 'version')) <> 8 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA拣货明细字段缺失';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg' AND non_unique = 0
|
||||
AND index_name IN ('uk_loading_pick_task_plan',
|
||||
'uk_loading_pick_item_plan_item',
|
||||
'uk_loading_pick_history_operation')) <> 3 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA唯一索引缺失';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.triggers
|
||||
WHERE trigger_schema = 'baiyue_firstleg'
|
||||
AND trigger_name IN ('trg_container_booking_before_update',
|
||||
'trg_loading_plan_before_update', 'trg_loading_plan_after_update',
|
||||
'trg_loading_item_before_insert', 'trg_loading_item_before_update',
|
||||
'trg_loading_item_before_delete', 'trg_container_document_before_insert',
|
||||
'trg_container_document_before_update', 'trg_container_document_before_delete',
|
||||
'trg_loading_history_before_update', 'trg_loading_history_before_delete',
|
||||
'trg_loading_pick_task_before_update',
|
||||
'trg_loading_pick_item_before_update',
|
||||
'trg_loading_pick_item_before_delete',
|
||||
'trg_loading_pick_history_before_update',
|
||||
'trg_loading_pick_history_before_delete')) <> 16 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:不可逆状态或冻结触发器缺失';
|
||||
END IF;
|
||||
IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission
|
||||
WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0
|
||||
AND permission_code IN (
|
||||
'platform:firstleg:container:list', 'platform:firstleg:container:view',
|
||||
'platform:firstleg:container:create', 'platform:firstleg:container:edit',
|
||||
'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:create',
|
||||
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
|
||||
'platform:firstleg:smart-loading:push', 'platform:firstleg:preload:list',
|
||||
'platform:firstleg:preload:view', 'platform:firstleg:preload:edit',
|
||||
'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm',
|
||||
'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 16 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code))
|
||||
FROM baiyue_iam.iam_role role
|
||||
JOIN baiyue_iam.iam_role_permission grant_row ON grant_row.role_id = role.id
|
||||
JOIN baiyue_iam.iam_permission permission ON permission.id = grant_row.permission_id
|
||||
WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')
|
||||
AND permission.tenant_id = 0
|
||||
AND permission.permission_code IN (
|
||||
'platform:firstleg:container:list', 'platform:firstleg:container:view',
|
||||
'platform:firstleg:container:create',
|
||||
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
|
||||
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign',
|
||||
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
|
||||
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
|
||||
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
|
||||
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
|
||||
'platform:firstleg:loading-list:view')) <> 32 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_phase3_smart_loading_schema();
|
||||
DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema;
|
||||
|
||||
-- ============================================================
|
||||
-- 全部增量完成后的最终结构强校验;只有通过此段才输出唯一 PASS
|
||||
-- ============================================================
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
|
||||
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
||||
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>
|
||||
</dependencies>
|
||||
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
|
||||
</project>
|
||||
|
||||
@@ -19,6 +19,14 @@ public interface OrderCargoClient {
|
||||
|
||||
record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
|
||||
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
|
||||
BigDecimal originalVolumeCbm) {
|
||||
BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn,
|
||||
Integer unitsPerPackage, Integer totalQuantity, BigDecimal netWeightKg,
|
||||
String cargoRemark) {
|
||||
public CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
|
||||
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
|
||||
BigDecimal originalVolumeCbm) {
|
||||
this(cargoId, orderId, itemNo, cargoName, originalPackageCount,
|
||||
originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@ public interface WarehousePalletLocationClient {
|
||||
ApiResponse<List<ActiveResource>> release(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody ReleaseRequest request);
|
||||
|
||||
@PostMapping("/release-selected")
|
||||
ApiResponse<List<SelectiveReleaseResult>> 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<Long> 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<AllocationResult> 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) {}
|
||||
}
|
||||
|
||||
+63
@@ -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<PageResult<ContainerBookingEntity>> 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<ContainerBookingEntity> 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<ContainerBookingEntity> 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<ContainerBookingEntity> 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<ContainerBookingEntity> 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));
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedDetail;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import com.baiyue.firstleg.service.LoadingListService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */
|
||||
@Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询")
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/first-leg")
|
||||
public class PlatformLoadingListController {
|
||||
@Resource
|
||||
private LoadingListService service;
|
||||
|
||||
@Operation(summary = "分页查询预装清单")
|
||||
@GetMapping("/loading-plans")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:preload:list')")
|
||||
public ApiResponse<PageResult<PreloadSummary>> 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> preloadDetail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.preloadDetail(planId));
|
||||
}
|
||||
|
||||
@Operation(summary = "修改装柜运输字段", description = "仅装柜中可修改集装箱号与航次。")
|
||||
@PatchMapping("/loading-plans/{planId}/transport")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
|
||||
public ApiResponse<PreloadDetail> updateTransport(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long planId,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody TransportUpdateRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.updateTransport(
|
||||
planId, user.userId(), user.username(), idempotencyKey, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "完成装柜", description = "状态仅允许 LOADING 推进到 COMPLETED,完成后主单和明细冻结。")
|
||||
@PostMapping("/loading-plans/{planId}/complete")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:preload:complete')")
|
||||
public ApiResponse<ConfirmedDetail> complete(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long planId,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody AdvanceRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.complete(
|
||||
planId, user.userId(), user.username(), idempotencyKey, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "分页查询完成装柜清单", description = "只读取 COMPLETED 状态的装柜计划。")
|
||||
@GetMapping("/loading-lists")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')")
|
||||
public ApiResponse<PageResult<ConfirmedSummary>> 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> confirmedDetail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.confirmedDetail(planId));
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import com.baiyue.firstleg.service.LoadingPickService;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** PDA 按集装箱号执行装柜拣货。 */
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/first-leg/pda-loading")
|
||||
public class PlatformLoadingPdaController {
|
||||
@Resource
|
||||
private LoadingPickService service;
|
||||
|
||||
@GetMapping("/tasks")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
|
||||
public ApiResponse<Task> task(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam String containerNo) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.task(containerNo));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/items/{itemId}/pick")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
|
||||
public ApiResponse<Task> 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()));
|
||||
}
|
||||
}
|
||||
+29
@@ -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<List<Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long accountId){FirstlegScopeGuard.platform(u,accountId);return ApiResponse.success(service.candidates(accountId));}
|
||||
@GetMapping("/plans/by-booking/{bookingId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
|
||||
public ApiResponse<Plan> draftByBooking(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long bookingId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.draftByBooking(bookingId));}
|
||||
@GetMapping("/plans/{planId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
|
||||
public ApiResponse<Plan> 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<Plan> create(@AuthenticationPrincipal AuthenticatedUser u,@Valid @RequestBody CreateRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.create(r.bookingId(),r.remark(),u.userId(),u.username()));}
|
||||
@PostMapping("/plans/{planId}/assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
|
||||
public ApiResponse<Plan> assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));}
|
||||
@PostMapping("/plans/{planId}/recommend") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
|
||||
public ApiResponse<Plan> recommend(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody RecommendRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.recommend(planId,r,u.userId(),u.username()));}
|
||||
@PatchMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
|
||||
public ApiResponse<Plan> adjust(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody AdjustRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.adjust(planId,itemId,r,u.userId(),u.username()));}
|
||||
@DeleteMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
|
||||
public ApiResponse<Plan> delete(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody DeleteRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.delete(planId,itemId,r,u.userId(),u.username()));}
|
||||
@PostMapping("/plans/{planId}/push-preload") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:push')")
|
||||
public ApiResponse<Plan> push(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody PushRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.pushPreload(planId,key,r,u.userId(),u.username()));}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
/** 智能装柜状态,只允许沿此方向推进。 */
|
||||
public enum SmartLoadingStatus {
|
||||
EDITING,
|
||||
LOADING,
|
||||
COMPLETED;
|
||||
|
||||
/** 装柜完成前可维护集装箱号和航次。 */
|
||||
public boolean allowsTransportUpdate() { return this != COMPLETED; }
|
||||
|
||||
/** 编辑中和装柜中均允许维护尚未拣货的明细。 */
|
||||
public boolean mutable() { return this != COMPLETED; }
|
||||
|
||||
public boolean canAdvanceTo(SmartLoadingStatus next) {
|
||||
return next != null && next.ordinal() == ordinal() + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import com.baiyue.firstleg.domain.SmartLoadingStatus;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 平台级货柜基础资料请求模型。 */
|
||||
public final class ContainerDtos {
|
||||
private ContainerDtos() {}
|
||||
|
||||
@Schema(name = "ContainerQuery", description = "货柜分页筛选条件")
|
||||
public record Query(
|
||||
@Size(max = 64) String containerNo,
|
||||
@Size(max = 64) String cabinetNo,
|
||||
@Size(max = 128) String billOfLadingNo,
|
||||
@Size(max = 128) String keyword,
|
||||
@Positive Long warehouseId,
|
||||
@Size(max = 32) String containerType,
|
||||
@Positive Long shippingCompanyId,
|
||||
@Size(max = 32) String departurePortCode,
|
||||
@Size(max = 32) String destinationPortCode,
|
||||
@Size(max = 64) String destinationWarehouseCode,
|
||||
SmartLoadingStatus status,
|
||||
LocalDateTime estimatedSailingFrom,
|
||||
LocalDateTime estimatedSailingTo,
|
||||
@Min(1) long page,
|
||||
@Min(1) @Max(200) long size
|
||||
) {
|
||||
public Query {
|
||||
if (page == 0) page = 1;
|
||||
if (size == 0) size = 20;
|
||||
}
|
||||
}
|
||||
|
||||
@Schema(name = "ContainerSaveRequest", description = "货柜基础资料;智能装柜草稿阶段可完整编辑")
|
||||
public record SaveRequest(
|
||||
@Size(max = 64) String containerNo,
|
||||
@NotBlank @Size(max = 64) String cabinetNo,
|
||||
@NotNull @Positive Long warehouseId,
|
||||
@NotBlank @Size(max = 128) String warehouseName,
|
||||
@Size(max = 128) String billOfLadingNo,
|
||||
@Size(max = 128) String vesselName,
|
||||
@Size(max = 64) String voyageNo,
|
||||
@NotBlank @Size(max = 32) String containerType,
|
||||
@Positive Long shippingCompanyId,
|
||||
@Size(max = 128) String shippingCompanyName,
|
||||
@Size(max = 32) String departurePortCode,
|
||||
@Size(max = 128) String departurePortName,
|
||||
@Size(max = 32) String destinationPortCode,
|
||||
@Size(max = 128) String destinationPortName,
|
||||
@Size(max = 64) String destinationWarehouseCode,
|
||||
@Size(max = 128) String destinationWarehouseName,
|
||||
@DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
|
||||
@DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg,
|
||||
@Size(max = 128) String terminalTruckingCompany,
|
||||
LocalDateTime loadingAt,
|
||||
LocalDateTime estimatedSailingAt,
|
||||
LocalDateTime actualSailingAt,
|
||||
LocalDateTime estimatedPortArrivalAt,
|
||||
LocalDateTime actualPortArrivalAt,
|
||||
LocalDateTime estimatedWarehouseArrivalAt,
|
||||
LocalDateTime actualWarehouseArrivalAt
|
||||
) {}
|
||||
|
||||
@Schema(name = "ContainerFullUpdateRequest", description = "智能装柜 EDITING 阶段完整编辑")
|
||||
public record FullUpdateRequest(
|
||||
@NotNull @PositiveOrZero Integer version,
|
||||
@Valid @NotNull SaveRequest data
|
||||
) {}
|
||||
|
||||
@Schema(name = "ContainerTransportUpdateRequest",
|
||||
description = "LOADING 阶段只允许修改集装箱号和航次")
|
||||
public record TransportUpdateRequest(
|
||||
@Size(max = 64) String containerNo,
|
||||
@Size(max = 64) String voyageNo,
|
||||
@NotNull @PositiveOrZero Integer version
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** 同一装柜计划聚合的预装清单和已确认装柜清单接口模型。 */
|
||||
public final class LoadingListDtos {
|
||||
private LoadingListDtos() {
|
||||
}
|
||||
|
||||
public record PreloadQuery(
|
||||
@Size(max = 128) String keyword,
|
||||
String status,
|
||||
@Min(1) Long accountId,
|
||||
@Min(1) Long warehouseId,
|
||||
LocalDateTime createdFrom,
|
||||
LocalDateTime createdTo,
|
||||
@Min(1) long page,
|
||||
@Min(1) @Max(200) long size
|
||||
) {
|
||||
public PreloadQuery {
|
||||
page = page == 0 ? 1 : page;
|
||||
size = size == 0 ? 20 : size;
|
||||
}
|
||||
}
|
||||
|
||||
public record ConfirmedQuery(
|
||||
@Size(max = 128) String keyword,
|
||||
@Min(1) Long accountId,
|
||||
@Min(1) Long warehouseId,
|
||||
LocalDateTime createdFrom,
|
||||
LocalDateTime createdTo,
|
||||
@Min(1) long page,
|
||||
@Min(1) @Max(200) long size
|
||||
) {
|
||||
public ConfirmedQuery {
|
||||
page = page == 0 ? 1 : page;
|
||||
size = size == 0 ? 20 : size;
|
||||
}
|
||||
}
|
||||
|
||||
public record TransportUpdateRequest(
|
||||
@Size(max = 64) String containerNo,
|
||||
@Size(max = 64) String voyageNo,
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
@Size(max = 500) String operationReason
|
||||
) {
|
||||
}
|
||||
|
||||
public record AdvanceRequest(
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
@Size(max = 500) String operationReason
|
||||
) {
|
||||
}
|
||||
|
||||
public record Customer(Long accountId, String customerName) {
|
||||
}
|
||||
|
||||
public record PreloadSummary(
|
||||
Long planId,
|
||||
String preloadNo,
|
||||
String containerNo,
|
||||
String internalContainerNo,
|
||||
List<Customer> 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<Item> items, List<History> 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<Item> items, List<History> histories) {
|
||||
public ConfirmedDetail {
|
||||
items = items == null ? List.of() : List.copyOf(items);
|
||||
histories = histories == null ? List.of() : List.copyOf(histories);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** PDA 装柜拣货接口模型。 */
|
||||
public final class LoadingPickDtos {
|
||||
private LoadingPickDtos() {
|
||||
}
|
||||
|
||||
public record PickRequest(
|
||||
@NotNull @Min(1) Integer pickedPackageCount,
|
||||
@JsonAlias("reservationIds") List<@Min(1) Long> locationIds,
|
||||
@NotNull @Min(0) Integer version,
|
||||
@NotBlank @Size(max = 128) String operationId
|
||||
) {
|
||||
}
|
||||
|
||||
public record PickItem(Long id, Long loadingPlanItemId, Long inboundId, Long inboundDetailId,
|
||||
Integer plannedPackageCount, Integer pickedPackageCount,
|
||||
String itemStatus, Integer version) {
|
||||
}
|
||||
|
||||
public record Task(Long id, Long loadingPlanId, String containerNo, String taskStatus,
|
||||
Integer version, List<PickItem> items) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.util.List;
|
||||
public final class SmartLoadingDtos { private SmartLoadingDtos(){}
|
||||
public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
|
||||
Long warehouseId, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount,
|
||||
BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) {
|
||||
public Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
|
||||
Long warehouseId, String destinationWarehouseCode, String customerName, String goodsName, Integer availablePackageCount,
|
||||
BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) {
|
||||
this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,destinationWarehouseCode,null,customerName,goodsName,availablePackageCount,unitWeightKg,unitVolumeCbm);
|
||||
}
|
||||
}
|
||||
public record CreateRequest(@NotNull @Positive Long bookingId, @Size(max=500) String remark) {}
|
||||
public record AssignRequest(@NotNull @Positive Long accountId,@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId,
|
||||
@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {}
|
||||
public record RecommendRequest(@NotNull @Positive Long accountId,@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotBlank @Size(max=128) String operationId) {}
|
||||
public record AdjustRequest(@NotNull @Positive Long accountId,@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedPlanVersion,
|
||||
@NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
|
||||
public record DeleteRequest(@NotNull @Positive Long accountId,@NotNull @Min(0) Integer expectedPlanVersion,
|
||||
@NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
|
||||
public record PushRequest(@NotNull @Min(0) Integer expectedVersion) {}
|
||||
public record Plan(Long id,String planNo,String status,Long bookingId,String containerNo,String voyageNo,Integer version,List<LoadingPlanItemEntity> items) {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/** PDA 拣货操作幂等历史。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_loading_pick_history")
|
||||
public class LoadingPickHistoryEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long pickTaskId;
|
||||
private Long pickItemId;
|
||||
private String operationId;
|
||||
private String requestHash;
|
||||
private Integer pickedPackageCount;
|
||||
private Long operatorId;
|
||||
private String operatorName;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ContainerBookingEntity> {
|
||||
@Select("""
|
||||
<script>
|
||||
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 (#{query.containerNo} IS NULL OR b.container_no = #{query.containerNo})
|
||||
AND (#{query.cabinetNo} IS NULL OR b.cabinet_no = #{query.cabinetNo})
|
||||
AND (#{query.billOfLadingNo} IS NULL OR b.bill_of_lading_no = #{query.billOfLadingNo})
|
||||
AND (#{query.warehouseId} IS NULL OR b.warehouse_id = #{query.warehouseId})
|
||||
AND (#{query.containerType} IS NULL OR b.container_type = #{query.containerType})
|
||||
AND (#{query.shippingCompanyId} IS NULL OR b.shipping_company_id = #{query.shippingCompanyId})
|
||||
AND (#{query.departurePortCode} IS NULL OR b.departure_port_code = #{query.departurePortCode})
|
||||
AND (#{query.destinationPortCode} IS NULL OR b.destination_port_code = #{query.destinationPortCode})
|
||||
AND (#{query.destinationWarehouseCode} IS NULL OR b.destination_warehouse_code = #{query.destinationWarehouseCode})
|
||||
AND (#{query.status} IS NULL OR COALESCE(p.plan_status, 'EDITING') = #{query.status})
|
||||
AND (#{query.estimatedSailingFrom} IS NULL OR b.estimated_sailing_at >= #{query.estimatedSailingFrom})
|
||||
AND (#{query.estimatedSailingTo} IS NULL OR b.estimated_sailing_at <= #{query.estimatedSailingTo})
|
||||
AND (#{query.keyword} IS NULL OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%')
|
||||
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%')
|
||||
OR b.bill_of_lading_no LIKE CONCAT('%', #{query.keyword}, '%')
|
||||
OR b.vessel_name LIKE CONCAT('%', #{query.keyword}, '%')
|
||||
OR b.voyage_no LIKE CONCAT('%', #{query.keyword}, '%'))
|
||||
ORDER BY b.updated_at DESC, b.id DESC
|
||||
</script>
|
||||
""")
|
||||
Page<ContainerBookingEntity> selectBookingPage(Page<ContainerBookingEntity> 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);
|
||||
}
|
||||
@@ -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<InboundPalletAllocationEntity> {
|
||||
@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<InboundPalletAllocationEntity> 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** 基于共享装柜计划表提供清单查询投影和状态推进持久化操作。 */
|
||||
@Mapper
|
||||
public interface LoadingListQueryMapper {
|
||||
String AGGREGATE_COLUMNS = """
|
||||
p.id AS plan_id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
|
||||
p.created_by, p.created_by_name, p.created_at, p.version,
|
||||
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
|
||||
b.vessel_name, b.voyage_no, b.container_type,
|
||||
COUNT(DISTINCT NULLIF(i.sku_code, '')) AS sku_count,
|
||||
COALESCE(SUM(i.package_count), 0) AS total_package_count,
|
||||
COALESCE(SUM(i.gross_weight_kg), 0) AS total_gross_weight_kg,
|
||||
COALESCE(SUM(i.net_weight_kg), 0) AS total_net_weight_kg,
|
||||
COALESCE(SUM(i.volume_cbm), 0) AS total_volume_cbm,
|
||||
COALESCE(SUM(i.product_quantity), 0) AS total_quantity
|
||||
""";
|
||||
|
||||
String AGGREGATE_GROUP = """
|
||||
p.id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
|
||||
p.created_by, p.created_by_name, p.created_at, p.version,
|
||||
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
|
||||
b.vessel_name, b.voyage_no, b.container_type
|
||||
""";
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
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.plan_status = 'LOADING'
|
||||
<if test="query.status != null and query.status != ''">AND p.plan_status = #{query.status}</if>
|
||||
<if test="query.keyword != null and query.keyword != ''">
|
||||
AND (p.plan_no LIKE CONCAT('%', #{query.keyword}, '%')
|
||||
OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%')
|
||||
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%'))
|
||||
</if>
|
||||
<if test="query.warehouseId != null">AND b.warehouse_id = #{query.warehouseId}</if>
|
||||
<if test="query.accountId != null">
|
||||
AND EXISTS (SELECT 1 FROM fl_loading_plan_item ai
|
||||
WHERE ai.loading_plan_id = p.id AND ai.deleted = 0
|
||||
AND ai.account_id = #{query.accountId})
|
||||
</if>
|
||||
<if test="query.createdFrom != null">AND p.created_at >= #{query.createdFrom}</if>
|
||||
<if test="query.createdTo != null">AND p.created_at <= #{query.createdTo}</if>
|
||||
GROUP BY
|
||||
""" + AGGREGATE_GROUP + """
|
||||
ORDER BY p.created_at DESC, p.id DESC
|
||||
</script>
|
||||
""")
|
||||
Page<AggregateRow> selectPreloadPage(Page<?> page, @Param("query") PreloadQuery query);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
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.plan_status = 'COMPLETED'
|
||||
<if test="query.keyword != null and query.keyword != ''">
|
||||
AND (p.plan_no LIKE CONCAT('%', #{query.keyword}, '%')
|
||||
OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%')
|
||||
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%'))
|
||||
</if>
|
||||
<if test="query.warehouseId != null">AND b.warehouse_id = #{query.warehouseId}</if>
|
||||
<if test="query.accountId != null">
|
||||
AND EXISTS (SELECT 1 FROM fl_loading_plan_item ai
|
||||
WHERE ai.loading_plan_id = p.id AND ai.deleted = 0
|
||||
AND ai.account_id = #{query.accountId})
|
||||
</if>
|
||||
<if test="query.createdFrom != null">AND p.created_at >= #{query.createdFrom}</if>
|
||||
<if test="query.createdTo != null">AND p.created_at <= #{query.createdTo}</if>
|
||||
GROUP BY
|
||||
""" + AGGREGATE_GROUP + """
|
||||
ORDER BY p.created_at DESC, p.id DESC
|
||||
</script>
|
||||
""")
|
||||
Page<AggregateRow> 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<ItemRow> selectItems(@Param("planId") long planId);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT DISTINCT loading_plan_id AS plan_id, account_id, customer_name
|
||||
FROM fl_loading_plan_item
|
||||
WHERE deleted = 0 AND loading_plan_id IN
|
||||
<foreach collection="planIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
ORDER BY loading_plan_id, account_id
|
||||
</script>
|
||||
""")
|
||||
List<CustomerRow> selectCustomers(@Param("planIds") List<Long> 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<HistoryRow> 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) {
|
||||
}
|
||||
}
|
||||
@@ -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<LoadingPickHistoryEntity> {
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity> {
|
||||
@Update("""
|
||||
UPDATE fl_loading_pick_item
|
||||
SET picked_package_count = picked_package_count + #{count},
|
||||
item_status = CASE WHEN picked_package_count + #{count} = planned_package_count
|
||||
THEN 'PICKED' ELSE 'PICKING' END,
|
||||
version = version + 1
|
||||
WHERE id = #{id} AND version = #{version}
|
||||
AND picked_package_count + #{count} <= planned_package_count
|
||||
""")
|
||||
int pick(@Param("id") long id, @Param("count") int count, @Param("version") int version);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> {
|
||||
}
|
||||
@@ -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<LoadingPlanHistoryEntity> {}
|
||||
@@ -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<LoadingPlanItemEntity> {
|
||||
@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);
|
||||
}
|
||||
@@ -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<LoadingPlanEntity> {
|
||||
@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);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.ibatis.annotations.*; import java.util.List;
|
||||
@Mapper public interface SmartLoadingCandidateMapper {
|
||||
@Select("""
|
||||
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
|
||||
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
|
||||
o.warehouse_id warehouseId,o.destination_warehouse_id destinationWarehouseId,
|
||||
o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName,
|
||||
o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName,
|
||||
i.package_count availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
|
||||
COALESCE(i.unit_volume_cbm,0) unitVolumeCbm
|
||||
FROM fl_inbound_order o
|
||||
JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id
|
||||
AND m.status='APPROVED'
|
||||
AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2
|
||||
WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED')
|
||||
JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
|
||||
LEFT JOIN fl_loading_plan_item l
|
||||
ON l.account_id=o.tenant_id AND l.active_inbound_detail_id=i.inbound_detail_id
|
||||
WHERE o.tenant_id=#{accountId} AND o.process_status='INBOUNDED'
|
||||
AND i.inbound_detail_id IS NOT NULL AND l.id IS NULL
|
||||
ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC
|
||||
""")
|
||||
List<Candidate> candidates(@Param("accountId") long accountId);
|
||||
|
||||
@Select("""
|
||||
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
|
||||
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
|
||||
o.warehouse_id warehouseId,o.destination_warehouse_id destinationWarehouseId,
|
||||
o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName,
|
||||
o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName,
|
||||
i.package_count availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
|
||||
COALESCE(i.unit_volume_cbm,0) unitVolumeCbm
|
||||
FROM fl_inbound_order o
|
||||
JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id
|
||||
AND m.status='APPROVED'
|
||||
AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2
|
||||
WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED')
|
||||
JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
|
||||
WHERE o.tenant_id=#{accountId} AND o.id=#{inboundId} AND o.process_status='INBOUNDED'
|
||||
AND i.inbound_detail_id=#{detailId}
|
||||
ORDER BY m.id DESC LIMIT 1
|
||||
""")
|
||||
Candidate candidate(@Param("accountId") long accountId,@Param("inboundId") long inboundId,
|
||||
@Param("detailId") long detailId);
|
||||
}
|
||||
@@ -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<ContainerBookingEntity> 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<ContainerBookingEntity>()
|
||||
.eq(ContainerBookingEntity::getContainerNo, no)
|
||||
.ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0;
|
||||
}
|
||||
public boolean cabinetNoExists(String no, Long excludedId) {
|
||||
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
+22
-4
@@ -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<Long, String> reservationNos = mapper.selectList(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
|
||||
.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<InboundPalletAllocationEntity> 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) {
|
||||
|
||||
@@ -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<PreloadSummary> preloadPage(PreloadQuery query) {
|
||||
Page<AggregateRow> page = mapper.selectPreloadPage(new Page<>(query.page(), query.size()), query);
|
||||
Map<Long, List<Customer>> customers = customerMap(page.getRecords());
|
||||
List<PreloadSummary> 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<ConfirmedSummary> confirmedPage(ConfirmedQuery query) {
|
||||
Page<AggregateRow> page = mapper.selectConfirmedPage(new Page<>(query.page(), query.size()), query);
|
||||
List<ConfirmedSummary> 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<ItemRow> items = mapper.selectItems(row.getPlanId());
|
||||
List<Customer> 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<Long, List<Customer>> customerMap(List<AggregateRow> rows) {
|
||||
List<Long> 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<Customer> 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<Item> items(List<ItemRow> 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<History> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
|
||||
import com.baiyue.firstleg.mapper.LoadingPickHistoryMapper;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
|
||||
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
|
||||
import com.baiyue.firstleg.mapper.LoadingPickTaskMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** PDA 拣货任务持久化边界。 */
|
||||
@Repository
|
||||
public class LoadingPickRepository {
|
||||
@Resource
|
||||
private LoadingPickTaskMapper tasks;
|
||||
@Resource
|
||||
private LoadingPickItemMapper items;
|
||||
@Resource
|
||||
private LoadingPickHistoryMapper histories;
|
||||
|
||||
public LoadingPickTaskEntity task(long taskId) {
|
||||
return tasks.selectById(taskId);
|
||||
}
|
||||
|
||||
public LoadingPickTaskEntity taskByPlan(long planId) {
|
||||
return tasks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
|
||||
.eq(LoadingPickTaskEntity::getLoadingPlanId, planId));
|
||||
}
|
||||
|
||||
public LoadingPickTaskEntity taskByContainer(String containerNo) {
|
||||
return tasks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
|
||||
.eq(LoadingPickTaskEntity::getContainerNo, containerNo)
|
||||
.eq(LoadingPickTaskEntity::getTaskStatus, "LOADING"));
|
||||
}
|
||||
|
||||
public LoadingPickItemEntity item(long taskId, long itemId) {
|
||||
return items.selectOne(new LambdaQueryWrapper<LoadingPickItemEntity>()
|
||||
.eq(LoadingPickItemEntity::getPickTaskId, taskId)
|
||||
.eq(LoadingPickItemEntity::getId, itemId));
|
||||
}
|
||||
|
||||
public List<LoadingPickItemEntity> items(long taskId) {
|
||||
return items.selectList(new LambdaQueryWrapper<LoadingPickItemEntity>()
|
||||
.eq(LoadingPickItemEntity::getPickTaskId, taskId)
|
||||
.orderByAsc(LoadingPickItemEntity::getId));
|
||||
}
|
||||
|
||||
public void insert(LoadingPickTaskEntity task) {
|
||||
tasks.insert(task);
|
||||
}
|
||||
|
||||
public void insert(LoadingPickItemEntity item) {
|
||||
items.insert(item);
|
||||
}
|
||||
|
||||
public LoadingPickItemEntity itemByPlanItem(long planItemId) {
|
||||
return items.selectOne(new LambdaQueryWrapper<LoadingPickItemEntity>()
|
||||
.eq(LoadingPickItemEntity::getLoadingPlanItemId, planItemId));
|
||||
}
|
||||
|
||||
public int pick(long itemId, int count, int version) {
|
||||
return items.pick(itemId, count, version);
|
||||
}
|
||||
|
||||
public LoadingPickHistoryEntity history(String operationId) {
|
||||
return histories.selectOne(new LambdaQueryWrapper<LoadingPickHistoryEntity>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
|
||||
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
|
||||
import com.baiyue.firstleg.mapper.LoadingPlanHistoryMapper;
|
||||
import com.baiyue.firstleg.mapper.LoadingPlanItemMapper;
|
||||
import com.baiyue.firstleg.mapper.LoadingPlanMapper;
|
||||
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 智能装柜聚合的真实 MySQL 持久化边界。 */
|
||||
@Repository
|
||||
@ConditionalOnProperty(name="firstleg.smart-loading.repository.enabled", havingValue="true", matchIfMissing=true)
|
||||
public class SmartLoadingRepository {
|
||||
@Resource private LoadingPlanMapper plans;
|
||||
@Resource private LoadingPlanItemMapper items;
|
||||
@Resource private LoadingPlanHistoryMapper histories;
|
||||
@Resource private ContainerBookingMapper bookings;
|
||||
@Resource private SmartLoadingCandidateMapper candidates;
|
||||
|
||||
public List<Candidate> candidates(long accountId) { return candidates.candidates(accountId); }
|
||||
public Candidate candidate(long accountId, long inboundId, long detailId) {
|
||||
return candidates.candidate(accountId, inboundId, detailId);
|
||||
}
|
||||
public LoadingPlanEntity plan(long planId) { return plans.selectById(planId); }
|
||||
public LoadingPlanEntity planByBooking(long bookingId) {
|
||||
return plans.selectOne(new LambdaQueryWrapper<LoadingPlanEntity>()
|
||||
.eq(LoadingPlanEntity::getContainerBookingId, bookingId));
|
||||
}
|
||||
public ContainerBookingEntity booking(long bookingId) { return bookings.selectById(bookingId); }
|
||||
public List<LoadingPlanItemEntity> items(long planId) {
|
||||
return items.selectList(new LambdaQueryWrapper<LoadingPlanItemEntity>()
|
||||
.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<LoadingPlanItemEntity>()
|
||||
.eq(LoadingPlanItemEntity::getLoadingPlanId, planId)
|
||||
.eq(LoadingPlanItemEntity::getId, itemId).eq(LoadingPlanItemEntity::getDeleted, false));
|
||||
}
|
||||
public LoadingPlanItemEntity activeItem(long accountId, long detailId) {
|
||||
return items.selectOne(new LambdaQueryWrapper<LoadingPlanItemEntity>()
|
||||
.eq(LoadingPlanItemEntity::getAccountId, accountId)
|
||||
.eq(LoadingPlanItemEntity::getInboundDetailId, detailId)
|
||||
.eq(LoadingPlanItemEntity::getDeleted, false));
|
||||
}
|
||||
public LoadingPlanHistoryEntity operation(String operationId) {
|
||||
return histories.selectOne(new LambdaQueryWrapper<LoadingPlanHistoryEntity>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.domain.SmartLoadingStatus;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.Query;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.repository.ContainerRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
public class ContainerService {
|
||||
@Resource private ContainerRepository repository;
|
||||
public PageResult<ContainerBookingEntity> page(Query query) {
|
||||
validateRange(query.estimatedSailingFrom(), query.estimatedSailingTo(), "预计船期筛选");
|
||||
return repository.page(normalize(query));
|
||||
}
|
||||
public ContainerBookingEntity detail(long id) { return required(id); }
|
||||
|
||||
@Transactional
|
||||
public ContainerBookingEntity create(long operatorId, String operatorName, SaveRequest request) {
|
||||
validateSchedule(request);
|
||||
ensureUnique(trim(request.containerNo()), request.cabinetNo().trim(), null);
|
||||
ContainerBookingEntity value = new ContainerBookingEntity();
|
||||
value.setId(IdWorker.getId());
|
||||
value.setBookingNo("CB" + value.getId());
|
||||
copy(value, request);
|
||||
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
|
||||
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw duplicate(); }
|
||||
value.setPlanStatus(SmartLoadingStatus.EDITING.name());
|
||||
return value;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ContainerBookingEntity updateDraft(long id, long operatorId, String operatorName,
|
||||
FullUpdateRequest request) {
|
||||
ContainerBookingEntity current = required(id);
|
||||
requireVersion(current, request.version());
|
||||
if (status(current) != SmartLoadingStatus.EDITING) {
|
||||
throw conflict("货柜已进入预装流程,只能修改集装箱号和航次");
|
||||
}
|
||||
validateSchedule(request.data());
|
||||
ensureUnique(trim(request.data().containerNo()), request.data().cabinetNo().trim(), id);
|
||||
copy(current, request.data());
|
||||
try {
|
||||
if (repository.updateDraft(current, request.version(), operatorId, operatorName) != 1) {
|
||||
throw conflict("货柜状态或版本已变化,请刷新后重试");
|
||||
}
|
||||
} catch (DuplicateKeyException ex) { throw duplicate(); }
|
||||
return required(id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ContainerBookingEntity updateTransport(long id, long operatorId, String operatorName,
|
||||
TransportUpdateRequest request) {
|
||||
ContainerBookingEntity current = required(id);
|
||||
requireVersion(current, request.version());
|
||||
if (status(current) == SmartLoadingStatus.COMPLETED) throw conflict("装柜完成后货柜全部只读,不能修改");
|
||||
String containerNo = trim(request.containerNo());
|
||||
if (repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
|
||||
try {
|
||||
if (repository.updateTransport(id, containerNo, trim(request.voyageNo()), request.version(),
|
||||
operatorId, operatorName) != 1) throw conflict("货柜状态或版本已变化,请刷新后重试");
|
||||
} catch (DuplicateKeyException ex) { throw conflict("集装箱号已存在"); }
|
||||
return required(id);
|
||||
}
|
||||
|
||||
private ContainerBookingEntity required(long id) {
|
||||
ContainerBookingEntity value = repository.find(id);
|
||||
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
|
||||
return value;
|
||||
}
|
||||
private void ensureUnique(String containerNo, String cabinetNo, Long excludedId) {
|
||||
if (repository.containerNoExists(containerNo, excludedId)) throw conflict("集装箱号已存在");
|
||||
if (repository.cabinetNoExists(cabinetNo, excludedId)) throw conflict("内部柜号已存在");
|
||||
}
|
||||
private static void requireVersion(ContainerBookingEntity value, Integer version) {
|
||||
if (!version.equals(value.getVersion())) throw conflict("货柜已被其他人修改,请刷新后重试");
|
||||
}
|
||||
private static SmartLoadingStatus status(ContainerBookingEntity value) {
|
||||
try { return SmartLoadingStatus.valueOf(value.getPlanStatus()); }
|
||||
catch (RuntimeException ex) { throw conflict("装柜计划状态无效,不能修改货柜"); }
|
||||
}
|
||||
private static void copy(ContainerBookingEntity v, SaveRequest r) {
|
||||
v.setContainerNo(trim(r.containerNo())); v.setCabinetNo(r.cabinetNo().trim());
|
||||
v.setWarehouseId(r.warehouseId()); v.setWarehouseName(r.warehouseName().trim());
|
||||
v.setBillOfLadingNo(trim(r.billOfLadingNo())); v.setVesselName(trim(r.vesselName()));
|
||||
v.setVoyageNo(trim(r.voyageNo())); v.setContainerType(r.containerType().trim());
|
||||
v.setShippingCompanyId(r.shippingCompanyId()); v.setShippingCompanyName(trim(r.shippingCompanyName()));
|
||||
v.setDeparturePortCode(trim(r.departurePortCode())); v.setDeparturePortName(trim(r.departurePortName()));
|
||||
v.setDestinationPortCode(trim(r.destinationPortCode())); v.setDestinationPortName(trim(r.destinationPortName()));
|
||||
v.setDestinationWarehouseCode(trim(r.destinationWarehouseCode()));
|
||||
v.setDestinationWarehouseName(trim(r.destinationWarehouseName()));
|
||||
v.setCapacityCbm(r.capacityCbm()); v.setMaxWeightKg(r.maxWeightKg());
|
||||
v.setTerminalTruckingCompany(trim(r.terminalTruckingCompany())); v.setLoadingAt(r.loadingAt());
|
||||
v.setEstimatedSailingAt(r.estimatedSailingAt()); v.setActualSailingAt(r.actualSailingAt());
|
||||
v.setEstimatedPortArrivalAt(r.estimatedPortArrivalAt()); v.setActualPortArrivalAt(r.actualPortArrivalAt());
|
||||
v.setEstimatedWarehouseArrivalAt(r.estimatedWarehouseArrivalAt());
|
||||
v.setActualWarehouseArrivalAt(r.actualWarehouseArrivalAt());
|
||||
}
|
||||
private static void validateSchedule(SaveRequest r) {
|
||||
validateRange(r.estimatedSailingAt(), r.estimatedPortArrivalAt(), "预计船期和预计到港时间");
|
||||
validateRange(r.estimatedPortArrivalAt(), r.estimatedWarehouseArrivalAt(), "预计到港和预计到库时间");
|
||||
validateRange(r.actualSailingAt(), r.actualPortArrivalAt(), "实际船期和实际到港时间");
|
||||
validateRange(r.actualPortArrivalAt(), r.actualWarehouseArrivalAt(), "实际到港和实际到库时间");
|
||||
}
|
||||
private static void validateRange(LocalDateTime from, LocalDateTime to, String label) {
|
||||
if (from != null && to != null && to.isBefore(from))
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, label + "先后顺序不正确");
|
||||
}
|
||||
private static Query normalize(Query q) {
|
||||
return new Query(trim(q.containerNo()), trim(q.cabinetNo()), trim(q.billOfLadingNo()), trim(q.keyword()),
|
||||
q.warehouseId(), trim(q.containerType()), q.shippingCompanyId(), trim(q.departurePortCode()),
|
||||
trim(q.destinationPortCode()), trim(q.destinationWarehouseCode()), q.status(), q.estimatedSailingFrom(),
|
||||
q.estimatedSailingTo(), q.page(), q.size());
|
||||
}
|
||||
private static String trim(String value) {
|
||||
if (value == null) return null;
|
||||
String result = value.trim(); return result.isEmpty() ? null : result;
|
||||
}
|
||||
private static BizException duplicate() { return conflict("集装箱号或内部柜号已存在"); }
|
||||
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedDetail;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
|
||||
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
|
||||
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
|
||||
import com.baiyue.firstleg.repository.LoadingListRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class LoadingListService {
|
||||
private static final Set<String> LOADING_STATUSES = Set.of("LOADING");
|
||||
@Resource
|
||||
private LoadingListRepository repository;
|
||||
@Resource
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
public PageResult<PreloadSummary> preloadPage(PreloadQuery query) {
|
||||
validateRange(query.createdFrom(), query.createdTo());
|
||||
String status = trim(query.status());
|
||||
if (StringUtils.hasText(status) && !LOADING_STATUSES.contains(status)) {
|
||||
throw badRequest("装柜清单状态只能是 LOADING");
|
||||
}
|
||||
return repository.preloadPage(new PreloadQuery(trim(query.keyword()), status, query.accountId(),
|
||||
query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size()));
|
||||
}
|
||||
|
||||
public PageResult<ConfirmedSummary> confirmedPage(ConfirmedQuery query) {
|
||||
validateRange(query.createdFrom(), query.createdTo());
|
||||
return repository.confirmedPage(new ConfirmedQuery(trim(query.keyword()), query.accountId(),
|
||||
query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size()));
|
||||
}
|
||||
|
||||
public PreloadDetail preloadDetail(long planId) {
|
||||
AggregateRow row = requirePlan(planId);
|
||||
if (!LOADING_STATUSES.contains(row.getPlanStatus())) {
|
||||
throw notFound("装柜中清单不存在");
|
||||
}
|
||||
return repository.preloadDetail(row);
|
||||
}
|
||||
|
||||
public ConfirmedDetail confirmedDetail(long planId) {
|
||||
AggregateRow row = requirePlan(planId);
|
||||
if (!"COMPLETED".equals(row.getPlanStatus())) {
|
||||
throw notFound("装柜清单不存在");
|
||||
}
|
||||
return repository.confirmedDetail(row);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "PRELOAD_TRANSPORT_UPDATE", resourceType = "LOADING_PLAN",
|
||||
resourceId = "#planId", description = "修改预装集装箱号与航次")
|
||||
public PreloadDetail updateTransport(long planId, long operatorId, String operatorName,
|
||||
String idempotencyKey, TransportUpdateRequest request) {
|
||||
String containerNo = nullableTrim(request.containerNo());
|
||||
String voyageNo = nullableTrim(request.voyageNo());
|
||||
String requestHash = sha256("TRANSPORT|" + planId + '|' + request.expectedVersion()
|
||||
+ '|' + containerNo + '|' + voyageNo + '|' + trim(request.operationReason()));
|
||||
String operationId = operationId("TRANSPORT", planId, idempotencyKey);
|
||||
HistoryRow replay = repository.history(operationId);
|
||||
if (replay != null) {
|
||||
requireSameRequest(replay, requestHash);
|
||||
return repository.preloadDetail(requirePlan(planId));
|
||||
}
|
||||
|
||||
AggregateRow before = requirePlan(planId);
|
||||
requireLoadingMutable(before);
|
||||
if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo,
|
||||
operatorId, trim(operatorName)) != 1) {
|
||||
throw conflict("预装计划已被其他操作修改,请刷新后重试");
|
||||
}
|
||||
repository.appendHistory(planId, operationId, "UPDATE_PRELOAD_TRANSPORT",
|
||||
before.getPlanStatus(), before.getPlanStatus(), json(snapshot(before)),
|
||||
json(values("requestHash", requestHash, "status", before.getPlanStatus(),
|
||||
"containerNo", containerNo, "voyageNo", voyageNo,
|
||||
"version", request.expectedVersion() + 1)), operatorId, trim(operatorName),
|
||||
trim(request.operationReason()));
|
||||
return repository.preloadDetail(requirePlan(planId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "PRELOAD_COMPLETE", resourceType = "LOADING_PLAN",
|
||||
resourceId = "#planId", description = "完成预装")
|
||||
public ConfirmedDetail complete(long planId, long operatorId, String operatorName,
|
||||
String idempotencyKey, AdvanceRequest request) {
|
||||
advance(planId, operatorId, operatorName, idempotencyKey, request,
|
||||
"LOADING", "COMPLETED");
|
||||
return repository.confirmedDetail(requirePlan(planId));
|
||||
}
|
||||
|
||||
private void advance(long planId, long operatorId, String operatorName, String idempotencyKey,
|
||||
AdvanceRequest request, String fromStatus, String toStatus) {
|
||||
String requestHash = sha256(toStatus + '|' + planId + '|' + request.expectedVersion()
|
||||
+ '|' + trim(request.operationReason()));
|
||||
String operationId = operationId(toStatus, planId, idempotencyKey);
|
||||
HistoryRow replay = repository.history(operationId);
|
||||
if (replay != null) {
|
||||
requireSameRequest(replay, requestHash);
|
||||
return;
|
||||
}
|
||||
|
||||
AggregateRow before = requirePlan(planId);
|
||||
if (!fromStatus.equals(before.getPlanStatus())) {
|
||||
throw conflict("当前状态为 " + before.getPlanStatus() + ",不能推进到 " + toStatus);
|
||||
}
|
||||
if (repository.advance(planId, fromStatus, toStatus, request.expectedVersion(),
|
||||
operatorId, trim(operatorName)) != 1) {
|
||||
throw conflict("预装计划已被其他操作修改,请刷新后重试");
|
||||
}
|
||||
repository.appendHistory(planId, operationId, "ADVANCE_PRELOAD_STATUS",
|
||||
fromStatus, toStatus, json(snapshot(before)),
|
||||
json(values("requestHash", requestHash, "status", toStatus,
|
||||
"version", request.expectedVersion() + 1)), operatorId, trim(operatorName),
|
||||
trim(request.operationReason()));
|
||||
}
|
||||
|
||||
private AggregateRow requirePlan(long planId) {
|
||||
AggregateRow row = repository.aggregate(planId);
|
||||
if (row == null) {
|
||||
throw notFound("装柜计划不存在");
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private static void requireLoadingMutable(AggregateRow row) {
|
||||
if (!LOADING_STATUSES.contains(row.getPlanStatus())) {
|
||||
throw conflict("仅装柜中可修改集装箱号与航次");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateRange(java.time.LocalDateTime from, java.time.LocalDateTime to) {
|
||||
if (from != null && to != null && from.isAfter(to)) {
|
||||
throw badRequest("创建开始时间不能晚于结束时间");
|
||||
}
|
||||
}
|
||||
|
||||
private String operationId(String operation, long planId, String key) {
|
||||
String normalized = trim(key);
|
||||
if (!StringUtils.hasText(normalized)) {
|
||||
throw badRequest("必须提供 Idempotency-Key");
|
||||
}
|
||||
if (normalized.length() > 128) {
|
||||
throw badRequest("Idempotency-Key 长度不能超过128");
|
||||
}
|
||||
return "PL-" + sha256(operation + '|' + planId + '|' + normalized);
|
||||
}
|
||||
|
||||
private static void requireSameRequest(HistoryRow replay, String requestHash) {
|
||||
String snapshot = replay.getAfterSnapshot();
|
||||
if (snapshot == null || !snapshot.contains("\"requestHash\":\"" + requestHash + "\"")) {
|
||||
throw conflict("Idempotency-Key 已用于不同请求");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> snapshot(AggregateRow row) {
|
||||
return values("status", row.getPlanStatus(), "containerNo", row.getContainerNo(),
|
||||
"voyageNo", row.getVoyageNo(), "version", row.getVersion());
|
||||
}
|
||||
|
||||
private static Map<String, Object> values(Object... values) {
|
||||
Map<String, Object> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.PickItem;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
|
||||
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
|
||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.List;
|
||||
|
||||
/** 装柜中 PDA 拣货任务及明细同步。 */
|
||||
@Service
|
||||
public class LoadingPickService {
|
||||
@Resource
|
||||
private LoadingPickRepository repository;
|
||||
|
||||
@Transactional
|
||||
public void begin(long planId, String containerNo, long operatorId, String operatorName,
|
||||
List<LoadingPlanItemEntity> planItems) {
|
||||
LoadingPickTaskEntity task = repository.taskByPlan(planId);
|
||||
if (task == null) {
|
||||
task = new LoadingPickTaskEntity();
|
||||
task.setLoadingPlanId(planId);
|
||||
task.setContainerNo(containerNo);
|
||||
task.setTaskStatus("LOADING");
|
||||
task.setStartedBy(operatorId);
|
||||
task.setStartedByName(operatorName);
|
||||
task.setVersion(0);
|
||||
try {
|
||||
repository.insert(task);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
task = repository.taskByPlan(planId);
|
||||
}
|
||||
}
|
||||
if (task == null || !"LOADING".equals(task.getTaskStatus())) {
|
||||
throw conflict("装柜任务已完成,不能继续同步明细");
|
||||
}
|
||||
for (LoadingPlanItemEntity planItem : planItems) {
|
||||
syncItem(task.getId(), planItem);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void sync(long planId, LoadingPlanItemEntity planItem) {
|
||||
LoadingPickTaskEntity task = repository.taskByPlan(planId);
|
||||
if (task != null && "LOADING".equals(task.getTaskStatus())) {
|
||||
syncItem(task.getId(), planItem);
|
||||
}
|
||||
}
|
||||
|
||||
public Task task(String containerNo) {
|
||||
if (!StringUtils.hasText(containerNo)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "集装箱号不能为空");
|
||||
}
|
||||
LoadingPickTaskEntity task = repository.taskByContainer(containerNo.trim());
|
||||
if (task == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "未找到装柜中的PDA拣货任务");
|
||||
}
|
||||
return view(task);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) {
|
||||
String operationId = request.operationId().trim();
|
||||
String requestHash = hash(taskId + "|" + itemId + "|" + request.pickedPackageCount());
|
||||
LoadingPickHistoryEntity replay = repository.history(operationId);
|
||||
if (replay != null) {
|
||||
if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId)
|
||||
|| !replay.getRequestHash().equals(requestHash)) {
|
||||
throw conflict("operationId 已用于不同拣货请求");
|
||||
}
|
||||
return view(requiredTask(taskId));
|
||||
}
|
||||
LoadingPickTaskEntity task = requiredTask(taskId);
|
||||
if (!"LOADING".equals(task.getTaskStatus())) {
|
||||
throw conflict("装柜任务已完成,不能继续拣货");
|
||||
}
|
||||
LoadingPickItemEntity item = repository.item(taskId, itemId);
|
||||
if (item == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在");
|
||||
}
|
||||
if (repository.pick(itemId, request.pickedPackageCount(), request.version()) != 1) {
|
||||
throw conflict("拣货数量超过计划箱数或明细版本已变化");
|
||||
}
|
||||
LoadingPickHistoryEntity history = new LoadingPickHistoryEntity();
|
||||
history.setPickTaskId(taskId);
|
||||
history.setPickItemId(itemId);
|
||||
history.setOperationId(operationId);
|
||||
history.setRequestHash(requestHash);
|
||||
history.setPickedPackageCount(request.pickedPackageCount());
|
||||
history.setOperatorId(operatorId);
|
||||
history.setOperatorName(operatorName);
|
||||
repository.insert(history);
|
||||
return view(requiredTask(taskId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void remove(long planItemId) {
|
||||
LoadingPickItemEntity item = repository.itemByPlanItem(planItemId);
|
||||
if (item == null) {
|
||||
return;
|
||||
}
|
||||
if (item.getPickedPackageCount() != null && item.getPickedPackageCount() > 0) {
|
||||
throw conflict("已有拣货记录的明细不能删除");
|
||||
}
|
||||
repository.deleteItem(item.getId());
|
||||
}
|
||||
|
||||
private void syncItem(long taskId, LoadingPlanItemEntity planItem) {
|
||||
LoadingPickItemEntity item = repository.itemByPlanItem(planItem.getId());
|
||||
if (item == null) {
|
||||
item = new LoadingPickItemEntity();
|
||||
item.setPickTaskId(taskId);
|
||||
item.setLoadingPlanItemId(planItem.getId());
|
||||
item.setInboundId(planItem.getInboundId());
|
||||
item.setInboundDetailId(planItem.getInboundDetailId());
|
||||
item.setPlannedPackageCount(planItem.getPackageCount());
|
||||
item.setPickedPackageCount(0);
|
||||
item.setItemStatus("PENDING");
|
||||
item.setVersion(0);
|
||||
repository.insert(item);
|
||||
return;
|
||||
}
|
||||
if (item.getPickedPackageCount() > planItem.getPackageCount()) {
|
||||
throw conflict("已拣货箱数不能低于计划箱数");
|
||||
}
|
||||
item.setPlannedPackageCount(planItem.getPackageCount());
|
||||
item.setItemStatus(item.getPickedPackageCount().equals(planItem.getPackageCount()) ? "PICKED" : "PICKING");
|
||||
repository.update(item);
|
||||
}
|
||||
|
||||
private LoadingPickTaskEntity requiredTask(long taskId) {
|
||||
LoadingPickTaskEntity task = repository.task(taskId);
|
||||
if (task == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在");
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
private Task view(LoadingPickTaskEntity task) {
|
||||
List<PickItem> items = repository.items(task.getId()).stream()
|
||||
.map(item -> new PickItem(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(),
|
||||
item.getInboundDetailId(), item.getPlannedPackageCount(), item.getPickedPackageCount(),
|
||||
item.getItemStatus(), item.getVersion())).toList();
|
||||
return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerNo(), task.getTaskStatus(),
|
||||
task.getVersion(), items);
|
||||
}
|
||||
|
||||
private static String hash(String value) {
|
||||
try {
|
||||
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("JVM 不支持 SHA-256", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static BizException conflict(String message) {
|
||||
return new BizException(ResultCode.CONFLICT, message);
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -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 <T> ApiResponse<T> call(RemoteCall<T> call) {
|
||||
try { return call.invoke(); }
|
||||
catch (FeignException exception) {
|
||||
|
||||
@@ -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<Candidate> candidates(long accountId) {
|
||||
requireAccount(accountId);
|
||||
return repository.candidates(accountId);
|
||||
}
|
||||
|
||||
public Plan draftByBooking(long bookingId) {
|
||||
ContainerBookingEntity booking = requireBooking(bookingId);
|
||||
LoadingPlanEntity plan = repository.planByBooking(bookingId);
|
||||
return draftView(plan, booking);
|
||||
}
|
||||
|
||||
public Plan detail(long planId) {
|
||||
LoadingPlanEntity plan = repository.plan(planId);
|
||||
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
|
||||
return draftView(plan, requireBooking(plan.getContainerBookingId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "SMART_LOADING_PLAN_CREATE", resourceType = "LOADING_PLAN",
|
||||
description = "创建智能装柜计划", captureBefore = false)
|
||||
public Plan create(long bookingId, String remark, long operatorId, String operatorName) {
|
||||
ContainerBookingEntity booking = requireBooking(bookingId);
|
||||
LoadingPlanEntity existing = repository.planByBooking(bookingId);
|
||||
if (existing != null) return draftView(existing, booking);
|
||||
LoadingPlanEntity plan = new LoadingPlanEntity();
|
||||
plan.setPlanNo("LP-" + UUID.randomUUID().toString().replace("-", "").substring(0, 20));
|
||||
plan.setContainerBookingId(bookingId); plan.setPlanStatus("EDITING"); plan.setRemark(trim(remark));
|
||||
plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName); plan.setUpdatedBy(operatorId);
|
||||
plan.setUpdatedByName(operatorName); plan.setVersion(0);
|
||||
try {
|
||||
repository.insert(plan);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
LoadingPlanEntity concurrent = repository.planByBooking(bookingId);
|
||||
if (concurrent != null) return draftView(concurrent, booking);
|
||||
throw conflict("装柜计划创建冲突,请刷新后重试");
|
||||
}
|
||||
history(plan.getId(), null, "CREATE_PLAN", null, "EDITING", UUID.randomUUID().toString(), operatorId, operatorName);
|
||||
return view(plan, booking);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "SMART_LOADING_ITEM_ASSIGN", resourceType = "LOADING_PLAN",
|
||||
resourceId = "#planId", description = "分配智能装柜明细")
|
||||
public Plan assign(long planId, AssignRequest request, long operatorId, String operatorName) {
|
||||
String operation = operation(request.operationId());
|
||||
String requestHash = sha256("ASSIGN|" + planId + '|' + request.accountId() + '|'
|
||||
+ request.inboundId() + '|' + request.inboundDetailId() + '|' + request.packageCount()
|
||||
+ '|' + request.expectedVersion());
|
||||
Plan replay = replay(operation, planId, "ADD_ITEM", null, requestHash);
|
||||
if (replay != null) return replay;
|
||||
LoadingPlanEntity plan = editing(planId, request.expectedVersion());
|
||||
Candidate candidate = repository.candidate(request.accountId(), request.inboundId(), request.inboundDetailId());
|
||||
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
|
||||
if (!Objects.equals(candidate.accountId(), request.accountId())) throw forbidden();
|
||||
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
|
||||
LoadingPlanItemEntity existing = repository.activeItem(request.accountId(), request.inboundDetailId());
|
||||
if (existing != null && !Objects.equals(existing.getLoadingPlanId(), planId)) throw conflict("货物明细已被有效计划锁定");
|
||||
int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount());
|
||||
if (total > candidate.availablePackageCount()) throw validation("累计分配箱数超过可用箱数");
|
||||
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
||||
List<LoadingPlanItemEntity> lines = new ArrayList<>(repository.items(planId));
|
||||
if (existing != null) lines.removeIf(x -> Objects.equals(x.getId(), existing.getId()));
|
||||
checkRouteAndCapacity(booking, lines, candidate, total);
|
||||
touch(plan, operatorId, operatorName);
|
||||
LoadingPlanItemEntity changed;
|
||||
if (existing == null) {
|
||||
LoadingPlanItemEntity item = snapshot(planId, candidate, cargo, request.packageCount(), operatorId, operatorName, lines.size() + 1);
|
||||
try { repository.insert(item); } catch (DuplicateKeyException e) { throw conflict("货物明细已被并发锁定"); }
|
||||
changed = item;
|
||||
} else {
|
||||
existing.setPackageCount(total); existing.setGrossWeightKg(unit(candidate.unitWeightKg()).multiply(BigDecimal.valueOf(total)));
|
||||
existing.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(total))); applyCargo(existing, cargo, total);
|
||||
existing.setUpdatedBy(operatorId); existing.setUpdatedByName(operatorName);
|
||||
if (repository.update(existing) != 1) throw conflict("装柜草稿已被并发修改");
|
||||
changed = existing;
|
||||
}
|
||||
history(planId, changed.getId(), "ADD_ITEM", "EDITING", "EDITING", operation,
|
||||
json(values("version", request.expectedVersion())),
|
||||
json(values("requestHash", requestHash, "itemId", changed.getId(),
|
||||
"packageCount", changed.getPackageCount())), operatorId, operatorName);
|
||||
loadingPickService.sync(planId, changed);
|
||||
return view(repository.plan(planId), booking);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "SMART_LOADING_RECOMMEND", resourceType = "LOADING_PLAN",
|
||||
resourceId = "#planId", description = "执行智能装柜推荐")
|
||||
public Plan recommend(long planId, RecommendRequest request, long operatorId, String operatorName) {
|
||||
String operation = operation(request.operationId());
|
||||
String requestHash = sha256("RECOMMEND|" + planId + '|' + request.accountId()
|
||||
+ '|' + request.expectedVersion());
|
||||
Plan replay = replay(operation, planId, "SMART_RECOMMEND", null, requestHash);
|
||||
if (replay != null) return replay;
|
||||
LoadingPlanEntity plan = editing(planId, request.expectedVersion());
|
||||
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
||||
for (Candidate candidate : candidates(request.accountId())) {
|
||||
if (!Objects.equals(candidate.warehouseId(), booking.getWarehouseId())) continue;
|
||||
if (StringUtils.hasText(booking.getDestinationWarehouseCode())
|
||||
&& !Objects.equals(booking.getDestinationWarehouseCode(), candidate.destinationWarehouseCode())) continue;
|
||||
int count = fittingBoxes(booking, repository.items(planId), candidate);
|
||||
if (count > 0) {
|
||||
plan = repository.plan(planId);
|
||||
assign(planId, new AssignRequest(request.accountId(), candidate.inboundId(), candidate.inboundDetailId(),
|
||||
count, plan.getVersion(), "REC-" + sha256(operation + '|' + candidate.inboundId()
|
||||
+ '|' + candidate.inboundDetailId())), operatorId, operatorName);
|
||||
}
|
||||
}
|
||||
LoadingPlanEntity recommended = repository.plan(planId);
|
||||
history(planId, null, "SMART_RECOMMEND", "EDITING", "EDITING", operation,
|
||||
json(values("version", request.expectedVersion())),
|
||||
json(values("requestHash", requestHash, "version", recommended.getVersion())),
|
||||
operatorId, operatorName);
|
||||
return view(recommended, requireBooking(plan.getContainerBookingId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "SMART_LOADING_ITEM_ADJUST", resourceType = "LOADING_PLAN_ITEM",
|
||||
resourceId = "#itemId", description = "调整智能装柜明细")
|
||||
public Plan adjust(long planId, long itemId, AdjustRequest request, long operatorId, String operatorName) {
|
||||
String operation = operation(request.operationId());
|
||||
String requestHash = sha256("ADJUST|" + planId + '|' + itemId + '|' + request.accountId()
|
||||
+ '|' + request.packageCount() + '|' + request.expectedPlanVersion()
|
||||
+ '|' + request.expectedItemVersion());
|
||||
Plan replay = replay(operation, planId, "ADJUST_ITEM", itemId, requestHash);
|
||||
if (replay != null) return replay;
|
||||
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion());
|
||||
LoadingPlanItemEntity item = repository.item(planId, itemId);
|
||||
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
|
||||
if (!Objects.equals(item.getAccountId(), request.accountId())) throw forbidden();
|
||||
if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突");
|
||||
Candidate candidate = repository.candidate(item.getAccountId(), item.getInboundId(), item.getInboundDetailId());
|
||||
if (candidate == null || request.packageCount() > candidate.availablePackageCount()) throw validation("调整箱数超过可用箱数");
|
||||
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
|
||||
List<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));
|
||||
others.removeIf(x -> Objects.equals(x.getId(), itemId));
|
||||
checkRouteAndCapacity(requireBooking(plan.getContainerBookingId()), others, candidate, request.packageCount());
|
||||
touch(plan, operatorId, operatorName);
|
||||
item.setPackageCount(request.packageCount());
|
||||
item.setGrossWeightKg(unit(candidate.unitWeightKg()).multiply(BigDecimal.valueOf(request.packageCount())));
|
||||
item.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(request.packageCount()))); applyCargo(item, cargo, request.packageCount());
|
||||
item.setUpdatedBy(operatorId); item.setUpdatedByName(operatorName);
|
||||
if (repository.update(item) != 1) throw conflict("装柜明细已被并发修改");
|
||||
history(planId, itemId, "ADJUST_ITEM", "EDITING", "EDITING", operation,
|
||||
json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())),
|
||||
json(values("requestHash", requestHash, "packageCount", request.packageCount())),
|
||||
operatorId, operatorName);
|
||||
loadingPickService.sync(planId, item);
|
||||
return view(repository.plan(planId), requireBooking(plan.getContainerBookingId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "SMART_LOADING_ITEM_DELETE", resourceType = "LOADING_PLAN_ITEM",
|
||||
resourceId = "#itemId", description = "删除智能装柜明细")
|
||||
public Plan delete(long planId, long itemId, DeleteRequest request, long operatorId, String operatorName) {
|
||||
String operation = operation(request.operationId());
|
||||
String requestHash = sha256("DELETE|" + planId + '|' + itemId + '|' + request.accountId()
|
||||
+ '|' + request.expectedPlanVersion() + '|' + request.expectedItemVersion());
|
||||
Plan replay = replay(operation, planId, "REMOVE_ITEM", itemId, requestHash);
|
||||
if (replay != null) return replay;
|
||||
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion());
|
||||
LoadingPlanItemEntity item = repository.item(planId, itemId);
|
||||
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
|
||||
if (!Objects.equals(item.getAccountId(), request.accountId())) throw forbidden();
|
||||
loadingPickService.remove(itemId);
|
||||
touch(plan, operatorId, operatorName);
|
||||
if (repository.remove(planId, itemId, request.expectedItemVersion(), operatorId, operatorName) != 1) throw conflict("装柜草稿已被并发修改");
|
||||
history(planId, itemId, "REMOVE_ITEM", "EDITING", "EDITING", operation,
|
||||
json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())),
|
||||
json(values("requestHash", requestHash, "deleted", true)), operatorId, operatorName);
|
||||
return view(repository.plan(planId), requireBooking(plan.getContainerBookingId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "SMART_LOADING_PUSH_PRELOAD", resourceType = "LOADING_PLAN",
|
||||
resourceId = "#planId", description = "推送智能装柜计划到预装清单")
|
||||
public Plan pushPreload(long planId, String idempotencyKey, PushRequest request, long operatorId, String operatorName) {
|
||||
String key = trim(idempotencyKey);
|
||||
if (!StringUtils.hasText(key) || key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "必须提供有效 Idempotency-Key");
|
||||
LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
|
||||
if ("LOADING".equals(plan.getPlanStatus()) && key.equals(plan.getPushIdempotencyKey())) return view(plan, requireBooking(plan.getContainerBookingId()));
|
||||
if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配");
|
||||
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
||||
if (!StringUtils.hasText(booking.getContainerNo())) throw validation("开始装柜前必须填写集装箱号");
|
||||
if (repository.items(planId).isEmpty()) throw validation("装柜明细不能为空");
|
||||
loadingPickService.begin(planId, booking.getContainerNo(), operatorId, operatorName, repository.items(planId));
|
||||
try {
|
||||
if (repository.push(planId, key, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("装柜计划已被并发修改");
|
||||
} catch (DuplicateKeyException e) { throw conflict("幂等键已用于其他推送"); }
|
||||
history(planId, null, "START_LOADING", "EDITING", "LOADING", key, operatorId, operatorName);
|
||||
LoadingPlanEntity pushed = repository.plan(planId);
|
||||
return view(pushed, requireBooking(pushed.getContainerBookingId()));
|
||||
}
|
||||
|
||||
private LoadingPlanItemEntity snapshot(long planId, Candidate c, OrderCargoClient.CargoBinding cargo, int count, long op, String name, int seq) {
|
||||
LoadingPlanItemEntity i = new LoadingPlanItemEntity(); i.setLoadingPlanId(planId); i.setAccountId(c.accountId()); i.setCustomerName(c.customerName());
|
||||
i.setInboundId(c.inboundId()); i.setInboundDetailId(c.inboundDetailId()); i.setOrderId(c.orderId()); i.setOrderNo(c.orderNo()); i.setInboundCode(c.inboundCode());
|
||||
i.setDestinationWarehouseId(c.destinationWarehouseId()); i.setDestinationWarehouseCode(c.destinationWarehouseCode());
|
||||
i.setDestinationWarehouseName(c.destinationWarehouseName()); i.setProductNameZh(cargo.cargoName()); i.setPackageCount(count);
|
||||
i.setGrossWeightKg(unit(c.unitWeightKg()).multiply(BigDecimal.valueOf(count))); i.setVolumeCbm(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count)));
|
||||
applyCargo(i, cargo, count); i.setLoadSequence(seq); i.setDeleted(false); i.setCreatedBy(op); i.setCreatedByName(name); i.setUpdatedBy(op); i.setUpdatedByName(name); i.setVersion(0); return i;
|
||||
}
|
||||
private void applyCargo(LoadingPlanItemEntity item, OrderCargoClient.CargoBinding cargo, int boxes) {
|
||||
item.setSkuCode(cargo.skuNo());
|
||||
item.setProductNameEn(cargo.cargoNameEn());
|
||||
item.setUnitsPerCarton(cargo.unitsPerPackage());
|
||||
item.setInternalRemark(cargo.cargoRemark());
|
||||
int original = cargo.originalPackageCount() == null || cargo.originalPackageCount() <= 0
|
||||
? boxes : cargo.originalPackageCount();
|
||||
if (cargo.unitsPerPackage() != null) {
|
||||
item.setProductQuantity(Math.multiplyExact(cargo.unitsPerPackage(), boxes));
|
||||
} else if (cargo.totalQuantity() != null) {
|
||||
item.setProductQuantity(BigDecimal.valueOf(cargo.totalQuantity())
|
||||
.multiply(BigDecimal.valueOf(boxes))
|
||||
.divide(BigDecimal.valueOf(original), 0, RoundingMode.HALF_UP).intValueExact());
|
||||
}
|
||||
if (cargo.netWeightKg() != null) {
|
||||
item.setNetWeightKg(cargo.netWeightKg().multiply(BigDecimal.valueOf(boxes))
|
||||
.divide(BigDecimal.valueOf(original), 6, RoundingMode.HALF_UP));
|
||||
}
|
||||
}
|
||||
|
||||
private OrderCargoClient.CargoBinding loadCargo(Candidate candidate) {
|
||||
try {
|
||||
ApiResponse<OrderCargoClient.CargoBinding> 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<LoadingPlanItemEntity> 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<LoadingPlanItemEntity> 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<String, Object> values(Object... values) {
|
||||
Map<String, Object> 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,"客户归属不匹配");}
|
||||
}
|
||||
@@ -0,0 +1,775 @@
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS baiyue_firstleg
|
||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||
USE baiyue_firstleg;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
|
||||
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
|
||||
container_no VARCHAR(64) NULL COMMENT '集装箱号;确认前允许修订',
|
||||
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
|
||||
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '装柜仓库ID;平台公共资源',
|
||||
warehouse_name VARCHAR(128) NOT NULL COMMENT '装柜仓库名称快照',
|
||||
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
|
||||
vessel_name VARCHAR(128) NULL COMMENT '船名快照',
|
||||
voyage_no VARCHAR(64) NULL COMMENT '航次;确认前允许修订',
|
||||
container_type VARCHAR(32) NOT NULL COMMENT '箱型,例如20GP、40GP、40HQ',
|
||||
shipping_company_id BIGINT UNSIGNED NULL COMMENT '船司ID快照',
|
||||
shipping_company_name VARCHAR(128) NULL COMMENT '船司名称快照',
|
||||
departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照',
|
||||
departure_port_name VARCHAR(128) NULL COMMENT '起运港名称快照',
|
||||
destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照',
|
||||
destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照',
|
||||
destination_warehouse_code VARCHAR(64) NULL COMMENT '目的仓编码快照',
|
||||
destination_warehouse_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
|
||||
capacity_cbm DECIMAL(18,4) NULL COMMENT '货柜容量m3',
|
||||
max_weight_kg DECIMAL(18,3) NULL COMMENT '货柜最大承重kg',
|
||||
terminal_trucking_company VARCHAR(128) NULL COMMENT '码头拖车公司',
|
||||
loading_at DATETIME(3) NULL COMMENT '计划或实际装柜时间',
|
||||
estimated_sailing_at DATETIME(3) NULL COMMENT '预计开船时间',
|
||||
actual_sailing_at DATETIME(3) NULL COMMENT '实际开船时间',
|
||||
estimated_port_arrival_at DATETIME(3) NULL COMMENT '预计到港时间',
|
||||
actual_port_arrival_at DATETIME(3) NULL COMMENT '实际到港时间',
|
||||
estimated_warehouse_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间',
|
||||
actual_warehouse_arrival_at DATETIME(3) NULL COMMENT '实际到仓时间',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
|
||||
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
|
||||
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
|
||||
COMMENT '更新时间',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_container_booking_no (booking_no),
|
||||
UNIQUE KEY uk_container_booking_cabinet (cabinet_no),
|
||||
KEY idx_container_booking_container (container_no),
|
||||
KEY idx_container_booking_warehouse (warehouse_id, updated_at, id),
|
||||
KEY idx_container_booking_voyage (voyage_no, updated_at),
|
||||
CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0),
|
||||
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0)
|
||||
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_plan (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID(雪花ID)',
|
||||
plan_no VARCHAR(40) NOT NULL COMMENT '平台唯一装柜计划号',
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
|
||||
plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING'
|
||||
COMMENT '唯一装柜状态:EDITING、LOADING、COMPLETED',
|
||||
push_idempotency_key VARCHAR(128) NULL COMMENT '推送预装幂等键',
|
||||
preload_pushed_by BIGINT UNSIGNED NULL COMMENT '推送预装操作人ID',
|
||||
preload_pushed_by_name VARCHAR(64) NULL COMMENT '推送预装操作人名称快照',
|
||||
preload_pushed_at DATETIME(3) NULL COMMENT '开始装柜并进入LOADING的时间',
|
||||
preload_completed_by BIGINT UNSIGNED NULL COMMENT '预装完成操作人ID',
|
||||
preload_completed_by_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照',
|
||||
preload_completed_at DATETIME(3) NULL COMMENT '预装完成时间',
|
||||
confirmed_by BIGINT UNSIGNED NULL COMMENT '确认操作人ID',
|
||||
confirmed_by_name VARCHAR(64) NULL COMMENT '确认操作人名称快照',
|
||||
confirmed_at DATETIME(3) NULL COMMENT '确认时间',
|
||||
remark VARCHAR(500) NULL COMMENT '平台装柜计划备注',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
|
||||
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
|
||||
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
|
||||
COMMENT '更新时间',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_loading_plan_no (plan_no),
|
||||
UNIQUE KEY uk_loading_plan_booking (container_booking_id),
|
||||
UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key),
|
||||
KEY idx_loading_plan_status (plan_status, updated_at, id),
|
||||
CONSTRAINT fk_loading_plan_booking FOREIGN KEY (container_booking_id)
|
||||
REFERENCES fl_container_booking (id),
|
||||
CONSTRAINT chk_loading_plan_status
|
||||
CHECK (plan_status IN ('EDITING', 'LOADING', 'COMPLETED'))
|
||||
) ENGINE=InnoDB COMMENT='平台智能装柜计划与唯一预装状态源';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划明细ID(雪花ID)',
|
||||
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID',
|
||||
account_id BIGINT UNSIGNED NOT NULL COMMENT '客户账号ID;装柜业务唯一客户归属字段',
|
||||
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
|
||||
inbound_id BIGINT UNSIGNED NOT NULL COMMENT 'Firstleg入库单ID',
|
||||
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物或入库货物明细ID,不建立跨库外键',
|
||||
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID快照,不建立跨库外键',
|
||||
order_no VARCHAR(32) NOT NULL COMMENT '订单号快照',
|
||||
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
|
||||
destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '目的仓ID快照',
|
||||
destination_warehouse_code VARCHAR(64) NULL COMMENT '目的仓编码快照',
|
||||
destination_warehouse_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
|
||||
sku_code VARCHAR(128) NULL COMMENT 'SKU编码快照',
|
||||
product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照',
|
||||
product_name_en VARCHAR(255) NULL COMMENT '英文品名快照',
|
||||
product_image_file_id BIGINT UNSIGNED NULL COMMENT '产品图片File对象ID',
|
||||
product_image_url VARCHAR(1000) NULL COMMENT '产品图片访问地址快照',
|
||||
package_count INT UNSIGNED NOT NULL COMMENT '本次装柜箱数',
|
||||
product_quantity INT UNSIGNED NULL COMMENT '产品总数量',
|
||||
units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数',
|
||||
pallet_count INT UNSIGNED NULL COMMENT '托盘数',
|
||||
gross_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜毛重kg',
|
||||
net_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜净重kg',
|
||||
dimensional_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜材积重kg',
|
||||
volume_cbm DECIMAL(18,4) NULL COMMENT '本次装柜体积m3',
|
||||
fba_tracking_no VARCHAR(128) NULL COMMENT 'FBA追踪号快照',
|
||||
shipment_no VARCHAR(128) NULL COMMENT '货件编号快照',
|
||||
shipping_mark VARCHAR(500) NULL COMMENT '唛头快照',
|
||||
load_sequence INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '装柜顺序',
|
||||
position_snapshot JSON NULL COMMENT '智能装柜位置与摆放结果快照',
|
||||
internal_remark VARCHAR(1000) NULL COMMENT '平台内部备注',
|
||||
customer_remark VARCHAR(1000) NULL COMMENT '客户备注快照',
|
||||
warehouse_remark VARCHAR(1000) NULL COMMENT '仓库备注快照',
|
||||
frozen_at DATETIME(3) NULL COMMENT '装柜完成后的永久冻结时间',
|
||||
frozen_by BIGINT UNSIGNED NULL COMMENT '冻结操作人ID',
|
||||
removed_by BIGINT UNSIGNED NULL COMMENT '推送前逻辑删除操作人ID',
|
||||
removed_by_name VARCHAR(64) NULL COMMENT '推送前逻辑删除操作人名称快照',
|
||||
removed_at DATETIME(3) NULL COMMENT '推送前逻辑删除时间',
|
||||
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '推送前逻辑删除标记;冻结后不可修改',
|
||||
active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS (
|
||||
CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END
|
||||
) STORED COMMENT '未删除明细唯一锁;删除后返回NULL释放分配资格',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
|
||||
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
|
||||
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
|
||||
COMMENT '更新时间',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_loading_item_active_detail (account_id, active_inbound_detail_id),
|
||||
KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id),
|
||||
KEY idx_loading_item_account (account_id, created_at, id),
|
||||
KEY idx_loading_item_inbound (account_id, inbound_id, deleted),
|
||||
KEY idx_loading_item_order (account_id, order_id, deleted),
|
||||
CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
|
||||
CONSTRAINT chk_loading_item_account CHECK (account_id > 0),
|
||||
CONSTRAINT chk_loading_item_package CHECK (package_count > 0),
|
||||
CONSTRAINT chk_loading_item_product_quantity CHECK (product_quantity IS NULL OR product_quantity > 0),
|
||||
CONSTRAINT chk_loading_item_units CHECK (units_per_carton IS NULL OR units_per_carton > 0),
|
||||
CONSTRAINT chk_loading_item_pallet CHECK (pallet_count IS NULL OR pallet_count >= 0),
|
||||
CONSTRAINT chk_loading_item_weight CHECK (
|
||||
(gross_weight_kg IS NULL OR gross_weight_kg >= 0)
|
||||
AND (net_weight_kg IS NULL OR net_weight_kg >= 0)
|
||||
AND (dimensional_weight_kg IS NULL OR dimensional_weight_kg >= 0)
|
||||
),
|
||||
CONSTRAINT chk_loading_item_volume CHECK (volume_cbm IS NULL OR volume_cbm >= 0),
|
||||
CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1))
|
||||
) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_container_document (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
|
||||
document_type VARCHAR(32) NOT NULL COMMENT 'CUSTOMS_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER',
|
||||
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键',
|
||||
file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照',
|
||||
file_url VARCHAR(1000) NULL COMMENT '文件访问地址快照',
|
||||
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID',
|
||||
uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
|
||||
COMMENT '更新时间',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_container_document_type (container_booking_id, document_type),
|
||||
KEY idx_container_document_file (file_id),
|
||||
CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id)
|
||||
REFERENCES fl_container_booking (id),
|
||||
CONSTRAINT chk_container_document_type
|
||||
CHECK (document_type IN ('CUSTOMS_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER'))
|
||||
) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_plan_history (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '装柜操作历史ID(雪花ID)',
|
||||
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID',
|
||||
loading_plan_item_id BIGINT UNSIGNED NULL COMMENT '装柜计划明细ID',
|
||||
operation_id VARCHAR(128) NOT NULL COMMENT '操作幂等ID',
|
||||
action_type VARCHAR(32) NOT NULL
|
||||
COMMENT 'CREATE_PLAN、ADD_ITEM、ADJUST_ITEM、REMOVE_ITEM、START_LOADING、COMPLETE_LOADING、PICK_ITEM',
|
||||
from_status VARCHAR(24) NULL COMMENT '变更前状态',
|
||||
to_status VARCHAR(24) NULL COMMENT '变更后状态',
|
||||
before_snapshot JSON NULL COMMENT '变更前快照',
|
||||
after_snapshot JSON NULL COMMENT '变更后快照',
|
||||
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
|
||||
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
|
||||
operation_reason VARCHAR(500) NULL COMMENT '操作原因',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_loading_history_operation (operation_id),
|
||||
KEY idx_loading_history_plan (loading_plan_id, created_at, id),
|
||||
KEY idx_loading_history_item (loading_plan_item_id, created_at, id),
|
||||
CONSTRAINT fk_loading_history_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
|
||||
CONSTRAINT fk_loading_history_item FOREIGN KEY (loading_plan_item_id)
|
||||
REFERENCES fl_loading_plan_item (id)
|
||||
) ENGINE=InnoDB COMMENT='智能装柜追加式不可变操作历史';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
|
||||
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID',
|
||||
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号快照',
|
||||
task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING' COMMENT 'LOADING、COMPLETED',
|
||||
started_by BIGINT UNSIGNED NOT NULL COMMENT '开始装柜人',
|
||||
started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称',
|
||||
started_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '开始时间',
|
||||
completed_by BIGINT UNSIGNED NULL COMMENT '完成装柜人',
|
||||
completed_by_name VARCHAR(64) NULL COMMENT '完成装柜人名称',
|
||||
completed_at DATETIME(3) NULL COMMENT '完成时间',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id),
|
||||
KEY idx_loading_pick_container (container_no, task_status, started_at),
|
||||
CONSTRAINT fk_loading_pick_task_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
|
||||
CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING', 'COMPLETED'))
|
||||
) ENGINE=InnoDB COMMENT='PDA装柜拣货任务';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
|
||||
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
|
||||
loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划明细ID',
|
||||
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '父入库单ID',
|
||||
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID',
|
||||
planned_package_count INT UNSIGNED NOT NULL COMMENT '计划拣货箱数;上层拆分按业务规则向上取整',
|
||||
picked_package_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已拣货箱数',
|
||||
item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PICKING、PICKED',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_loading_pick_item_plan_item (loading_plan_item_id),
|
||||
KEY idx_loading_pick_item_task (pick_task_id, item_status, id),
|
||||
CONSTRAINT fk_loading_pick_item_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
|
||||
CONSTRAINT fk_loading_pick_item_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id),
|
||||
CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0),
|
||||
CONSTRAINT chk_loading_pick_item_count CHECK (picked_package_count <= planned_package_count),
|
||||
CONSTRAINT chk_loading_pick_item_status CHECK (item_status IN ('PENDING', 'PICKING', 'PICKED'))
|
||||
) ENGINE=InnoDB COMMENT='PDA装柜拣货明细';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_pick_history (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货操作历史ID',
|
||||
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
|
||||
pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
|
||||
operation_id VARCHAR(128) NOT NULL COMMENT '客户端幂等操作号',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '请求指纹',
|
||||
picked_package_count INT UNSIGNED NOT NULL COMMENT '本次拣货箱数',
|
||||
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人',
|
||||
operator_name VARCHAR(64) NULL COMMENT '操作人名称',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_loading_pick_history_operation (operation_id),
|
||||
KEY idx_loading_pick_history_item (pick_item_id, created_at),
|
||||
CONSTRAINT fk_loading_pick_history_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
|
||||
CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id)
|
||||
) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史';
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_container_booking_before_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_container_booking_before_update
|
||||
BEFORE UPDATE ON fl_container_booking
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
DECLARE current_plan_status VARCHAR(24);
|
||||
SELECT MAX(plan_status) INTO current_plan_status
|
||||
FROM fl_loading_plan WHERE container_booking_id = OLD.id;
|
||||
IF current_plan_status = 'COMPLETED' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改';
|
||||
END IF;
|
||||
IF current_plan_status = 'LOADING' AND (
|
||||
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
|
||||
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
|
||||
OR NOT (OLD.warehouse_id <=> NEW.warehouse_id)
|
||||
OR NOT (OLD.warehouse_name <=> NEW.warehouse_name)
|
||||
OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no)
|
||||
OR NOT (OLD.vessel_name <=> NEW.vessel_name)
|
||||
OR NOT (OLD.container_type <=> NEW.container_type)
|
||||
OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id)
|
||||
OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name)
|
||||
OR NOT (OLD.departure_port_code <=> NEW.departure_port_code)
|
||||
OR NOT (OLD.departure_port_name <=> NEW.departure_port_name)
|
||||
OR NOT (OLD.destination_port_code <=> NEW.destination_port_code)
|
||||
OR NOT (OLD.destination_port_name <=> NEW.destination_port_name)
|
||||
OR NOT (OLD.destination_warehouse_code <=> NEW.destination_warehouse_code)
|
||||
OR NOT (OLD.destination_warehouse_name <=> NEW.destination_warehouse_name)
|
||||
OR NOT (OLD.capacity_cbm <=> NEW.capacity_cbm)
|
||||
OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg)
|
||||
OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company)
|
||||
OR NOT (OLD.loading_at <=> NEW.loading_at)
|
||||
OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at)
|
||||
OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at)
|
||||
OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at)
|
||||
OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at)
|
||||
OR NOT (OLD.estimated_warehouse_arrival_at <=> NEW.estimated_warehouse_arrival_at)
|
||||
OR NOT (OLD.actual_warehouse_arrival_at <=> NEW.actual_warehouse_arrival_at)
|
||||
OR NOT (OLD.created_by <=> NEW.created_by)
|
||||
OR NOT (OLD.created_by_name <=> NEW.created_by_name)
|
||||
OR NOT (OLD.created_at <=> NEW.created_at)
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '装柜中仅允许修改集装箱号与航次';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_plan_before_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_plan_before_update
|
||||
BEFORE UPDATE ON fl_loading_plan
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
IF OLD.plan_status = 'COMPLETED' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成装柜计划全部只读';
|
||||
END IF;
|
||||
IF NOT (
|
||||
(OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING'))
|
||||
OR (OLD.plan_status = 'LOADING'
|
||||
AND NEW.plan_status IN ('LOADING', 'COMPLETED'))
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜状态只允许单向推进';
|
||||
END IF;
|
||||
IF OLD.plan_status = 'LOADING' AND (
|
||||
NOT (OLD.id <=> NEW.id) OR NOT (OLD.plan_no <=> NEW.plan_no)
|
||||
OR NOT (OLD.container_booking_id <=> NEW.container_booking_id)
|
||||
OR NOT (OLD.push_idempotency_key <=> NEW.push_idempotency_key)
|
||||
OR NOT (OLD.preload_pushed_by <=> NEW.preload_pushed_by)
|
||||
OR NOT (OLD.preload_pushed_by_name <=> NEW.preload_pushed_by_name)
|
||||
OR NOT (OLD.preload_pushed_at <=> NEW.preload_pushed_at)
|
||||
OR NOT (OLD.remark <=> NEW.remark)
|
||||
OR NOT (OLD.created_by <=> NEW.created_by)
|
||||
OR NOT (OLD.created_by_name <=> NEW.created_by_name)
|
||||
OR NOT (OLD.created_at <=> NEW.created_at)
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '装柜中计划仅允许乐观锁触碰或完成装柜';
|
||||
END IF;
|
||||
IF OLD.plan_status = NEW.plan_status
|
||||
AND OLD.plan_status = 'LOADING' AND (
|
||||
NOT (OLD.preload_completed_by <=> NEW.preload_completed_by)
|
||||
OR NOT (OLD.preload_completed_by_name <=> NEW.preload_completed_by_name)
|
||||
OR NOT (OLD.preload_completed_at <=> NEW.preload_completed_at)
|
||||
OR NOT (OLD.confirmed_by <=> NEW.confirmed_by)
|
||||
OR NOT (OLD.confirmed_by_name <=> NEW.confirmed_by_name)
|
||||
OR NOT (OLD.confirmed_at <=> NEW.confirmed_at)
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同状态乐观锁触碰不得修改阶段审计字段';
|
||||
END IF;
|
||||
IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN
|
||||
IF NEW.push_idempotency_key IS NULL OR CHAR_LENGTH(TRIM(NEW.push_idempotency_key)) = 0 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '开始装柜必须提供幂等键';
|
||||
END IF;
|
||||
SET NEW.preload_pushed_at = COALESCE(NEW.preload_pushed_at, CURRENT_TIMESTAMP(3));
|
||||
ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN
|
||||
IF EXISTS (SELECT 1 FROM fl_loading_plan_item item
|
||||
LEFT JOIN fl_loading_pick_item pick_item
|
||||
ON pick_item.loading_plan_item_id = item.id
|
||||
LEFT JOIN fl_loading_pick_task pick_task
|
||||
ON pick_task.id = pick_item.pick_task_id
|
||||
AND pick_task.loading_plan_id = OLD.id
|
||||
WHERE item.loading_plan_id = OLD.id AND item.deleted = 0
|
||||
AND (pick_task.id IS NULL
|
||||
OR pick_item.picked_package_count <> pick_item.planned_package_count)) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仍有未完成拣货明细,不能完成装柜';
|
||||
END IF;
|
||||
SET NEW.confirmed_at = COALESCE(NEW.confirmed_at, CURRENT_TIMESTAMP(3));
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_plan_after_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_plan_after_update
|
||||
AFTER UPDATE ON fl_loading_plan
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN
|
||||
UPDATE fl_loading_plan_item
|
||||
SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3)),
|
||||
frozen_by = COALESCE(frozen_by, NEW.updated_by),
|
||||
updated_by = NEW.updated_by,
|
||||
updated_by_name = NEW.updated_by_name,
|
||||
version = version + 1
|
||||
WHERE loading_plan_id = NEW.id AND deleted = 0;
|
||||
UPDATE fl_loading_pick_task
|
||||
SET task_status = 'COMPLETED', completed_by = NEW.updated_by,
|
||||
completed_by_name = NEW.updated_by_name, completed_at = CURRENT_TIMESTAMP(3),
|
||||
version = version + 1
|
||||
WHERE loading_plan_id = NEW.id AND task_status = 'LOADING';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_item_before_insert;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_item_before_insert
|
||||
BEFORE INSERT ON fl_loading_plan_item
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
DECLARE current_plan_status VARCHAR(24);
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE id = NEW.loading_plan_id;
|
||||
IF current_plan_status IS NULL OR current_plan_status NOT IN ('EDITING', 'LOADING') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仅编辑中或装柜中的计划允许新增明细';
|
||||
END IF;
|
||||
IF NEW.deleted <> 0 OR NEW.frozen_at IS NOT NULL OR NEW.frozen_by IS NOT NULL THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '新增装柜明细必须为未删除且未冻结状态';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_item_before_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_item_before_update
|
||||
BEFORE UPDATE ON fl_loading_plan_item
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
DECLARE current_plan_status VARCHAR(24);
|
||||
SELECT MAX(plan_status) INTO current_plan_status
|
||||
FROM fl_loading_plan WHERE id = OLD.loading_plan_id;
|
||||
IF current_plan_status = 'LOADING' AND EXISTS (
|
||||
SELECT 1 FROM fl_loading_pick_item
|
||||
WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细不可修改或删除';
|
||||
END IF;
|
||||
IF OLD.frozen_at IS NOT NULL THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结';
|
||||
END IF;
|
||||
IF OLD.deleted = 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已删除装柜明细不可恢复或修改';
|
||||
END IF;
|
||||
IF NEW.loading_plan_id <> OLD.loading_plan_id OR NEW.account_id <> OLD.account_id
|
||||
OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜明细归属键不可修改,请删除后重新分配';
|
||||
END IF;
|
||||
IF NEW.deleted = 1 THEN
|
||||
IF NEW.removed_by IS NULL THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除装柜明细必须记录操作人';
|
||||
END IF;
|
||||
SET NEW.removed_at = COALESCE(NEW.removed_at, CURRENT_TIMESTAMP(3));
|
||||
ELSEIF NEW.removed_by IS NOT NULL OR NEW.removed_by_name IS NOT NULL OR NEW.removed_at IS NOT NULL THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除审计字段只能在逻辑删除时写入';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_pick_task_before_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_pick_task_before_update
|
||||
BEFORE UPDATE ON fl_loading_pick_task
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
IF OLD.task_status = 'COMPLETED' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成PDA拣货任务不可修改';
|
||||
END IF;
|
||||
IF NEW.loading_plan_id <> OLD.loading_plan_id OR NOT (NEW.container_no <=> OLD.container_no) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务归属不可修改';
|
||||
END IF;
|
||||
IF NOT (OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING', 'COMPLETED')) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态只允许完成';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_pick_item_before_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_pick_item_before_update
|
||||
BEFORE UPDATE ON fl_loading_pick_item
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
DECLARE current_task_status VARCHAR(16);
|
||||
SELECT MAX(task_status) INTO current_task_status
|
||||
FROM fl_loading_pick_task WHERE id = OLD.pick_task_id;
|
||||
IF current_task_status = 'COMPLETED' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后PDA拣货明细不可修改';
|
||||
END IF;
|
||||
IF NEW.pick_task_id <> OLD.pick_task_id
|
||||
OR NEW.loading_plan_item_id <> OLD.loading_plan_item_id
|
||||
OR NEW.inbound_id <> OLD.inbound_id
|
||||
OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货明细归属不可修改';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_pick_item_before_delete;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_pick_item_before_delete
|
||||
BEFORE DELETE ON fl_loading_pick_item
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
DECLARE current_task_status VARCHAR(16);
|
||||
SELECT MAX(task_status) INTO current_task_status
|
||||
FROM fl_loading_pick_task WHERE id = OLD.pick_task_id;
|
||||
IF current_task_status = 'COMPLETED' OR OLD.picked_package_count > 0 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已拣货或已完成的PDA明细不可删除';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_pick_history_before_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_pick_history_before_update
|
||||
BEFORE UPDATE ON fl_loading_pick_history
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货历史不可修改';
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_pick_history_before_delete;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_pick_history_before_delete
|
||||
BEFORE DELETE ON fl_loading_pick_history
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货历史不可删除';
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_item_before_delete;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_item_before_delete
|
||||
BEFORE DELETE ON fl_loading_plan_item
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜明细只允许推送前逻辑删除';
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_container_document_before_insert;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_container_document_before_insert
|
||||
BEFORE INSERT ON fl_container_document
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
DECLARE current_plan_status VARCHAR(24);
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
|
||||
WHERE container_booking_id = NEW.container_booking_id;
|
||||
IF current_plan_status = 'COMPLETED' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_container_document_before_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_container_document_before_update
|
||||
BEFORE UPDATE ON fl_container_document
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
DECLARE current_plan_status VARCHAR(24);
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
|
||||
WHERE container_booking_id = OLD.container_booking_id;
|
||||
IF current_plan_status = 'COMPLETED' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_container_document_before_delete;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_container_document_before_delete
|
||||
BEFORE DELETE ON fl_container_document
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
DECLARE current_plan_status VARCHAR(24);
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
|
||||
WHERE container_booking_id = OLD.container_booking_id;
|
||||
IF current_plan_status = 'COMPLETED' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可删除';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_history_before_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_history_before_update
|
||||
BEFORE UPDATE ON fl_loading_plan_history
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜操作历史不可修改';
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_history_before_delete;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_history_before_delete
|
||||
BEFORE DELETE ON fl_loading_plan_history
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜操作历史不可删除';
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
-- 三期智能装柜、预装清单和装柜清单平台权限;与一期平台根菜单幂等合并。
|
||||
USE baiyue_iam;
|
||||
INSERT INTO iam_permission
|
||||
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
|
||||
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
|
||||
VALUES
|
||||
(5102090, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:container:list',
|
||||
'货柜', '/first-leg/containers', 'GET', 40, 'ACTIVE', 1, 0),
|
||||
(5102091, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:view',
|
||||
'查看货柜', NULL, 'GET', 10, 'ACTIVE', 1, 0),
|
||||
(5102092, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:create',
|
||||
'建立货柜', NULL, 'POST', 20, 'ACTIVE', 1, 0),
|
||||
(5102093, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:edit',
|
||||
'编辑货柜', NULL, 'PUT', 30, 'ACTIVE', 1, 0),
|
||||
(5102094, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:smart-loading:list',
|
||||
'智能装柜', '/first-leg/smart-loading', 'GET', 50, 'ACTIVE', 1, 0),
|
||||
(5102096, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:create',
|
||||
'创建智能装柜计划', NULL, 'POST', 20, 'ACTIVE', 1, 0),
|
||||
(5102097, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:assign',
|
||||
'分配装柜明细', NULL, 'POST', 30, 'ACTIVE', 1, 0),
|
||||
(5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete',
|
||||
'删除装柜明细', NULL, 'DELETE', 40, 'ACTIVE', 1, 0),
|
||||
(5102099, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:push',
|
||||
'推送预装', NULL, 'POST', 50, 'ACTIVE', 1, 0),
|
||||
(5102100, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:preload:list',
|
||||
'预装清单', '/first-leg/preload-list', 'GET', 60, 'ACTIVE', 1, 0),
|
||||
(5102102, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:view',
|
||||
'查看预装详情', NULL, 'GET', 20, 'ACTIVE', 1, 0),
|
||||
(5102103, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:edit',
|
||||
'编辑预装运输字段', NULL, 'PUT', 30, 'ACTIVE', 1, 0),
|
||||
(5102104, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:complete',
|
||||
'完成预装', NULL, 'POST', 40, 'ACTIVE', 1, 0),
|
||||
(5102105, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:confirm',
|
||||
'确认预装', NULL, 'POST', 50, 'ACTIVE', 1, 0),
|
||||
(5102106, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:loading-list:list',
|
||||
'装柜清单', '/first-leg/loading-list', 'GET', 70, 'ACTIVE', 1, 0),
|
||||
(5102108, 0, 'PLATFORM', 'BUTTON', 5102106, 'platform:firstleg:loading-list:view',
|
||||
'查看装柜详情', NULL, 'GET', 20, 'ACTIVE', 1, 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
|
||||
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
|
||||
http_method = VALUES(http_method), sort_no = VALUES(sort_no), status = 'ACTIVE',
|
||||
built_in = 1, deleted = 0;
|
||||
|
||||
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||
SELECT role.id, permission.id, role.tenant_id
|
||||
FROM iam_role role
|
||||
JOIN iam_permission permission
|
||||
ON permission.tenant_id = 0
|
||||
AND permission.permission_code IN (
|
||||
'platform:firstleg:container:list', 'platform:firstleg:container:view',
|
||||
'platform:firstleg:container:create',
|
||||
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
|
||||
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign',
|
||||
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
|
||||
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
|
||||
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
|
||||
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
|
||||
'platform:firstleg:loading-list:view')
|
||||
WHERE role.tenant_id = 0
|
||||
AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR');
|
||||
|
||||
USE baiyue_firstleg;
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_phase3_smart_loading_schema()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name IN ('fl_container_booking', 'fl_loading_plan', 'fl_loading_plan_item',
|
||||
'fl_container_document', 'fl_loading_plan_history',
|
||||
'fl_loading_pick_task', 'fl_loading_pick_item',
|
||||
'fl_loading_pick_history')) <> 8 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:核心表未完整创建';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM fl_loading_plan
|
||||
WHERE plan_status NOT IN ('EDITING', 'LOADING', 'COMPLETED')) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:存在旧装柜状态';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.check_constraints
|
||||
WHERE constraint_schema = 'baiyue_firstleg'
|
||||
AND constraint_name = 'chk_loading_plan_status'
|
||||
AND check_clause LIKE '%''EDITING''%'
|
||||
AND check_clause LIKE '%''LOADING''%'
|
||||
AND check_clause LIKE '%''COMPLETED''%') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:装柜状态约束不是最终三状态';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_container_booking'
|
||||
AND column_name IN ('booking_no', 'cabinet_no', 'warehouse_id', 'bill_of_lading_no',
|
||||
'vessel_name', 'voyage_no', 'container_type', 'capacity_cbm',
|
||||
'max_weight_kg', 'terminal_trucking_company')) <> 10 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:货柜原型字段缺失';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item'
|
||||
AND column_name IN ('account_id', 'customer_name', 'active_inbound_detail_id', 'frozen_at',
|
||||
'destination_warehouse_name', 'product_name_zh', 'product_name_en',
|
||||
'product_image_file_id', 'pallet_count', 'net_weight_kg',
|
||||
'dimensional_weight_kg', 'product_quantity', 'units_per_carton',
|
||||
'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 17 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item'
|
||||
AND index_name = 'uk_loading_item_active_detail' AND non_unique = 0) <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:唯一活动明细锁缺失';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name = 'fl_loading_pick_item'
|
||||
AND column_name IN ('pick_task_id', 'loading_plan_item_id', 'inbound_id',
|
||||
'inbound_detail_id', 'planned_package_count',
|
||||
'picked_package_count', 'item_status', 'version')) <> 8 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA拣货明细字段缺失';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg' AND non_unique = 0
|
||||
AND index_name IN ('uk_loading_pick_task_plan',
|
||||
'uk_loading_pick_item_plan_item',
|
||||
'uk_loading_pick_history_operation')) <> 3 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA唯一索引缺失';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.triggers
|
||||
WHERE trigger_schema = 'baiyue_firstleg'
|
||||
AND trigger_name IN ('trg_container_booking_before_update',
|
||||
'trg_loading_plan_before_update', 'trg_loading_plan_after_update',
|
||||
'trg_loading_item_before_insert', 'trg_loading_item_before_update',
|
||||
'trg_loading_item_before_delete', 'trg_container_document_before_insert',
|
||||
'trg_container_document_before_update', 'trg_container_document_before_delete',
|
||||
'trg_loading_history_before_update', 'trg_loading_history_before_delete',
|
||||
'trg_loading_pick_task_before_update',
|
||||
'trg_loading_pick_item_before_update',
|
||||
'trg_loading_pick_item_before_delete',
|
||||
'trg_loading_pick_history_before_update',
|
||||
'trg_loading_pick_history_before_delete')) <> 16 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:不可逆状态或冻结触发器缺失';
|
||||
END IF;
|
||||
IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission
|
||||
WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0
|
||||
AND permission_code IN (
|
||||
'platform:firstleg:container:list', 'platform:firstleg:container:view',
|
||||
'platform:firstleg:container:create', 'platform:firstleg:container:edit',
|
||||
'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:create',
|
||||
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
|
||||
'platform:firstleg:smart-loading:push', 'platform:firstleg:preload:list',
|
||||
'platform:firstleg:preload:view', 'platform:firstleg:preload:edit',
|
||||
'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm',
|
||||
'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 16 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code))
|
||||
FROM baiyue_iam.iam_role role
|
||||
JOIN baiyue_iam.iam_role_permission grant_row ON grant_row.role_id = role.id
|
||||
JOIN baiyue_iam.iam_permission permission ON permission.id = grant_row.permission_id
|
||||
WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')
|
||||
AND permission.tenant_id = 0
|
||||
AND permission.permission_code IN (
|
||||
'platform:firstleg:container:list', 'platform:firstleg:container:view',
|
||||
'platform:firstleg:container:create',
|
||||
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
|
||||
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign',
|
||||
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
|
||||
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
|
||||
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
|
||||
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
|
||||
'platform:firstleg:loading-list:view')) <> 32 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_phase3_smart_loading_schema();
|
||||
DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema;
|
||||
@@ -67,6 +67,90 @@ paths:
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||
'403': {description: 无分配权限}
|
||||
/api/platform/v1/containers:
|
||||
get:
|
||||
summary: 分页查询平台货柜
|
||||
description: 需要 platform:firstleg:container:list 权限;货柜为平台级数据,不传 tenant/account
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/Page'}
|
||||
- {$ref: '#/components/parameters/Size'}
|
||||
- {name: keyword, in: query, schema: {type: string, maxLength: 128}}
|
||||
- {name: containerNo, in: query, schema: {type: string, maxLength: 64}}
|
||||
- {name: cabinetNo, in: query, schema: {type: string, maxLength: 64}}
|
||||
- {name: warehouseId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: status, in: query, schema: {type: string, enum: [EDITING, LOADING, COMPLETED]}}
|
||||
responses:
|
||||
'200':
|
||||
description: 成功或业务失败,按响应体 code 判断
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ContainerBookingPageEnvelope'}
|
||||
'403': {description: 无货柜查询权限}
|
||||
post:
|
||||
summary: 新增平台货柜
|
||||
description: 需要 platform:firstleg:container:create 权限;新增后聚合状态为 EDITING
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ContainerSaveRequest'}
|
||||
responses:
|
||||
'200': {description: 成功或重复号、参数校验等业务失败}
|
||||
'403': {description: 无新增按钮权限}
|
||||
/api/platform/v1/containers/{id}:
|
||||
get:
|
||||
summary: 查询平台货柜详情
|
||||
description: 需要 platform:firstleg:container:view 权限
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/ContainerBookingId'}
|
||||
responses:
|
||||
'200': {description: 成功或货柜不存在}
|
||||
'403': {description: 无货柜查询权限}
|
||||
put:
|
||||
summary: 草稿阶段完整编辑货柜
|
||||
description: 需要 platform:firstleg:container:edit 权限;仅无装柜计划或计划为 EDITING 时允许
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/ContainerBookingId'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ContainerFullUpdateRequest'}
|
||||
responses:
|
||||
'200': {description: 成功或状态、版本、重复号冲突}
|
||||
'403': {description: 无货柜编辑权限}
|
||||
/api/platform/v1/containers/{id}/transport:
|
||||
patch:
|
||||
summary: 修改集装箱号和航次
|
||||
description: 需要 platform:firstleg:container:edit 权限;LOADING 仅开放此白名单,COMPLETED 全部只读
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/ContainerBookingId'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ContainerTransportUpdateRequest'}
|
||||
responses:
|
||||
'200': {description: 成功或状态、版本、重复号冲突}
|
||||
'403': {description: 无货柜编辑权限}
|
||||
/api/platform/v1/first-leg/smart-loading/plans/by-booking/{bookingId}:
|
||||
get:
|
||||
summary: 按货柜恢复智能装柜草稿
|
||||
description: 需要 platform:firstleg:smart-loading:list 权限;仅返回 EDITING 草稿
|
||||
parameters:
|
||||
- {name: bookingId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
responses:
|
||||
'200': {description: 成功、草稿不存在或计划已进入预装流程}
|
||||
'403': {description: 无智能装柜查询权限}
|
||||
/api/platform/v1/first-leg/smart-loading/plans/{planId}:
|
||||
get:
|
||||
summary: 查询智能装柜草稿详情
|
||||
description: 需要 platform:firstleg:smart-loading:list 权限;返回草稿及当前未删除明细
|
||||
parameters:
|
||||
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
responses:
|
||||
'200': {description: 成功、计划不存在或计划已进入预装流程}
|
||||
'403': {description: 无智能装柜查询权限}
|
||||
/openapi/v1/inbound-measurements:
|
||||
post:
|
||||
summary: 提交一条第三方箱规测量结果和一张图片
|
||||
@@ -258,7 +342,103 @@ components:
|
||||
name: size
|
||||
in: query
|
||||
schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}
|
||||
ContainerBookingId:
|
||||
name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
schemas:
|
||||
ContainerBookingPageEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string, example: '200'}
|
||||
message: {type: string}
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/ContainerBooking'}}
|
||||
page: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
size: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
total: {type: string, pattern: '^[0-9]+$'}
|
||||
pages: {type: string, pattern: '^[0-9]+$'}
|
||||
ContainerBooking:
|
||||
type: object
|
||||
required: [id, bookingNo, cabinetNo, warehouseId, warehouseName, containerType, planStatus, version]
|
||||
properties:
|
||||
id: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
|
||||
bookingNo: {type: string, maxLength: 40}
|
||||
containerNo: {type: string, maxLength: 64, nullable: true}
|
||||
cabinetNo: {type: string, maxLength: 64}
|
||||
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
warehouseName: {type: string, maxLength: 128}
|
||||
billOfLadingNo: {type: string, maxLength: 128, nullable: true}
|
||||
vesselName: {type: string, maxLength: 128, nullable: true}
|
||||
voyageNo: {type: string, maxLength: 64, nullable: true}
|
||||
containerType: {type: string, maxLength: 32}
|
||||
shippingCompanyId: {type: string, nullable: true}
|
||||
shippingCompanyName: {type: string, maxLength: 128, nullable: true}
|
||||
departurePortCode: {type: string, maxLength: 32, nullable: true}
|
||||
departurePortName: {type: string, maxLength: 128, nullable: true}
|
||||
destinationPortCode: {type: string, maxLength: 32, nullable: true}
|
||||
destinationPortName: {type: string, maxLength: 128, nullable: true}
|
||||
destinationWarehouseCode: {type: string, maxLength: 64, nullable: true}
|
||||
destinationWarehouseName: {type: string, maxLength: 128, nullable: true}
|
||||
capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
|
||||
maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
|
||||
terminalTruckingCompany: {type: string, maxLength: 128, nullable: true}
|
||||
loadingAt: {type: string, format: date-time, nullable: true}
|
||||
estimatedSailingAt: {type: string, format: date-time, nullable: true}
|
||||
actualSailingAt: {type: string, format: date-time, nullable: true}
|
||||
estimatedPortArrivalAt: {type: string, format: date-time, nullable: true}
|
||||
actualPortArrivalAt: {type: string, format: date-time, nullable: true}
|
||||
estimatedWarehouseArrivalAt: {type: string, format: date-time, nullable: true}
|
||||
actualWarehouseArrivalAt: {type: string, format: date-time, nullable: true}
|
||||
planStatus: {type: string, enum: [EDITING, LOADING, COMPLETED]}
|
||||
version: {type: integer, minimum: 0}
|
||||
ContainerSaveRequest:
|
||||
type: object
|
||||
required: [cabinetNo, warehouseId, warehouseName, containerType]
|
||||
properties:
|
||||
containerNo: {type: string, maxLength: 64, nullable: true}
|
||||
cabinetNo: {type: string, maxLength: 64}
|
||||
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
warehouseName: {type: string, maxLength: 128}
|
||||
billOfLadingNo: {type: string, maxLength: 128, nullable: true}
|
||||
vesselName: {type: string, maxLength: 128, nullable: true}
|
||||
voyageNo: {type: string, maxLength: 64, nullable: true}
|
||||
containerType: {type: string, maxLength: 32}
|
||||
shippingCompanyId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
shippingCompanyName: {type: string, maxLength: 128, nullable: true}
|
||||
departurePortCode: {type: string, maxLength: 32, nullable: true}
|
||||
departurePortName: {type: string, maxLength: 128, nullable: true}
|
||||
destinationPortCode: {type: string, maxLength: 32, nullable: true}
|
||||
destinationPortName: {type: string, maxLength: 128, nullable: true}
|
||||
destinationWarehouseCode: {type: string, maxLength: 64, nullable: true}
|
||||
destinationWarehouseName: {type: string, maxLength: 128, nullable: true}
|
||||
capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
|
||||
maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
|
||||
terminalTruckingCompany: {type: string, maxLength: 128, nullable: true}
|
||||
loadingAt: {type: string, format: date-time, nullable: true}
|
||||
estimatedSailingAt: {type: string, format: date-time, nullable: true}
|
||||
actualSailingAt: {type: string, format: date-time, nullable: true}
|
||||
estimatedPortArrivalAt: {type: string, format: date-time, nullable: true}
|
||||
actualPortArrivalAt: {type: string, format: date-time, nullable: true}
|
||||
estimatedWarehouseArrivalAt: {type: string, format: date-time, nullable: true}
|
||||
actualWarehouseArrivalAt: {type: string, format: date-time, nullable: true}
|
||||
ContainerFullUpdateRequest:
|
||||
type: object
|
||||
required: [version, data]
|
||||
properties:
|
||||
version: {type: integer, minimum: 0}
|
||||
data: {$ref: '#/components/schemas/ContainerSaveRequest'}
|
||||
ContainerTransportUpdateRequest:
|
||||
type: object
|
||||
required: [version]
|
||||
properties:
|
||||
containerNo: {type: string, maxLength: 64, nullable: true}
|
||||
voyageNo: {type: string, maxLength: 64, nullable: true}
|
||||
version: {type: integer, minimum: 0}
|
||||
MerchantInboundBillingEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 头程装柜与完成装柜清单接口
|
||||
version: 1.0.0
|
||||
description: 装柜计划仅允许 EDITING 到 LOADING 再到 COMPLETED 单向推进;EDITING 和 LOADING 均可维护未拣货明细,COMPLETED 后计划、明细与货柜运输信息永久只读,不提供回退或撤回接口。
|
||||
paths:
|
||||
/api/platform/v1/first-leg/loading-plans:
|
||||
get:
|
||||
summary: 分页查询预装清单
|
||||
description: 仅返回预装柜中或预装柜完成的计划,计划为平台作业数据,客户账号仅作为明细筛选条件。
|
||||
x-permission: platform:firstleg:preload:list
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/Page'}
|
||||
- {$ref: '#/components/parameters/Size'}
|
||||
- {$ref: '#/components/parameters/Keyword'}
|
||||
- name: status
|
||||
in: query
|
||||
description: 预装状态
|
||||
schema: {type: string, enum: [LOADING]}
|
||||
- {$ref: '#/components/parameters/AccountId'}
|
||||
- {$ref: '#/components/parameters/WarehouseId'}
|
||||
- {$ref: '#/components/parameters/CreatedFrom'}
|
||||
- {$ref: '#/components/parameters/CreatedTo'}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/PreloadPageResponse'}
|
||||
/api/platform/v1/first-leg/loading-plans/{planId}:
|
||||
get:
|
||||
summary: 查询预装清单详情
|
||||
description: 返回同一装柜计划的汇总、冻结明细和完整操作历史。
|
||||
x-permission: platform:firstleg:preload:view
|
||||
parameters: [{$ref: '#/components/parameters/PlanId'}]
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/PreloadDetailResponse'}
|
||||
/api/platform/v1/first-leg/loading-plans/{planId}/transport:
|
||||
patch:
|
||||
summary: 修改预装运输信息
|
||||
description: 仅 LOADING 状态允许修改集装箱号与航次;装柜中可以维护未拣货明细。
|
||||
x-permission: platform:firstleg:preload:edit
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/PlanId'}
|
||||
- {$ref: '#/components/parameters/IdempotencyKey'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/TransportUpdateRequest'}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/PreloadDetailResponse'}
|
||||
/api/platform/v1/first-leg/loading-plans/{planId}/complete:
|
||||
post:
|
||||
summary: 完成预装
|
||||
description: 仅允许将 LOADING 单向推进为 COMPLETED;完成前必须完成全部 PDA 拣货。
|
||||
x-permission: platform:firstleg:preload:complete
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/PlanId'}
|
||||
- {$ref: '#/components/parameters/IdempotencyKey'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/AdvanceRequest'}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/PreloadDetailResponse'}
|
||||
/api/platform/v1/first-leg/loading-lists:
|
||||
get:
|
||||
summary: 分页查询装柜清单
|
||||
description: 读取同一个装柜计划聚合,仅返回 COMPLETED 状态记录。
|
||||
x-permission: platform:firstleg:loading-list:list
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/Page'}
|
||||
- {$ref: '#/components/parameters/Size'}
|
||||
- {$ref: '#/components/parameters/Keyword'}
|
||||
- {$ref: '#/components/parameters/AccountId'}
|
||||
- {$ref: '#/components/parameters/WarehouseId'}
|
||||
- {$ref: '#/components/parameters/CreatedFrom'}
|
||||
- {$ref: '#/components/parameters/CreatedTo'}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/ConfirmedPageResponse'}
|
||||
/api/platform/v1/first-leg/loading-lists/{planId}:
|
||||
get:
|
||||
summary: 查询装柜清单详情
|
||||
description: 返回已确认计划的汇总、冻结明细和操作历史,全部只读。
|
||||
x-permission: platform:firstleg:loading-list:view
|
||||
parameters: [{$ref: '#/components/parameters/PlanId'}]
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/ConfirmedDetailResponse'}
|
||||
components:
|
||||
parameters:
|
||||
PlanId:
|
||||
name: planId
|
||||
in: path
|
||||
required: true
|
||||
description: 装柜计划 ID
|
||||
schema: {$ref: '#/components/schemas/LongId'}
|
||||
IdempotencyKey:
|
||||
name: Idempotency-Key
|
||||
in: header
|
||||
required: true
|
||||
description: 写操作幂等键,同一业务操作重试时必须保持不变
|
||||
schema: {type: string, minLength: 1, maxLength: 128}
|
||||
Page:
|
||||
name: page
|
||||
in: query
|
||||
description: 页码,从 1 开始
|
||||
schema: {type: integer, format: int64, minimum: 1, default: 1}
|
||||
Size:
|
||||
name: size
|
||||
in: query
|
||||
description: 每页条数
|
||||
schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}
|
||||
Keyword:
|
||||
name: keyword
|
||||
in: query
|
||||
description: 按预装单号、集装箱号或柜号模糊查询
|
||||
schema: {type: string, maxLength: 128}
|
||||
AccountId:
|
||||
name: accountId
|
||||
in: query
|
||||
description: 客户账号 ID 筛选条件,计划本身仍属于平台作业数据
|
||||
schema: {$ref: '#/components/schemas/LongId'}
|
||||
WarehouseId:
|
||||
name: warehouseId
|
||||
in: query
|
||||
description: 仓库 ID
|
||||
schema: {$ref: '#/components/schemas/LongId'}
|
||||
CreatedFrom:
|
||||
name: createdFrom
|
||||
in: query
|
||||
description: 创建时间起点,包含该时间
|
||||
schema: {type: string, format: date-time}
|
||||
CreatedTo:
|
||||
name: createdTo
|
||||
in: query
|
||||
description: 创建时间终点,包含该时间
|
||||
schema: {type: string, format: date-time}
|
||||
responses:
|
||||
PreloadPageResponse:
|
||||
description: 预装清单分页查询成功或业务失败,按响应体 code 判断
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/PreloadPageEnvelope'}}}
|
||||
PreloadDetailResponse:
|
||||
description: 预装清单详情查询或操作成功,也可能返回业务失败
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/PreloadDetailEnvelope'}}}
|
||||
ConfirmedPageResponse:
|
||||
description: 装柜清单分页查询成功或业务失败,按响应体 code 判断
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmedPageEnvelope'}}}
|
||||
ConfirmedDetailResponse:
|
||||
description: 装柜清单详情查询或确认成功,也可能返回业务失败
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmedDetailEnvelope'}}}
|
||||
schemas:
|
||||
LongId:
|
||||
type: string
|
||||
pattern: '^[1-9][0-9]*$'
|
||||
example: '1900000000000000001'
|
||||
description: 长整型 ID,JSON 中使用字符串避免前端精度丢失
|
||||
NullableLongId:
|
||||
allOf: [{$ref: '#/components/schemas/LongId'}]
|
||||
nullable: true
|
||||
ApiEnvelope:
|
||||
type: object
|
||||
required: [code, message, timestamp]
|
||||
properties:
|
||||
code: {type: string, description: 业务结果码,仅字符串 200 表示成功, example: '200'}
|
||||
message: {type: string, description: 结果说明}
|
||||
requestId: {type: string, nullable: true, description: 请求标识}
|
||||
traceId: {type: string, nullable: true, description: 链路标识}
|
||||
timestamp: {type: string, format: date-time, description: 服务端响应时间}
|
||||
PreloadPageEnvelope:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiEnvelope'}
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
allOf: [{$ref: '#/components/schemas/PreloadPage'}]
|
||||
nullable: true
|
||||
PreloadDetailEnvelope:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiEnvelope'}
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
allOf: [{$ref: '#/components/schemas/PreloadDetail'}]
|
||||
nullable: true
|
||||
ConfirmedPageEnvelope:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiEnvelope'}
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
allOf: [{$ref: '#/components/schemas/ConfirmedPage'}]
|
||||
nullable: true
|
||||
ConfirmedDetailEnvelope:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiEnvelope'}
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
allOf: [{$ref: '#/components/schemas/ConfirmedDetail'}]
|
||||
nullable: true
|
||||
PreloadPage:
|
||||
type: object
|
||||
required: [records, page, size, total, pages]
|
||||
properties:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/PreloadSummary'}}
|
||||
page: {type: string, pattern: '^[1-9][0-9]*$', description: 当前页码}
|
||||
size: {type: string, pattern: '^[1-9][0-9]*$', description: 每页条数}
|
||||
total: {type: string, pattern: '^[0-9]+$', description: 总记录数}
|
||||
pages: {type: string, pattern: '^[0-9]+$', description: 总页数}
|
||||
ConfirmedPage:
|
||||
type: object
|
||||
required: [records, page, size, total, pages]
|
||||
properties:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/ConfirmedSummary'}}
|
||||
page: {type: string, pattern: '^[1-9][0-9]*$', description: 当前页码}
|
||||
size: {type: string, pattern: '^[1-9][0-9]*$', description: 每页条数}
|
||||
total: {type: string, pattern: '^[0-9]+$', description: 总记录数}
|
||||
pages: {type: string, pattern: '^[0-9]+$', description: 总页数}
|
||||
Customer:
|
||||
type: object
|
||||
required: [accountId, customerName]
|
||||
properties:
|
||||
accountId: {$ref: '#/components/schemas/LongId'}
|
||||
customerName: {type: string, nullable: true, description: 客户名称}
|
||||
PreloadSummary:
|
||||
type: object
|
||||
required: [planId, preloadNo, customers, status, skuCount, totalPackageCount, totalVolumeCbm, totalGrossWeightKg, createdAt, version]
|
||||
properties:
|
||||
planId: {$ref: '#/components/schemas/LongId'}
|
||||
preloadNo: {type: string, description: 预装单号}
|
||||
containerNo: {type: string, nullable: true, description: 集装箱号}
|
||||
internalContainerNo: {type: string, nullable: true, description: 内部柜号}
|
||||
customers: {type: array, description: 计划包含的客户, items: {$ref: '#/components/schemas/Customer'}}
|
||||
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
|
||||
warehouseName: {type: string, nullable: true, description: 仓库名称}
|
||||
vesselName: {type: string, nullable: true, description: 船名}
|
||||
voyageNo: {type: string, nullable: true, description: 航次}
|
||||
containerType: {type: string, nullable: true, description: 货柜类型}
|
||||
status: {type: string, enum: [LOADING], description: 装柜状态}
|
||||
skuCount: {type: string, pattern: '^[0-9]+$', description: SKU 种类数}
|
||||
totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数}
|
||||
totalVolumeCbm: {type: number, description: 总体积,单位立方米}
|
||||
totalGrossWeightKg: {type: number, description: 总毛重,单位千克}
|
||||
createdAt: {type: string, format: date-time, description: 创建时间}
|
||||
version: {type: integer, format: int32, minimum: 0, description: 乐观锁版本}
|
||||
ConfirmedSummary:
|
||||
type: object
|
||||
required: [planId, loadingListNo, status, totalPackageCount, totalGrossWeightKg, totalNetWeightKg, totalVolumeCbm, totalQuantity, createdAt, version]
|
||||
properties:
|
||||
planId: {$ref: '#/components/schemas/LongId'}
|
||||
loadingListNo: {type: string, description: 装柜清单号}
|
||||
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
|
||||
warehouseName: {type: string, nullable: true, description: 仓库名称}
|
||||
containerNo: {type: string, nullable: true, description: 集装箱号}
|
||||
internalContainerNo: {type: string, nullable: true, description: 内部柜号}
|
||||
status: {type: string, enum: [COMPLETED], description: 装柜清单状态}
|
||||
totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数}
|
||||
totalGrossWeightKg: {type: number, description: 总毛重,单位千克}
|
||||
totalNetWeightKg: {type: number, description: 总净重,单位千克}
|
||||
totalVolumeCbm: {type: number, description: 总体积,单位立方米}
|
||||
totalQuantity: {type: string, pattern: '^[0-9]+$', description: 产品总数量}
|
||||
remark: {type: string, nullable: true, description: 备注}
|
||||
createdBy: {$ref: '#/components/schemas/NullableLongId'}
|
||||
createdByName: {type: string, nullable: true, description: 创建人姓名}
|
||||
createdAt: {type: string, format: date-time, description: 创建时间}
|
||||
version: {type: integer, format: int32, minimum: 0, description: 乐观锁版本}
|
||||
LoadingItem:
|
||||
type: object
|
||||
required: [itemId, accountId, inboundId, inboundDetailId, packageCount, palletCount, grossWeightKg, netWeightKg, volumeCbm, dimensionalWeightKg, productQuantity, unitsPerPackage, loadSequence]
|
||||
properties:
|
||||
itemId: {$ref: '#/components/schemas/LongId'}
|
||||
accountId: {$ref: '#/components/schemas/LongId'}
|
||||
customerName: {type: string, nullable: true, description: 客户名称快照}
|
||||
inboundId: {$ref: '#/components/schemas/LongId'}
|
||||
inboundDetailId: {$ref: '#/components/schemas/LongId'}
|
||||
inboundCode: {type: string, nullable: true, description: 入仓码}
|
||||
skuCode: {type: string, nullable: true, description: SKU 编码快照}
|
||||
destinationWarehouse: {type: string, nullable: true, description: 目的仓名称快照}
|
||||
chineseName: {type: string, nullable: true, description: 中文品名快照}
|
||||
englishName: {type: string, nullable: true, description: 英文品名快照}
|
||||
goodsImageUrl: {type: string, nullable: true, description: 产品图片地址快照}
|
||||
packageCount: {type: integer, format: int32, minimum: 0, description: 箱数}
|
||||
palletCount: {type: integer, format: int32, minimum: 0, description: 托盘数}
|
||||
grossWeightKg: {type: number, description: 毛重,单位千克}
|
||||
netWeightKg: {type: number, description: 净重,单位千克}
|
||||
volumeCbm: {type: number, description: 体积,单位立方米}
|
||||
dimensionalWeightKg: {type: number, description: 材积重,单位千克}
|
||||
productQuantity: {type: string, pattern: '^[0-9]+$', description: 产品数量}
|
||||
unitsPerPackage: {type: integer, format: int32, minimum: 0, description: 每箱数量}
|
||||
fbaTrackingNo: {type: string, nullable: true, description: FBA 跟踪编号}
|
||||
shipmentNo: {type: string, nullable: true, description: 货件号}
|
||||
shippingMark: {type: string, nullable: true, description: 唛头}
|
||||
internalRemark: {type: string, nullable: true, description: 内部备注}
|
||||
customerRemark: {type: string, nullable: true, description: 客户备注}
|
||||
warehouseRemark: {type: string, nullable: true, description: 仓库备注}
|
||||
loadSequence: {type: integer, format: int32, minimum: 0, description: 装柜顺序}
|
||||
frozenAt: {type: string, format: date-time, nullable: true, description: 明细冻结时间}
|
||||
OperationHistory:
|
||||
type: object
|
||||
required: [historyId, actionType, operatorId, occurredAt]
|
||||
properties:
|
||||
historyId: {$ref: '#/components/schemas/LongId'}
|
||||
itemId: {$ref: '#/components/schemas/NullableLongId'}
|
||||
actionType: {type: string, description: 操作类型}
|
||||
fromStatus: {type: string, nullable: true, description: 操作前状态}
|
||||
toStatus: {type: string, nullable: true, description: 操作后状态}
|
||||
beforeSnapshot: {type: string, nullable: true, description: 操作前快照}
|
||||
afterSnapshot: {type: string, nullable: true, description: 操作后快照}
|
||||
operatorId: {$ref: '#/components/schemas/LongId'}
|
||||
operatorName: {type: string, nullable: true, description: 操作人姓名}
|
||||
operationReason: {type: string, nullable: true, description: 操作原因}
|
||||
occurredAt: {type: string, format: date-time, description: 操作时间}
|
||||
PreloadDetail:
|
||||
type: object
|
||||
required: [summary, items, histories]
|
||||
properties:
|
||||
summary: {$ref: '#/components/schemas/PreloadSummary'}
|
||||
items: {type: array, description: 装柜中明细,可维护未拣货项, items: {$ref: '#/components/schemas/LoadingItem'}}
|
||||
histories: {type: array, description: 计划操作历史, items: {$ref: '#/components/schemas/OperationHistory'}}
|
||||
ConfirmedDetail:
|
||||
type: object
|
||||
required: [summary, items, histories]
|
||||
properties:
|
||||
summary: {$ref: '#/components/schemas/ConfirmedSummary'}
|
||||
items: {type: array, description: 已完成且永久只读的装柜明细, items: {$ref: '#/components/schemas/LoadingItem'}}
|
||||
histories: {type: array, description: 计划操作历史, items: {$ref: '#/components/schemas/OperationHistory'}}
|
||||
TransportUpdateRequest:
|
||||
type: object
|
||||
required: [expectedVersion]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
containerNo: {type: string, maxLength: 64, nullable: true, description: 集装箱号}
|
||||
voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次}
|
||||
expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本}
|
||||
operationReason: {type: string, maxLength: 500, nullable: true, description: 操作原因}
|
||||
AdvanceRequest:
|
||||
type: object
|
||||
required: [expectedVersion]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本}
|
||||
operationReason: {type: string, maxLength: 500, nullable: true, description: 操作原因}
|
||||
+51
@@ -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 + "')");
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
|
||||
import com.baiyue.firstleg.service.LoadingListService;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DisplayName("预装与装柜清单控制器契约")
|
||||
class LoadingListControllerContractTest {
|
||||
@Test
|
||||
void permissionsMatch() throws Exception {
|
||||
authority("preloadPage", "platform:firstleg:preload:list", AuthenticatedUser.class, PreloadQuery.class);
|
||||
authority("preloadDetail", "platform:firstleg:preload:view", AuthenticatedUser.class, long.class);
|
||||
authority("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.class,
|
||||
long.class, String.class, TransportUpdateRequest.class);
|
||||
authority("complete", "platform:firstleg:preload:complete", AuthenticatedUser.class,
|
||||
long.class, String.class, AdvanceRequest.class);
|
||||
authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class,
|
||||
ConfirmedQuery.class);
|
||||
authority("confirmedDetail", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesAudited() throws Exception {
|
||||
audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, String.class,
|
||||
String.class, TransportUpdateRequest.class);
|
||||
audit("complete", "PRELOAD_COMPLETE", long.class, long.class, String.class,
|
||||
String.class, AdvanceRequest.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void openApiComplete() throws Exception {
|
||||
String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(yaml)
|
||||
.contains("EDITING 到 LOADING 再到 COMPLETED")
|
||||
.contains("Idempotency-Key")
|
||||
.contains("expectedVersion")
|
||||
.contains("createdFrom")
|
||||
.contains("createdTo")
|
||||
.contains("platform:firstleg:loading-list:view")
|
||||
.contains("PreloadPageEnvelope")
|
||||
.contains("ConfirmedPageEnvelope")
|
||||
.contains("PreloadSummary")
|
||||
.contains("ConfirmedSummary")
|
||||
.contains("LoadingItem")
|
||||
.contains("OperationHistory")
|
||||
.contains("customers")
|
||||
.contains("productQuantity")
|
||||
.contains("beforeSnapshot")
|
||||
.contains("containerNo")
|
||||
.contains("voyageNo")
|
||||
.doesNotContain("rollback")
|
||||
.doesNotContain("delete:")
|
||||
.doesNotContain("/items/{itemId}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void yamlParses() throws Exception {
|
||||
String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
Map<String, Object> document = stringMap(new Yaml().load(yaml));
|
||||
assertThat(document.get("openapi")).isEqualTo("3.0.3");
|
||||
assertThat((Map<?, ?>) document.get("paths")).hasSize(6);
|
||||
Map<String, Object> components = stringMap(document.get("components"));
|
||||
Map<String, Object> 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<String, Object> properties(Map<String, Object> schemas, String schemaName) {
|
||||
return stringMap(stringMap(schemas.get(schemaName)).get("properties"));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> stringMap(Object value) {
|
||||
return (Map<String, Object>) 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");
|
||||
}
|
||||
}
|
||||
+23
@@ -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+"')");}
|
||||
}
|
||||
+46
@@ -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}");
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baiyue.firstleg.mapper.LoadingListQueryMapper;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DisplayName("清单持久化SQL契约")
|
||||
class LoadingListQueryMapperContractTest {
|
||||
@Test
|
||||
void annotationScriptsShouldParseAsMyBatisStatements() {
|
||||
Configuration configuration = new Configuration();
|
||||
configuration.addMapper(LoadingListQueryMapper.class);
|
||||
|
||||
assertThat(configuration.hasStatement(
|
||||
LoadingListQueryMapper.class.getName() + ".selectPreloadPage")).isTrue();
|
||||
assertThat(configuration.hasStatement(
|
||||
LoadingListQueryMapper.class.getName() + ".advance")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void bothListsShouldProjectTheSameLoadingPlanAggregate() throws Exception {
|
||||
String preload = sql("selectPreloadPage", Select.class);
|
||||
String confirmed = sql("selectConfirmedPage", Select.class);
|
||||
|
||||
assertThat(preload)
|
||||
.contains("FROM fl_loading_plan p")
|
||||
.contains("JOIN fl_container_booking b")
|
||||
.contains("LEFT JOIN fl_loading_plan_item i")
|
||||
.contains("p.plan_status = 'LOADING'")
|
||||
.doesNotContain("PRELOAD_PUSHED");
|
||||
assertThat(confirmed)
|
||||
.contains("FROM fl_loading_plan p")
|
||||
.contains("p.plan_status = 'COMPLETED'")
|
||||
.doesNotContain("fl_loading_list");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mutationsShouldUseStatusAndOptimisticVersionConditions() throws Exception {
|
||||
String touch = sql("touchEditable", Update.class);
|
||||
String transport = sql("updateTransport", Update.class);
|
||||
String advance = sql("advance", Update.class);
|
||||
|
||||
assertThat(touch)
|
||||
.contains("plan_status = 'LOADING'")
|
||||
.contains("version = #{expectedVersion}")
|
||||
.contains("version = version + 1");
|
||||
assertThat(transport)
|
||||
.contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}")
|
||||
.doesNotContain("DELETE")
|
||||
.doesNotContain("fl_loading_plan_item");
|
||||
assertThat(advance)
|
||||
.contains("plan_status = #{fromStatus}")
|
||||
.contains("plan_status = #{toStatus}")
|
||||
.contains("version = #{expectedVersion}")
|
||||
.doesNotContain("ROLLED_BACK");
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyWriteShouldAppendAnImmutableHistoryOperation() throws Exception {
|
||||
String insert = sql("insertHistory", Insert.class);
|
||||
assertThat(insert)
|
||||
.contains("INSERT INTO fl_loading_plan_history")
|
||||
.contains("operation_id")
|
||||
.contains("before_snapshot")
|
||||
.contains("after_snapshot")
|
||||
.contains("operator_id");
|
||||
}
|
||||
|
||||
private static String sql(String methodName, Class<?> annotationType) throws Exception {
|
||||
Method method = java.util.Arrays.stream(LoadingListQueryMapper.class.getDeclaredMethods())
|
||||
.filter(candidate -> candidate.getName().equals(methodName))
|
||||
.findFirst().orElseThrow();
|
||||
if (annotationType == Select.class) {
|
||||
return String.join("\n", method.getAnnotation(Select.class).value());
|
||||
}
|
||||
if (annotationType == Update.class) {
|
||||
return String.join("\n", method.getAnnotation(Update.class).value());
|
||||
}
|
||||
return String.join("\n", method.getAnnotation(Insert.class).value());
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class SmartLoadingCandidateMapperContractTest {
|
||||
@Test
|
||||
void selectsLatestApprovedBatch() throws Exception {
|
||||
Method list = SmartLoadingCandidateMapper.class.getDeclaredMethod("candidates", long.class);
|
||||
String sql = String.join(" ", list.getAnnotation(Select.class).value());
|
||||
|
||||
assertThat(sql)
|
||||
.contains("o.destination_warehouse_id destinationWarehouseId")
|
||||
.contains("m.status='APPROVED'")
|
||||
.contains("SELECT MAX(m2.id)")
|
||||
.contains("m2.status='APPROVED'")
|
||||
.contains("i.inbound_detail_id IS NOT NULL")
|
||||
.contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC");
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailUsesSameBatchRule() throws Exception {
|
||||
Method detail = SmartLoadingCandidateMapper.class.getDeclaredMethod(
|
||||
"candidate", long.class, long.class, long.class);
|
||||
String sql = String.join(" ", detail.getAnnotation(Select.class).value());
|
||||
|
||||
assertThat(sql)
|
||||
.contains("o.destination_warehouse_id destinationWarehouseId")
|
||||
.contains("m.status='APPROVED'")
|
||||
.contains("SELECT MAX(m2.id)");
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DisplayName("智能装柜SQL契约")
|
||||
class SmartLoadingSqlContractTest {
|
||||
private static final Pattern PERMISSION_PATTERN = Pattern.compile("'((?:platform:firstleg:)[^']+)'");
|
||||
private static final Set<String> PHASE3_PERMISSIONS = Set.of(
|
||||
"platform:firstleg:container:list", "platform:firstleg:container:view",
|
||||
"platform:firstleg:container:create", "platform:firstleg:container:edit",
|
||||
"platform:firstleg:smart-loading:list", "platform:firstleg:smart-loading:create",
|
||||
"platform:firstleg:smart-loading:assign", "platform:firstleg:smart-loading:delete",
|
||||
"platform:firstleg:smart-loading:push", "platform:firstleg:preload:list",
|
||||
"platform:firstleg:preload:view", "platform:firstleg:preload:edit",
|
||||
"platform:firstleg:preload:complete", "platform:firstleg:preload:confirm",
|
||||
"platform:firstleg:loading-list:list", "platform:firstleg:loading-list:view");
|
||||
|
||||
@Test
|
||||
@DisplayName("平台聚合与客户明细边界及原型快照字段完整")
|
||||
void shouldProvidePlatformAggregatesAndAccountScopedSnapshots() throws Exception {
|
||||
assertThat(migration())
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_container_booking")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_item")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_container_document")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_history")
|
||||
.contains("account_id BIGINT UNSIGNED NOT NULL")
|
||||
.contains("customer_name VARCHAR(128) NOT NULL")
|
||||
.contains("warehouse_id BIGINT UNSIGNED NOT NULL")
|
||||
.contains("cabinet_no VARCHAR(64) NOT NULL")
|
||||
.contains("bill_of_lading_no VARCHAR(128)")
|
||||
.contains("destination_warehouse_name VARCHAR(128)")
|
||||
.contains("product_name_zh VARCHAR(255)")
|
||||
.contains("product_name_en VARCHAR(255)")
|
||||
.contains("product_image_file_id BIGINT UNSIGNED")
|
||||
.contains("pallet_count INT UNSIGNED")
|
||||
.contains("net_weight_kg DECIMAL(18,3)")
|
||||
.contains("dimensional_weight_kg DECIMAL(18,3)")
|
||||
.contains("product_quantity INT UNSIGNED")
|
||||
.contains("units_per_carton INT UNSIGNED")
|
||||
.contains("fba_tracking_no VARCHAR(128)")
|
||||
.contains("shipment_no VARCHAR(128)")
|
||||
.contains("shipping_mark VARCHAR(500)")
|
||||
.contains("internal_remark VARCHAR(1000)")
|
||||
.contains("customer_remark VARCHAR(1000)")
|
||||
.contains("warehouse_remark VARCHAR(1000)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("活动明细唯一锁、乐观锁和双层幂等字段完整")
|
||||
void shouldProvideConcurrencyAndIdempotencyBoundaries() throws Exception {
|
||||
assertThat(migration())
|
||||
.contains("active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS")
|
||||
.contains("CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END")
|
||||
.contains("UNIQUE KEY uk_loading_item_active_detail (account_id, active_inbound_detail_id)")
|
||||
.contains("UNIQUE KEY uk_container_booking_no (booking_no)")
|
||||
.contains("UNIQUE KEY uk_container_booking_cabinet (cabinet_no)")
|
||||
.contains("UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key)")
|
||||
.contains("UNIQUE KEY uk_loading_history_operation (operation_id)")
|
||||
.contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("平台菜单按钮权限使用统一前缀并授权运营角色")
|
||||
void shouldProvidePlatformPermissions() throws Exception {
|
||||
assertThat(migration())
|
||||
.contains("platform:firstleg:container:view")
|
||||
.contains("platform:firstleg:smart-loading:push")
|
||||
.contains("platform:firstleg:preload:confirm")
|
||||
.contains("platform:firstleg:loading-list:view")
|
||||
.contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')")
|
||||
.contains("智能装柜迁移失败:平台权限资源未完整创建")
|
||||
.contains("智能装柜迁移失败:平台角色授权未完整写入");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("模块脚本和统一脚本均校验16项权限与两个角色32项授权")
|
||||
void shouldAssertCompletePermissionGrants() throws Exception {
|
||||
assertPermissionContract(migration());
|
||||
assertPermissionContract(unifiedMigration());
|
||||
assertThat(unifiedMigration()).contains(migration().trim());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("计划状态只允许编辑中、装柜中和已完成单向推进")
|
||||
void shouldEnforceOneWayStatusAndPermanentFreeze() throws Exception {
|
||||
assertThat(migration())
|
||||
.contains("CHECK (plan_status IN ('EDITING', 'LOADING', 'COMPLETED'))")
|
||||
.contains("OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING')")
|
||||
.contains("NEW.plan_status IN ('LOADING', 'COMPLETED')")
|
||||
.contains("SET MESSAGE_TEXT = '装柜状态只允许单向推进'")
|
||||
.contains("IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN")
|
||||
.contains("ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN")
|
||||
.contains("LEFT JOIN fl_loading_pick_item pick_item")
|
||||
.contains("pick_item.picked_package_count <> pick_item.planned_package_count")
|
||||
.contains("IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN")
|
||||
.contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))")
|
||||
.contains("已有PDA拣货记录的装柜明细不可修改或删除")
|
||||
.contains("装柜完成后的明细永久冻结")
|
||||
.contains("装柜中仅允许修改集装箱号与航次")
|
||||
.contains("装柜完成后货柜全部只读,不允许修改")
|
||||
.doesNotContain("preload_status")
|
||||
.doesNotContain("PRELOAD_PUSHED")
|
||||
.doesNotContain("PRELOADING")
|
||||
.doesNotContain("PRELOAD_COMPLETED")
|
||||
.doesNotContain("CONFIRMED")
|
||||
.doesNotContain("ROLLED_BACK")
|
||||
.doesNotContain("WITHDRAWN");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PDA三表、唯一索引、向上取整和不可变审计契约完整")
|
||||
void shouldProvidePdaContract() throws Exception {
|
||||
assertThat(migration())
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_task")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_item")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_history")
|
||||
.contains("UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id)")
|
||||
.contains("UNIQUE KEY uk_loading_pick_item_plan_item (loading_plan_item_id)")
|
||||
.contains("UNIQUE KEY uk_loading_pick_history_operation (operation_id)")
|
||||
.contains("上层拆分按业务规则向上取整")
|
||||
.contains("CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0)")
|
||||
.contains("trg_loading_pick_task_before_update")
|
||||
.contains("trg_loading_pick_item_before_update")
|
||||
.contains("trg_loading_pick_item_before_delete")
|
||||
.contains("trg_loading_pick_history_before_update")
|
||||
.contains("trg_loading_pick_history_before_delete")
|
||||
.contains("PDA唯一索引缺失")
|
||||
.contains(")) <> 16 THEN");
|
||||
}
|
||||
|
||||
private static String migration() throws Exception {
|
||||
return new ClassPathResource("db/phase3-smart-loading-schema.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static String unifiedMigration() throws Exception {
|
||||
Path working = Path.of("").toAbsolutePath();
|
||||
Path file = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql");
|
||||
if (!Files.exists(file)) {
|
||||
file = working.resolve("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize();
|
||||
}
|
||||
return Files.readString(file, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static void assertPermissionContract(String sql) {
|
||||
int message = sql.indexOf("智能装柜迁移失败:平台角色授权未完整写入");
|
||||
assertThat(message).isGreaterThan(0);
|
||||
|
||||
int grantStart = sql.lastIndexOf("INSERT IGNORE INTO iam_role_permission", message);
|
||||
int grantEnd = sql.indexOf(';', grantStart);
|
||||
assertThat(permissions(sql.substring(grantStart, grantEnd))).isEqualTo(PHASE3_PERMISSIONS);
|
||||
|
||||
int assertionStart = sql.lastIndexOf("IF (SELECT COUNT(DISTINCT CONCAT", message);
|
||||
String assertion = sql.substring(assertionStart, message);
|
||||
assertThat(permissions(assertion)).isEqualTo(PHASE3_PERMISSIONS);
|
||||
assertThat(assertion).contains("<> 32 THEN");
|
||||
}
|
||||
|
||||
private static Set<String> permissions(String sql) {
|
||||
Matcher matcher = PERMISSION_PATTERN.matcher(sql);
|
||||
return matcher.results().map(result -> result.group(1)).collect(Collectors.toSet());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.repository.ContainerRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("货柜基础资料服务")
|
||||
@SpringJUnitConfig(ContainerService.class)
|
||||
class ContainerServiceTest {
|
||||
@MockBean private ContainerRepository repository;
|
||||
@Resource private ContainerService service;
|
||||
|
||||
@Test
|
||||
@DisplayName("新增货柜保存最终数据库契约字段并生成平台预订单号")
|
||||
void createsContainerBooking() {
|
||||
ContainerBookingEntity created = service.create(9L, "admin", request("柜-001"));
|
||||
|
||||
ArgumentCaptor<ContainerBookingEntity> captor = ArgumentCaptor.forClass(ContainerBookingEntity.class);
|
||||
verify(repository).insert(captor.capture());
|
||||
ContainerBookingEntity saved = captor.getValue();
|
||||
assertThat(created).isSameAs(saved);
|
||||
assertThat(saved.getBookingNo()).startsWith("CB");
|
||||
assertThat(saved.getCabinetNo()).isEqualTo("柜-001");
|
||||
assertThat(saved.getDeparturePortCode()).isEqualTo("CNSHA");
|
||||
assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000");
|
||||
assertThat(saved.getPlanStatus()).isEqualTo("EDITING");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("智能装柜草稿阶段允许完整编辑")
|
||||
void updatesAllFieldsWhileEditing() {
|
||||
ContainerBookingEntity current = booking("EDITING", 2);
|
||||
when(repository.find(11L)).thenReturn(current);
|
||||
when(repository.updateDraft(any(), org.mockito.ArgumentMatchers.eq(2),
|
||||
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1);
|
||||
|
||||
service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW")));
|
||||
|
||||
assertThat(current.getCabinetNo()).isEqualTo("柜-NEW");
|
||||
verify(repository).updateDraft(current, 2, 9L, "admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("进入预装柜中后拒绝完整编辑")
|
||||
void rejectsFullUpdateAfterPreloadPush() {
|
||||
when(repository.find(11L)).thenReturn(booking("LOADING", 2));
|
||||
|
||||
assertThatThrownBy(() -> service.updateDraft(
|
||||
11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW"))))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("只能修改集装箱号和航次");
|
||||
|
||||
verify(repository, never()).updateDraft(any(), any(Integer.class),
|
||||
any(Long.class), any(String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("预装柜中和预装柜完成只更新集装箱号与航次")
|
||||
void updatesTransportWhitelistAfterPush() {
|
||||
for (String status : new String[] {"LOADING"}) {
|
||||
ContainerBookingEntity current = booking(status, 2);
|
||||
when(repository.find(11L)).thenReturn(current);
|
||||
when(repository.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin"))
|
||||
.thenReturn(1);
|
||||
|
||||
service.updateTransport(11L, 9L, "admin",
|
||||
new TransportUpdateRequest("MSCU1234567", "V002", 2));
|
||||
}
|
||||
|
||||
verify(repository, org.mockito.Mockito.times(1))
|
||||
.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已确认货柜全部只读")
|
||||
void rejectsConfirmedUpdate() {
|
||||
when(repository.find(11L)).thenReturn(booking("COMPLETED", 2));
|
||||
|
||||
assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin",
|
||||
new TransportUpdateRequest("MSCU1234567", "V002", 2)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("全部只读");
|
||||
|
||||
verify(repository, never()).updateTransport(any(Long.class), any(), any(),
|
||||
any(Integer.class), any(Long.class), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("旧版本编辑请求触发乐观锁冲突")
|
||||
void rejectsStaleVersion() {
|
||||
when(repository.find(11L)).thenReturn(booking("EDITING", 3));
|
||||
|
||||
assertThatThrownBy(() -> service.updateDraft(
|
||||
11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW"))))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("刷新后重试");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("新增时校验重复集装箱号和内部柜号")
|
||||
void rejectsDuplicateNumbers() {
|
||||
when(repository.containerNoExists("MSCU1234567", null)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001")))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("集装箱号已存在");
|
||||
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
private static ContainerBookingEntity booking(String status, int version) {
|
||||
ContainerBookingEntity value = new ContainerBookingEntity();
|
||||
value.setId(11L);
|
||||
value.setContainerNo("MSCU1234567");
|
||||
value.setCabinetNo("柜-001");
|
||||
value.setPlanStatus(status);
|
||||
value.setVersion(version);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static SaveRequest request(String cabinetNo) {
|
||||
return new SaveRequest("MSCU1234567", cabinetNo, 101L, "上海仓", "BOL-001", "柏悦轮",
|
||||
"V001", "40HQ", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港",
|
||||
"ONT8", "ONT8", new BigDecimal("68.0000"), new BigDecimal("26000.000"), "拖车公司A",
|
||||
LocalDateTime.of(2026, 8, 10, 8, 0), LocalDateTime.of(2026, 8, 12, 8, 0), null,
|
||||
LocalDateTime.of(2026, 8, 25, 8, 0), null,
|
||||
LocalDateTime.of(2026, 8, 27, 8, 0), null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
|
||||
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
|
||||
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
|
||||
import com.baiyue.firstleg.repository.LoadingListRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("预装与装柜清单状态服务")
|
||||
@SpringJUnitConfig(classes = {LoadingListService.class, LoadingListServiceTest.Config.class})
|
||||
class LoadingListServiceTest {
|
||||
@MockBean
|
||||
private LoadingListRepository repository;
|
||||
@Resource
|
||||
private LoadingListService service;
|
||||
|
||||
@TestConfiguration
|
||||
static class Config {
|
||||
@Bean
|
||||
ObjectMapper objectMapper() {
|
||||
return new ObjectMapper();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectEditingStatusFromPreloadList() {
|
||||
PreloadQuery query = new PreloadQuery(null, "EDITING", null, null, null, null, 1, 20);
|
||||
|
||||
assertThatThrownBy(() -> service.preloadPage(query))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode())
|
||||
.isEqualTo(ResultCode.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldOnlyUpdateTransportFieldsWithOptimisticLockAndHistory() {
|
||||
AggregateRow before = row("LOADING", 3);
|
||||
before.setContainerNo("OLD");
|
||||
before.setVoyageNo("001E");
|
||||
AggregateRow after = row("LOADING", 4);
|
||||
after.setContainerNo("CSLU1234567");
|
||||
after.setVoyageNo("023E");
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.aggregate(10L)).thenReturn(before, after);
|
||||
when(repository.updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator"))
|
||||
.thenReturn(1);
|
||||
when(repository.preloadDetail(after)).thenReturn(
|
||||
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
|
||||
|
||||
service.updateTransport(10L, 9L, "operator", "key-1",
|
||||
new TransportUpdateRequest(" CSLU1234567 ", "023E", 3, "补录箱号"));
|
||||
|
||||
verify(repository).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator");
|
||||
verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"),
|
||||
eq("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L),
|
||||
eq("operator"), eq("补录箱号"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectTransportUpdateAfterConfirmation() {
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.aggregate(10L)).thenReturn(row("COMPLETED", 5));
|
||||
|
||||
assertThatThrownBy(() -> service.updateTransport(10L, 9L, "operator", "key-1",
|
||||
new TransportUpdateRequest("CSLU1234567", "023E", 5, null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode())
|
||||
.isEqualTo(ResultCode.CONFLICT);
|
||||
verify(repository, never()).updateTransport(eq(10L), eq(5), anyString(), anyString(), eq(9L), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAdvanceOnlyLoadingToCompleted() {
|
||||
AggregateRow before = row("LOADING", 2);
|
||||
AggregateRow after = row("COMPLETED", 3);
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.aggregate(10L)).thenReturn(before, after);
|
||||
when(repository.advance(10L, "LOADING", "COMPLETED", 2, 9L, "operator"))
|
||||
.thenReturn(1);
|
||||
when(repository.preloadDetail(after)).thenReturn(
|
||||
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
|
||||
|
||||
service.complete(10L, 9L, "operator", "complete-key", new AdvanceRequest(2, null));
|
||||
|
||||
verify(repository).advance(10L, "LOADING", "COMPLETED", 2, 9L, "operator");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReplaySameTransportRequestWithoutSecondMutation() {
|
||||
AggregateRow before = row("LOADING", 3);
|
||||
AggregateRow after = row("LOADING", 4);
|
||||
after.setContainerNo("CSLU1234567");
|
||||
after.setVoyageNo("023E");
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.aggregate(10L)).thenReturn(before, after);
|
||||
when(repository.updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator"))
|
||||
.thenReturn(1);
|
||||
when(repository.preloadDetail(after)).thenReturn(
|
||||
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
|
||||
ArgumentCaptor<String> snapshot = ArgumentCaptor.forClass(String.class);
|
||||
|
||||
TransportUpdateRequest request = new TransportUpdateRequest("CSLU1234567", "023E", 3, null);
|
||||
service.updateTransport(10L, 9L, "operator", "key-1", request);
|
||||
verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"),
|
||||
eq("LOADING"), eq("LOADING"), anyString(), snapshot.capture(), eq(9L),
|
||||
eq("operator"), eq(null));
|
||||
HistoryRow replay = new HistoryRow();
|
||||
replay.setAfterSnapshot(snapshot.getValue());
|
||||
when(repository.history(anyString())).thenReturn(replay);
|
||||
when(repository.aggregate(10L)).thenReturn(after);
|
||||
|
||||
service.updateTransport(10L, 9L, "operator", "key-1", request);
|
||||
|
||||
verify(repository, times(1)).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator");
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmedPageAlwaysUsesRepositoryConfirmedProjection() {
|
||||
ConfirmedQuery query = new ConfirmedQuery(" plan ", null, null, null, null, 1, 20);
|
||||
when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn(
|
||||
new PageResult<>(List.of(), 1, 20, 0, 0));
|
||||
|
||||
service.confirmedPage(query);
|
||||
|
||||
verify(repository).confirmedPage(org.mockito.ArgumentMatchers.argThat(value ->
|
||||
"plan".equals(value.keyword())));
|
||||
}
|
||||
|
||||
private static AggregateRow row(String status, int version) {
|
||||
AggregateRow row = new AggregateRow();
|
||||
row.setPlanId(10L);
|
||||
row.setPlanNo("PL-10");
|
||||
row.setPlanStatus(status);
|
||||
row.setVersion(version);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.OrderCargoClient;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignRequest;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.PushRequest;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.RecommendRequest;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
|
||||
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.catchThrowableOfType;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(SmartLoadingServiceTest.Config.class)
|
||||
class SmartLoadingServiceTest {
|
||||
@MockBean private SmartLoadingRepository repository;
|
||||
@MockBean private OrderCargoClient cargoClient;
|
||||
@MockBean private LoadingPickService loadingPickService;
|
||||
@Resource private SmartLoadingService service;
|
||||
|
||||
@TestConfiguration
|
||||
static class Config {
|
||||
@Bean SmartLoadingService smartLoadingService() { return new SmartLoadingService(); }
|
||||
@Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(
|
||||
ApiResponse.success(new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 5,
|
||||
new BigDecimal("10"), new BigDecimal("2"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoresDrafts() {
|
||||
LoadingPlanEntity plan = plan("EDITING", 2);
|
||||
when(repository.booking(99L)).thenReturn(booking());
|
||||
when(repository.planByBooking(99L)).thenReturn(plan);
|
||||
when(repository.plan(1L)).thenReturn(plan);
|
||||
when(repository.items(1L)).thenReturn(List.of(item(8, 9, 20, 1)));
|
||||
|
||||
assertThat(service.draftByBooking(99L).id()).isEqualTo(1L);
|
||||
assertThat(service.detail(1L).bookingId()).isEqualTo(99L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsExistingDraftOnCreate() {
|
||||
LoadingPlanEntity plan = plan("EDITING", 2);
|
||||
when(repository.booking(99L)).thenReturn(booking());
|
||||
when(repository.planByBooking(99L)).thenReturn(plan);
|
||||
when(repository.items(1L)).thenReturn(List.of());
|
||||
|
||||
Plan result = service.create(99L, "备注", 7L, "u");
|
||||
|
||||
assertThat(result.id()).isEqualTo(1L);
|
||||
verify(repository, never()).insert(any(LoadingPlanEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsExcessBoxes() {
|
||||
LoadingPlanEntity plan = plan("EDITING", 0);
|
||||
when(repository.plan(1)).thenReturn(plan);
|
||||
when(repository.candidate(9, 10, 20)).thenReturn(candidate(5));
|
||||
when(repository.activeItem(9, 20)).thenReturn(item(7, 9, 20, 4));
|
||||
|
||||
BizException error = catchThrowableOfType(() -> service.assign(1,
|
||||
new AssignRequest(9L, 10L, 20L, 2, 0, "assign-1"), 7, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
|
||||
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotsCargoFields() {
|
||||
stubAssignment();
|
||||
when(cargoClient.detail(anyString(), eq(9L), eq(30L), eq(20L))).thenReturn(ApiResponse.success(
|
||||
new OrderCargoClient.CargoBinding(20L, 30L, 1, "中文品名", 10,
|
||||
new BigDecimal("20"), new BigDecimal("2"), "SKU-1", "English name",
|
||||
4, 100, new BigDecimal("10"), "货物备注")));
|
||||
doAnswer(invocation -> {
|
||||
Object value = invocation.getArgument(0);
|
||||
if (value instanceof LoadingPlanItemEntity item) item.setId(88L);
|
||||
return null;
|
||||
}).when(repository).insert(any(LoadingPlanItemEntity.class));
|
||||
|
||||
service.assign(1, new AssignRequest(9L, 10L, 20L, 5, 0, "assign-snapshot"), 7, "u");
|
||||
|
||||
ArgumentCaptor<LoadingPlanItemEntity> 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<LoadingPlanHistoryEntity> historyCaptor = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class);
|
||||
verify(repository).insert(historyCaptor.capture());
|
||||
assertThat(historyCaptor.getValue().getLoadingPlanItemId()).isEqualTo(88L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundsQuantityWithoutLoss() {
|
||||
stubAssignment();
|
||||
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(ApiResponse.success(
|
||||
new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 3,
|
||||
BigDecimal.ONE, BigDecimal.ONE, null, null, null, 100, null, null)));
|
||||
|
||||
service.assign(1, new AssignRequest(9L, 10L, 20L, 2, 0, "assign-round"), 7, "u");
|
||||
|
||||
ArgumentCaptor<LoadingPlanItemEntity> captor = ArgumentCaptor.forClass(LoadingPlanItemEntity.class);
|
||||
verify(repository).insert(captor.capture());
|
||||
assertThat(captor.getValue().getProductQuantity()).isEqualTo(67);
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaysBeforeVersionCheck() {
|
||||
AssignRequest request = new AssignRequest(9L, 10L, 20L, 2, 0, "assign-replay");
|
||||
LoadingPlanHistoryEntity replay = history(1, null, "ADD_ITEM",
|
||||
sha256("ASSIGN|1|9|10|20|2|0"));
|
||||
LoadingPlanEntity frozen = plan("LOADING", 9);
|
||||
when(repository.operation("assign-replay")).thenReturn(replay);
|
||||
when(repository.plan(1)).thenReturn(frozen);
|
||||
when(repository.booking(99)).thenReturn(booking());
|
||||
when(repository.items(1)).thenReturn(List.of());
|
||||
|
||||
Plan result = service.assign(1, request, 7, "u");
|
||||
|
||||
assertThat(result.status()).isEqualTo("LOADING");
|
||||
verify(repository, never()).candidate(anyLong(), anyLong(), anyLong());
|
||||
verify(cargoClient, never()).detail(anyString(), anyLong(), anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsReusedOperation() {
|
||||
AssignRequest request = new AssignRequest(9L, 10L, 20L, 2, 0, "shared-op");
|
||||
when(repository.operation("shared-op")).thenReturn(history(2, null, "ADD_ITEM",
|
||||
sha256("ASSIGN|1|9|10|20|2|0")));
|
||||
|
||||
BizException error = catchThrowableOfType(() -> service.assign(1, request, 7, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaysRecommendation() {
|
||||
RecommendRequest request = new RecommendRequest(9L, 0, "recommend-replay");
|
||||
when(repository.operation("recommend-replay")).thenReturn(history(1, null,
|
||||
"SMART_RECOMMEND", sha256("RECOMMEND|1|9|0")));
|
||||
when(repository.plan(1)).thenReturn(plan("LOADING", 8));
|
||||
when(repository.booking(99)).thenReturn(booking());
|
||||
when(repository.items(1)).thenReturn(List.of());
|
||||
|
||||
Plan result = service.recommend(1, request, 7, "u");
|
||||
|
||||
assertThat(result.status()).isEqualTo("LOADING");
|
||||
verify(repository, never()).candidates(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaysDeleteBeforeVersionCheck() {
|
||||
DeleteRequest request = new DeleteRequest(9L, 2, 0, "delete-replay");
|
||||
when(repository.operation("delete-replay")).thenReturn(history(1, 8L,
|
||||
"REMOVE_ITEM", sha256("DELETE|1|8|9|2|0")));
|
||||
when(repository.plan(1)).thenReturn(plan("LOADING", 7));
|
||||
when(repository.booking(99)).thenReturn(booking());
|
||||
when(repository.items(1)).thenReturn(List.of());
|
||||
|
||||
Plan result = service.delete(1, 8, request, 7, "u");
|
||||
|
||||
assertThat(result.status()).isEqualTo("LOADING");
|
||||
verify(repository, never()).item(anyLong(), anyLong());
|
||||
verify(repository, never()).remove(anyLong(), anyLong(), anyInt(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteTouchesPlan() {
|
||||
LoadingPlanEntity plan = plan("EDITING", 2);
|
||||
LoadingPlanItemEntity item = item(8, 9, 20, 1);
|
||||
when(repository.plan(1)).thenReturn(plan, plan("EDITING", 3));
|
||||
when(repository.item(1, 8)).thenReturn(item);
|
||||
when(repository.touch(1, 2, 7, "u")).thenReturn(1);
|
||||
when(repository.remove(1, 8, 0, 7, "u")).thenReturn(1);
|
||||
when(repository.booking(99)).thenReturn(booking());
|
||||
when(repository.items(1)).thenReturn(List.of());
|
||||
|
||||
Plan result = service.delete(1, 8, new DeleteRequest(9L, 2, 0, "delete-1"), 7, "u");
|
||||
|
||||
assertThat(result.version()).isEqualTo(3);
|
||||
verify(repository).touch(1, 2, 7, "u");
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesDownstreamCode() {
|
||||
stubAssignment();
|
||||
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(
|
||||
ApiResponse.failure(ResultCode.NOT_FOUND, "货物不存在", null, null));
|
||||
|
||||
BizException error = catchThrowableOfType(() -> service.assign(1,
|
||||
new AssignRequest(9L, 10L, 20L, 2, 0, "assign-not-found"), 7, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsFeignFailure() {
|
||||
stubAssignment();
|
||||
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong()))
|
||||
.thenThrow(mock(FeignException.class));
|
||||
|
||||
BizException error = catchThrowableOfType(() -> service.assign(1,
|
||||
new AssignRequest(9L, 10L, 20L, 2, 0, "assign-down"), 7, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCargoMismatch() {
|
||||
stubAssignment();
|
||||
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(ApiResponse.success(
|
||||
new OrderCargoClient.CargoBinding(99L, 30L, 1, "货物", 5, BigDecimal.ONE, BigDecimal.ONE)));
|
||||
|
||||
BizException error = catchThrowableOfType(() -> service.assign(1,
|
||||
new AssignRequest(9L, 10L, 20L, 2, 0, "assign-mismatch"), 7, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWrongAccountDelete() {
|
||||
LoadingPlanEntity plan = plan("EDITING", 2);
|
||||
when(repository.plan(1)).thenReturn(plan);
|
||||
when(repository.item(1, 8)).thenReturn(item(8, 22, 20, 1));
|
||||
|
||||
BizException error = catchThrowableOfType(() -> service.delete(1, 8,
|
||||
new DeleteRequest(9L, 2, 0, "delete-owner"), 7, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.FORBIDDEN);
|
||||
verify(repository, never()).remove(anyLong(), anyLong(), anyInt(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pushesIdempotently() {
|
||||
LoadingPlanEntity editing = plan("EDITING", 3);
|
||||
LoadingPlanEntity pushed = plan("LOADING", 4);
|
||||
pushed.setPushIdempotencyKey("key");
|
||||
when(repository.plan(1)).thenReturn(editing, pushed);
|
||||
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
|
||||
when(repository.booking(99)).thenReturn(booking());
|
||||
when(repository.push(1, "key", 3, 7, "u")).thenReturn(1);
|
||||
|
||||
Plan result = service.pushPreload(1, "key", new PushRequest(3), 7, "u");
|
||||
|
||||
assertThat(result.status()).isEqualTo("LOADING");
|
||||
verify(repository).push(1, "key", 3, 7, "u");
|
||||
}
|
||||
|
||||
private void stubAssignment() {
|
||||
LoadingPlanEntity plan = plan("EDITING", 0);
|
||||
when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1));
|
||||
when(repository.booking(99)).thenReturn(booking());
|
||||
when(repository.items(1)).thenReturn(List.of());
|
||||
when(repository.candidate(9, 10, 20)).thenReturn(candidate(10));
|
||||
when(repository.activeItem(9, 20)).thenReturn(null);
|
||||
when(repository.touch(1, 0, 7, "u")).thenReturn(1);
|
||||
}
|
||||
|
||||
private static LoadingPlanHistoryEntity history(long planId, Long itemId, String action, String hash) {
|
||||
LoadingPlanHistoryEntity value = new LoadingPlanHistoryEntity();
|
||||
value.setLoadingPlanId(planId);
|
||||
value.setLoadingPlanItemId(itemId);
|
||||
value.setActionType(action);
|
||||
value.setAfterSnapshot("{\"requestHash\":\"" + hash + "\"}");
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String sha256(String value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static LoadingPlanEntity plan(String status, int version) {
|
||||
LoadingPlanEntity value = new LoadingPlanEntity();
|
||||
value.setId(1L); value.setContainerBookingId(99L); value.setPlanNo("LP1");
|
||||
value.setPlanStatus(status); value.setVersion(version);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static LoadingPlanItemEntity item(long id, long account, long detail, int boxes) {
|
||||
LoadingPlanItemEntity value = new LoadingPlanItemEntity();
|
||||
value.setId(id); value.setLoadingPlanId(1L); value.setAccountId(account);
|
||||
value.setInboundDetailId(detail); value.setPackageCount(boxes);
|
||||
value.setVersion(0); value.setDeleted(false);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static Candidate candidate(int boxes) {
|
||||
return new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L, 200L,
|
||||
"FBA1", "FBA仓", "客户", "货物", boxes, new BigDecimal("2"), new BigDecimal("0.5"));
|
||||
}
|
||||
|
||||
private static ContainerBookingEntity booking() {
|
||||
ContainerBookingEntity value = new ContainerBookingEntity();
|
||||
value.setId(99L); value.setWarehouseId(100L); value.setDestinationWarehouseCode("FBA1");
|
||||
value.setContainerNo("MSCU1234567");
|
||||
value.setCapacityCbm(new BigDecimal("60")); value.setMaxWeightKg(new BigDecimal("20000"));
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+18
@@ -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/**");
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,9 @@ public class InternalOrderCargoController {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "订单货物明细不存在或不属于该订单");
|
||||
}
|
||||
return ApiResponse.success(new CargoBinding(cargo.getId(), cargo.getOrderId(), cargo.getItemNo(),
|
||||
cargo.getCargoName(), cargo.getPackageCount(), cargo.getGrossWeightKg(), cargo.getVolumeCbm()));
|
||||
cargo.getCargoName(), cargo.getPackageCount(), cargo.getGrossWeightKg(), cargo.getVolumeCbm(),
|
||||
cargo.getSkuNo(), cargo.getCargoNameEn(), cargo.getUnitsPerPackage(), cargo.getTotalQuantity(),
|
||||
cargo.getNetWeightKg(), cargo.getCargoRemark()));
|
||||
}
|
||||
|
||||
private void requireToken(String token) {
|
||||
@@ -58,6 +60,14 @@ public class InternalOrderCargoController {
|
||||
public record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
|
||||
Integer originalPackageCount,
|
||||
java.math.BigDecimal originalGrossWeightKg,
|
||||
java.math.BigDecimal originalVolumeCbm) {
|
||||
java.math.BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn,
|
||||
Integer unitsPerPackage, Integer totalQuantity,
|
||||
java.math.BigDecimal netWeightKg, String cargoRemark) {
|
||||
public CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
|
||||
Integer originalPackageCount, java.math.BigDecimal originalGrossWeightKg,
|
||||
java.math.BigDecimal originalVolumeCbm) {
|
||||
this(cargoId, orderId, itemNo, cargoName, originalPackageCount,
|
||||
originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ class InternalOrderCargoControllerTest {
|
||||
cargo.setItemNo(1);
|
||||
cargo.setCargoName("测试货物");
|
||||
cargo.setPackageCount(50);
|
||||
cargo.setSkuNo("SKU-31");
|
||||
cargo.setCargoNameEn("Test cargo");
|
||||
cargo.setUnitsPerPackage(4);
|
||||
cargo.setTotalQuantity(200);
|
||||
cargo.setNetWeightKg(new java.math.BigDecimal("18.500"));
|
||||
cargo.setCargoRemark("内部货物备注");
|
||||
when(orders.findCargo(11L, 21L, 31L)).thenReturn(cargo);
|
||||
|
||||
var response = controller.detail("internal-token", 11L, 21L, 31L);
|
||||
@@ -37,6 +43,12 @@ class InternalOrderCargoControllerTest {
|
||||
assertThat(response.successful()).isTrue();
|
||||
assertThat(response.data().cargoId()).isEqualTo(31L);
|
||||
assertThat(response.data().itemNo()).isEqualTo(1);
|
||||
assertThat(response.data().skuNo()).isEqualTo("SKU-31");
|
||||
assertThat(response.data().cargoNameEn()).isEqualTo("Test cargo");
|
||||
assertThat(response.data().unitsPerPackage()).isEqualTo(4);
|
||||
assertThat(response.data().totalQuantity()).isEqualTo(200);
|
||||
assertThat(response.data().netWeightKg()).isEqualByComparingTo("18.500");
|
||||
assertThat(response.data().cargoRemark()).isEqualTo("内部货物备注");
|
||||
verify(orders).findCargo(11L, 21L, 31L);
|
||||
}
|
||||
|
||||
|
||||
+10
@@ -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<List<PalletAllocationDtos.SelectiveReleaseResult>> 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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<LocationOccupancyEntity> {
|
||||
@@ -26,4 +28,15 @@ public interface LocationOccupancyMapper extends BaseMapper<LocationOccupancyEnt
|
||||
""")
|
||||
int release(@Param("tenantId") long tenantId, @Param("id") long id,
|
||||
@Param("version") int version, @Param("reason") String reason);
|
||||
|
||||
@Select("""
|
||||
SELECT * FROM wh_location_occupancy
|
||||
WHERE tenant_id = #{tenantId} AND source_service = #{sourceService}
|
||||
AND business_id = #{businessId} AND occupancy_status = 'OCCUPIED'
|
||||
ORDER BY reservation_id ASC, id ASC
|
||||
FOR UPDATE
|
||||
""")
|
||||
List<LocationOccupancyEntity> lockOccupiedByBusiness(@Param("tenantId") long tenantId,
|
||||
@Param("sourceService") String sourceService,
|
||||
@Param("businessId") long businessId);
|
||||
}
|
||||
|
||||
@@ -143,6 +143,11 @@ public class LocationRepository {
|
||||
.eq(LocationOccupancyEntity::getReservationId, reservationId));
|
||||
}
|
||||
|
||||
public List<LocationOccupancyEntity> lockOccupiedByBusiness(long tenantId, String sourceService,
|
||||
long businessId) {
|
||||
return occupancies.lockOccupiedByBusiness(tenantId, sourceService, businessId);
|
||||
}
|
||||
|
||||
public void insertOccupancy(LocationOccupancyEntity entity) {
|
||||
occupancies.insert(entity);
|
||||
}
|
||||
|
||||
@@ -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<LocationEntity> 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<SelectiveReleaseResult> releaseSelected(@Valid SelectiveReleaseCommand command) {
|
||||
List<LocationOccupancyEntity> 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<Integer> packages = packageAllocator.allocate(command.totalPackageCount(), active.size());
|
||||
Set<Long> 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<Integer> 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<SelectiveReleaseResult> 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<ActiveResource> findActiveResources(@Min(1) long tenantId,
|
||||
@NotBlank String sourceService,
|
||||
|
||||
+22
@@ -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<PalletAllocationDtos.SelectiveReleaseResult> releaseSelected(
|
||||
PalletAllocationDtos.SelectiveReleaseRequest request) {
|
||||
List<LocationDtos.SelectiveReleaseResult> released = locationResourceService.releaseSelected(
|
||||
new LocationDtos.SelectiveReleaseCommand(request.tenantId(), request.sourceService(), request.businessId(),
|
||||
request.operatorId(), request.reason(), request.totalPackageCount(), request.locationIds(),
|
||||
request.releaseLocationCount()));
|
||||
List<PalletAllocationDtos.SelectiveReleaseResult> 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<LocationDtos.ActiveResource> active(long tenantId, String sourceService, long businessId) {
|
||||
return new ArrayList<>(locationResourceService.findActiveResources(tenantId, sourceService, businessId));
|
||||
}
|
||||
|
||||
@@ -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<Integer> 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<Integer> 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);
|
||||
}
|
||||
}
|
||||
+100
-1
@@ -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<LocationOccupancyEntity> 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<LocationOccupancyEntity> 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);
|
||||
|
||||
+19
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user