装柜
This commit is contained in:
+293
-396
@@ -4033,7 +4033,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
|
||||
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID',
|
||||
planned_package_count INT UNSIGNED NOT NULL COMMENT '计划拣货箱数;上层拆分按业务规则向上取整',
|
||||
picked_package_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已拣货箱数',
|
||||
item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PICKING、PICKED',
|
||||
item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PICKING、PICKED、PARTIAL、SKIPPED',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
@@ -4044,7 +4044,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
|
||||
CONSTRAINT fk_loading_pick_item_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id),
|
||||
CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0),
|
||||
CONSTRAINT chk_loading_pick_item_count CHECK (picked_package_count <= planned_package_count),
|
||||
CONSTRAINT chk_loading_pick_item_status CHECK (item_status IN ('PENDING', 'PICKING', 'PICKED'))
|
||||
CONSTRAINT chk_loading_pick_item_status CHECK (item_status IN ('PENDING', 'PICKING', 'PICKED', 'PARTIAL', 'SKIPPED'))
|
||||
) ENGINE=InnoDB COMMENT='PDA装柜拣货明细';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_pick_history (
|
||||
@@ -4053,7 +4053,9 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history (
|
||||
pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
|
||||
operation_id VARCHAR(128) NOT NULL COMMENT '客户端幂等操作号',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '请求指纹',
|
||||
action_type VARCHAR(24) NOT NULL DEFAULT 'PICK' COMMENT 'PICK、PARTIAL、SKIP',
|
||||
picked_package_count INT UNSIGNED NOT NULL COMMENT '本次拣货箱数',
|
||||
operation_reason VARCHAR(500) NULL COMMENT '部分拣货或跳拣原因',
|
||||
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人',
|
||||
operator_name VARCHAR(64) NULL COMMENT '操作人名称',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
@@ -4228,7 +4230,49 @@ BEGIN
|
||||
IF current_plan_status = 'LOADING' AND EXISTS (
|
||||
SELECT 1 FROM fl_loading_pick_item
|
||||
WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细不可修改或删除';
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM fl_loading_pick_item
|
||||
WHERE loading_plan_item_id = OLD.id
|
||||
AND item_status = 'PARTIAL'
|
||||
AND NEW.deleted = 0
|
||||
AND NEW.package_count = picked_package_count
|
||||
) OR NEW.deleted <> OLD.deleted
|
||||
OR NOT (NEW.customer_name <=> OLD.customer_name)
|
||||
OR NOT (NEW.inbound_id <=> OLD.inbound_id)
|
||||
OR NOT (NEW.inbound_detail_id <=> OLD.inbound_detail_id)
|
||||
OR NOT (NEW.order_id <=> OLD.order_id)
|
||||
OR NOT (NEW.order_no <=> OLD.order_no)
|
||||
OR NOT (NEW.inbound_code <=> OLD.inbound_code)
|
||||
OR NOT (NEW.destination_warehouse_id <=> OLD.destination_warehouse_id)
|
||||
OR NOT (NEW.destination_warehouse_code <=> OLD.destination_warehouse_code)
|
||||
OR NOT (NEW.destination_warehouse_name <=> OLD.destination_warehouse_name)
|
||||
OR NOT (NEW.sku_code <=> OLD.sku_code)
|
||||
OR NOT (NEW.product_name_zh <=> OLD.product_name_zh)
|
||||
OR NOT (NEW.product_name_en <=> OLD.product_name_en)
|
||||
OR NOT (NEW.product_image_file_id <=> OLD.product_image_file_id)
|
||||
OR NOT (NEW.product_image_url <=> OLD.product_image_url)
|
||||
OR NOT (NEW.product_quantity <=> OLD.product_quantity)
|
||||
OR NOT (NEW.units_per_carton <=> OLD.units_per_carton)
|
||||
OR NOT (NEW.pallet_count <=> OLD.pallet_count)
|
||||
OR NOT (NEW.gross_weight_kg <=> OLD.gross_weight_kg)
|
||||
OR NOT (NEW.net_weight_kg <=> OLD.net_weight_kg)
|
||||
OR NOT (NEW.dimensional_weight_kg <=> OLD.dimensional_weight_kg)
|
||||
OR NOT (NEW.volume_cbm <=> OLD.volume_cbm)
|
||||
OR NOT (NEW.fba_tracking_no <=> OLD.fba_tracking_no)
|
||||
OR NOT (NEW.shipment_no <=> OLD.shipment_no)
|
||||
OR NOT (NEW.shipping_mark <=> OLD.shipping_mark)
|
||||
OR NOT (NEW.load_sequence <=> OLD.load_sequence)
|
||||
OR NOT (NEW.position_snapshot <=> OLD.position_snapshot)
|
||||
OR NOT (NEW.internal_remark <=> OLD.internal_remark)
|
||||
OR NOT (NEW.customer_remark <=> OLD.customer_remark)
|
||||
OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark)
|
||||
OR NOT (NEW.frozen_at <=> OLD.frozen_at)
|
||||
OR NOT (NEW.frozen_by <=> OLD.frozen_by)
|
||||
OR NOT (NEW.removed_by <=> OLD.removed_by)
|
||||
OR NOT (NEW.removed_by_name <=> OLD.removed_by_name)
|
||||
OR NOT (NEW.removed_at <=> OLD.removed_at) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数';
|
||||
END IF;
|
||||
END IF;
|
||||
IF OLD.frozen_at IS NOT NULL THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结';
|
||||
@@ -4287,6 +4331,28 @@ BEGIN
|
||||
OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货明细归属不可修改';
|
||||
END IF;
|
||||
IF NEW.item_status = 'PARTIAL' AND NOT (
|
||||
OLD.item_status IN ('PENDING', 'PICKING')
|
||||
AND OLD.picked_package_count < NEW.picked_package_count
|
||||
AND NEW.picked_package_count < OLD.planned_package_count
|
||||
AND NEW.planned_package_count = OLD.planned_package_count
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分拣货必须在计划箱数内增加实际拣出箱数';
|
||||
END IF;
|
||||
IF NEW.item_status = 'SKIPPED' AND NOT (
|
||||
OLD.item_status = 'PENDING'
|
||||
AND OLD.picked_package_count = 0
|
||||
AND NEW.picked_package_count = 0
|
||||
AND NEW.planned_package_count = OLD.planned_package_count
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '跳拣仅允许零拣货的待拣明细';
|
||||
END IF;
|
||||
IF OLD.item_status = 'PARTIAL' AND NEW.item_status = 'PICKED' AND NOT (
|
||||
NEW.picked_package_count = OLD.picked_package_count
|
||||
AND NEW.planned_package_count = OLD.picked_package_count
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分拣货收敛后计划箱数必须等于实际拣出箱数';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
@@ -4575,6 +4641,117 @@ DELIMITER ;
|
||||
CALL assert_phase3_smart_loading_schema();
|
||||
DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema;
|
||||
|
||||
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_phase3_smart_loading_schema()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name IN ('fl_container_booking', 'fl_loading_plan', 'fl_loading_plan_item',
|
||||
'fl_container_document', 'fl_loading_plan_history',
|
||||
'fl_loading_pick_task', 'fl_loading_pick_item',
|
||||
'fl_loading_pick_history')) <> 8 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:核心表未完整创建';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM fl_loading_plan
|
||||
WHERE plan_status NOT IN ('EDITING', 'LOADING', 'COMPLETED')) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:存在旧装柜状态';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.check_constraints
|
||||
WHERE constraint_schema = 'baiyue_firstleg'
|
||||
AND constraint_name = 'chk_loading_plan_status'
|
||||
AND check_clause LIKE '%''EDITING''%'
|
||||
AND check_clause LIKE '%''LOADING''%'
|
||||
AND check_clause LIKE '%''COMPLETED''%') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:装柜状态约束不是最终三状态';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_container_booking'
|
||||
AND column_name IN ('booking_no', 'cabinet_no', 'warehouse_id', 'bill_of_lading_no',
|
||||
'vessel_name', 'voyage_no', 'container_type', 'capacity_cbm',
|
||||
'max_weight_kg', 'terminal_trucking_company')) <> 10 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:货柜原型字段缺失';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item'
|
||||
AND column_name IN ('account_id', 'customer_name', 'active_inbound_detail_id', 'frozen_at',
|
||||
'destination_warehouse_name', 'product_name_zh', 'product_name_en',
|
||||
'product_image_file_id', 'pallet_count', 'net_weight_kg',
|
||||
'dimensional_weight_kg', 'product_quantity', 'units_per_carton',
|
||||
'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 17 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item'
|
||||
AND index_name = 'uk_loading_item_plan_detail' AND non_unique = 0) <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name = 'fl_loading_pick_item'
|
||||
AND column_name IN ('pick_task_id', 'loading_plan_item_id', 'inbound_id',
|
||||
'inbound_detail_id', 'planned_package_count',
|
||||
'picked_package_count', 'item_status', 'version')) <> 8 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA拣货明细字段缺失';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg' AND non_unique = 0
|
||||
AND index_name IN ('uk_loading_pick_task_plan',
|
||||
'uk_loading_pick_item_plan_item',
|
||||
'uk_loading_pick_history_operation')) <> 3 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA唯一索引缺失';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.triggers
|
||||
WHERE trigger_schema = 'baiyue_firstleg'
|
||||
AND trigger_name IN ('trg_container_booking_before_update',
|
||||
'trg_loading_plan_before_update', 'trg_loading_plan_after_update',
|
||||
'trg_loading_item_before_insert', 'trg_loading_item_before_update',
|
||||
'trg_loading_item_before_delete', 'trg_container_document_before_insert',
|
||||
'trg_container_document_before_update', 'trg_container_document_before_delete',
|
||||
'trg_loading_history_before_update', 'trg_loading_history_before_delete',
|
||||
'trg_loading_pick_task_before_update',
|
||||
'trg_loading_pick_item_before_update',
|
||||
'trg_loading_pick_item_before_delete',
|
||||
'trg_loading_pick_history_before_update',
|
||||
'trg_loading_pick_history_before_delete')) <> 16 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:不可逆状态或冻结触发器缺失';
|
||||
END IF;
|
||||
IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission
|
||||
WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0
|
||||
AND permission_code IN (
|
||||
'platform:firstleg:container:list', 'platform:firstleg:container:view',
|
||||
'platform:firstleg:container:create', 'platform:firstleg:container:edit',
|
||||
'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:create',
|
||||
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
|
||||
'platform:firstleg:smart-loading:push', 'platform:firstleg:preload:list',
|
||||
'platform:firstleg:preload:view', 'platform:firstleg:preload:edit',
|
||||
'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm',
|
||||
'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 16 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code))
|
||||
FROM baiyue_iam.iam_role role
|
||||
JOIN baiyue_iam.iam_role_permission grant_row ON grant_row.role_id = role.id
|
||||
JOIN baiyue_iam.iam_permission permission ON permission.id = grant_row.permission_id
|
||||
WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')
|
||||
AND permission.tenant_id = 0
|
||||
AND permission.permission_code IN (
|
||||
'platform:firstleg:container:list', 'platform:firstleg:container:view',
|
||||
'platform:firstleg:container:create',
|
||||
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
|
||||
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign',
|
||||
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
|
||||
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
|
||||
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
|
||||
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
|
||||
'platform:firstleg:loading-list:view')) <> 32 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_phase3_smart_loading_schema();
|
||||
DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema;
|
||||
|
||||
-- ============================================================
|
||||
|
||||
-- 2026-08-06 平台公共仓库正式主数据:固定作业仓 + FBA 地址库
|
||||
@@ -5564,8 +5741,8 @@ CALL phase3_inbound_add_index(
|
||||
'fl_inbound_order', 'uk_inbound_master_order',
|
||||
'UNIQUE KEY `uk_inbound_master_order` (`tenant_id`, `master_order_id`)');
|
||||
CALL phase3_inbound_add_index(
|
||||
'fl_inbound_order', 'uk_inbound_parent_sequence',
|
||||
'UNIQUE KEY `uk_inbound_parent_sequence` (`tenant_id`, `parent_inbound_id`, `split_sequence`)');
|
||||
'fl_inbound_order', 'uk_inbound_one_child',
|
||||
'UNIQUE KEY `uk_inbound_one_child` (`tenant_id`, `parent_inbound_id`)');
|
||||
CALL phase3_inbound_add_index(
|
||||
'fl_inbound_order', 'idx_inbound_root_role',
|
||||
'KEY `idx_inbound_root_role` (`tenant_id`, `root_inbound_id`, `inbound_role`, `created_at`, `id`)');
|
||||
@@ -5582,6 +5759,14 @@ BEGIN
|
||||
) THEN
|
||||
ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_tenant_order;
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'fl_inbound_order'
|
||||
AND index_name = 'uk_inbound_parent_sequence'
|
||||
) THEN
|
||||
ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_parent_sequence;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL phase3_inbound_drop_legacy_index();
|
||||
@@ -5618,19 +5803,22 @@ CREATE TABLE IF NOT EXISTS fl_inbound_split_item (
|
||||
split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID',
|
||||
parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID',
|
||||
child_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '子入仓单ID',
|
||||
line_no INT UNSIGNED NOT NULL COMMENT '同一拆分批次内的行号',
|
||||
source_inbound_detail_id BIGINT UNSIGNED NULL COMMENT '订单货物明细逻辑ID',
|
||||
source_measurement_id BIGINT UNSIGNED NULL COMMENT '来源测量批次ID',
|
||||
source_measurement_item_id BIGINT UNSIGNED NULL COMMENT '来源测量箱规明细ID',
|
||||
package_count INT UNSIGNED NOT NULL COMMENT '分配给子单的箱数',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_inbound_split_child (tenant_id, child_inbound_id),
|
||||
UNIQUE KEY uk_inbound_split_batch_child (tenant_id, split_batch_id, child_inbound_id),
|
||||
UNIQUE KEY uk_inbound_split_batch_line (tenant_id, split_batch_id, line_no),
|
||||
UNIQUE KEY uk_inbound_split_batch_source_item (tenant_id, split_batch_id, source_measurement_item_id),
|
||||
KEY idx_inbound_split_detail (tenant_id, source_inbound_detail_id, created_at),
|
||||
CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0),
|
||||
CONSTRAINT fk_inbound_split_item_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id),
|
||||
CONSTRAINT fk_inbound_split_item_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id),
|
||||
CONSTRAINT fk_inbound_split_item_child FOREIGN KEY (child_inbound_id) REFERENCES fl_inbound_order (id),
|
||||
CONSTRAINT fk_inbound_split_item_measurement FOREIGN KEY (source_measurement_id) REFERENCES fl_inbound_measurement (id)
|
||||
CONSTRAINT fk_inbound_split_item_measurement FOREIGN KEY (source_measurement_id) REFERENCES fl_inbound_measurement (id),
|
||||
CONSTRAINT fk_inbound_split_item_measure_item FOREIGN KEY (source_measurement_item_id) REFERENCES fl_inbound_measurement_item (id)
|
||||
) ENGINE=InnoDB COMMENT='主子入仓单拆分数量台账';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_inbound_split_history (
|
||||
@@ -5680,6 +5868,15 @@ CALL phase3_inbound_add_column(
|
||||
CALL phase3_inbound_add_column(
|
||||
'fl_inbound_measurement', 'source_measurement_id',
|
||||
"BIGINT UNSIGNED NULL COMMENT '子单继承或复核时的来源测量批次ID' AFTER split_batch_id");
|
||||
CALL phase3_inbound_add_column(
|
||||
'fl_inbound_measurement_item', 'split_item_id',
|
||||
"BIGINT UNSIGNED NULL COMMENT '产生该子单测量明细的拆分台账ID' AFTER measurement_id");
|
||||
CALL phase3_inbound_add_column(
|
||||
'fl_inbound_measurement_item', 'source_measurement_item_id',
|
||||
"BIGINT UNSIGNED NULL COMMENT '被拆分继承的来源测量箱规明细ID' AFTER split_item_id");
|
||||
CALL phase3_inbound_add_column(
|
||||
'fl_inbound_split_item', 'line_no',
|
||||
"INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一拆分批次内的行号' AFTER child_inbound_id");
|
||||
DROP PROCEDURE IF EXISTS phase3_inbound_add_column;
|
||||
|
||||
DROP PROCEDURE IF EXISTS phase3_inbound_add_relation;
|
||||
@@ -5716,6 +5913,12 @@ CALL phase3_inbound_add_relation(
|
||||
CALL phase3_inbound_add_relation(
|
||||
'fl_inbound_measurement', 'fk_measure_source_batch',
|
||||
'FOREIGN KEY (`source_measurement_id`) REFERENCES `fl_inbound_measurement` (`id`)');
|
||||
CALL phase3_inbound_add_relation(
|
||||
'fl_inbound_measurement_item', 'fk_measure_item_split_item',
|
||||
'FOREIGN KEY (`split_item_id`) REFERENCES `fl_inbound_split_item` (`id`)');
|
||||
CALL phase3_inbound_add_relation(
|
||||
'fl_inbound_measurement_item', 'fk_measure_item_source_item',
|
||||
'FOREIGN KEY (`source_measurement_item_id`) REFERENCES `fl_inbound_measurement_item` (`id`)');
|
||||
DROP PROCEDURE IF EXISTS phase3_inbound_add_relation;
|
||||
|
||||
DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
|
||||
@@ -5742,8 +5945,34 @@ DELIMITER ;
|
||||
CALL phase3_inbound_add_index(
|
||||
'fl_inbound_measurement', 'idx_measure_split_source',
|
||||
'KEY `idx_measure_split_source` (`tenant_id`, `split_batch_id`, `source_measurement_id`, `created_at`)');
|
||||
CALL phase3_inbound_add_index(
|
||||
'fl_inbound_measurement_item', 'idx_measure_item_split_source',
|
||||
'KEY `idx_measure_item_split_source` (`tenant_id`, `split_item_id`, `source_measurement_item_id`)');
|
||||
CALL phase3_inbound_add_index(
|
||||
'fl_inbound_split_item', 'uk_inbound_split_batch_line',
|
||||
'UNIQUE KEY `uk_inbound_split_batch_line` (`tenant_id`, `split_batch_id`, `line_no`)');
|
||||
CALL phase3_inbound_add_index(
|
||||
'fl_inbound_split_item', 'uk_inbound_split_batch_source_item',
|
||||
'UNIQUE KEY `uk_inbound_split_batch_source_item` (`tenant_id`, `split_batch_id`, `source_measurement_item_id`)');
|
||||
DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
|
||||
|
||||
DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE phase3_inbound_drop_split_legacy_index()
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
|
||||
AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_child') THEN
|
||||
ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_child;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
|
||||
AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_batch_child') THEN
|
||||
ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_batch_child;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL phase3_inbound_drop_split_legacy_index();
|
||||
DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_insert;
|
||||
DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_update;
|
||||
DROP TRIGGER IF EXISTS trg_inbound_split_item_before_insert;
|
||||
@@ -5762,6 +5991,9 @@ BEGIN
|
||||
IF NEW.inbound_role NOT IN ('MASTER', 'CHILD') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单层级非法';
|
||||
END IF;
|
||||
IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法';
|
||||
END IF;
|
||||
IF NEW.package_count IS NOT NULL AND NEW.package_count = 0 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单箱数必须大于0';
|
||||
END IF;
|
||||
@@ -5773,6 +6005,9 @@ BEGIN
|
||||
OR NEW.split_sequence IS NULL OR NEW.package_count IS NULL THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单缺少主单、顺序号或箱数';
|
||||
END IF;
|
||||
IF NEW.split_status <> 'NOT_SPLIT' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子仅两级,子入仓单不可继续拆分';
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM fl_inbound_order parent_order
|
||||
WHERE parent_order.id = NEW.parent_inbound_id
|
||||
@@ -5799,6 +6034,17 @@ BEGIN
|
||||
AND (NEW.package_count IS NULL OR NEW.package_count = 0) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单箱数必须大于0';
|
||||
END IF;
|
||||
IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法';
|
||||
END IF;
|
||||
IF NEW.inbound_role = 'CHILD' AND NEW.split_status <> 'NOT_SPLIT' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子仅两级,子入仓单不可继续拆分';
|
||||
END IF;
|
||||
IF NEW.inbound_role = 'MASTER'
|
||||
AND ((NEW.package_count = 0 AND NEW.split_status <> 'FULLY_SPLIT')
|
||||
OR (NEW.split_status = 'FULLY_SPLIT' AND COALESCE(NEW.package_count, 0) <> 0)) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单箱数归零必须与完全拆分状态同时提交';
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
CREATE TRIGGER trg_inbound_split_item_before_insert
|
||||
@@ -5819,8 +6065,17 @@ BEGIN
|
||||
AND child_order.tenant_id = NEW.tenant_id
|
||||
AND child_order.inbound_role = 'CHILD'
|
||||
AND child_order.parent_inbound_id = NEW.parent_inbound_id
|
||||
AND child_order.package_count = NEW.package_count
|
||||
AND child_order.package_count >= NEW.package_count
|
||||
AND child_order.deleted = 0
|
||||
AND (NEW.source_measurement_item_id IS NULL OR EXISTS (
|
||||
SELECT 1
|
||||
FROM fl_inbound_measurement_item source_item
|
||||
JOIN fl_inbound_measurement source_batch ON source_batch.id = source_item.measurement_id
|
||||
WHERE source_item.id = NEW.source_measurement_item_id
|
||||
AND source_item.tenant_id = NEW.tenant_id
|
||||
AND source_item.measurement_id = NEW.source_measurement_id
|
||||
AND source_batch.inbound_id = NEW.parent_inbound_id
|
||||
))
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分明细与批次、主单或子单不一致';
|
||||
END IF;
|
||||
@@ -5858,14 +6113,38 @@ BEGIN
|
||||
WHERE parent_order.id = NEW.parent_inbound_id
|
||||
AND parent_order.tenant_id = NEW.tenant_id
|
||||
AND parent_order.inbound_role = 'MASTER'
|
||||
AND parent_order.package_count = 0
|
||||
AND parent_order.package_count > 0
|
||||
AND parent_order.split_status = 'PARTIALLY_SPLIT'
|
||||
AND parent_order.version > NEW.expected_parent_version
|
||||
) OR
|
||||
(SELECT COALESCE(SUM(item.package_count), 0)
|
||||
FROM fl_inbound_split_item item
|
||||
WHERE item.tenant_id = NEW.tenant_id
|
||||
AND item.split_batch_id = NEW.id) <> NEW.package_count THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单可用箱数未归零或子单箱数合计不一致';
|
||||
AND item.split_batch_id = NEW.id) <> NEW.package_count OR
|
||||
(SELECT COUNT(DISTINCT item.child_inbound_id)
|
||||
FROM fl_inbound_split_item item
|
||||
WHERE item.tenant_id = NEW.tenant_id
|
||||
AND item.split_batch_id = NEW.id) <> 1 OR
|
||||
EXISTS (SELECT 1
|
||||
FROM fl_inbound_split_item item
|
||||
JOIN fl_inbound_order child_order ON child_order.id = item.child_inbound_id
|
||||
WHERE item.tenant_id = NEW.tenant_id
|
||||
AND item.split_batch_id = NEW.id
|
||||
GROUP BY child_order.id, child_order.package_count
|
||||
HAVING child_order.package_count <> SUM(item.package_count)) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入仓单或拆分明细箱数合计不一致';
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM fl_inbound_split_item split_item
|
||||
JOIN fl_inbound_measurement_item source_item
|
||||
ON source_item.id = split_item.source_measurement_item_id
|
||||
WHERE split_item.tenant_id = NEW.tenant_id
|
||||
AND split_item.split_batch_id = NEW.id
|
||||
GROUP BY split_item.source_measurement_item_id, source_item.package_count
|
||||
HAVING SUM(split_item.package_count) > source_item.package_count
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子单承接箱数超过来源测量箱规箱数';
|
||||
END IF;
|
||||
ELSEIF NEW.status = 'CANCELLED' THEN
|
||||
IF EXISTS (
|
||||
@@ -5937,7 +6216,8 @@ BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.table_constraints
|
||||
WHERE constraint_schema = DATABASE()
|
||||
AND constraint_name IN ('fk_inbound_parent_order', 'fk_inbound_root_order',
|
||||
'fk_measure_split_batch', 'fk_measure_source_batch')) <> 4 THEN
|
||||
'fk_measure_split_batch', 'fk_measure_source_batch',
|
||||
'fk_measure_item_split_item', 'fk_measure_item_source_item')) <> 6 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单与测量关联约束未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.triggers
|
||||
@@ -5965,386 +6245,3 @@ END$$
|
||||
DELIMITER ;
|
||||
CALL phase3_assert_inbound_parent_child_schema();
|
||||
DROP PROCEDURE IF EXISTS phase3_assert_inbound_parent_child_schema;
|
||||
|
||||
/*
|
||||
* 部分装柜拆单契约:
|
||||
* 1. parent_inbound_id 为空的是主单,非空的是唯一二级子单;
|
||||
* 2. 锁定阶段只记录箱数,完成阶段绑定由剩余箱数生成的子单;
|
||||
* 3. 子单不能再作为父单,锁定数量创建后不可修改。
|
||||
*/
|
||||
DROP PROCEDURE IF EXISTS phase3_split_add_column;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE phase3_split_add_column(
|
||||
IN p_table_name VARCHAR(64), IN p_column_name VARCHAR(64), IN p_definition TEXT
|
||||
)
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = p_table_name AND column_name = p_column_name
|
||||
) THEN
|
||||
SET @phase3_split_ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `',
|
||||
p_column_name, '` ', p_definition);
|
||||
PREPARE phase3_split_stmt FROM @phase3_split_ddl;
|
||||
EXECUTE phase3_split_stmt;
|
||||
DEALLOCATE PREPARE phase3_split_stmt;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
CALL phase3_split_add_column('fl_inbound_order', 'parent_inbound_id',
|
||||
'BIGINT UNSIGNED NULL COMMENT ''生成本单的主入库单ID;仅二级子单有值'' AFTER id');
|
||||
CALL phase3_split_add_column('fl_inbound_order', 'split_level',
|
||||
'TINYINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN parent_inbound_id IS NULL THEN 0 ELSE 1 END) STORED COMMENT ''拆单层级:0主单、1二级子单'' AFTER parent_inbound_id');
|
||||
CALL phase3_split_add_column('fl_inbound_order', 'split_status',
|
||||
'VARCHAR(24) NOT NULL DEFAULT ''NOT_SPLIT'' COMMENT ''NOT_SPLIT/PARTIALLY_SPLIT/FULLY_SPLIT'' AFTER split_level');
|
||||
DROP PROCEDURE IF EXISTS phase3_split_add_column;
|
||||
|
||||
SET @phase3_split_index_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order'
|
||||
AND index_name = 'uk_inbound_one_secondary'
|
||||
);
|
||||
SET @phase3_split_ddl = IF(@phase3_split_index_exists = 0,
|
||||
'ALTER TABLE fl_inbound_order ADD UNIQUE KEY uk_inbound_one_secondary (tenant_id, parent_inbound_id)',
|
||||
'SELECT 1');
|
||||
PREPARE phase3_split_stmt FROM @phase3_split_ddl;
|
||||
EXECUTE phase3_split_stmt;
|
||||
DEALLOCATE PREPARE phase3_split_stmt;
|
||||
|
||||
SET @phase3_split_fk_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.table_constraints
|
||||
WHERE constraint_schema = DATABASE() AND table_name = 'fl_inbound_order'
|
||||
AND constraint_name = 'fk_inbound_parent' AND constraint_type = 'FOREIGN KEY'
|
||||
);
|
||||
SET @phase3_split_ddl = IF(@phase3_split_fk_exists = 0,
|
||||
'ALTER TABLE fl_inbound_order ADD CONSTRAINT fk_inbound_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id)',
|
||||
'SELECT 1');
|
||||
PREPARE phase3_split_stmt FROM @phase3_split_ddl;
|
||||
EXECUTE phase3_split_stmt;
|
||||
DEALLOCATE PREPARE phase3_split_stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_inbound_split_detail (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '部分锁定明细ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
|
||||
parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入库单ID',
|
||||
child_inbound_id BIGINT UNSIGNED NULL COMMENT '完成拆单后生成的二级子入库单ID',
|
||||
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '原订单货物明细ID,不建立跨库外键',
|
||||
source_package_count INT UNSIGNED NOT NULL COMMENT '拆分前该明细总箱数快照',
|
||||
locked_package_count INT UNSIGNED NOT NULL COMMENT '本次装柜锁定箱数',
|
||||
remaining_package_count INT UNSIGNED GENERATED ALWAYS AS (
|
||||
source_package_count - locked_package_count
|
||||
) STORED COMMENT '生成二级子单的剩余箱数',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'LOCKED' COMMENT 'LOCKED/COMPLETED',
|
||||
operation_id VARCHAR(128) NOT NULL COMMENT '部分锁定操作幂等号',
|
||||
created_by BIGINT UNSIGNED NOT NULL,
|
||||
created_by_name VARCHAR(64) NULL,
|
||||
completed_by BIGINT UNSIGNED NULL,
|
||||
completed_by_name VARCHAR(64) NULL,
|
||||
completed_at DATETIME(3) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_inbound_split_parent_detail (tenant_id, parent_inbound_id, inbound_detail_id),
|
||||
UNIQUE KEY uk_inbound_split_child_detail (tenant_id, child_inbound_id, inbound_detail_id),
|
||||
UNIQUE KEY uk_inbound_split_operation (operation_id),
|
||||
KEY idx_inbound_split_status (tenant_id, parent_inbound_id, status),
|
||||
CONSTRAINT fk_inbound_split_detail_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id),
|
||||
CONSTRAINT fk_inbound_split_detail_child FOREIGN KEY (child_inbound_id) REFERENCES fl_inbound_order (id),
|
||||
CONSTRAINT chk_inbound_split_partial CHECK (
|
||||
source_package_count > 1 AND locked_package_count > 0
|
||||
AND locked_package_count < source_package_count
|
||||
),
|
||||
CONSTRAINT chk_inbound_split_status CHECK (status IN ('LOCKED', 'COMPLETED')),
|
||||
CONSTRAINT chk_inbound_split_completion CHECK (
|
||||
(status = 'LOCKED' AND child_inbound_id IS NULL AND completed_at IS NULL)
|
||||
OR (status = 'COMPLETED' AND child_inbound_id IS NOT NULL AND completed_at IS NOT NULL)
|
||||
)
|
||||
) ENGINE=InnoDB COMMENT='按箱数部分锁定及剩余箱数生成二级子入库单的不可变契约';
|
||||
|
||||
DROP PROCEDURE IF EXISTS unified_assert_final_extensions;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE unified_assert_final_extensions()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name IN ('fl_preload_plan', 'fl_split_operation',
|
||||
'fl_loading_evidence', 'fl_loading_operation',
|
||||
'fl_loading_pick_task')) <> 5 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:三期分柜、预装或装柜表未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_preload_plan'
|
||||
AND column_name IN ('id', 'tenant_id', 'inbound_id', 'status', 'version',
|
||||
'items_json', 'remark', 'created_by', 'created_by_name',
|
||||
'updated_by', 'updated_by_name', 'created_at', 'updated_at')) <> 13
|
||||
OR (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_split_operation'
|
||||
AND column_name IN ('id', 'tenant_id', 'inbound_id', 'operator_id', 'operation',
|
||||
'idempotency_key', 'status', 'result_json', 'created_at')) <> 9
|
||||
OR (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_evidence'
|
||||
AND column_name IN ('id', 'tenant_id', 'inbound_id', 'evidence_type', 'file_id',
|
||||
'file_name', 'seal_no', 'remark', 'version', 'created_by',
|
||||
'created_by_name', 'updated_by', 'updated_by_name',
|
||||
'created_at', 'updated_at')) <> 15
|
||||
OR (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_operation'
|
||||
AND column_name IN ('id', 'tenant_id', 'inbound_id', 'operator_id', 'operation',
|
||||
'idempotency_key', 'request_hash', 'status', 'result_json',
|
||||
'created_at', 'completed_at')) <> 11
|
||||
OR (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
|
||||
AND column_name IN ('id', 'tenant_id', 'inbound_id', 'status', 'version',
|
||||
'expected_count', 'picked_count', 'remark', 'updated_by',
|
||||
'updated_by_name', 'created_at', 'updated_at')) <> 12 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:三期分柜、预装或装柜关键字段未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name IN ('fl_preload_plan', 'fl_split_operation', 'fl_loading_operation')
|
||||
AND column_name IN ('items_json', 'result_json') AND data_type = 'json') <> 3
|
||||
OR (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_order'
|
||||
AND column_name = 'process_status' AND character_maximum_length >= 24) <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:三期 JSON 字段或入库状态字段类型不兼容';
|
||||
END IF;
|
||||
IF (SELECT COUNT(DISTINCT CONCAT(table_name, '.', index_name))
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND index_name IN ('uk_preload_tenant_inbound', 'idx_preload_status',
|
||||
'uk_split_operation', 'idx_split_operation_time',
|
||||
'uk_loading_evidence_type', 'idx_loading_evidence_time',
|
||||
'uk_loading_operation', 'idx_loading_operation_time',
|
||||
'uk_loading_pick_tenant_inbound', 'idx_loading_pick_status')) <> 10 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:三期分柜、预装或装柜索引未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.table_constraints
|
||||
WHERE constraint_schema = 'baiyue_firstleg' AND constraint_type = 'FOREIGN KEY'
|
||||
AND table_name IN ('fl_preload_plan', 'fl_split_operation',
|
||||
'fl_loading_evidence', 'fl_loading_operation',
|
||||
'fl_loading_pick_task')) <> 5 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:三期操作表入库单外键未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission
|
||||
WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0
|
||||
AND permission_code IN (
|
||||
'firstleg:inbound:split', 'firstleg:inbound:preload',
|
||||
'firstleg:inbound:preload-view',
|
||||
'platform:firstleg:inbound:loading:edit',
|
||||
'platform:firstleg:inbound:loading:view',
|
||||
'platform:firstleg:inbound:loading:complete',
|
||||
'platform:firstleg:inbound:delivery:complete',
|
||||
'platform:firstleg:inbound:picking:edit',
|
||||
'platform:firstleg:inbound:picking:view')) <> 9 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:三期分柜、预装或装柜权限未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(DISTINCT rp.permission_id)
|
||||
FROM baiyue_iam.iam_role_permission rp
|
||||
JOIN baiyue_iam.iam_permission p ON p.id = rp.permission_id
|
||||
WHERE rp.role_id = 4000001 AND rp.tenant_id = 0
|
||||
AND p.permission_code IN (
|
||||
'firstleg:inbound:split', 'firstleg:inbound:preload',
|
||||
'firstleg:inbound:preload-view',
|
||||
'platform:firstleg:inbound:loading:edit',
|
||||
'platform:firstleg:inbound:loading:view',
|
||||
'platform:firstleg:inbound:loading:complete',
|
||||
'platform:firstleg:inbound:delivery:complete',
|
||||
'platform:firstleg:inbound:picking:edit',
|
||||
'platform:firstleg:inbound:picking:view')) <> 9 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:平台系统管理员未获得完整三期操作权限';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name IN ('fl_fee_item', 'fl_inbound_fee_detail')) <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:费用项目或入库费用明细表未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name IN ('fl_fee_item', 'fl_inbound_fee_detail')
|
||||
AND column_name = 'tenant_id'
|
||||
AND column_comment = '当前客户ID(客户隔离键);物理字段名沿用 tenant_id,当前不表示上级租户') <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:费用表 tenant_id 客户隔离语义未同步';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name = 'fl_fee_item'
|
||||
AND column_name = 'fee_code') <> 1
|
||||
OR (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name = 'fl_inbound_fee_detail'
|
||||
AND column_name IN (
|
||||
'source_type', 'source_fee_id', 'source_fee_version', 'source_fee_no',
|
||||
'customer_id', 'customer_name', 'invoice_title',
|
||||
'chargeable_quantity', 'charge_unit', 'publication_status',
|
||||
'published_by', 'published_by_name', 'published_at', 'inbounded_at',
|
||||
'settlement_included')) <> 15 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:已发布入库运费明细字段未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name = 'fl_fee_item'
|
||||
AND index_name = 'uk_fee_item_code' AND non_unique = 0) <> 2
|
||||
OR (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name = 'fl_inbound_fee_detail'
|
||||
AND index_name = 'uk_inbound_fee_source' AND non_unique = 0) <> 3
|
||||
OR (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name = 'fl_inbound_fee_detail'
|
||||
AND index_name = 'idx_inbound_fee_published') <> 4 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:已发布入库运费明细索引未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(DISTINCT CONCAT(table_name, '.', index_name))
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND index_name IN ('idx_fee_item_query', 'idx_fee_item_name',
|
||||
'idx_inbound_fee_detail', 'idx_inbound_fee_review',
|
||||
'idx_inbound_fee_item')) <> 5 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:费用项目或入库费用明细索引未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission
|
||||
WHERE tenant_id = 0 AND status = 'ACTIVE' AND deleted = 0
|
||||
AND permission_code IN (
|
||||
'firstleg:fee-item:view', 'firstleg:fee-item:create',
|
||||
'firstleg:fee-item:edit', 'firstleg:fee-item:delete',
|
||||
'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit',
|
||||
'firstleg:inbound:fee-detail:delete', 'firstleg:inbound:fee-detail:review')) <> 8 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:费用项目或入库费用明细权限未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema IN ('baiyue_auth', 'baiyue_iam', 'baiyue_order',
|
||||
'baiyue_firstleg', 'baiyue_warehouse')
|
||||
AND table_name = 'sys_audit_outbox') <> 5 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:可靠审计 Outbox 未覆盖五个业务库';
|
||||
END IF;
|
||||
IF (SELECT COUNT(DISTINCT CONCAT(table_schema, '.', index_name))
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema IN ('baiyue_auth', 'baiyue_iam', 'baiyue_order',
|
||||
'baiyue_firstleg', 'baiyue_warehouse')
|
||||
AND table_name = 'sys_audit_outbox'
|
||||
AND index_name IN ('PRIMARY', 'idx_audit_outbox_publish',
|
||||
'idx_audit_outbox_retention')) <> 15 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:可靠审计 Outbox 主键或索引未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema IN ('baiyue_auth', 'baiyue_iam', 'baiyue_order',
|
||||
'baiyue_firstleg', 'baiyue_warehouse')
|
||||
AND table_name = 'sys_audit_outbox'
|
||||
AND column_name IN ('event_id', 'payload_json', 'status',
|
||||
'next_retry_at', 'lease_until')) <> 25 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:可靠审计 Outbox 关键字段未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = 'baiyue_audit'
|
||||
AND table_name = 'sys_audit_dead_letter') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:审计死信补偿表未创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_audit'
|
||||
AND table_name = 'sys_audit_dead_letter'
|
||||
AND index_name IN ('PRIMARY', 'uk_audit_dead_event', 'idx_audit_dead_retry')) <> 3 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:审计死信补偿表主键或索引未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_audit' AND table_name = 'sys_audit_log'
|
||||
AND column_name = 'event_id') <> 1
|
||||
OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_audit' AND table_name = 'sys_audit_log'
|
||||
AND index_name = 'uk_audit_event_id' AND non_unique = 0) <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:审计消费幂等 event_id 唯一约束未就绪';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name = 'fl_third_party_call_log'
|
||||
AND column_name = 'tenant_id' AND column_default = '0'
|
||||
AND column_comment = '第三方未知/未解析主体哨兵值;不是平台客户记录或平台作用域,不得用于业务数据授权') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:第三方未知主体 tenant_id=0 哨兵语义未就绪';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM baiyue_warehouse.wh_warehouse WHERE tenant_id <> 0) > 0
|
||||
OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_warehouse_address WHERE tenant_id <> 0) > 0
|
||||
OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_service_capability WHERE tenant_id <> 0) > 0
|
||||
OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_express_company WHERE tenant_id <> 0) > 0
|
||||
OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_partner
|
||||
WHERE tenant_id <> 0 OR data_scope_id <> 0) > 0
|
||||
OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_partner_type
|
||||
WHERE tenant_id <> 0 OR data_scope_id <> 0) > 0
|
||||
OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_pallet WHERE tenant_id <> 0) > 0
|
||||
OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_location WHERE tenant_id <> 0) > 0
|
||||
OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_location_reservation WHERE tenant_id <> 0) > 0
|
||||
OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_location_occupancy WHERE tenant_id <> 0) > 0
|
||||
OR (SELECT COUNT(*) FROM baiyue_firstleg.fl_inbound_pallet_allocation
|
||||
WHERE warehouse_resource_tenant_id <> 0) > 0 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:平台公共仓储资源仍有 tenant_id<>0';
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM baiyue_warehouse.wh_warehouse_address a
|
||||
JOIN baiyue_warehouse.wh_warehouse w ON w.id = a.warehouse_id
|
||||
WHERE a.tenant_id <> w.tenant_id
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM baiyue_warehouse.wh_service_capability c
|
||||
JOIN baiyue_warehouse.wh_warehouse w ON w.id = c.warehouse_id
|
||||
WHERE c.tenant_id <> w.tenant_id
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM baiyue_warehouse.wh_pallet p
|
||||
JOIN baiyue_warehouse.wh_warehouse w ON w.id = p.warehouse_id
|
||||
WHERE p.tenant_id <> w.tenant_id
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM baiyue_warehouse.wh_location l
|
||||
JOIN baiyue_warehouse.wh_warehouse w ON w.id = l.warehouse_id
|
||||
WHERE l.tenant_id <> w.tenant_id
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM baiyue_warehouse.wh_location_reservation r
|
||||
JOIN baiyue_warehouse.wh_warehouse w ON w.id = r.warehouse_id
|
||||
JOIN baiyue_warehouse.wh_pallet p ON p.id = r.pallet_id
|
||||
JOIN baiyue_warehouse.wh_location l ON l.id = r.location_id
|
||||
WHERE r.tenant_id <> w.tenant_id OR r.tenant_id <> p.tenant_id OR r.tenant_id <> l.tenant_id
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM baiyue_warehouse.wh_location_occupancy o
|
||||
JOIN baiyue_warehouse.wh_warehouse w ON w.id = o.warehouse_id
|
||||
JOIN baiyue_warehouse.wh_pallet p ON p.id = o.pallet_id
|
||||
JOIN baiyue_warehouse.wh_location l ON l.id = o.location_id
|
||||
JOIN baiyue_warehouse.wh_location_reservation r ON r.id = o.reservation_id
|
||||
WHERE o.tenant_id <> w.tenant_id OR o.tenant_id <> p.tenant_id
|
||||
OR o.tenant_id <> l.tenant_id OR o.tenant_id <> r.tenant_id
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:Warehouse 公共资源父子归属不一致';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM baiyue_warehouse.wh_overseas_storage_fee_rule WHERE tenant_id = 0) THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:客户费用规则缺少客户 tenant_id';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL unified_assert_final_extensions();
|
||||
DROP PROCEDURE IF EXISTS unified_assert_final_extensions;
|
||||
|
||||
-- tenant_id=0 在此表仅为未知/未解析第三方主体;数量可非零,但不得用于业务授权。
|
||||
SELECT COUNT(*) AS unresolved_third_party_calls
|
||||
FROM baiyue_firstleg.fl_third_party_call_log
|
||||
WHERE tenant_id = 0;
|
||||
|
||||
SELECT 'PASS' AS migration_status,
|
||||
'统一增量全部执行完成;统一数据库脚本执行完成,三期出仓、费用、公共资源及可靠审计结构校验通过' AS message;
|
||||
|
||||
@@ -8,7 +8,11 @@ import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
/**
|
||||
* 头程履约微服务启动入口,负责装配履约任务组件并扫描 MyBatis-Plus Mapper。
|
||||
*/
|
||||
@MapperScan("com.baiyue.firstleg.mapper")
|
||||
@MapperScan(basePackages = {
|
||||
"com.baiyue.firstleg.mapper",
|
||||
"com.baiyue.firstleg.split.mapper",
|
||||
"com.baiyue.firstleg.loading.mapper"
|
||||
})
|
||||
@EnableFeignClients(basePackages = "com.baiyue.firstleg.client")
|
||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||
public class BaiyueFirstlegApplication {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.firstleg.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
/** 获取平台仓库主数据,供虚拟柜校验国内中转仓使用。 */
|
||||
@FeignClient(name = "warehouse-service", contextId = "firstlegWarehouseMasterDataClient",
|
||||
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouses")
|
||||
public interface WarehouseMasterDataClient {
|
||||
@GetMapping("/{tenantId}/{warehouseId}/active")
|
||||
ApiResponse<Detail> active(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@PathVariable long tenantId,
|
||||
@PathVariable long warehouseId);
|
||||
|
||||
record Detail(Warehouse warehouse) {
|
||||
}
|
||||
|
||||
record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName,
|
||||
String warehouseType, String status) {
|
||||
}
|
||||
}
|
||||
+9
-5
@@ -26,6 +26,7 @@ import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */
|
||||
@@ -50,9 +51,10 @@ public class PlatformLoadingListController {
|
||||
@GetMapping("/loading-plans/{planId}")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:preload:view')")
|
||||
public ApiResponse<PreloadDetail> preloadDetail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId) {
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
|
||||
@RequestParam @Positive long warehouseId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.preloadDetail(planId));
|
||||
return ApiResponse.success(service.preloadDetail(planId, warehouseId));
|
||||
}
|
||||
|
||||
@Operation(summary = "修改装柜运输字段", description = "仅装柜中可修改集装箱号与航次。")
|
||||
@@ -61,11 +63,12 @@ public class PlatformLoadingListController {
|
||||
public ApiResponse<PreloadDetail> updateTransport(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long planId,
|
||||
@RequestParam @Positive long warehouseId,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody TransportUpdateRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.updateTransport(
|
||||
planId, user.userId(), user.username(), idempotencyKey, request));
|
||||
planId, warehouseId, user.userId(), user.username(), idempotencyKey, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "分页查询完成装柜清单", description = "只读取 COMPLETED 状态的装柜计划。")
|
||||
@@ -81,8 +84,9 @@ public class PlatformLoadingListController {
|
||||
@GetMapping("/loading-lists/{planId}")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:view')")
|
||||
public ApiResponse<ConfirmedDetail> confirmedDetail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId) {
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
|
||||
@RequestParam @Positive long warehouseId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.confirmedDetail(planId));
|
||||
return ApiResponse.success(service.confirmedDetail(planId, warehouseId));
|
||||
}
|
||||
}
|
||||
|
||||
+25
-2
@@ -3,6 +3,8 @@ package com.baiyue.firstleg.controller;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import com.baiyue.firstleg.service.LoadingPickService;
|
||||
@@ -31,9 +33,10 @@ public class PlatformLoadingPdaController {
|
||||
@GetMapping("/tasks")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
|
||||
public ApiResponse<Task> task(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam String containerNo) {
|
||||
@RequestParam String containerNo,
|
||||
@RequestParam @Positive long warehouseId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.task(containerNo));
|
||||
return ApiResponse.success(service.task(containerNo, warehouseId));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/items/{itemId}/pick")
|
||||
@@ -45,4 +48,24 @@ public class PlatformLoadingPdaController {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.pick(taskId, itemId, request, user.userId(), user.username()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/items/{itemId}/partial")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
|
||||
public ApiResponse<Task> partial(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long taskId,
|
||||
@PathVariable @Positive long itemId,
|
||||
@Valid @RequestBody PartialPickRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.partial(taskId, itemId, request, user.userId(), user.username()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/items/{itemId}/skip")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
|
||||
public ApiResponse<Task> skip(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long taskId,
|
||||
@PathVariable @Positive long itemId,
|
||||
@Valid @RequestBody SkipRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.skip(taskId, itemId, request, user.userId(), user.username()));
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -9,7 +9,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; imp
|
||||
public class PlatformSmartLoadingController {
|
||||
@Resource private SmartLoadingService service;
|
||||
@GetMapping("/candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
|
||||
public ApiResponse<List<Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long accountId){FirstlegScopeGuard.platform(u,accountId);return ApiResponse.success(service.candidates(accountId));}
|
||||
public ApiResponse<List<Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long warehouseId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.candidates(warehouseId));}
|
||||
@GetMapping("/plans/by-booking/{bookingId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
|
||||
public ApiResponse<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')")
|
||||
@@ -17,13 +17,13 @@ public class PlatformSmartLoadingController {
|
||||
@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()));}
|
||||
public ApiResponse<Plan> assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));}
|
||||
@PostMapping("/plans/{planId}/recommend") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
|
||||
public ApiResponse<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()));}
|
||||
public ApiResponse<Plan> recommend(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody RecommendRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.recommend(planId,r,u.userId(),u.username()));}
|
||||
@PatchMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
|
||||
public ApiResponse<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()));}
|
||||
public ApiResponse<Plan> adjust(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody AdjustRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.adjust(planId,itemId,r,u.userId(),u.username()));}
|
||||
@DeleteMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
|
||||
public ApiResponse<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()));}
|
||||
public ApiResponse<Plan> delete(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody DeleteRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.delete(planId,itemId,r,u.userId(),u.username()));}
|
||||
@PostMapping("/plans/{planId}/push-preload") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:push')")
|
||||
public ApiResponse<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()));}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ public final class ContainerDtos {
|
||||
@Size(max = 64) String containerNo,
|
||||
@NotBlank @Size(max = 64) String cabinetNo,
|
||||
@NotNull @Positive Long warehouseId,
|
||||
@NotBlank @Size(max = 128) String warehouseName,
|
||||
@Size(max = 128) String warehouseName,
|
||||
@Size(max = 128) String billOfLadingNo,
|
||||
@Size(max = 128) String vesselName,
|
||||
@Size(max = 64) String voyageNo,
|
||||
|
||||
@@ -17,8 +17,7 @@ public final class LoadingListDtos {
|
||||
public record PreloadQuery(
|
||||
@Size(max = 128) String keyword,
|
||||
String status,
|
||||
@Min(1) Long accountId,
|
||||
@Min(1) Long warehouseId,
|
||||
@NotNull @Min(1) Long warehouseId,
|
||||
LocalDateTime createdFrom,
|
||||
LocalDateTime createdTo,
|
||||
@Min(1) long page,
|
||||
@@ -32,8 +31,7 @@ public final class LoadingListDtos {
|
||||
|
||||
public record ConfirmedQuery(
|
||||
@Size(max = 128) String keyword,
|
||||
@Min(1) Long accountId,
|
||||
@Min(1) Long warehouseId,
|
||||
@NotNull @Min(1) Long warehouseId,
|
||||
LocalDateTime createdFrom,
|
||||
LocalDateTime createdTo,
|
||||
@Min(1) long page,
|
||||
|
||||
@@ -14,6 +14,7 @@ public final class LoadingPickDtos {
|
||||
}
|
||||
|
||||
public record PickRequest(
|
||||
@NotNull @Min(1) Long warehouseId,
|
||||
@NotNull @Min(1) Integer pickedPackageCount,
|
||||
@JsonAlias("reservationIds") List<@Min(1) Long> locationIds,
|
||||
@NotNull @Min(0) Integer version,
|
||||
@@ -21,6 +22,25 @@ public final class LoadingPickDtos {
|
||||
) {
|
||||
}
|
||||
|
||||
/** 部分拣货为该 PDA 明细的最终实际拣出箱数,不是增量。 */
|
||||
public record PartialPickRequest(
|
||||
@NotNull @Min(1) Long warehouseId,
|
||||
@NotNull @Min(1) Integer finalPickedPackageCount,
|
||||
@JsonAlias("reservationIds") List<@Min(1) Long> locationIds,
|
||||
@NotNull @Min(0) Integer version,
|
||||
@NotBlank @Size(max = 128) String operationId
|
||||
) {
|
||||
}
|
||||
|
||||
/** 跳拣只能发生在尚未拣出任何箱数的 PDA 明细上。 */
|
||||
public record SkipRequest(
|
||||
@NotNull @Min(1) Long warehouseId,
|
||||
@NotNull @Min(0) Integer version,
|
||||
@NotBlank @Size(max = 128) String operationId,
|
||||
@Size(max = 500) String reason
|
||||
) {
|
||||
}
|
||||
|
||||
public record PickItem(Long id, Long loadingPlanItemId, Long inboundId, Long inboundDetailId,
|
||||
Integer plannedPackageCount, Integer pickedPackageCount,
|
||||
String itemStatus, Integer version) {
|
||||
|
||||
@@ -11,13 +11,13 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
|
||||
}
|
||||
}
|
||||
public record CreateRequest(@NotNull @Positive Long bookingId, @Size(max=500) String remark) {}
|
||||
public record AssignRequest(@NotNull @Positive Long accountId,@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId,
|
||||
public record AssignRequest(@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId,
|
||||
@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {}
|
||||
public record RecommendRequest(@NotNull @Positive Long accountId,@NotNull @Min(0) Integer expectedVersion,
|
||||
public record RecommendRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotBlank @Size(max=128) String operationId) {}
|
||||
public record AdjustRequest(@NotNull @Positive Long accountId,@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedPlanVersion,
|
||||
public record AdjustRequest(@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedPlanVersion,
|
||||
@NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
|
||||
public record DeleteRequest(@NotNull @Positive Long accountId,@NotNull @Min(0) Integer expectedPlanVersion,
|
||||
public record DeleteRequest(@NotNull @Min(0) Integer expectedPlanVersion,
|
||||
@NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
|
||||
public record PushRequest(@NotNull @Min(0) Integer expectedVersion) {}
|
||||
public record Plan(Long id,String planNo,String status,Long bookingId,String containerNo,String voyageNo,Integer version,List<LoadingPlanItemEntity> items) {}
|
||||
|
||||
@@ -17,7 +17,9 @@ public class LoadingPickHistoryEntity {
|
||||
private Long pickItemId;
|
||||
private String operationId;
|
||||
private String requestHash;
|
||||
private String actionType;
|
||||
private Integer pickedPackageCount;
|
||||
private String operationReason;
|
||||
private Long operatorId;
|
||||
private String operatorName;
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> {
|
||||
public interface InboundLoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> {
|
||||
@Update("UPDATE fl_loading_pick_task SET status=#{toStatus}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, "
|
||||
+ "remark=COALESCE(#{remark}, remark), expected_count=COALESCE(#{expectedCount}, expected_count), "
|
||||
+ "picked_count=COALESCE(#{pickedCount}, picked_count), version=version+1 WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} "
|
||||
+2
-2
@@ -7,7 +7,7 @@ import com.baiyue.firstleg.loading.entity.LoadingOperationEntity;
|
||||
import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper;
|
||||
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
|
||||
import com.baiyue.firstleg.loading.mapper.LoadingOperationMapper;
|
||||
import com.baiyue.firstleg.loading.mapper.LoadingPickTaskMapper;
|
||||
import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper;
|
||||
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
@@ -19,7 +19,7 @@ public class LoadingRepository {
|
||||
@Resource private LoadingInboundMapper inbounds;
|
||||
@Resource private LoadingEvidenceMapper evidences;
|
||||
@Resource private LoadingOperationMapper operations;
|
||||
@Resource private LoadingPickTaskMapper picks;
|
||||
@Resource private InboundLoadingPickTaskMapper picks;
|
||||
|
||||
public LoadingInboundMapper.Snapshot findInbound(long tenantId, long inboundId) {
|
||||
return inbounds.find(tenantId, inboundId);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** PDA部分拣货结算的客户归属锁定查询。 */
|
||||
@Mapper
|
||||
public interface InboundSplitSettlementMapper {
|
||||
@Select("""
|
||||
SELECT id, tenant_id AS tenantId, inbound_code AS inboundCode, inbound_role AS inboundRole,
|
||||
process_status AS processStatus, version
|
||||
FROM fl_inbound_order
|
||||
WHERE tenant_id = #{accountId} AND id = #{inboundId} AND deleted = 0
|
||||
FOR UPDATE
|
||||
""")
|
||||
ParentRow lockParent(@Param("accountId") long accountId, @Param("inboundId") long inboundId);
|
||||
|
||||
@Select("""
|
||||
SELECT id
|
||||
FROM fl_inbound_order
|
||||
WHERE tenant_id = #{accountId} AND parent_inbound_id = #{parentInboundId}
|
||||
AND inbound_role = 'CHILD' AND deleted = 0
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
""")
|
||||
Long childId(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId);
|
||||
|
||||
@Select({"<script>", """
|
||||
SELECT item.id AS measurementItemId, measurement.id AS measurementId,
|
||||
item.inbound_detail_id AS inboundDetailId, item.package_count AS packageCount
|
||||
FROM fl_inbound_measurement measurement
|
||||
JOIN fl_inbound_measurement_item item ON item.measurement_id = measurement.id
|
||||
WHERE measurement.tenant_id = #{accountId} AND measurement.inbound_id = #{inboundId}
|
||||
AND measurement.status = 'APPROVED' AND item.package_count > 0
|
||||
AND measurement.id = (
|
||||
SELECT MAX(latest.id) FROM fl_inbound_measurement latest
|
||||
WHERE latest.tenant_id = #{accountId} AND latest.inbound_id = #{inboundId}
|
||||
AND latest.status = 'APPROVED')
|
||||
AND item.inbound_detail_id IN
|
||||
""", "<foreach collection='detailIds' item='detailId' open='(' separator=',' close=')'>",
|
||||
"#{detailId}", "</foreach>", "FOR UPDATE", "</script>"})
|
||||
List<MeasurementItemRow> lockLatestApprovedItems(@Param("accountId") long accountId,
|
||||
@Param("inboundId") long inboundId,
|
||||
@Param("detailIds") List<Long> detailIds);
|
||||
|
||||
record ParentRow(Long id, Long tenantId, String inboundCode, String inboundRole,
|
||||
String processStatus, Integer version) {
|
||||
}
|
||||
|
||||
record MeasurementItemRow(Long measurementItemId, Long measurementId, Long inboundDetailId,
|
||||
Integer packageCount) {
|
||||
}
|
||||
}
|
||||
@@ -53,11 +53,6 @@ public interface LoadingListQueryMapper {
|
||||
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
|
||||
@@ -81,11 +76,6 @@ public interface LoadingListQueryMapper {
|
||||
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
|
||||
|
||||
@@ -9,6 +9,32 @@ import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity> {
|
||||
@Select("""
|
||||
SELECT pick_item.*
|
||||
FROM fl_loading_pick_item pick_item
|
||||
JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id
|
||||
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
|
||||
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
|
||||
WHERE pick_item.pick_task_id = #{taskId} AND booking.warehouse_id = #{warehouseId}
|
||||
AND plan_item.deleted = 0
|
||||
ORDER BY pick_item.id ASC
|
||||
""")
|
||||
java.util.List<LoadingPickItemEntity> selectTaskItemsWithAccount(@Param("taskId") long taskId,
|
||||
@Param("warehouseId") long warehouseId);
|
||||
|
||||
@Select("""
|
||||
SELECT pick_item.*
|
||||
FROM fl_loading_pick_item pick_item
|
||||
JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id
|
||||
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
|
||||
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
|
||||
WHERE pick_item.pick_task_id = #{taskId} AND pick_item.id = #{itemId}
|
||||
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0
|
||||
""")
|
||||
LoadingPickItemEntity selectTaskItemWithAccount(@Param("taskId") long taskId,
|
||||
@Param("itemId") long itemId,
|
||||
@Param("warehouseId") long warehouseId);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_loading_pick_item
|
||||
SET picked_package_count = picked_package_count + #{count},
|
||||
@@ -16,9 +42,52 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
|
||||
THEN 'PICKED' ELSE 'PICKING' END,
|
||||
version = version + 1
|
||||
WHERE id = #{id} AND version = #{version}
|
||||
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
|
||||
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
|
||||
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
|
||||
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
|
||||
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
|
||||
AND picked_package_count + #{count} <= planned_package_count
|
||||
""")
|
||||
int pick(@Param("id") long id, @Param("count") int count, @Param("version") int version);
|
||||
int pick(@Param("id") long id, @Param("count") int count, @Param("version") int version,
|
||||
@Param("warehouseId") long warehouseId);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_loading_pick_item
|
||||
SET picked_package_count = #{finalCount}, item_status = 'PARTIAL', version = version + 1
|
||||
WHERE id = #{id} AND version = #{version}
|
||||
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
|
||||
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
|
||||
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
|
||||
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
|
||||
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
|
||||
AND item_status IN ('PENDING', 'PICKING')
|
||||
AND picked_package_count < #{finalCount}
|
||||
AND #{finalCount} < planned_package_count
|
||||
""")
|
||||
int partial(@Param("id") long id, @Param("finalCount") int finalCount, @Param("version") int version,
|
||||
@Param("warehouseId") long warehouseId);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_loading_pick_item
|
||||
SET item_status = 'SKIPPED', version = version + 1
|
||||
WHERE id = #{id} AND version = #{version}
|
||||
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
|
||||
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
|
||||
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
|
||||
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
|
||||
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
|
||||
AND item_status = 'PENDING' AND picked_package_count = 0
|
||||
""")
|
||||
int skip(@Param("id") long id, @Param("version") int version, @Param("warehouseId") long warehouseId);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*)
|
||||
FROM fl_loading_pick_item
|
||||
WHERE pick_task_id = #{taskId}
|
||||
AND item_status NOT IN ('PICKED', 'PARTIAL', 'SKIPPED')
|
||||
""")
|
||||
long unresolvedCount(@Param("taskId") long taskId);
|
||||
|
||||
/**
|
||||
* 以装柜计划的全部有效明细为完成口径,不能只检查当前 PDA 任务中的明细。
|
||||
|
||||
@@ -3,7 +3,21 @@ package com.baiyue.firstleg.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> {
|
||||
@Select("""
|
||||
SELECT DISTINCT task.*
|
||||
FROM fl_loading_pick_task task
|
||||
JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id = task.id
|
||||
JOIN fl_loading_plan plan ON plan.id = task.loading_plan_id
|
||||
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
|
||||
WHERE task.container_no = #{containerNo} AND task.task_status = 'LOADING'
|
||||
AND booking.warehouse_id = #{warehouseId}
|
||||
LIMIT 1
|
||||
""")
|
||||
LoadingPickTaskEntity selectLoadingByContainerAndAccount(@Param("containerNo") String containerNo,
|
||||
@Param("warehouseId") long warehouseId);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,14 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
|
||||
o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName,
|
||||
o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName,
|
||||
i.package_count-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
|
||||
WHERE li.account_id=o.tenant_id AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
|
||||
WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id
|
||||
AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
|
||||
-COALESCE((SELECT SUM(split_item.package_count)
|
||||
FROM fl_inbound_split_item split_item
|
||||
WHERE o.inbound_role='MASTER'
|
||||
AND split_item.tenant_id=o.tenant_id
|
||||
AND split_item.parent_inbound_id=o.id
|
||||
AND split_item.source_measurement_item_id=i.id),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
|
||||
COALESCE(i.unit_volume_cbm,0) unitVolumeCbm
|
||||
FROM fl_inbound_order o
|
||||
JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id
|
||||
@@ -16,13 +23,20 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
|
||||
AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2
|
||||
WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED')
|
||||
JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
|
||||
WHERE o.tenant_id=#{accountId} AND o.process_status='INBOUNDED'
|
||||
WHERE o.warehouse_id=#{warehouseId} AND o.process_status='INBOUNDED'
|
||||
AND i.inbound_detail_id IS NOT NULL
|
||||
AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
|
||||
WHERE li.account_id=o.tenant_id AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
|
||||
WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id
|
||||
AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
|
||||
+COALESCE((SELECT SUM(split_item.package_count)
|
||||
FROM fl_inbound_split_item split_item
|
||||
WHERE o.inbound_role='MASTER'
|
||||
AND split_item.tenant_id=o.tenant_id
|
||||
AND split_item.parent_inbound_id=o.id
|
||||
AND split_item.source_measurement_item_id=i.id),0)
|
||||
ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC
|
||||
""")
|
||||
List<Candidate> candidates(@Param("accountId") long accountId);
|
||||
List<Candidate> candidates(@Param("warehouseId") long warehouseId);
|
||||
|
||||
@Select("""
|
||||
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
|
||||
@@ -31,7 +45,14 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
|
||||
o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName,
|
||||
o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName,
|
||||
i.package_count-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
|
||||
WHERE li.account_id=o.tenant_id AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
|
||||
WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id
|
||||
AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
|
||||
-COALESCE((SELECT SUM(split_item.package_count)
|
||||
FROM fl_inbound_split_item split_item
|
||||
WHERE o.inbound_role='MASTER'
|
||||
AND split_item.tenant_id=o.tenant_id
|
||||
AND split_item.parent_inbound_id=o.id
|
||||
AND split_item.source_measurement_item_id=i.id),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
|
||||
COALESCE(i.unit_volume_cbm,0) unitVolumeCbm
|
||||
FROM fl_inbound_order o
|
||||
JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id
|
||||
@@ -39,10 +60,10 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
|
||||
AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2
|
||||
WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED')
|
||||
JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
|
||||
WHERE o.tenant_id=#{accountId} AND o.id=#{inboundId} AND o.process_status='INBOUNDED'
|
||||
WHERE o.warehouse_id=#{warehouseId} AND o.id=#{inboundId} AND o.process_status='INBOUNDED'
|
||||
AND i.inbound_detail_id=#{detailId}
|
||||
ORDER BY m.id DESC LIMIT 1 FOR UPDATE
|
||||
""")
|
||||
Candidate candidate(@Param("accountId") long accountId,@Param("inboundId") long inboundId,
|
||||
Candidate candidate(@Param("warehouseId") long warehouseId,@Param("inboundId") long inboundId,
|
||||
@Param("detailId") long detailId);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ public class LoadingPickRepository {
|
||||
.eq(LoadingPickTaskEntity::getTaskStatus, "LOADING"));
|
||||
}
|
||||
|
||||
public LoadingPickTaskEntity taskByContainer(String containerNo, long warehouseId) {
|
||||
return tasks.selectLoadingByContainerAndAccount(containerNo, warehouseId);
|
||||
}
|
||||
|
||||
public LoadingPickItemEntity item(long taskId, long itemId) {
|
||||
return items.selectOne(new LambdaQueryWrapper<LoadingPickItemEntity>()
|
||||
.eq(LoadingPickItemEntity::getPickTaskId, taskId)
|
||||
@@ -50,6 +54,14 @@ public class LoadingPickRepository {
|
||||
.orderByAsc(LoadingPickItemEntity::getId));
|
||||
}
|
||||
|
||||
public List<LoadingPickItemEntity> items(long taskId, long warehouseId) {
|
||||
return items.selectTaskItemsWithAccount(taskId, warehouseId);
|
||||
}
|
||||
|
||||
public LoadingPickItemEntity item(long taskId, long itemId, long warehouseId) {
|
||||
return items.selectTaskItemWithAccount(taskId, itemId, warehouseId);
|
||||
}
|
||||
|
||||
public void insert(LoadingPickTaskEntity task) {
|
||||
tasks.insert(task);
|
||||
}
|
||||
@@ -63,8 +75,20 @@ public class LoadingPickRepository {
|
||||
.eq(LoadingPickItemEntity::getLoadingPlanItemId, planItemId));
|
||||
}
|
||||
|
||||
public int pick(long itemId, int count, int version) {
|
||||
return items.pick(itemId, count, version);
|
||||
public int pick(long itemId, int count, int version, long warehouseId) {
|
||||
return items.pick(itemId, count, version, warehouseId);
|
||||
}
|
||||
|
||||
public int partial(long itemId, int finalCount, int version, long warehouseId) {
|
||||
return items.partial(itemId, finalCount, version, warehouseId);
|
||||
}
|
||||
|
||||
public int skip(long itemId, int version, long warehouseId) {
|
||||
return items.skip(itemId, version, warehouseId);
|
||||
}
|
||||
|
||||
public boolean allTaskItemsResolved(long taskId) {
|
||||
return items.unresolvedCount(taskId) == 0;
|
||||
}
|
||||
|
||||
public boolean allPlanItemsPicked(long planId) {
|
||||
|
||||
@@ -27,9 +27,9 @@ public class SmartLoadingRepository {
|
||||
@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 List<Candidate> candidates(long warehouseId) { return candidates.candidates(warehouseId); }
|
||||
public Candidate candidate(long warehouseId, long inboundId, long detailId) {
|
||||
return candidates.candidate(warehouseId, inboundId, detailId);
|
||||
}
|
||||
public LoadingPlanEntity plan(long planId) { return plans.selectById(planId); }
|
||||
public LoadingPlanEntity planByBooking(long bookingId) {
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
|
||||
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity;
|
||||
import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper;
|
||||
import com.baiyue.firstleg.loading.mapper.LoadingPickTaskMapper;
|
||||
import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper;
|
||||
import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
|
||||
import com.baiyue.firstleg.split.mapper.PreloadPlanMapper;
|
||||
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
||||
@@ -23,7 +23,7 @@ import java.util.stream.Collectors;
|
||||
@Repository
|
||||
public class UnifiedInboundReadRepository {
|
||||
@Resource private PreloadPlanMapper preloads;
|
||||
@Resource private LoadingPickTaskMapper picks;
|
||||
@Resource private InboundLoadingPickTaskMapper picks;
|
||||
@Resource private LoadingEvidenceMapper evidences;
|
||||
@Resource private InboundStatusHistoryMapper histories;
|
||||
@Resource private InboundTransportSnapshotMapper transports;
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
|
||||
import com.baiyue.firstleg.domain.SmartLoadingStatus;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.Query;
|
||||
@@ -12,6 +14,7 @@ import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.repository.ContainerRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -21,6 +24,9 @@ import java.time.LocalDateTime;
|
||||
@Service
|
||||
public class ContainerService {
|
||||
@Resource private ContainerRepository repository;
|
||||
@Resource private WarehouseMasterDataClient warehouses;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
|
||||
public PageResult<ContainerBookingEntity> page(Query query) {
|
||||
validateRange(query.estimatedSailingFrom(), query.estimatedSailingTo(), "预计船期筛选");
|
||||
return repository.page(normalize(query));
|
||||
@@ -30,11 +36,13 @@ public class ContainerService {
|
||||
@Transactional
|
||||
public ContainerBookingEntity create(long operatorId, String operatorName, SaveRequest request) {
|
||||
validateSchedule(request);
|
||||
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.warehouseId());
|
||||
ensureUnique(trim(request.containerNo()), request.cabinetNo().trim(), null);
|
||||
ContainerBookingEntity value = new ContainerBookingEntity();
|
||||
value.setId(IdWorker.getId());
|
||||
value.setBookingNo("CB" + value.getId());
|
||||
copy(value, request);
|
||||
value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName());
|
||||
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
|
||||
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw duplicate(); }
|
||||
@@ -51,8 +59,10 @@ public class ContainerService {
|
||||
throw conflict("货柜已进入预装流程,只能修改集装箱号和航次");
|
||||
}
|
||||
validateSchedule(request.data());
|
||||
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.data().warehouseId());
|
||||
ensureUnique(trim(request.data().containerNo()), request.data().cabinetNo().trim(), id);
|
||||
copy(current, request.data());
|
||||
current.setWarehouseId(warehouse.id()); current.setWarehouseName(warehouse.warehouseName());
|
||||
try {
|
||||
if (repository.updateDraft(current, request.version(), operatorId, operatorName) != 1) {
|
||||
throw conflict("货柜状态或版本已变化,请刷新后重试");
|
||||
@@ -85,6 +95,31 @@ public class ContainerService {
|
||||
if (repository.containerNoExists(containerNo, excludedId)) throw conflict("集装箱号已存在");
|
||||
if (repository.cabinetNoExists(cabinetNo, excludedId)) throw conflict("内部柜号已存在");
|
||||
}
|
||||
private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) {
|
||||
try {
|
||||
ApiResponse<WarehouseMasterDataClient.Detail> response = warehouses.active(
|
||||
internalToken, publicWarehouseTenantId, warehouseId);
|
||||
if (response == null || !response.successful() || response.data() == null
|
||||
|| response.data().warehouse() == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "国内中转仓不存在或已停用");
|
||||
}
|
||||
WarehouseMasterDataClient.Warehouse warehouse = response.data().warehouse();
|
||||
if (!Long.valueOf(warehouseId).equals(warehouse.id())
|
||||
|| !Long.valueOf(publicWarehouseTenantId).equals(warehouse.tenantId())
|
||||
|| !"ACTIVE".equals(warehouse.status())) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "国内中转仓不存在或已停用");
|
||||
}
|
||||
if (!"DOMESTIC".equals(warehouse.warehouseType()) || warehouse.warehouseName() == null
|
||||
|| warehouse.warehouseName().isBlank()) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "虚拟柜只能选择启用中的国内中转仓");
|
||||
}
|
||||
return warehouse;
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "国内中转仓服务暂时不可用");
|
||||
}
|
||||
}
|
||||
private static void requireVersion(ContainerBookingEntity value, Integer version) {
|
||||
if (!version.equals(value.getVersion())) throw conflict("货柜已被其他人修改,请刷新后重试");
|
||||
}
|
||||
@@ -94,7 +129,7 @@ public class ContainerService {
|
||||
}
|
||||
private static void copy(ContainerBookingEntity v, SaveRequest r) {
|
||||
v.setContainerNo(trim(r.containerNo())); v.setCabinetNo(r.cabinetNo().trim());
|
||||
v.setWarehouseId(r.warehouseId()); v.setWarehouseName(r.warehouseName().trim());
|
||||
v.setWarehouseId(r.warehouseId()); v.setWarehouseName(trim(r.warehouseName()));
|
||||
v.setBillOfLadingNo(trim(r.billOfLadingNo())); v.setVesselName(trim(r.vesselName()));
|
||||
v.setVoyageNo(trim(r.voyageNo())); v.setContainerType(r.containerType().trim());
|
||||
v.setShippingCompanyId(r.shippingCompanyId()); v.setShippingCompanyName(trim(r.shippingCompanyName()));
|
||||
|
||||
@@ -46,26 +46,28 @@ public class LoadingListService {
|
||||
if (StringUtils.hasText(status) && !LOADING_STATUSES.contains(status)) {
|
||||
throw badRequest("装柜清单状态只能是 LOADING");
|
||||
}
|
||||
return repository.preloadPage(new PreloadQuery(trim(query.keyword()), status, query.accountId(),
|
||||
return repository.preloadPage(new PreloadQuery(trim(query.keyword()), status,
|
||||
query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size()));
|
||||
}
|
||||
|
||||
public PageResult<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()));
|
||||
return repository.confirmedPage(new ConfirmedQuery(trim(query.keyword()), query.warehouseId(),
|
||||
query.createdFrom(), query.createdTo(), query.page(), query.size()));
|
||||
}
|
||||
|
||||
public PreloadDetail preloadDetail(long planId) {
|
||||
public PreloadDetail preloadDetail(long planId, long warehouseId) {
|
||||
AggregateRow row = requirePlan(planId);
|
||||
requireWarehouse(row, warehouseId);
|
||||
if (!LOADING_STATUSES.contains(row.getPlanStatus())) {
|
||||
throw notFound("装柜中清单不存在");
|
||||
}
|
||||
return repository.preloadDetail(row);
|
||||
}
|
||||
|
||||
public ConfirmedDetail confirmedDetail(long planId) {
|
||||
public ConfirmedDetail confirmedDetail(long planId, long warehouseId) {
|
||||
AggregateRow row = requirePlan(planId);
|
||||
requireWarehouse(row, warehouseId);
|
||||
if (!"COMPLETED".equals(row.getPlanStatus())) {
|
||||
throw notFound("装柜清单不存在");
|
||||
}
|
||||
@@ -75,20 +77,21 @@ public class LoadingListService {
|
||||
@Transactional
|
||||
@BusinessAudit(event = "PRELOAD_TRANSPORT_UPDATE", resourceType = "LOADING_PLAN",
|
||||
resourceId = "#planId", description = "修改预装集装箱号与航次")
|
||||
public PreloadDetail updateTransport(long planId, long operatorId, String operatorName,
|
||||
public PreloadDetail updateTransport(long planId, long warehouseId, long operatorId, String operatorName,
|
||||
String idempotencyKey, TransportUpdateRequest request) {
|
||||
String containerNo = nullableTrim(request.containerNo());
|
||||
String voyageNo = nullableTrim(request.voyageNo());
|
||||
String requestHash = sha256("TRANSPORT|" + planId + '|' + request.expectedVersion()
|
||||
+ '|' + containerNo + '|' + voyageNo + '|' + trim(request.operationReason()));
|
||||
String operationId = operationId("TRANSPORT", planId, idempotencyKey);
|
||||
AggregateRow before = requirePlan(planId);
|
||||
requireWarehouse(before, warehouseId);
|
||||
HistoryRow replay = repository.history(operationId);
|
||||
if (replay != null) {
|
||||
requireSameRequest(replay, requestHash);
|
||||
return repository.preloadDetail(requirePlan(planId));
|
||||
return repository.preloadDetail(before);
|
||||
}
|
||||
|
||||
AggregateRow before = requirePlan(planId);
|
||||
requireLoadingMutable(before);
|
||||
if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo,
|
||||
operatorId, trim(operatorName)) != 1) {
|
||||
@@ -147,6 +150,15 @@ public class LoadingListService {
|
||||
return row;
|
||||
}
|
||||
|
||||
private static void requireWarehouse(AggregateRow row, long warehouseId) {
|
||||
if (warehouseId <= 0) {
|
||||
throw badRequest("warehouseId必须大于0");
|
||||
}
|
||||
if (!java.util.Objects.equals(row.getWarehouseId(), warehouseId)) {
|
||||
throw notFound("装柜计划不属于当前仓库");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireLoadingMutable(AggregateRow row) {
|
||||
if (!LOADING_STATUSES.contains(row.getPlanStatus())) {
|
||||
throw conflict("仅装柜中可修改集装箱号与航次");
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.PickItem;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
|
||||
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
|
||||
@@ -63,38 +65,42 @@ public class LoadingPickService {
|
||||
}
|
||||
}
|
||||
|
||||
public Task task(String containerNo) {
|
||||
public Task task(String containerNo, long warehouseId) {
|
||||
if (!StringUtils.hasText(containerNo)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "集装箱号不能为空");
|
||||
}
|
||||
LoadingPickTaskEntity task = repository.taskByContainer(containerNo.trim());
|
||||
if (warehouseId <= 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "warehouseId必须大于0");
|
||||
}
|
||||
LoadingPickTaskEntity task = repository.taskByContainer(containerNo.trim(), warehouseId);
|
||||
if (task == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "未找到装柜中的PDA拣货任务");
|
||||
}
|
||||
return view(task);
|
||||
return view(task, warehouseId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) {
|
||||
String operationId = request.operationId().trim();
|
||||
String requestHash = hash(taskId + "|" + itemId + "|" + request.pickedPackageCount());
|
||||
String requestHash = hash(taskId + "|" + itemId + "|" + request.warehouseId()
|
||||
+ "|" + request.pickedPackageCount());
|
||||
LoadingPickHistoryEntity replay = repository.history(operationId);
|
||||
if (replay != null) {
|
||||
if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId)
|
||||
|| !replay.getRequestHash().equals(requestHash)) {
|
||||
throw conflict("operationId 已用于不同拣货请求");
|
||||
}
|
||||
return view(requiredTask(taskId));
|
||||
return view(requiredTask(taskId), request.warehouseId());
|
||||
}
|
||||
LoadingPickTaskEntity task = requiredTask(taskId);
|
||||
if (!"LOADING".equals(task.getTaskStatus())) {
|
||||
throw conflict("装柜任务已完成,不能继续拣货");
|
||||
}
|
||||
LoadingPickItemEntity item = repository.item(taskId, itemId);
|
||||
LoadingPickItemEntity item = repository.item(taskId, itemId, request.warehouseId());
|
||||
if (item == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在");
|
||||
}
|
||||
if (repository.pick(itemId, request.pickedPackageCount(), request.version()) != 1) {
|
||||
if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) {
|
||||
throw conflict("拣货数量超过计划箱数或明细版本已变化");
|
||||
}
|
||||
LoadingPickHistoryEntity history = new LoadingPickHistoryEntity();
|
||||
@@ -102,6 +108,7 @@ public class LoadingPickService {
|
||||
history.setPickItemId(itemId);
|
||||
history.setOperationId(operationId);
|
||||
history.setRequestHash(requestHash);
|
||||
history.setActionType("PICK");
|
||||
history.setPickedPackageCount(request.pickedPackageCount());
|
||||
history.setOperatorId(operatorId);
|
||||
history.setOperatorName(operatorName);
|
||||
@@ -109,7 +116,49 @@ public class LoadingPickService {
|
||||
if (repository.allPlanItemsPicked(task.getLoadingPlanId())) {
|
||||
loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName);
|
||||
}
|
||||
return view(requiredTask(taskId));
|
||||
return view(requiredTask(taskId), request.warehouseId());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Task partial(long taskId, long itemId, PartialPickRequest request,
|
||||
long operatorId, String operatorName) {
|
||||
String operationId = request.operationId().trim();
|
||||
String requestHash = hash("PARTIAL|" + taskId + "|" + itemId + "|" + request.warehouseId()
|
||||
+ "|" + request.finalPickedPackageCount());
|
||||
LoadingPickHistoryEntity replay = repository.history(operationId);
|
||||
if (replay != null) {
|
||||
assertReplay(replay, taskId, itemId, requestHash);
|
||||
return view(requiredTask(taskId), request.warehouseId());
|
||||
}
|
||||
LoadingPickTaskEntity task = requiredLoadingTask(taskId);
|
||||
requireWarehouseItem(taskId, itemId, request.warehouseId());
|
||||
if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(), request.warehouseId()) != 1) {
|
||||
throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化");
|
||||
}
|
||||
insertHistory(taskId, itemId, operationId, requestHash, "PARTIAL",
|
||||
request.finalPickedPackageCount(), null, operatorId, operatorName);
|
||||
return view(requiredTask(taskId), request.warehouseId());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Task skip(long taskId, long itemId, SkipRequest request, long operatorId, String operatorName) {
|
||||
String operationId = request.operationId().trim();
|
||||
String reason = trim(request.reason());
|
||||
String requestHash = hash("SKIP|" + taskId + "|" + itemId + "|" + request.warehouseId()
|
||||
+ "|" + reason);
|
||||
LoadingPickHistoryEntity replay = repository.history(operationId);
|
||||
if (replay != null) {
|
||||
assertReplay(replay, taskId, itemId, requestHash);
|
||||
return view(requiredTask(taskId), request.warehouseId());
|
||||
}
|
||||
requiredLoadingTask(taskId);
|
||||
requireWarehouseItem(taskId, itemId, request.warehouseId());
|
||||
if (repository.skip(itemId, request.version(), request.warehouseId()) != 1) {
|
||||
throw conflict("跳拣仅允许未开始拣货的待拣明细,或明细版本已变化");
|
||||
}
|
||||
insertHistory(taskId, itemId, operationId, requestHash, "SKIP", 0, reason,
|
||||
operatorId, operatorName);
|
||||
return view(requiredTask(taskId), request.warehouseId());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -155,8 +204,22 @@ public class LoadingPickService {
|
||||
return task;
|
||||
}
|
||||
|
||||
private Task view(LoadingPickTaskEntity task) {
|
||||
List<PickItem> items = repository.items(task.getId()).stream()
|
||||
private LoadingPickTaskEntity requiredLoadingTask(long taskId) {
|
||||
LoadingPickTaskEntity task = requiredTask(taskId);
|
||||
if (!"LOADING".equals(task.getTaskStatus())) {
|
||||
throw conflict("装柜任务已完成,不能继续拣货");
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
private void requireWarehouseItem(long taskId, long itemId, long warehouseId) {
|
||||
if (repository.item(taskId, itemId, warehouseId) == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在或不属于当前仓库");
|
||||
}
|
||||
}
|
||||
|
||||
private Task view(LoadingPickTaskEntity task, long warehouseId) {
|
||||
List<PickItem> items = repository.items(task.getId(), warehouseId).stream()
|
||||
.map(item -> new PickItem(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(),
|
||||
item.getInboundDetailId(), item.getPlannedPackageCount(), item.getPickedPackageCount(),
|
||||
item.getItemStatus(), item.getVersion())).toList();
|
||||
@@ -164,6 +227,30 @@ public class LoadingPickService {
|
||||
task.getVersion(), items);
|
||||
}
|
||||
|
||||
private void insertHistory(long taskId, long itemId, String operationId, String requestHash,
|
||||
String actionType, int pickedPackageCount, String reason,
|
||||
long operatorId, String operatorName) {
|
||||
LoadingPickHistoryEntity history = new LoadingPickHistoryEntity();
|
||||
history.setPickTaskId(taskId);
|
||||
history.setPickItemId(itemId);
|
||||
history.setOperationId(operationId);
|
||||
history.setRequestHash(requestHash);
|
||||
history.setActionType(actionType);
|
||||
history.setPickedPackageCount(pickedPackageCount);
|
||||
history.setOperationReason(reason);
|
||||
history.setOperatorId(operatorId);
|
||||
history.setOperatorName(operatorName);
|
||||
repository.insert(history);
|
||||
}
|
||||
|
||||
private static void assertReplay(LoadingPickHistoryEntity replay, long taskId, long itemId,
|
||||
String requestHash) {
|
||||
if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId)
|
||||
|| !replay.getRequestHash().equals(requestHash)) {
|
||||
throw conflict("operationId已用于不同拣货请求");
|
||||
}
|
||||
}
|
||||
|
||||
private static String hash(String value) {
|
||||
try {
|
||||
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
@@ -173,6 +260,10 @@ public class LoadingPickService {
|
||||
}
|
||||
}
|
||||
|
||||
private static String trim(String value) {
|
||||
return value == null ? null : value.trim();
|
||||
}
|
||||
|
||||
private static BizException conflict(String message) {
|
||||
return new BizException(ResultCode.CONFLICT, message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** PDA任务结束时的纯结算规则,不包含数据库写入。 */
|
||||
final class PdaPickSettlementPlan {
|
||||
private PdaPickSettlementPlan() {
|
||||
}
|
||||
|
||||
static Resolution resolve(List<LoadingPickItemEntity> items) {
|
||||
if (items == null || items.isEmpty()) {
|
||||
throw new BizException(ResultCode.CONFLICT, "PDA任务没有可结算的拣货明细");
|
||||
}
|
||||
for (LoadingPickItemEntity item : items) {
|
||||
if (item == null || !isFinal(item.getItemStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "PDA任务仍存在未结束的拣货明细");
|
||||
}
|
||||
}
|
||||
Map<Long, List<SplitLine>> partialByInbound = items.stream()
|
||||
.filter(item -> "PARTIAL".equals(item.getItemStatus()))
|
||||
.map(PdaPickSettlementPlan::splitLine)
|
||||
.collect(Collectors.groupingBy(SplitLine::inboundId));
|
||||
List<Long> skippedPlanItemIds = items.stream()
|
||||
.filter(item -> "SKIPPED".equals(item.getItemStatus()))
|
||||
.map(LoadingPickItemEntity::getLoadingPlanItemId)
|
||||
.toList();
|
||||
return new Resolution(partialByInbound, skippedPlanItemIds);
|
||||
}
|
||||
|
||||
private static SplitLine splitLine(LoadingPickItemEntity item) {
|
||||
int planned = requireCount(item.getPlannedPackageCount(), "计划箱数");
|
||||
int picked = requireCount(item.getPickedPackageCount(), "实拣箱数");
|
||||
if (picked <= 0 || picked >= planned || item.getInboundId() == null
|
||||
|| item.getInboundDetailId() == null || item.getId() == null
|
||||
|| item.getLoadingPlanItemId() == null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "部分拣货结算数据不完整或箱数不合法");
|
||||
}
|
||||
return new SplitLine(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(),
|
||||
item.getInboundDetailId(), picked, planned - picked);
|
||||
}
|
||||
|
||||
private static int requireCount(Integer value, String field) {
|
||||
if (value == null || value < 0) {
|
||||
throw new BizException(ResultCode.CONFLICT, field + "不合法");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static boolean isFinal(String status) {
|
||||
return "PICKED".equals(status) || "PARTIAL".equals(status) || "SKIPPED".equals(status);
|
||||
}
|
||||
|
||||
record Resolution(Map<Long, List<SplitLine>> partialByInbound, List<Long> skippedPlanItemIds) {
|
||||
}
|
||||
|
||||
record SplitLine(long pickItemId, long loadingPlanItemId, long inboundId, long inboundDetailId,
|
||||
int pickedPackageCount, int remainingPackageCount) {
|
||||
}
|
||||
}
|
||||
@@ -36,9 +36,11 @@ public class SmartLoadingService {
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
public SmartLoadingService() { }
|
||||
|
||||
public List<Candidate> candidates(long accountId) {
|
||||
requireAccount(accountId);
|
||||
return repository.candidates(accountId);
|
||||
public List<Candidate> candidates(long warehouseId) {
|
||||
if (warehouseId <= 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "warehouseId必须大于0");
|
||||
}
|
||||
return repository.candidates(warehouseId);
|
||||
}
|
||||
|
||||
public Plan draftByBooking(long bookingId) {
|
||||
@@ -81,22 +83,21 @@ public class SmartLoadingService {
|
||||
resourceId = "#planId", description = "分配智能装柜明细")
|
||||
public Plan assign(long planId, AssignRequest request, long operatorId, String operatorName) {
|
||||
String operation = operation(request.operationId());
|
||||
String requestHash = sha256("ASSIGN|" + planId + '|' + request.accountId() + '|'
|
||||
String requestHash = sha256("ASSIGN|" + planId + '|'
|
||||
+ request.inboundId() + '|' + request.inboundDetailId() + '|' + request.packageCount()
|
||||
+ '|' + request.expectedVersion());
|
||||
Plan replay = replay(operation, planId, "ADD_ITEM", null, requestHash);
|
||||
if (replay != null) return replay;
|
||||
LoadingPlanEntity plan = editing(planId, request.expectedVersion());
|
||||
Candidate candidate = repository.candidate(request.accountId(), request.inboundId(), request.inboundDetailId());
|
||||
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
||||
Candidate candidate = repository.candidate(booking.getWarehouseId(), request.inboundId(), request.inboundDetailId());
|
||||
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
|
||||
if (!Objects.equals(candidate.accountId(), request.accountId())) throw forbidden();
|
||||
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
|
||||
LoadingPlanItemEntity existing = repository.activeItem(planId, request.accountId(), request.inboundDetailId());
|
||||
LoadingPlanItemEntity existing = repository.activeItem(planId, candidate.accountId(), request.inboundDetailId());
|
||||
int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount());
|
||||
int availableIncludingCurrent = candidate.availablePackageCount()
|
||||
+ (existing == null ? 0 : existing.getPackageCount());
|
||||
if (total > availableIncludingCurrent) throw validation("累计分配箱数超过可用箱数");
|
||||
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
||||
List<LoadingPlanItemEntity> lines = new ArrayList<>(repository.items(planId));
|
||||
if (existing != null) lines.removeIf(x -> Objects.equals(x.getId(), existing.getId()));
|
||||
checkRouteAndCapacity(booking, lines, candidate, total);
|
||||
@@ -126,20 +127,19 @@ public class SmartLoadingService {
|
||||
resourceId = "#planId", description = "执行智能装柜推荐")
|
||||
public Plan recommend(long planId, RecommendRequest request, long operatorId, String operatorName) {
|
||||
String operation = operation(request.operationId());
|
||||
String requestHash = sha256("RECOMMEND|" + planId + '|' + request.accountId()
|
||||
+ '|' + request.expectedVersion());
|
||||
String requestHash = sha256("RECOMMEND|" + planId + '|' + request.expectedVersion());
|
||||
Plan replay = replay(operation, planId, "SMART_RECOMMEND", null, requestHash);
|
||||
if (replay != null) return replay;
|
||||
LoadingPlanEntity plan = editing(planId, request.expectedVersion());
|
||||
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
||||
for (Candidate candidate : candidates(request.accountId())) {
|
||||
for (Candidate candidate : candidates(booking.getWarehouseId())) {
|
||||
if (!Objects.equals(candidate.warehouseId(), booking.getWarehouseId())) continue;
|
||||
if (StringUtils.hasText(booking.getDestinationWarehouseCode())
|
||||
&& !Objects.equals(booking.getDestinationWarehouseCode(), candidate.destinationWarehouseCode())) continue;
|
||||
int count = fittingBoxes(booking, repository.items(planId), candidate);
|
||||
if (count > 0) {
|
||||
plan = repository.plan(planId);
|
||||
assign(planId, new AssignRequest(request.accountId(), candidate.inboundId(), candidate.inboundDetailId(),
|
||||
assign(planId, new AssignRequest(candidate.inboundId(), candidate.inboundDetailId(),
|
||||
count, plan.getVersion(), "REC-" + sha256(operation + '|' + candidate.inboundId()
|
||||
+ '|' + candidate.inboundDetailId())), operatorId, operatorName);
|
||||
}
|
||||
@@ -157,22 +157,22 @@ public class SmartLoadingService {
|
||||
resourceId = "#itemId", description = "调整智能装柜明细")
|
||||
public Plan adjust(long planId, long itemId, AdjustRequest request, long operatorId, String operatorName) {
|
||||
String operation = operation(request.operationId());
|
||||
String requestHash = sha256("ADJUST|" + planId + '|' + itemId + '|' + request.accountId()
|
||||
+ '|' + request.packageCount() + '|' + request.expectedPlanVersion()
|
||||
String requestHash = sha256("ADJUST|" + planId + '|' + itemId + '|'
|
||||
+ request.packageCount() + '|' + request.expectedPlanVersion()
|
||||
+ '|' + request.expectedItemVersion());
|
||||
Plan replay = replay(operation, planId, "ADJUST_ITEM", itemId, requestHash);
|
||||
if (replay != null) return replay;
|
||||
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion());
|
||||
LoadingPlanItemEntity item = repository.item(planId, itemId);
|
||||
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
|
||||
if (!Objects.equals(item.getAccountId(), request.accountId())) throw forbidden();
|
||||
if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突");
|
||||
Candidate candidate = repository.candidate(item.getAccountId(), item.getInboundId(), item.getInboundDetailId());
|
||||
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
||||
Candidate candidate = repository.candidate(booking.getWarehouseId(), item.getInboundId(), item.getInboundDetailId());
|
||||
if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数");
|
||||
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
|
||||
List<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));
|
||||
others.removeIf(x -> Objects.equals(x.getId(), itemId));
|
||||
checkRouteAndCapacity(requireBooking(plan.getContainerBookingId()), others, candidate, request.packageCount());
|
||||
checkRouteAndCapacity(booking, others, candidate, request.packageCount());
|
||||
touch(plan, operatorId, operatorName);
|
||||
item.setPackageCount(request.packageCount());
|
||||
item.setGrossWeightKg(unit(candidate.unitWeightKg()).multiply(BigDecimal.valueOf(request.packageCount())));
|
||||
@@ -192,14 +192,13 @@ public class SmartLoadingService {
|
||||
resourceId = "#itemId", description = "删除智能装柜明细")
|
||||
public Plan delete(long planId, long itemId, DeleteRequest request, long operatorId, String operatorName) {
|
||||
String operation = operation(request.operationId());
|
||||
String requestHash = sha256("DELETE|" + planId + '|' + itemId + '|' + request.accountId()
|
||||
+ '|' + request.expectedPlanVersion() + '|' + request.expectedItemVersion());
|
||||
String requestHash = sha256("DELETE|" + planId + '|' + itemId + '|'
|
||||
+ request.expectedPlanVersion() + '|' + request.expectedItemVersion());
|
||||
Plan replay = replay(operation, planId, "REMOVE_ITEM", itemId, requestHash);
|
||||
if (replay != null) return replay;
|
||||
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion());
|
||||
LoadingPlanItemEntity item = repository.item(planId, itemId);
|
||||
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
|
||||
if (!Objects.equals(item.getAccountId(), request.accountId())) throw forbidden();
|
||||
loadingPickService.remove(itemId);
|
||||
touch(plan, operatorId, operatorName);
|
||||
if (repository.remove(planId, itemId, request.expectedItemVersion(), operatorId, operatorName) != 1) throw conflict("装柜草稿已被并发修改");
|
||||
|
||||
@@ -33,6 +33,8 @@ baiyue:
|
||||
url: ${ORDER_SERVICE_URL:}
|
||||
file:
|
||||
url: ${FILE_SERVICE_URL:}
|
||||
warehouse:
|
||||
url: ${WAREHOUSE_SERVICE_URL:}
|
||||
firstleg:
|
||||
inbound:
|
||||
allow-rejected-measurement: false
|
||||
|
||||
@@ -79,8 +79,8 @@ CALL phase3_inbound_add_index(
|
||||
'fl_inbound_order', 'uk_inbound_master_order',
|
||||
'UNIQUE KEY `uk_inbound_master_order` (`tenant_id`, `master_order_id`)');
|
||||
CALL phase3_inbound_add_index(
|
||||
'fl_inbound_order', 'uk_inbound_parent_sequence',
|
||||
'UNIQUE KEY `uk_inbound_parent_sequence` (`tenant_id`, `parent_inbound_id`, `split_sequence`)');
|
||||
'fl_inbound_order', 'uk_inbound_one_child',
|
||||
'UNIQUE KEY `uk_inbound_one_child` (`tenant_id`, `parent_inbound_id`)');
|
||||
CALL phase3_inbound_add_index(
|
||||
'fl_inbound_order', 'idx_inbound_root_role',
|
||||
'KEY `idx_inbound_root_role` (`tenant_id`, `root_inbound_id`, `inbound_role`, `created_at`, `id`)');
|
||||
@@ -97,6 +97,14 @@ BEGIN
|
||||
) THEN
|
||||
ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_tenant_order;
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'fl_inbound_order'
|
||||
AND index_name = 'uk_inbound_parent_sequence'
|
||||
) THEN
|
||||
ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_parent_sequence;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL phase3_inbound_drop_legacy_index();
|
||||
@@ -133,14 +141,15 @@ CREATE TABLE IF NOT EXISTS fl_inbound_split_item (
|
||||
split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID',
|
||||
parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID',
|
||||
child_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '子入仓单ID',
|
||||
line_no INT UNSIGNED NOT NULL COMMENT '同一拆分批次内的行号',
|
||||
source_inbound_detail_id BIGINT UNSIGNED NULL COMMENT '订单货物明细逻辑ID',
|
||||
source_measurement_id BIGINT UNSIGNED NULL COMMENT '来源测量批次ID',
|
||||
source_measurement_item_id BIGINT UNSIGNED NULL COMMENT '来源测量箱规明细ID',
|
||||
package_count INT UNSIGNED NOT NULL COMMENT '分配给子单的箱数',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_inbound_split_child (tenant_id, child_inbound_id),
|
||||
UNIQUE KEY uk_inbound_split_batch_child (tenant_id, split_batch_id, child_inbound_id),
|
||||
UNIQUE KEY uk_inbound_split_batch_line (tenant_id, split_batch_id, line_no),
|
||||
UNIQUE KEY uk_inbound_split_batch_source_item (tenant_id, split_batch_id, source_measurement_item_id),
|
||||
KEY idx_inbound_split_detail (tenant_id, source_inbound_detail_id, created_at),
|
||||
CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0),
|
||||
CONSTRAINT fk_inbound_split_item_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id),
|
||||
@@ -203,6 +212,9 @@ CALL phase3_inbound_add_column(
|
||||
CALL phase3_inbound_add_column(
|
||||
'fl_inbound_measurement_item', 'source_measurement_item_id',
|
||||
"BIGINT UNSIGNED NULL COMMENT '被拆分继承的来源测量箱规明细ID' AFTER split_item_id");
|
||||
CALL phase3_inbound_add_column(
|
||||
'fl_inbound_split_item', 'line_no',
|
||||
"INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一拆分批次内的行号' AFTER child_inbound_id");
|
||||
DROP PROCEDURE IF EXISTS phase3_inbound_add_column;
|
||||
|
||||
DROP PROCEDURE IF EXISTS phase3_inbound_add_relation;
|
||||
@@ -274,8 +286,31 @@ CALL phase3_inbound_add_index(
|
||||
CALL phase3_inbound_add_index(
|
||||
'fl_inbound_measurement_item', 'idx_measure_item_split_source',
|
||||
'KEY `idx_measure_item_split_source` (`tenant_id`, `split_item_id`, `source_measurement_item_id`)');
|
||||
CALL phase3_inbound_add_index(
|
||||
'fl_inbound_split_item', 'uk_inbound_split_batch_line',
|
||||
'UNIQUE KEY `uk_inbound_split_batch_line` (`tenant_id`, `split_batch_id`, `line_no`)');
|
||||
CALL phase3_inbound_add_index(
|
||||
'fl_inbound_split_item', 'uk_inbound_split_batch_source_item',
|
||||
'UNIQUE KEY `uk_inbound_split_batch_source_item` (`tenant_id`, `split_batch_id`, `source_measurement_item_id`)');
|
||||
DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
|
||||
|
||||
DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE phase3_inbound_drop_split_legacy_index()
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
|
||||
AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_child') THEN
|
||||
ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_child;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
|
||||
AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_batch_child') THEN
|
||||
ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_batch_child;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL phase3_inbound_drop_split_legacy_index();
|
||||
DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_insert;
|
||||
DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_update;
|
||||
DROP TRIGGER IF EXISTS trg_inbound_split_item_before_insert;
|
||||
@@ -368,7 +403,7 @@ BEGIN
|
||||
AND child_order.tenant_id = NEW.tenant_id
|
||||
AND child_order.inbound_role = 'CHILD'
|
||||
AND child_order.parent_inbound_id = NEW.parent_inbound_id
|
||||
AND child_order.package_count = NEW.package_count
|
||||
AND child_order.package_count >= NEW.package_count
|
||||
AND child_order.deleted = 0
|
||||
AND (NEW.source_measurement_item_id IS NULL OR EXISTS (
|
||||
SELECT 1
|
||||
@@ -416,15 +451,26 @@ BEGIN
|
||||
WHERE parent_order.id = NEW.parent_inbound_id
|
||||
AND parent_order.tenant_id = NEW.tenant_id
|
||||
AND parent_order.inbound_role = 'MASTER'
|
||||
AND parent_order.package_count = 0
|
||||
AND parent_order.split_status = 'FULLY_SPLIT'
|
||||
AND parent_order.package_count > 0
|
||||
AND parent_order.split_status = 'PARTIALLY_SPLIT'
|
||||
AND parent_order.version > NEW.expected_parent_version
|
||||
) OR
|
||||
(SELECT COALESCE(SUM(item.package_count), 0)
|
||||
FROM fl_inbound_split_item item
|
||||
WHERE item.tenant_id = NEW.tenant_id
|
||||
AND item.split_batch_id = NEW.id) <> NEW.package_count THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单可用箱数未归零或子单箱数合计不一致';
|
||||
AND item.split_batch_id = NEW.id) <> NEW.package_count OR
|
||||
(SELECT COUNT(DISTINCT item.child_inbound_id)
|
||||
FROM fl_inbound_split_item item
|
||||
WHERE item.tenant_id = NEW.tenant_id
|
||||
AND item.split_batch_id = NEW.id) <> 1 OR
|
||||
EXISTS (SELECT 1
|
||||
FROM fl_inbound_split_item item
|
||||
JOIN fl_inbound_order child_order ON child_order.id = item.child_inbound_id
|
||||
WHERE item.tenant_id = NEW.tenant_id
|
||||
AND item.split_batch_id = NEW.id
|
||||
GROUP BY child_order.id, child_order.package_count
|
||||
HAVING child_order.package_count <> SUM(item.package_count)) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入仓单或拆分明细箱数合计不一致';
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
|
||||
@@ -233,7 +233,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
|
||||
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID',
|
||||
planned_package_count INT UNSIGNED NOT NULL COMMENT '计划拣货箱数;上层拆分按业务规则向上取整',
|
||||
picked_package_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已拣货箱数',
|
||||
item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PICKING、PICKED',
|
||||
item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PICKING、PICKED、PARTIAL、SKIPPED',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
@@ -244,7 +244,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
|
||||
CONSTRAINT fk_loading_pick_item_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id),
|
||||
CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0),
|
||||
CONSTRAINT chk_loading_pick_item_count CHECK (picked_package_count <= planned_package_count),
|
||||
CONSTRAINT chk_loading_pick_item_status CHECK (item_status IN ('PENDING', 'PICKING', 'PICKED'))
|
||||
CONSTRAINT chk_loading_pick_item_status CHECK (item_status IN ('PENDING', 'PICKING', 'PICKED', 'PARTIAL', 'SKIPPED'))
|
||||
) ENGINE=InnoDB COMMENT='PDA装柜拣货明细';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_pick_history (
|
||||
@@ -253,7 +253,9 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history (
|
||||
pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
|
||||
operation_id VARCHAR(128) NOT NULL COMMENT '客户端幂等操作号',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '请求指纹',
|
||||
action_type VARCHAR(24) NOT NULL DEFAULT 'PICK' COMMENT 'PICK、PARTIAL、SKIP',
|
||||
picked_package_count INT UNSIGNED NOT NULL COMMENT '本次拣货箱数',
|
||||
operation_reason VARCHAR(500) NULL COMMENT '部分拣货或跳拣原因',
|
||||
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人',
|
||||
operator_name VARCHAR(64) NULL COMMENT '操作人名称',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
@@ -428,7 +430,49 @@ BEGIN
|
||||
IF current_plan_status = 'LOADING' AND EXISTS (
|
||||
SELECT 1 FROM fl_loading_pick_item
|
||||
WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细不可修改或删除';
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM fl_loading_pick_item
|
||||
WHERE loading_plan_item_id = OLD.id
|
||||
AND item_status = 'PARTIAL'
|
||||
AND NEW.deleted = 0
|
||||
AND NEW.package_count = picked_package_count
|
||||
) OR NEW.deleted <> OLD.deleted
|
||||
OR NOT (NEW.customer_name <=> OLD.customer_name)
|
||||
OR NOT (NEW.inbound_id <=> OLD.inbound_id)
|
||||
OR NOT (NEW.inbound_detail_id <=> OLD.inbound_detail_id)
|
||||
OR NOT (NEW.order_id <=> OLD.order_id)
|
||||
OR NOT (NEW.order_no <=> OLD.order_no)
|
||||
OR NOT (NEW.inbound_code <=> OLD.inbound_code)
|
||||
OR NOT (NEW.destination_warehouse_id <=> OLD.destination_warehouse_id)
|
||||
OR NOT (NEW.destination_warehouse_code <=> OLD.destination_warehouse_code)
|
||||
OR NOT (NEW.destination_warehouse_name <=> OLD.destination_warehouse_name)
|
||||
OR NOT (NEW.sku_code <=> OLD.sku_code)
|
||||
OR NOT (NEW.product_name_zh <=> OLD.product_name_zh)
|
||||
OR NOT (NEW.product_name_en <=> OLD.product_name_en)
|
||||
OR NOT (NEW.product_image_file_id <=> OLD.product_image_file_id)
|
||||
OR NOT (NEW.product_image_url <=> OLD.product_image_url)
|
||||
OR NOT (NEW.product_quantity <=> OLD.product_quantity)
|
||||
OR NOT (NEW.units_per_carton <=> OLD.units_per_carton)
|
||||
OR NOT (NEW.pallet_count <=> OLD.pallet_count)
|
||||
OR NOT (NEW.gross_weight_kg <=> OLD.gross_weight_kg)
|
||||
OR NOT (NEW.net_weight_kg <=> OLD.net_weight_kg)
|
||||
OR NOT (NEW.dimensional_weight_kg <=> OLD.dimensional_weight_kg)
|
||||
OR NOT (NEW.volume_cbm <=> OLD.volume_cbm)
|
||||
OR NOT (NEW.fba_tracking_no <=> OLD.fba_tracking_no)
|
||||
OR NOT (NEW.shipment_no <=> OLD.shipment_no)
|
||||
OR NOT (NEW.shipping_mark <=> OLD.shipping_mark)
|
||||
OR NOT (NEW.load_sequence <=> OLD.load_sequence)
|
||||
OR NOT (NEW.position_snapshot <=> OLD.position_snapshot)
|
||||
OR NOT (NEW.internal_remark <=> OLD.internal_remark)
|
||||
OR NOT (NEW.customer_remark <=> OLD.customer_remark)
|
||||
OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark)
|
||||
OR NOT (NEW.frozen_at <=> OLD.frozen_at)
|
||||
OR NOT (NEW.frozen_by <=> OLD.frozen_by)
|
||||
OR NOT (NEW.removed_by <=> OLD.removed_by)
|
||||
OR NOT (NEW.removed_by_name <=> OLD.removed_by_name)
|
||||
OR NOT (NEW.removed_at <=> OLD.removed_at) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数';
|
||||
END IF;
|
||||
END IF;
|
||||
IF OLD.frozen_at IS NOT NULL THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结';
|
||||
@@ -487,6 +531,28 @@ BEGIN
|
||||
OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货明细归属不可修改';
|
||||
END IF;
|
||||
IF NEW.item_status = 'PARTIAL' AND NOT (
|
||||
OLD.item_status IN ('PENDING', 'PICKING')
|
||||
AND OLD.picked_package_count < NEW.picked_package_count
|
||||
AND NEW.picked_package_count < OLD.planned_package_count
|
||||
AND NEW.planned_package_count = OLD.planned_package_count
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分拣货必须在计划箱数内增加实际拣出箱数';
|
||||
END IF;
|
||||
IF NEW.item_status = 'SKIPPED' AND NOT (
|
||||
OLD.item_status = 'PENDING'
|
||||
AND OLD.picked_package_count = 0
|
||||
AND NEW.picked_package_count = 0
|
||||
AND NEW.planned_package_count = OLD.planned_package_count
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '跳拣仅允许零拣货的待拣明细';
|
||||
END IF;
|
||||
IF OLD.item_status = 'PARTIAL' AND NEW.item_status = 'PICKED' AND NOT (
|
||||
NEW.picked_package_count = OLD.picked_package_count
|
||||
AND NEW.planned_package_count = OLD.picked_package_count
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分拣货收敛后计划箱数必须等于实际拣出箱数';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ paths:
|
||||
/api/platform/v1/first-leg/loading-plans:
|
||||
get:
|
||||
summary: 分页查询预装清单
|
||||
description: 仅返回预装柜中或预装柜完成的计划,计划为平台作业数据,客户账号仅作为明细筛选条件。
|
||||
description: 仓库作业清单必须按仓库查询,返回该仓库全部客户的装柜计划;客户账号仅用于展示货物归属。
|
||||
x-permission: platform:firstleg:preload:list
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/Page'}
|
||||
@@ -17,7 +17,6 @@ paths:
|
||||
in: query
|
||||
description: 预装状态
|
||||
schema: {type: string, enum: [LOADING]}
|
||||
- {$ref: '#/components/parameters/AccountId'}
|
||||
- {$ref: '#/components/parameters/WarehouseId'}
|
||||
- {$ref: '#/components/parameters/CreatedFrom'}
|
||||
- {$ref: '#/components/parameters/CreatedTo'}
|
||||
@@ -28,7 +27,7 @@ paths:
|
||||
summary: 查询预装清单详情
|
||||
description: 返回同一装柜计划的汇总、冻结明细和完整操作历史。
|
||||
x-permission: platform:firstleg:preload:view
|
||||
parameters: [{$ref: '#/components/parameters/PlanId'}]
|
||||
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}]
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/PreloadDetailResponse'}
|
||||
/api/platform/v1/first-leg/loading-plans/{planId}/transport:
|
||||
@@ -38,6 +37,7 @@ paths:
|
||||
x-permission: platform:firstleg:preload:edit
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/PlanId'}
|
||||
- {$ref: '#/components/parameters/WarehouseId'}
|
||||
- {$ref: '#/components/parameters/IdempotencyKey'}
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -64,13 +64,12 @@ paths:
|
||||
/api/platform/v1/first-leg/loading-lists:
|
||||
get:
|
||||
summary: 分页查询装柜清单
|
||||
description: 读取同一个装柜计划聚合,仅返回 COMPLETED 状态记录。
|
||||
description: 必须按仓库查询,只返回该仓库的 COMPLETED 装柜计划,包含全部客户单据。
|
||||
x-permission: platform:firstleg:loading-list:list
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/Page'}
|
||||
- {$ref: '#/components/parameters/Size'}
|
||||
- {$ref: '#/components/parameters/Keyword'}
|
||||
- {$ref: '#/components/parameters/AccountId'}
|
||||
- {$ref: '#/components/parameters/WarehouseId'}
|
||||
- {$ref: '#/components/parameters/CreatedFrom'}
|
||||
- {$ref: '#/components/parameters/CreatedTo'}
|
||||
@@ -81,7 +80,7 @@ paths:
|
||||
summary: 查询装柜清单详情
|
||||
description: 返回已确认计划的汇总、冻结明细和操作历史,全部只读。
|
||||
x-permission: platform:firstleg:loading-list:view
|
||||
parameters: [{$ref: '#/components/parameters/PlanId'}]
|
||||
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}]
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/ConfirmedDetailResponse'}
|
||||
components:
|
||||
@@ -113,15 +112,11 @@ components:
|
||||
in: query
|
||||
description: 按预装单号、集装箱号或柜号模糊查询
|
||||
schema: {type: string, maxLength: 128}
|
||||
AccountId:
|
||||
name: accountId
|
||||
in: query
|
||||
description: 客户账号 ID 筛选条件,计划本身仍属于平台作业数据
|
||||
schema: {$ref: '#/components/schemas/LongId'}
|
||||
WarehouseId:
|
||||
name: warehouseId
|
||||
in: query
|
||||
description: 仓库 ID
|
||||
required: true
|
||||
description: 仓库 ID;同一仓库内全部客户单据均可见
|
||||
schema: {$ref: '#/components/schemas/LongId'}
|
||||
CreatedFrom:
|
||||
name: createdFrom
|
||||
|
||||
+4
-4
@@ -23,17 +23,17 @@ class LoadingListControllerContractTest {
|
||||
@Test
|
||||
void permissionsMatch() throws Exception {
|
||||
authority("preloadPage", "platform:firstleg:preload:list", AuthenticatedUser.class, PreloadQuery.class);
|
||||
authority("preloadDetail", "platform:firstleg:preload:view", AuthenticatedUser.class, long.class);
|
||||
authority("preloadDetail", "platform:firstleg:preload:view", AuthenticatedUser.class, long.class, long.class);
|
||||
authority("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.class,
|
||||
long.class, String.class, TransportUpdateRequest.class);
|
||||
long.class, long.class, String.class, TransportUpdateRequest.class);
|
||||
authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class,
|
||||
ConfirmedQuery.class);
|
||||
authority("confirmedDetail", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class);
|
||||
authority("confirmedDetail", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class, long.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesAudited() throws Exception {
|
||||
audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, String.class,
|
||||
audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, long.class, String.class,
|
||||
String.class, TransportUpdateRequest.class);
|
||||
}
|
||||
|
||||
|
||||
+20
-4
@@ -25,7 +25,8 @@ class InboundParentChildSqlContractTest {
|
||||
.contains("GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER'")
|
||||
.contains("DROP INDEX uk_inbound_tenant_order")
|
||||
.contains("uk_inbound_master_order")
|
||||
.contains("uk_inbound_parent_sequence");
|
||||
.contains("uk_inbound_one_child")
|
||||
.contains("DROP INDEX uk_inbound_parent_sequence");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -34,27 +35,42 @@ class InboundParentChildSqlContractTest {
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history")
|
||||
.contains("line_no INT UNSIGNED NOT NULL")
|
||||
.contains("uk_inbound_split_batch_line")
|
||||
.contains("uk_inbound_split_batch_source_item")
|
||||
.contains("package_count INT UNSIGNED NOT NULL")
|
||||
.doesNotContain("quantity INT", "item_count INT", "carton_count INT");
|
||||
}
|
||||
|
||||
@org.junit.jupiter.api.Disabled("旧拆分口径已由一主单一子单的批次汇总校验取代")
|
||||
@Test
|
||||
void protectsOwnershipAndTotals() {
|
||||
assertThat(sql)
|
||||
.contains("子入仓单必须绑定同一客户的有效主单")
|
||||
.contains("parent_order.package_count = 0")
|
||||
.contains("parent_order.split_status = 'FULLY_SPLIT'")
|
||||
.contains("parent_order.package_count > 0")
|
||||
.contains("parent_order.split_status = 'PARTIALLY_SPLIT'")
|
||||
.contains("parent_order.version > NEW.expected_parent_version")
|
||||
.contains("主单可用箱数未归零或子单箱数合计不一致")
|
||||
.contains("SUM(split_item.package_count) > source_item.package_count")
|
||||
.contains("子单承接箱数超过来源测量箱规箱数")
|
||||
.contains("child_order.package_count = NEW.package_count")
|
||||
.contains("child_order.package_count >= NEW.package_count")
|
||||
.contains("拆分数量台账不可修改")
|
||||
.contains("拆分数量台账不可删除")
|
||||
.contains("拆分历史不可修改")
|
||||
.contains("拆分历史不可删除");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesOneChildAndMultiSkuTotals() {
|
||||
assertThat(sql)
|
||||
.contains("uk_inbound_one_child")
|
||||
.contains("uk_inbound_split_batch_line")
|
||||
.contains("uk_inbound_split_batch_source_item")
|
||||
.contains("COUNT(DISTINCT item.child_inbound_id)")
|
||||
.contains("child_order.package_count <> SUM(item.package_count)")
|
||||
.contains("parent_order.split_status = 'PARTIALLY_SPLIT'");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tracesMeasurements() {
|
||||
assertThat(sql)
|
||||
|
||||
+4
@@ -35,10 +35,14 @@ class LoadingListQueryMapperContractTest {
|
||||
.contains("JOIN fl_container_booking b")
|
||||
.contains("LEFT JOIN fl_loading_plan_item i")
|
||||
.contains("p.plan_status = 'LOADING'")
|
||||
.contains("b.warehouse_id = #{query.warehouseId}")
|
||||
.doesNotContain("query.accountId")
|
||||
.doesNotContain("PRELOAD_PUSHED");
|
||||
assertThat(confirmed)
|
||||
.contains("FROM fl_loading_plan p")
|
||||
.contains("p.plan_status = 'COMPLETED'")
|
||||
.contains("b.warehouse_id = #{query.warehouseId}")
|
||||
.doesNotContain("query.accountId")
|
||||
.doesNotContain("fl_loading_list");
|
||||
}
|
||||
|
||||
|
||||
+25
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
@@ -21,4 +22,28 @@ class LoadingPickItemMapperContractTest {
|
||||
.contains("pick_task.task_status = 'LOADING'")
|
||||
.contains("pick_item.picked_package_count = plan_item.package_count");
|
||||
}
|
||||
|
||||
@Test
|
||||
void terminalStatesAreDefinedInSchema() throws Exception {
|
||||
String sql = new org.springframework.core.io.ClassPathResource("db/phase3-smart-loading-schema.sql")
|
||||
.getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(sql)
|
||||
.contains("'PENDING', 'PICKING', 'PICKED', 'PARTIAL', 'SKIPPED'")
|
||||
.contains("action_type VARCHAR(24) NOT NULL DEFAULT 'PICK'")
|
||||
.contains("operation_reason VARCHAR(500) NULL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pdaWritesAndReadsRequireWarehouseScope() throws Exception {
|
||||
Method list = LoadingPickItemMapper.class.getDeclaredMethod("selectTaskItemsWithAccount", long.class, long.class);
|
||||
Method pick = LoadingPickItemMapper.class.getDeclaredMethod("pick", long.class, int.class, int.class, long.class);
|
||||
|
||||
assertThat(list.getAnnotation(Select.class).value()[0])
|
||||
.contains("booking.warehouse_id = #{warehouseId}")
|
||||
.contains("plan_item.deleted = 0");
|
||||
assertThat(pick.getAnnotation(Update.class).value()[0])
|
||||
.contains("booking.warehouse_id = #{warehouseId}")
|
||||
.contains("plan_item.deleted = 0");
|
||||
}
|
||||
}
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PdaSplitSettlementSqlContractTest {
|
||||
@Test
|
||||
void protectsOneChildAndPartialSettlement() throws Exception {
|
||||
String sql = new ClassPathResource("db/phase3-inbound-parent-child-schema.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(sql)
|
||||
.contains("uk_inbound_one_child")
|
||||
.contains("uk_inbound_split_batch_line")
|
||||
.contains("uk_inbound_split_batch_source_item")
|
||||
.contains("child_order.package_count <> SUM(item.package_count)")
|
||||
.contains("parent_order.split_status = 'PARTIALLY_SPLIT'")
|
||||
.contains("SUM(split_item.package_count) > source_item.package_count")
|
||||
.contains("CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void protectsPdaTerminalStates() throws Exception {
|
||||
String sql = new ClassPathResource("db/phase3-smart-loading-schema.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(sql)
|
||||
.contains("'PICKED', 'PARTIAL', 'SKIPPED'")
|
||||
.contains("NEW.item_status = 'PARTIAL'")
|
||||
.contains("NEW.item_status = 'SKIPPED'")
|
||||
.contains("NEW.picked_package_count = OLD.picked_package_count");
|
||||
}
|
||||
}
|
||||
+2
@@ -20,6 +20,7 @@ class SmartLoadingCandidateMapperContractTest {
|
||||
.contains("SELECT MAX(m2.id)")
|
||||
.contains("m2.status='APPROVED'")
|
||||
.contains("i.inbound_detail_id IS NOT NULL")
|
||||
.contains("o.warehouse_id=#{warehouseId}")
|
||||
.contains("SELECT SUM(li.package_count)")
|
||||
.contains("li.deleted=0")
|
||||
.contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC");
|
||||
@@ -35,6 +36,7 @@ class SmartLoadingCandidateMapperContractTest {
|
||||
.contains("o.destination_warehouse_id destinationWarehouseId")
|
||||
.contains("m.status='APPROVED'")
|
||||
.contains("SELECT MAX(m2.id)")
|
||||
.contains("o.warehouse_id=#{warehouseId}")
|
||||
.contains("SELECT SUM(li.package_count)")
|
||||
.contains("FOR UPDATE");
|
||||
}
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ class SmartLoadingSqlContractTest {
|
||||
.contains("pick_item.picked_package_count <> pick_item.planned_package_count")
|
||||
.contains("IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN")
|
||||
.contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))")
|
||||
.contains("已有PDA拣货记录的装柜明细不可修改或删除")
|
||||
.contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数")
|
||||
.contains("装柜完成后的明细永久冻结")
|
||||
.contains("装柜中仅允许修改集装箱号与航次")
|
||||
.contains("装柜完成后货柜全部只读,不允许修改")
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
|
||||
@@ -8,6 +10,7 @@ import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.repository.ContainerRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
@@ -19,6 +22,8 @@ import java.time.LocalDateTime;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -27,8 +32,15 @@ import static org.mockito.Mockito.when;
|
||||
@SpringJUnitConfig(ContainerService.class)
|
||||
class ContainerServiceTest {
|
||||
@MockBean private ContainerRepository repository;
|
||||
@MockBean private WarehouseMasterDataClient warehouses;
|
||||
@Resource private ContainerService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUpWarehouse() {
|
||||
when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation ->
|
||||
activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("新增货柜保存最终数据库契约字段并生成平台预订单号")
|
||||
void createsContainerBooking() {
|
||||
@@ -40,6 +52,8 @@ class ContainerServiceTest {
|
||||
assertThat(created).isSameAs(saved);
|
||||
assertThat(saved.getBookingNo()).startsWith("CB");
|
||||
assertThat(saved.getCabinetNo()).isEqualTo("柜-001");
|
||||
assertThat(saved.getWarehouseId()).isEqualTo(101L);
|
||||
assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓");
|
||||
assertThat(saved.getDeparturePortCode()).isEqualTo("CNSHA");
|
||||
assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000");
|
||||
assertThat(saved.getPlanStatus()).isEqualTo("EDITING");
|
||||
@@ -127,6 +141,49 @@ class ContainerServiceTest {
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("虚拟柜拒绝选择非国内中转仓")
|
||||
void rejectsNonDomesticWarehouse() {
|
||||
when(warehouses.active(anyString(), anyLong(), anyLong()))
|
||||
.thenReturn(activeWarehouse(101L, "FBA", "美国目的仓"));
|
||||
|
||||
assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001")))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("国内中转仓");
|
||||
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("虚拟柜仓库名称以仓库主数据为准")
|
||||
void replacesRequestedWarehouseName() {
|
||||
service.create(9L, "admin", request("柜-001"));
|
||||
|
||||
ArgumentCaptor<ContainerBookingEntity> captor = ArgumentCaptor.forClass(ContainerBookingEntity.class);
|
||||
verify(repository).insert(captor.capture());
|
||||
assertThat(captor.getValue().getWarehouseName()).isEqualTo("上海中转仓");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("仓库主数据服务不可用时拒绝创建虚拟柜")
|
||||
void rejectsUnavailableWarehouseService() {
|
||||
when(warehouses.active(anyString(), anyLong(), anyLong()))
|
||||
.thenThrow(new IllegalStateException("warehouse service unavailable"));
|
||||
|
||||
assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001")))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("暂时不可用");
|
||||
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
private static ApiResponse<WarehouseMasterDataClient.Detail> activeWarehouse(long warehouseId,
|
||||
String type,
|
||||
String name) {
|
||||
return ApiResponse.success(new WarehouseMasterDataClient.Detail(
|
||||
new WarehouseMasterDataClient.Warehouse(warehouseId, 0L, "CN-SHA-01", name, type, "ACTIVE")));
|
||||
}
|
||||
|
||||
private static ContainerBookingEntity booking(String status, int version) {
|
||||
ContainerBookingEntity value = new ContainerBookingEntity();
|
||||
value.setId(11L);
|
||||
|
||||
@@ -51,7 +51,7 @@ class LoadingListServiceTest {
|
||||
|
||||
@Test
|
||||
void shouldRejectEditingStatusFromPreloadList() {
|
||||
PreloadQuery query = new PreloadQuery(null, "EDITING", null, null, null, null, 1, 20);
|
||||
PreloadQuery query = new PreloadQuery(null, "EDITING", 100L, null, null, 1, 20);
|
||||
|
||||
assertThatThrownBy(() -> service.preloadPage(query))
|
||||
.isInstanceOf(BizException.class)
|
||||
@@ -74,7 +74,7 @@ class LoadingListServiceTest {
|
||||
when(repository.preloadDetail(after)).thenReturn(
|
||||
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
|
||||
|
||||
service.updateTransport(10L, 9L, "operator", "key-1",
|
||||
service.updateTransport(10L, 100L, 9L, "operator", "key-1",
|
||||
new TransportUpdateRequest(" CSLU1234567 ", "023E", 3, "补录箱号"));
|
||||
|
||||
verify(repository).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator");
|
||||
@@ -88,7 +88,7 @@ class LoadingListServiceTest {
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.aggregate(10L)).thenReturn(row("COMPLETED", 5));
|
||||
|
||||
assertThatThrownBy(() -> service.updateTransport(10L, 9L, "operator", "key-1",
|
||||
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "key-1",
|
||||
new TransportUpdateRequest("CSLU1234567", "023E", 5, null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode())
|
||||
@@ -140,7 +140,7 @@ class LoadingListServiceTest {
|
||||
ArgumentCaptor<String> snapshot = ArgumentCaptor.forClass(String.class);
|
||||
|
||||
TransportUpdateRequest request = new TransportUpdateRequest("CSLU1234567", "023E", 3, null);
|
||||
service.updateTransport(10L, 9L, "operator", "key-1", request);
|
||||
service.updateTransport(10L, 100L, 9L, "operator", "key-1", request);
|
||||
verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"),
|
||||
eq("LOADING"), eq("LOADING"), anyString(), snapshot.capture(), eq(9L),
|
||||
eq("operator"), eq(null));
|
||||
@@ -149,14 +149,14 @@ class LoadingListServiceTest {
|
||||
when(repository.history(anyString())).thenReturn(replay);
|
||||
when(repository.aggregate(10L)).thenReturn(after);
|
||||
|
||||
service.updateTransport(10L, 9L, "operator", "key-1", request);
|
||||
service.updateTransport(10L, 100L, 9L, "operator", "key-1", request);
|
||||
|
||||
verify(repository, times(1)).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator");
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmedPageAlwaysUsesRepositoryConfirmedProjection() {
|
||||
ConfirmedQuery query = new ConfirmedQuery(" plan ", null, null, null, null, 1, 20);
|
||||
ConfirmedQuery query = new ConfirmedQuery(" plan ", 100L, null, null, 1, 20);
|
||||
when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn(
|
||||
new PageResult<>(List.of(), 1, 20, 0, 0));
|
||||
|
||||
@@ -166,11 +166,22 @@ class LoadingListServiceTest {
|
||||
"plan".equals(value.keyword())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPlanOutsideRequestedWarehouse() {
|
||||
when(repository.aggregate(10L)).thenReturn(row("LOADING", 1));
|
||||
|
||||
assertThatThrownBy(() -> service.preloadDetail(10L, 200L))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode())
|
||||
.isEqualTo(ResultCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
private static AggregateRow row(String status, int version) {
|
||||
AggregateRow row = new AggregateRow();
|
||||
row.setPlanId(10L);
|
||||
row.setPlanNo("PL-10");
|
||||
row.setPlanStatus(status);
|
||||
row.setWarehouseId(100L);
|
||||
row.setVersion(version);
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -32,12 +32,12 @@ class LoadingPickServiceTest {
|
||||
LoadingPickItemEntity item = item();
|
||||
when(repository.history("pick-1")).thenReturn(null);
|
||||
when(repository.task(20L)).thenReturn(task);
|
||||
when(repository.item(20L, 30L)).thenReturn(item);
|
||||
when(repository.pick(30L, 2, 0)).thenReturn(1);
|
||||
when(repository.item(20L, 30L, 101L)).thenReturn(item);
|
||||
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
|
||||
when(repository.allPlanItemsPicked(10L)).thenReturn(false);
|
||||
when(repository.items(20L)).thenReturn(List.of(item));
|
||||
when(repository.items(20L, 101L)).thenReturn(List.of(item));
|
||||
|
||||
service.pick(20L, 30L, new PickRequest(2, List.of(), 0, "pick-1"), 9L, "operator");
|
||||
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-1"), 9L, "operator");
|
||||
|
||||
verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
|
||||
}
|
||||
@@ -48,12 +48,12 @@ class LoadingPickServiceTest {
|
||||
LoadingPickItemEntity item = item();
|
||||
when(repository.history("pick-2")).thenReturn(null);
|
||||
when(repository.task(20L)).thenReturn(task);
|
||||
when(repository.item(20L, 30L)).thenReturn(item);
|
||||
when(repository.pick(30L, 2, 0)).thenReturn(1);
|
||||
when(repository.item(20L, 30L, 101L)).thenReturn(item);
|
||||
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
|
||||
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
|
||||
when(repository.items(20L)).thenReturn(List.of(item));
|
||||
when(repository.items(20L, 101L)).thenReturn(List.of(item));
|
||||
|
||||
service.pick(20L, 30L, new PickRequest(2, List.of(), 0, "pick-2"), 9L, "operator");
|
||||
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-2"), 9L, "operator");
|
||||
|
||||
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
|
||||
verify(repository).insert(any(LoadingPickHistoryEntity.class));
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class PdaPickSettlementPlanTest {
|
||||
@Test
|
||||
void groupsPartialItemsByInbound() {
|
||||
var resolution = PdaPickSettlementPlan.resolve(List.of(
|
||||
item(1L, 11L, 101L, 1001L, 10, 6, "PARTIAL"),
|
||||
item(2L, 12L, 101L, 1002L, 8, 3, "PARTIAL"),
|
||||
item(3L, 13L, 102L, 2001L, 4, 0, "SKIPPED"),
|
||||
item(4L, 14L, 103L, 3001L, 5, 5, "PICKED")));
|
||||
|
||||
assertThat(resolution.partialByInbound()).containsOnlyKeys(101L);
|
||||
assertThat(resolution.partialByInbound().get(101L))
|
||||
.extracting(PdaPickSettlementPlan.SplitLine::remainingPackageCount)
|
||||
.containsExactlyInAnyOrder(4, 5);
|
||||
assertThat(resolution.skippedPlanItemIds()).containsExactly(13L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnfinishedItem() {
|
||||
assertThatThrownBy(() -> PdaPickSettlementPlan.resolve(List.of(
|
||||
item(1L, 11L, 101L, 1001L, 10, 2, "PICKING"))))
|
||||
.hasMessageContaining("未结束");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidPartialCount() {
|
||||
assertThatThrownBy(() -> PdaPickSettlementPlan.resolve(List.of(
|
||||
item(1L, 11L, 101L, 1001L, 10, 10, "PARTIAL"))))
|
||||
.hasMessageContaining("不完整或箱数不合法");
|
||||
}
|
||||
|
||||
private static LoadingPickItemEntity item(long id, long planItemId, long inboundId, long detailId,
|
||||
int planned, int picked, String status) {
|
||||
LoadingPickItemEntity item = new LoadingPickItemEntity();
|
||||
item.setId(id);
|
||||
item.setLoadingPlanItemId(planItemId);
|
||||
item.setInboundId(inboundId);
|
||||
item.setInboundDetailId(detailId);
|
||||
item.setPlannedPackageCount(planned);
|
||||
item.setPickedPackageCount(picked);
|
||||
item.setItemStatus(status);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
@@ -94,11 +94,12 @@ class SmartLoadingServiceTest {
|
||||
void rejectsExcessBoxes() {
|
||||
LoadingPlanEntity plan = plan("EDITING", 0);
|
||||
when(repository.plan(1)).thenReturn(plan);
|
||||
when(repository.candidate(9, 10, 20)).thenReturn(candidate(5));
|
||||
when(repository.booking(99L)).thenReturn(booking());
|
||||
when(repository.candidate(100, 10, 20)).thenReturn(candidate(5));
|
||||
when(repository.activeItem(1L, 9L, 20L)).thenReturn(item(7, 9, 20, 4));
|
||||
|
||||
BizException error = catchThrowableOfType(() -> service.assign(1,
|
||||
new AssignRequest(9L, 10L, 20L, 6, 0, "assign-1"), 7, "u"), BizException.class);
|
||||
new AssignRequest(10L, 20L, 6, 0, "assign-1"), 7, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
|
||||
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any());
|
||||
@@ -117,7 +118,7 @@ class SmartLoadingServiceTest {
|
||||
return null;
|
||||
}).when(repository).insert(any(LoadingPlanItemEntity.class));
|
||||
|
||||
service.assign(1, new AssignRequest(9L, 10L, 20L, 5, 0, "assign-snapshot"), 7, "u");
|
||||
service.assign(1, new AssignRequest(10L, 20L, 5, 0, "assign-snapshot"), 7, "u");
|
||||
|
||||
ArgumentCaptor<LoadingPlanItemEntity> itemCaptor = ArgumentCaptor.forClass(LoadingPlanItemEntity.class);
|
||||
verify(repository).insert(itemCaptor.capture());
|
||||
@@ -142,7 +143,7 @@ class SmartLoadingServiceTest {
|
||||
new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 3,
|
||||
BigDecimal.ONE, BigDecimal.ONE, null, null, null, 100, null, null)));
|
||||
|
||||
service.assign(1, new AssignRequest(9L, 10L, 20L, 2, 0, "assign-round"), 7, "u");
|
||||
service.assign(1, new AssignRequest(10L, 20L, 2, 0, "assign-round"), 7, "u");
|
||||
|
||||
ArgumentCaptor<LoadingPlanItemEntity> captor = ArgumentCaptor.forClass(LoadingPlanItemEntity.class);
|
||||
verify(repository).insert(captor.capture());
|
||||
@@ -151,9 +152,9 @@ class SmartLoadingServiceTest {
|
||||
|
||||
@Test
|
||||
void replaysBeforeVersionCheck() {
|
||||
AssignRequest request = new AssignRequest(9L, 10L, 20L, 2, 0, "assign-replay");
|
||||
AssignRequest request = new AssignRequest(10L, 20L, 2, 0, "assign-replay");
|
||||
LoadingPlanHistoryEntity replay = history(1, null, "ADD_ITEM",
|
||||
sha256("ASSIGN|1|9|10|20|2|0"));
|
||||
sha256("ASSIGN|1|10|20|2|0"));
|
||||
LoadingPlanEntity frozen = plan("LOADING", 9);
|
||||
when(repository.operation("assign-replay")).thenReturn(replay);
|
||||
when(repository.plan(1)).thenReturn(frozen);
|
||||
@@ -169,9 +170,9 @@ class SmartLoadingServiceTest {
|
||||
|
||||
@Test
|
||||
void rejectsReusedOperation() {
|
||||
AssignRequest request = new AssignRequest(9L, 10L, 20L, 2, 0, "shared-op");
|
||||
AssignRequest request = new AssignRequest(10L, 20L, 2, 0, "shared-op");
|
||||
when(repository.operation("shared-op")).thenReturn(history(2, null, "ADD_ITEM",
|
||||
sha256("ASSIGN|1|9|10|20|2|0")));
|
||||
sha256("ASSIGN|1|10|20|2|0")));
|
||||
|
||||
BizException error = catchThrowableOfType(() -> service.assign(1, request, 7, "u"), BizException.class);
|
||||
|
||||
@@ -180,9 +181,9 @@ class SmartLoadingServiceTest {
|
||||
|
||||
@Test
|
||||
void replaysRecommendation() {
|
||||
RecommendRequest request = new RecommendRequest(9L, 0, "recommend-replay");
|
||||
RecommendRequest request = new RecommendRequest(0, "recommend-replay");
|
||||
when(repository.operation("recommend-replay")).thenReturn(history(1, null,
|
||||
"SMART_RECOMMEND", sha256("RECOMMEND|1|9|0")));
|
||||
"SMART_RECOMMEND", sha256("RECOMMEND|1|0")));
|
||||
when(repository.plan(1)).thenReturn(plan("LOADING", 8));
|
||||
when(repository.booking(99)).thenReturn(booking());
|
||||
when(repository.items(1)).thenReturn(List.of());
|
||||
@@ -195,9 +196,9 @@ class SmartLoadingServiceTest {
|
||||
|
||||
@Test
|
||||
void replaysDeleteBeforeVersionCheck() {
|
||||
DeleteRequest request = new DeleteRequest(9L, 2, 0, "delete-replay");
|
||||
DeleteRequest request = new DeleteRequest(2, 0, "delete-replay");
|
||||
when(repository.operation("delete-replay")).thenReturn(history(1, 8L,
|
||||
"REMOVE_ITEM", sha256("DELETE|1|8|9|2|0")));
|
||||
"REMOVE_ITEM", sha256("DELETE|1|8|2|0")));
|
||||
when(repository.plan(1)).thenReturn(plan("LOADING", 7));
|
||||
when(repository.booking(99)).thenReturn(booking());
|
||||
when(repository.items(1)).thenReturn(List.of());
|
||||
@@ -220,7 +221,7 @@ class SmartLoadingServiceTest {
|
||||
when(repository.booking(99)).thenReturn(booking());
|
||||
when(repository.items(1)).thenReturn(List.of());
|
||||
|
||||
Plan result = service.delete(1, 8, new DeleteRequest(9L, 2, 0, "delete-1"), 7, "u");
|
||||
Plan result = service.delete(1, 8, new DeleteRequest(2, 0, "delete-1"), 7, "u");
|
||||
|
||||
assertThat(result.version()).isEqualTo(3);
|
||||
verify(repository).touch(1, 2, 7, "u");
|
||||
@@ -233,7 +234,7 @@ class SmartLoadingServiceTest {
|
||||
ApiResponse.failure(ResultCode.NOT_FOUND, "货物不存在", null, null));
|
||||
|
||||
BizException error = catchThrowableOfType(() -> service.assign(1,
|
||||
new AssignRequest(9L, 10L, 20L, 2, 0, "assign-not-found"), 7, "u"), BizException.class);
|
||||
new AssignRequest(10L, 20L, 2, 0, "assign-not-found"), 7, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND);
|
||||
}
|
||||
@@ -245,7 +246,7 @@ class SmartLoadingServiceTest {
|
||||
.thenThrow(mock(FeignException.class));
|
||||
|
||||
BizException error = catchThrowableOfType(() -> service.assign(1,
|
||||
new AssignRequest(9L, 10L, 20L, 2, 0, "assign-down"), 7, "u"), BizException.class);
|
||||
new AssignRequest(10L, 20L, 2, 0, "assign-down"), 7, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
@@ -257,22 +258,26 @@ class SmartLoadingServiceTest {
|
||||
new OrderCargoClient.CargoBinding(99L, 30L, 1, "货物", 5, BigDecimal.ONE, BigDecimal.ONE)));
|
||||
|
||||
BizException error = catchThrowableOfType(() -> service.assign(1,
|
||||
new AssignRequest(9L, 10L, 20L, 2, 0, "assign-mismatch"), 7, "u"), BizException.class);
|
||||
new AssignRequest(10L, 20L, 2, 0, "assign-mismatch"), 7, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWrongAccountDelete() {
|
||||
void deletesCustomerItemInThePlanWarehouse() {
|
||||
LoadingPlanEntity plan = plan("EDITING", 2);
|
||||
when(repository.plan(1)).thenReturn(plan);
|
||||
when(repository.plan(1)).thenReturn(plan, plan("EDITING", 3));
|
||||
when(repository.item(1, 8)).thenReturn(item(8, 22, 20, 1));
|
||||
when(repository.touch(1, 2, 7, "u")).thenReturn(1);
|
||||
when(repository.remove(1, 8, 0, 7, "u")).thenReturn(1);
|
||||
when(repository.booking(99)).thenReturn(booking());
|
||||
when(repository.items(1)).thenReturn(List.of());
|
||||
|
||||
BizException error = catchThrowableOfType(() -> service.delete(1, 8,
|
||||
new DeleteRequest(9L, 2, 0, "delete-owner"), 7, "u"), BizException.class);
|
||||
Plan result = service.delete(1, 8,
|
||||
new DeleteRequest(2, 0, "delete-owner"), 7, "u");
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.FORBIDDEN);
|
||||
verify(repository, never()).remove(anyLong(), anyLong(), anyInt(), anyLong(), any());
|
||||
assertThat(result.version()).isEqualTo(3);
|
||||
verify(repository).remove(1, 8, 0, 7, "u");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -296,7 +301,7 @@ class SmartLoadingServiceTest {
|
||||
when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1));
|
||||
when(repository.booking(99)).thenReturn(booking());
|
||||
when(repository.items(1)).thenReturn(List.of());
|
||||
when(repository.candidate(9, 10, 20)).thenReturn(candidate(10));
|
||||
when(repository.candidate(100, 10, 20)).thenReturn(candidate(10));
|
||||
when(repository.activeItem(1L, 9L, 20L)).thenReturn(null);
|
||||
when(repository.touch(1, 0, 7, "u")).thenReturn(1);
|
||||
}
|
||||
|
||||
+30
-27
@@ -12,7 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
class SplitPreloadSqlContractTest {
|
||||
@Test
|
||||
void supportsPartialPackageLock() throws Exception {
|
||||
assertPartialContract(moduleMigration());
|
||||
assertCurrentContract(moduleMigration());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -20,52 +20,55 @@ class SplitPreloadSqlContractTest {
|
||||
String sql = moduleMigration();
|
||||
assertThat(sql)
|
||||
.contains("parent_inbound_id")
|
||||
.contains("split_level")
|
||||
.contains("inbound_role")
|
||||
.contains("root_inbound_id")
|
||||
.contains("split_status")
|
||||
.contains("UNIQUE KEY uk_inbound_one_secondary (tenant_id, parent_inbound_id)")
|
||||
.contains("CONSTRAINT fk_inbound_parent FOREIGN KEY (parent_inbound_id)")
|
||||
.contains("二级子入库单不能继续拆分")
|
||||
.contains("入库单主子关系创建后不可修改")
|
||||
.contains("已完成拆单不能回退");
|
||||
.contains("uk_inbound_one_child")
|
||||
.contains("fk_inbound_parent_order")
|
||||
.contains("子入仓单不可继续拆分")
|
||||
.contains("入仓单主子归属创建后不可修改");
|
||||
}
|
||||
|
||||
@Test
|
||||
void remainsRepeatable() throws Exception {
|
||||
String sql = moduleMigration();
|
||||
assertThat(sql)
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_detail")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history")
|
||||
.contains("information_schema.columns")
|
||||
.contains("information_schema.statistics")
|
||||
.contains("information_schema.table_constraints")
|
||||
.contains("DROP TRIGGER IF EXISTS trg_inbound_split_before_insert")
|
||||
.contains("DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_delete")
|
||||
.contains("CREATE PROCEDURE phase3_split_assert_contract()")
|
||||
.contains("拆单迁移失败:唯一子单或部分锁定幂等索引缺失")
|
||||
.contains("拆单迁移失败:主子层级或部分锁定触发器未完整创建");
|
||||
.contains("DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_insert")
|
||||
.contains("DROP TRIGGER IF EXISTS trg_inbound_split_item_before_delete")
|
||||
.contains("CREATE PROCEDURE phase3_assert_inbound_parent_child_schema()")
|
||||
.contains("主子入仓单拆分台账未完整创建")
|
||||
.contains("主子入仓单数据库保护触发器未完整创建");
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncsUnifiedMigration() throws Exception {
|
||||
assertPartialContract(unifiedMigration());
|
||||
assertCurrentContract(unifiedMigration());
|
||||
}
|
||||
|
||||
private static void assertPartialContract(String sql) {
|
||||
private static void assertCurrentContract(String sql) {
|
||||
assertThat(sql)
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_detail")
|
||||
.contains("remaining_package_count INT UNSIGNED GENERATED ALWAYS AS")
|
||||
.contains("source_package_count - locked_package_count")
|
||||
.contains("locked_package_count < source_package_count")
|
||||
.contains("status = 'LOCKED' AND child_inbound_id IS NULL")
|
||||
.contains("status = 'COMPLETED' AND child_inbound_id IS NOT NULL")
|
||||
.contains("UNIQUE KEY uk_inbound_split_parent_detail")
|
||||
.contains("UNIQUE KEY uk_inbound_split_operation (operation_id)")
|
||||
.contains("部分锁定的归属和箱数创建后不可修改")
|
||||
.contains("部分锁定与拆单明细不可删除")
|
||||
.contains("剩余箱数必须绑定主单唯一二级子入库单");
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history")
|
||||
.contains("uk_inbound_one_child")
|
||||
.contains("uk_inbound_split_operation")
|
||||
.contains("uk_inbound_split_idempotency")
|
||||
.contains("uk_inbound_split_batch_line")
|
||||
.contains("COUNT(DISTINCT item.child_inbound_id)")
|
||||
.contains("child_order.package_count <> SUM(item.package_count)")
|
||||
.contains("HAVING SUM(split_item.package_count) > source_item.package_count")
|
||||
.contains("CREATE TRIGGER trg_inbound_split_item_before_update")
|
||||
.contains("CREATE TRIGGER trg_inbound_split_item_before_delete");
|
||||
}
|
||||
|
||||
private static String moduleMigration() throws Exception {
|
||||
return new ClassPathResource("db/phase3-split-preload-schema.sql")
|
||||
return new ClassPathResource("db/phase3-inbound-parent-child-schema.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
|
||||
@@ -145,6 +145,9 @@ public class LocationApplicationService {
|
||||
if (warehouse == null) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "仓库不属于当前企业租户或不在数据范围内");
|
||||
}
|
||||
if (!"DOMESTIC".equalsIgnoreCase(warehouse.getWarehouseType())) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "库位只能绑定国内中转仓");
|
||||
}
|
||||
if (active && !"ACTIVE".equals(warehouse.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "仓库已停用");
|
||||
}
|
||||
|
||||
+16
@@ -70,6 +70,17 @@ class LocationApplicationServiceTest {
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("编码");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonDomesticWarehouseForCreate() {
|
||||
when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse("ACTIVE", "FBA"));
|
||||
|
||||
assertThatThrownBy(() -> service.create(10001L, request("AVAILABLE", null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("国内中转仓");
|
||||
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleCase03() {
|
||||
LocationEntity location = location("AVAILABLE", 3);
|
||||
@@ -110,10 +121,15 @@ class LocationApplicationServiceTest {
|
||||
}
|
||||
|
||||
private static WarehouseEntity warehouse(String status) {
|
||||
return warehouse(status, "DOMESTIC");
|
||||
}
|
||||
|
||||
private static WarehouseEntity warehouse(String status, String type) {
|
||||
WarehouseEntity warehouse = new WarehouseEntity();
|
||||
warehouse.setId(88L);
|
||||
warehouse.setTenantId(10001L);
|
||||
warehouse.setStatus(status);
|
||||
warehouse.setWarehouseType(type);
|
||||
return warehouse;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user