fix(firstleg): settle and validate actual partial loading cargo

This commit is contained in:
2026-10-04 19:01:40 +08:00
parent 22133fe604
commit 7319fba35a
16 changed files with 936 additions and 49 deletions
+2 -2
View File
@@ -72,12 +72,12 @@ IAM保留subject/tenant持久版本表;安全事件专用Outbox及Lastleg授
| 区域 / schema | 迁移 | 目的 |
|---|---|---|
| CN / `baiyue_iam` | `migrations/cn/baiyue_iam/V1__align_schema_baseline.sql`、`V2__add_pda_release_permissions.sql`、`V3__align_domestic_pda_mojia_sailing_permissions.sql` | 收敛客户编号、昵称和资质原文件名定义;补齐 PDA 发布、国内作业仓绑定、墨家 SKU 同步、仓管 PDA 和船期日历权限 |
| CN / `baiyue_firstleg` | `migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql`、`V2__align_measurement_and_fee_schema.sql`、`V3__add_customs_receipt_declaration_schema.sql`、`V4__add_sailing_calendar_schema.sql` | 收敛一件代发托盘码非空定义;补齐墨家测量和人工报价字段、报关行回执/申报事实、船期日历事实表;V2 保留未上报重量为 NULL,放宽已有非空重量列,不回填或删除收件记录 |
| CN / `baiyue_firstleg` | `migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql`、`V2__align_measurement_and_fee_schema.sql`、`V3__add_customs_receipt_declaration_schema.sql`、`V4__add_sailing_calendar_schema.sql`、`V5__event_delivery_retry_audit.sql`、`V6__event_delivery_attempt.sql`、`V7__settle_partial_loaded_cargo.sql` | 收敛一件代发托盘码非空定义;补齐测量/报价、报关、船期和事件重试审计;V7 仅更新装柜明细保护触发器,允许普通箱件按冻结分配和实际拣货精确收敛件数、重量、体积和托数,保留身份及已完成冻结保护,不回填原事件或库存 |
| CN / `baiyue_file` | `migrations/cn/baiyue_file/V1__align_schema_baseline.sql`、`V2__widen_business_type.sql` | 补齐 PDA 发布表和固定渠道,并支持正式长业务类型 |
| OVERSEAS / `baiyue_lastleg` | `migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`、`V2__align_schema_baseline.sql`、`V3__relax_valid_nullable_fields.sql`、`V4__cargo_discard_return_inventory.sql`、`V5__allow_multiple_relabel_applications.sql` | 补齐尾程字段并收敛字段定义;放宽退货出库和预入仓批量 SKU 换标合法空值;货物废弃支持退货库存箱;允许同一尾程订单多个换标申请 |
| OVERSEAS / `baiyue_lastlag_warehouse` | `migrations/overseas/baiyue_lastlag_warehouse/V1__add_shared_overflow_location.sql`、`V2__retire_location_zone_and_rebuild_query_index.sql` | 补齐共享溢出库位并移除退役 `zone_code`;V2 含 `DROP COLUMN`,生产执行必须单独授权并确认无外部依赖 |
上述迁移候选只完成编写和离线检查,尚未在测试或生产执行。`db-migrations.mjs plan --services iam,firstleg,lastleg-service,warehouse-overseas-service --regions CN,OVERSEAS` 生成的 manifest 目标版本应为 `baiyue_iam=3`、`baiyue_firstleg=4`、`baiyue_lastleg=5`、`baiyue_lastlag_warehouse=2`。测试通过后必须复用同一份 SQL 和 manifest 晋级生产,不得重新生成或修改候选。`scripts/prod-database-schema-acceptance.mjs` 仍只负责历史尾程 V1 的专用只读核对/受控执行,不代表本批跨 schema 发布入口;本批按迁移 manifest 管理,实际执行须等待环境执行器接入或当前轮明确授权后的受控人工执行。
上述迁移候选只完成编写和离线检查,尚未在测试或生产执行。`db-migrations.mjs plan --services iam,firstleg,lastleg-service,warehouse-overseas-service --regions CN,OVERSEAS` 生成的 manifest 目标版本应为 `baiyue_iam=3`、`baiyue_firstleg=7`、`baiyue_lastleg=5`、`baiyue_lastlag_warehouse=2`。测试通过后必须复用同一份 SQL 和 manifest 晋级生产,不得重新生成或修改候选。`scripts/prod-database-schema-acceptance.mjs` 仍只负责历史尾程 V1 的专用只读核对/受控执行,不代表本批跨 schema 发布入口;本批按迁移 manifest 管理,实际执行须等待环境执行器接入或当前轮明确授权后的受控人工执行。
迁移范围映射位于 `migration-scopes.json`,按区域、schema、所属服务和顺序登记。迁移文件位于 `migrations/{cn|overseas}/<schema>/`,一个文件只允许修改一个 schema。目录中的 `.gitkeep` 仅用于保留空目录,不是迁移文件。
@@ -0,0 +1,124 @@
-- Region: CN
-- Schema: baiyue_firstleg
-- Purpose: settle_partial_loaded_cargo
-- Preconditions: existing loading item/pick tables and trg_loading_item_before_update.
-- Postconditions: exact PACKAGE partial convergence; all other frozen fields remain protected.
-- No business-row backfill or replay of existing loading events.
DROP TRIGGER IF EXISTS trg_loading_item_before_update;
DELIMITER $$
CREATE TRIGGER trg_loading_item_before_update
BEFORE UPDATE ON fl_loading_plan_item
FOR EACH ROW
BEGIN
DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status
FROM fl_loading_plan WHERE id = OLD.loading_plan_id;
IF current_plan_status = 'LOADING' AND EXISTS (
SELECT 1 FROM fl_loading_pick_item
WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN
IF NOT (NEW.internal_remark <=> OLD.internal_remark)
OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark)
OR NOT (NEW.customer_service_remark <=> OLD.customer_service_remark) THEN
IF NEW.package_count <> OLD.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.customer_remark <=> OLD.customer_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;
ELSEIF NOT EXISTS (
SELECT 1 FROM fl_loading_pick_item actual_pick
JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id
WHERE actual_pick.loading_plan_item_id = OLD.id
AND actual_task.loading_plan_id = OLD.loading_plan_id
AND actual_pick.item_status = 'PARTIAL'
AND OLD.unit_type = 'PACKAGE' AND OLD.pallet_id IS NULL
AND actual_pick.picked_package_count > 0
AND actual_pick.picked_package_count < actual_pick.planned_package_count
AND OLD.package_count = actual_pick.planned_package_count
AND NEW.deleted = 0
AND NEW.package_count = actual_pick.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 <=> CASE WHEN OLD.units_per_carton IS NOT NULL THEN OLD.units_per_carton * NEW.package_count ELSE ROUND(CAST(OLD.product_quantity AS DECIMAL(30,6)) * NEW.package_count / NULLIF(OLD.package_count,0),0) END)
OR NOT (NEW.units_per_carton <=> OLD.units_per_carton)
OR NOT (NEW.pallet_count <=> CEIL(CAST(COALESCE(OLD.pallet_count,0) AS DECIMAL(30,6)) * NEW.package_count / NULLIF(OLD.package_count,0)))
OR NOT (NEW.gross_weight_kg <=> ROUND(OLD.gross_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3))
OR NOT (NEW.net_weight_kg <=> ROUND(OLD.net_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3))
OR NOT (NEW.dimensional_weight_kg <=> ROUND(OLD.dimensional_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3))
OR NOT (NEW.volume_cbm <=> ROUND(OLD.volume_cbm * NEW.package_count / NULLIF(OLD.package_count,0),4))
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.customer_remark <=> OLD.customer_remark)
OR NOT (NEW.frozen_at <=> OLD.frozen_at)
OR NOT (NEW.frozen_by <=> OLD.frozen_by)
OR NOT (NEW.removed_by <=> OLD.removed_by)
OR NOT (NEW.removed_by_name <=> OLD.removed_by_name)
OR NOT (NEW.removed_at <=> OLD.removed_at) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细仅允许按冻结分配比例收敛实际装载';
END IF;
END IF;
IF OLD.frozen_at IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结';
END IF;
IF OLD.deleted = 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已删除装柜明细不可恢复或修改';
END IF;
IF NEW.loading_plan_id <> OLD.loading_plan_id OR NEW.account_id <> OLD.account_id
OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜明细归属键不可修改,请删除后重新分配';
END IF;
IF NEW.deleted = 1 THEN
IF NEW.removed_by IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除装柜明细必须记录操作人';
END IF;
SET NEW.removed_at = COALESCE(NEW.removed_at, CURRENT_TIMESTAMP(3));
ELSEIF NEW.removed_by IS NOT NULL OR NEW.removed_by_name IS NOT NULL OR NEW.removed_at IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除审计字段只能在逻辑删除时写入';
END IF;
END$$
DELIMITER ;
@@ -5771,11 +5771,17 @@ CREATE TRIGGER trg_loading_item_before_update BEFORE UPDATE ON fl_loading_plan_i
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细修改备注时不得变更其他业务字段';
END IF;
ELSEIF NOT EXISTS (
SELECT 1 FROM fl_loading_pick_item
WHERE loading_plan_item_id = OLD.id
AND item_status = 'PARTIAL'
SELECT 1 FROM fl_loading_pick_item actual_pick
JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id
WHERE actual_pick.loading_plan_item_id = OLD.id
AND actual_task.loading_plan_id = OLD.loading_plan_id
AND actual_pick.item_status = 'PARTIAL'
AND OLD.unit_type = 'PACKAGE' AND OLD.pallet_id IS NULL
AND actual_pick.picked_package_count > 0
AND actual_pick.picked_package_count < actual_pick.planned_package_count
AND OLD.package_count = actual_pick.planned_package_count
AND NEW.deleted = 0
AND NEW.package_count = picked_package_count
AND NEW.package_count = actual_pick.picked_package_count
) OR NEW.deleted <> OLD.deleted
OR NOT (NEW.customer_name <=> OLD.customer_name)
OR NOT (NEW.inbound_id <=> OLD.inbound_id)
@@ -5791,13 +5797,13 @@ CREATE TRIGGER trg_loading_item_before_update BEFORE UPDATE ON fl_loading_plan_i
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.product_quantity <=> CASE WHEN OLD.units_per_carton IS NOT NULL THEN OLD.units_per_carton * NEW.package_count ELSE ROUND(CAST(OLD.product_quantity AS DECIMAL(30,6)) * NEW.package_count / NULLIF(OLD.package_count,0),0) END)
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.pallet_count <=> CEIL(CAST(COALESCE(OLD.pallet_count,0) AS DECIMAL(30,6)) * NEW.package_count / NULLIF(OLD.package_count,0)))
OR NOT (NEW.gross_weight_kg <=> ROUND(OLD.gross_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3))
OR NOT (NEW.net_weight_kg <=> ROUND(OLD.net_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3))
OR NOT (NEW.dimensional_weight_kg <=> ROUND(OLD.dimensional_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3))
OR NOT (NEW.volume_cbm <=> ROUND(OLD.volume_cbm * NEW.package_count / NULLIF(OLD.package_count,0),4))
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)
@@ -5809,7 +5815,7 @@ CREATE TRIGGER trg_loading_item_before_update BEFORE UPDATE ON fl_loading_plan_i
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拣货记录的装柜明细仅允许收敛部分拣货实际箱数';
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细仅允许按冻结分配比例收敛实际装载';
END IF;
END IF;
IF OLD.frozen_at IS NOT NULL THEN