diff --git a/deploy/sql/README.md b/deploy/sql/README.md index b5ddec95d..69ba2021a 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -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。目录中的 `.gitkeep` 仅用于保留空目录,不是迁移文件。 diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V7__settle_partial_loaded_cargo.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V7__settle_partial_loaded_cargo.sql new file mode 100644 index 000000000..8ceefa595 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_firstleg/V7__settle_partial_loaded_cargo.sql @@ -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 ; diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 84a641170..9149abd55 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java index e4946da34..96b2c564d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java @@ -8,6 +8,16 @@ import java.util.List; @Mapper public interface ContainerCustomsMapper { + /** Same settled loading-item scope as the V3 handover; retained skipped rows are not customs cargo. */ + String ACTUAL_ITEM_SCOPE = """ + 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_task.loading_plan_id=item.loading_plan_id + AND actual_pick.loading_plan_item_id=item.id + AND actual_pick.item_status IN ('PICKED','PARTIAL') + AND actual_pick.picked_package_count > 0 + AND actual_pick.picked_package_count=item.package_count) + """; String DOCUMENT_COLUMNS = """ d.id id,d.customs_container_id customsContainerId,d.tenant_id tenantId,d.file_id fileId, d.file_name fileName,d.file_hash fileHash,d.content_type contentType,d.size_bytes sizeBytes, @@ -36,6 +46,9 @@ public interface ContainerCustomsMapper { JOIN fl_loading_plan_item item ON item.loading_plan_id=p.id AND item.deleted=0 JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE p.id=#{planId} + AND (p.plan_status != 'COMPLETED' OR + """ + ACTUAL_ITEM_SCOPE + """ + ) ORDER BY i.tenant_id,i.id """) List planInbounds(@Param("planId") long planId); @@ -46,6 +59,8 @@ public interface ContainerCustomsMapper { JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED' JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE item.loading_plan_id=#{planId} AND item.deleted=0 + AND + """ + ACTUAL_ITEM_SCOPE + """ AND (#{tenantId} IS NULL OR i.tenant_id=#{tenantId}) AND (#{taxMode} IS NULL OR i.tax_mode=#{taxMode}) """) @@ -66,6 +81,8 @@ public interface ContainerCustomsMapper { JOIN fl_loading_plan_item item ON item.loading_plan_id=p.id AND item.deleted=0 JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE p.plan_status='COMPLETED' AND i.tenant_id=#{tenantId} AND i.order_id=#{orderId} AND i.tax_mode='TAX_REFUND' + AND + """ + ACTUAL_ITEM_SCOPE + """ ORDER BY COALESCE(p.confirmed_at,p.updated_at) DESC,p.id DESC LIMIT 1 """) Long completedPlanForOrder(@Param("tenantId") long tenantId, @Param("orderId") long orderId); @@ -325,6 +342,8 @@ public interface ContainerCustomsMapper { SELECT DISTINCT i.id FROM fl_loading_plan_item item JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE item.loading_plan_id=#{planId} AND item.deleted=0 AND i.tax_mode='TAX_REFUND' + AND + """ + ACTUAL_ITEM_SCOPE + """ ) target LEFT JOIN fl_customs_customer_document_inbound s ON s.inbound_id=target.id LEFT JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.customs_container_id=#{containerId} AND d.status='ACTIVE' WHERE d.id IS NULL @@ -336,6 +355,8 @@ public interface ContainerCustomsMapper { FROM fl_loading_plan_item item JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE item.loading_plan_id=#{planId} AND item.deleted=0 AND i.tax_mode='TAX_REFUND' + AND + """ + ACTUAL_ITEM_SCOPE + """ """) int taxRefundCount(@Param("planId") long planId); @@ -541,6 +562,8 @@ public interface ContainerCustomsMapper { LEFT JOIN fl_outbound_dispatch d ON d.inbound_id=item.inbound_id AND d.tenant_id=item.account_id AND d.container_booking_id=c.container_booking_id WHERE item.loading_plan_id=c.loading_plan_id AND item.deleted=0 + AND + """ + ACTUAL_ITEM_SCOPE + """ GROUP BY item.loading_plan_id HAVING COUNT(*)=SUM(CASE WHEN d.status='WAITING_FOR_LOADING' THEN 1 ELSE 0 END) ) ORDER BY c.completed_at,c.id LIMIT #{limit} """) @@ -557,6 +580,8 @@ public interface ContainerCustomsMapper { JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE' JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION' + AND + """ + ACTUAL_ITEM_SCOPE + """ AND item.id>#{cursor} AND item.id<=#{maximumId} ORDER BY item.id LIMIT #{limit} """) @@ -575,6 +600,8 @@ public interface ContainerCustomsMapper { JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED' JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION' + AND + """ + ACTUAL_ITEM_SCOPE + """ """) long maximumBuyItemId(@Param("planId") long planId); @@ -583,6 +610,8 @@ public interface ContainerCustomsMapper { JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED' JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION' AND item.id<=#{maximumId} + AND + """ + ACTUAL_ITEM_SCOPE + """ """) long countBuyRows(@Param("planId") long planId,@Param("maximumId") long maximumId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java index 683c17012..807698ed8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java @@ -250,14 +250,29 @@ public interface InboundSplitSettlementMapper { @Update(""" UPDATE fl_loading_plan_item plan_item JOIN fl_loading_pick_item pick_item ON pick_item.loading_plan_item_id = plan_item.id - SET plan_item.pallet_count = CEIL(COALESCE(plan_item.pallet_count,0) - * pick_item.picked_package_count / NULLIF(plan_item.package_count,0)), + SET plan_item.pallet_count = CEIL(CAST(COALESCE(plan_item.pallet_count,0) AS DECIMAL(30,6)) + * pick_item.picked_package_count / NULLIF(pick_item.planned_package_count,0)), + plan_item.product_quantity = CASE WHEN plan_item.units_per_carton IS NOT NULL + THEN plan_item.units_per_carton * pick_item.picked_package_count + ELSE ROUND(CAST(plan_item.product_quantity AS DECIMAL(30,6)) * pick_item.picked_package_count + / NULLIF(pick_item.planned_package_count,0),0) END, + plan_item.gross_weight_kg = ROUND(plan_item.gross_weight_kg * pick_item.picked_package_count + / NULLIF(pick_item.planned_package_count,0),3), + plan_item.net_weight_kg = ROUND(plan_item.net_weight_kg * pick_item.picked_package_count + / NULLIF(pick_item.planned_package_count,0),3), + plan_item.dimensional_weight_kg = ROUND(plan_item.dimensional_weight_kg * pick_item.picked_package_count + / NULLIF(pick_item.planned_package_count,0),3), + plan_item.volume_cbm = ROUND(plan_item.volume_cbm * pick_item.picked_package_count + / NULLIF(pick_item.planned_package_count,0),4), plan_item.package_count = pick_item.picked_package_count, plan_item.updated_by = #{operatorId}, plan_item.updated_by_name = #{operatorName}, plan_item.updated_at = CURRENT_TIMESTAMP(3), plan_item.version = plan_item.version + 1 WHERE plan_item.id = #{planItemId} AND plan_item.deleted = 0 + AND plan_item.unit_type = 'PACKAGE' AND plan_item.pallet_id IS NULL AND pick_item.item_status = 'PARTIAL' AND pick_item.picked_package_count > 0 + AND pick_item.picked_package_count < pick_item.planned_package_count + AND plan_item.package_count = pick_item.planned_package_count """) int convergePartialPlanItem(@Param("planItemId") long planItemId, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index 073fad613..7cc76633b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -13,7 +13,7 @@ import java.util.List; /** 读取 PDA 结算后的最终实装入仓单和冻结货物快照。 */ @Mapper public interface LoadingCompletedEventMapper { - @Select(""" + String SNAPSHOT_ROWS = """ SELECT p.id AS loading_plan_id, p.plan_no, p.container_booking_id, p.version AS aggregate_version, b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name, i.customer_name, @@ -29,7 +29,10 @@ public interface LoadingCompletedEventMapper { i.product_name_zh, i.product_name_en, i.package_count, i.product_quantity, i.pallet_count, i.gross_weight_kg, i.net_weight_kg, i.dimensional_weight_kg, i.volume_cbm, i.fba_tracking_no, i.shipment_no, i.shipping_mark - ,i.customer_remark, i.internal_remark, i.warehouse_remark + ,i.customer_remark, i.internal_remark, i.warehouse_remark, + i.units_per_carton, i.unit_type, i.pallet_id, + actual_pick.planned_package_count, actual_pick.picked_package_count, + actual_pick.item_status AS pick_status FROM fl_loading_plan p JOIN fl_container_booking b ON b.id = p.container_booking_id JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 @@ -37,17 +40,33 @@ public interface LoadingCompletedEventMapper { ON actual_pick.loading_plan_item_id = i.id AND actual_pick.item_status IN ('PICKED','PARTIAL') AND actual_pick.picked_package_count > 0 - AND actual_pick.picked_package_count = i.package_count JOIN fl_loading_pick_task actual_task ON actual_task.id = actual_pick.pick_task_id AND actual_task.loading_plan_id = p.id JOIN fl_inbound_order o ON o.id = i.inbound_id AND o.tenant_id = i.account_id AND o.deleted = 0 LEFT JOIN fl_inbound_order root_order ON root_order.id = COALESCE(o.root_inbound_id, o.id) WHERE p.id = #{planId} AND i.package_count > 0 - ORDER BY i.account_id, i.inbound_id, i.inbound_detail_id, i.id - """) + """; + String SNAPSHOT_ORDER = " ORDER BY i.account_id, i.inbound_id, i.inbound_detail_id, i.id"; + + @Select(SNAPSHOT_ROWS + " AND actual_pick.picked_package_count = i.package_count" + SNAPSHOT_ORDER) List rows(@Param("planId") long planId); + /** 尚未结算的 PACKAGE/PARTIAL 也必须接受整柜预检;这里只读原始冻结行。 */ + @Select(SNAPSHOT_ROWS + SNAPSHOT_ORDER) + List previewRows(@Param("planId") long planId); + + @Select(""" + SELECT COUNT(*) FROM fl_loading_plan_item i + JOIN fl_loading_pick_item actual_pick ON actual_pick.loading_plan_item_id=i.id + JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id + AND actual_task.loading_plan_id=i.loading_plan_id + WHERE i.loading_plan_id=#{planId} AND i.deleted=0 + AND actual_pick.item_status IN ('PICKED','PARTIAL') AND actual_pick.picked_package_count>0 + AND actual_pick.picked_package_count<>i.package_count + """) + int unsettledActualItems(@Param("planId") long planId); + /** 读取头程托盘及箱唛快照;普通订单向尾程交接打印编号,一键代发同时冻结托盘库存。 */ @Select(""" SELECT i.account_id, i.inbound_id AS source_inbound_id, @@ -139,6 +158,8 @@ public interface LoadingCompletedEventMapper { private String fbaTrackingNo; private String shipmentNo; private String shippingMark; private String businessType; private String fulfillmentType; private String lxCode; private String destinationType; private String customerRemark; private String internalRemark; private String warehouseRemark; + private Integer unitsPerCarton; private String unitType; private Long palletId; + private Integer plannedPackageCount; private Integer pickedPackageCount; private String pickStatus; } @Getter @Setter diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java index faca5e4c3..883cd3b80 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java @@ -18,7 +18,7 @@ public interface LoadingPalletLabelMapper { i.id AS item_id,i.account_id,i.inbound_id,i.inbound_code,i.destination_warehouse_id, i.destination_warehouse_code,i.destination_warehouse_name, COALESCE(NULLIF(i.pallet_count,0), - CEIL(COALESCE(NULLIF(o.pallet_count,0), + CEIL(CAST(COALESCE(NULLIF(o.pallet_count,0), NULLIF((SELECT COUNT(DISTINCT weight.id) FROM fl_inbound_pallet_allocation allocation JOIN fl_inbound_weight_result weight @@ -31,7 +31,7 @@ public interface LoadingPalletLabelMapper { AND allocation.allocation_status IN ('CONFIRMED','TRANSFERRED','RELEASED')),0), (SELECT COUNT(*) FROM fl_inbound_weight_result weight WHERE weight.tenant_id=i.account_id AND weight.inbound_id=i.inbound_id - AND weight.deleted=0 AND weight.recognition_status='CONFIRMED'),0) + AND weight.deleted=0 AND weight.recognition_status='CONFIRMED'),0) AS DECIMAL(30,6)) * i.package_count / NULLIF(o.total_package_count,0)),0) AS pallet_count, COALESCE(o.fulfillment_type,'STANDARD') AS fulfillment_type, o.destination_type,i.load_sequence @@ -45,6 +45,44 @@ public interface LoadingPalletLabelMapper { """) List lockSources(@Param("planId") long planId); + /** 预测结算后的来源范围和托数;不锁行、不生成或占用标签。 */ + @Select(""" + SELECT p.id AS plan_id,p.plan_no,p.plan_status,b.container_no,b.cabinet_no,b.warehouse_id, + i.id AS item_id,i.account_id,i.inbound_id,i.inbound_code,i.destination_warehouse_id, + i.destination_warehouse_code,i.destination_warehouse_name, + COALESCE(NULLIF(CASE WHEN pick.picked_package_count0 AND allocation.slot_no>0 AND allocation.reservation_id>0 + AND allocation.allocated_package_count>0 + AND allocation.allocation_status IN ('CONFIRMED','TRANSFERRED','RELEASED')),0), + (SELECT COUNT(*) FROM fl_inbound_weight_result weight + WHERE weight.tenant_id=i.account_id AND weight.inbound_id=i.inbound_id + AND weight.deleted=0 AND weight.recognition_status='CONFIRMED'),0) AS DECIMAL(30,6)) + * pick.picked_package_count / NULLIF(o.total_package_count,0)),0) AS pallet_count, + COALESCE(o.fulfillment_type,'STANDARD') AS fulfillment_type, + o.destination_type,i.load_sequence + FROM fl_loading_plan p + JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE' + JOIN fl_loading_plan_item i ON i.loading_plan_id=p.id AND i.deleted=0 + JOIN fl_loading_pick_item pick ON pick.loading_plan_item_id=i.id + AND pick.item_status IN ('PICKED','PARTIAL') AND pick.picked_package_count>0 + JOIN fl_loading_pick_task task ON task.id=pick.pick_task_id AND task.loading_plan_id=p.id + LEFT JOIN fl_inbound_order o ON o.id=i.inbound_id AND o.tenant_id=i.account_id AND o.deleted=0 + WHERE p.id=#{planId} + ORDER BY i.destination_warehouse_code,i.load_sequence,i.id + """) + List previewSources(@Param("planId") long planId); + @Select(""" SELECT id AS label_id,loading_plan_id AS plan_id,loading_plan_item_id AS item_id, account_id,inbound_id,inbound_code,container_no,destination_warehouse_code, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 93b11a592..63f00e5cb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -28,6 +28,7 @@ import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import java.math.BigDecimal; +import java.math.RoundingMode; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.LocalDateTime; @@ -53,24 +54,33 @@ public class LoadingCompletedOutboxService { @Resource private ObjectMapper objectMapper; public void publish(long planId) { - preparedEnvelopes(planId).forEach(this::insert); + if (snapshotMapper.unsettledActualItems(planId) != 0) { + throw new BizException(ResultCode.CONFLICT, "实装明细尚未完成结算,不能发布尾程交接事件"); + } + preparedEnvelopes(planId, false).forEach(this::insert); } /** 在任何入库结算前验证跨区交接快照,避免整托/箱唛冲突留下半完成状态。 */ public void requirePublishable(long planId) { - preparedEnvelopes(planId); + preparedEnvelopes(planId, true); } - private List preparedEnvelopes(long planId) { - List rows = snapshotMapper.rows(planId); + private List preparedEnvelopes(long planId, boolean preview) { + List rows = preview ? snapshotMapper.previewRows(planId) : snapshotMapper.rows(planId); if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件"); + if (preview) rows.forEach(LoadingCompletedOutboxService::projectSettledRow); validateContainerIdentity(rows); validateCustomerSnapshots(rows); - palletLabelService.prepare(planId, rows.get(0).getWarehouseId()); + List standardLabels; + if (preview) { + standardLabels = palletLabelService.preview(planId, rows.get(0).getWarehouseId()); + } else { + palletLabelService.prepare(planId, rows.get(0).getWarehouseId()); + standardLabels = palletLabelMapper.labels(planId); + } Map> cartonMarks = cartonLabelPools(rows); List palletRows = palletRows(planId); Map palletManifests = palletManifests(palletRows); - List standardLabels = palletLabelMapper.labels(planId); return rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId, java.util.TreeMap::new, java.util.stream.Collectors.toList())) .entrySet().stream().map(entry -> envelope(entry.getKey(), entry.getValue(), @@ -80,6 +90,37 @@ public class LoadingCompletedOutboxService { .toList(); } + /** 与 convergePartialPlanItem 相同的冻结分配口径;已结算的行绝不再次缩放。 */ + static void projectSettledRow(Row row) { + Integer actual = row.getPickedPackageCount(); + Integer planned = row.getPlannedPackageCount(); + Integer frozen = row.getPackageCount(); + if (actual == null || actual <= 0 || planned == null || planned < actual) { + throw new BizException(ResultCode.CONFLICT, "装柜实拣数量与计划快照不一致"); + } + if (actual.equals(frozen)) return; + if (!"PARTIAL".equals(row.getPickStatus()) || !"PACKAGE".equals(row.getUnitType()) + || row.getPalletId() != null || !planned.equals(frozen) || actual >= planned) { + throw new BizException(ResultCode.CONFLICT, "部分装柜尚未形成合法的冻结分配快照"); + } + Integer units = row.getUnitsPerCarton(); + row.setProductQuantity(units == null + ? (row.getProductQuantity() == null ? null : scaled(BigDecimal.valueOf(row.getProductQuantity()), actual, planned, 0).intValueExact()) + : Math.multiplyExact(units, actual)); + row.setPalletCount(BigDecimal.valueOf(Objects.requireNonNullElse(row.getPalletCount(), 0)) + .multiply(BigDecimal.valueOf(actual)).divide(BigDecimal.valueOf(planned), 0, RoundingMode.CEILING).intValueExact()); + row.setGrossWeightKg(scaled(row.getGrossWeightKg(), actual, planned, 3)); + row.setNetWeightKg(scaled(row.getNetWeightKg(), actual, planned, 3)); + row.setDimensionalWeightKg(scaled(row.getDimensionalWeightKg(), actual, planned, 3)); + row.setVolumeCbm(scaled(row.getVolumeCbm(), actual, planned, 4)); + row.setPackageCount(actual); + } + + private static BigDecimal scaled(BigDecimal value, int actual, int planned, int scale) { + return value == null ? null : value.multiply(BigDecimal.valueOf(actual)) + .divide(BigDecimal.valueOf(planned), scale, RoundingMode.HALF_UP); + } + /** 已开展逐箱打托的入仓单使用现场事实;未开展的存量入仓单继续兼容原托盘分配快照。 */ private List palletRows(long planId) { int workPalletCount = snapshotMapper.workPalletCount(planId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPalletLabelService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPalletLabelService.java index 84ca04530..07295e586 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPalletLabelService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPalletLabelService.java @@ -47,6 +47,28 @@ public class LoadingPalletLabelService { @Transactional public PalletLabelManifest prepare(long planId, long warehouseId) { List sources = mapper.lockSources(planId); + List expected = expectedSources(sources, warehouseId); + List existing = mapper.labels(planId); + if (existing.isEmpty()) { + expected.forEach(row -> { + row.setLabelId(IdWorker.getId()); + if (mapper.insert(row) != 1) throw new BizException(ResultCode.CONFLICT, "托盘码生成失败,请刷新后重试"); + }); + existing = mapper.labels(planId); + } + requireSame(expected, existing); + return manifest(sources.get(0), existing); + } + + /** 结算前验证预测标签,仅读取;不分配持久标识、锁行或新增标签。 */ + public List preview(long planId, long warehouseId) { + List expected = expectedSources(mapper.previewSources(planId), warehouseId); + List existing = mapper.labels(planId); + if (!existing.isEmpty()) requireSame(expected, existing); + return existing.isEmpty() ? expected : existing; + } + + private List expectedSources(List sources, long warehouseId) { if (sources.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在或没有有效明细"); SourceRow first = sources.get(0); if (!Objects.equals(first.getWarehouseId(), warehouseId)) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不属于当前仓库"); @@ -64,14 +86,7 @@ public class LoadingPalletLabelService { throw new BizException(ResultCode.CONFLICT, "散货明细缺少 FBA 仓信息,不能生成托盘码"); } Map internalCodes = resolveInternalCodes(standard); - List existing = mapper.labels(planId); - List expected = expected(first, standard, containerNo, internalCodes); - if (existing.isEmpty()) { - expected.forEach(row -> { if (mapper.insert(row) != 1) throw new BizException(ResultCode.CONFLICT, "托盘码生成失败,请刷新后重试"); }); - existing = mapper.labels(planId); - } - requireSame(expected, existing); - return manifest(first, existing); + return expected(first, standard, containerNo, internalCodes); } @Transactional @@ -103,7 +118,7 @@ public class LoadingPalletLabelService { for (int local = 0; local < source.getPalletCount(); local++) { int index = indexes.merge(source.getDestinationWarehouseId(), 1, Integer::sum); if (index > 999) throw new BizException(ResultCode.CONFLICT, "单柜同一 FBA 仓托盘数不能超过 999"); - LabelRow row = new LabelRow(); row.setLabelId(IdWorker.getId()); row.setPlanId(plan.getPlanId()); + LabelRow row = new LabelRow(); row.setPlanId(plan.getPlanId()); row.setItemId(source.getItemId()); row.setAccountId(source.getAccountId()); row.setInboundId(source.getInboundId()); row.setInboundCode(source.getInboundCode()); row.setContainerNo(containerNo); row.setDestinationWarehouseCode(warehouseCode); row.setDestinationWarehouseName(source.getDestinationWarehouseName()); diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 8c289977c..cbb43637f 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -1094,11 +1094,17 @@ BEGIN 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) @@ -1114,13 +1120,13 @@ BEGIN 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) @@ -1132,7 +1138,7 @@ BEGIN 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 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsActualCargoTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsActualCargoTest.java new file mode 100644 index 000000000..81debbe9f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsActualCargoTest.java @@ -0,0 +1,135 @@ +package com.baiyue.firstleg.customs.container; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Real mapper checks: customs must use physical loading scope, not retained skipped plan rows. */ +class ContainerCustomsActualCargoTest { + private JdbcTemplate jdbc; + private ContainerCustomsMapper mapper; + + @BeforeEach + void setup() throws Exception { + var ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:customs-loaded-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"); + jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT,plan_no VARCHAR,container_booking_id BIGINT,plan_status VARCHAR,confirmed_at TIMESTAMP,updated_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR,record_status VARCHAR)"); + jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,loading_plan_id BIGINT,inbound_id BIGINT,account_id BIGINT,deleted INT,package_count INT,customer_name VARCHAR,order_no VARCHAR,inbound_code VARCHAR,sku_code VARCHAR,product_name_zh VARCHAR,product_name_en VARCHAR,product_quantity INT,gross_weight_kg DECIMAL,net_weight_kg DECIMAL,volume_cbm DECIMAL,shipping_mark VARCHAR,customer_remark VARCHAR)"); + jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT,tenant_id BIGINT,order_id BIGINT,order_no VARCHAR,inbound_code VARCHAR,customer_name VARCHAR,tax_mode VARCHAR,deleted INT)"); + jdbc.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT)"); + jdbc.execute("CREATE TABLE fl_loading_pick_item(loading_plan_item_id BIGINT,pick_task_id BIGINT,item_status VARCHAR,picked_package_count INT)"); + jdbc.execute("CREATE TABLE fl_customs_customer_document_inbound(document_id BIGINT,inbound_id BIGINT)"); + jdbc.execute("CREATE TABLE fl_customs_customer_document(id BIGINT,customs_container_id BIGINT,status VARCHAR)"); + jdbc.execute("CREATE TABLE fl_customs_container(id BIGINT,loading_plan_id BIGINT,container_booking_id BIGINT,status VARCHAR,completed_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE fl_outbound_dispatch(inbound_id BIGINT,tenant_id BIGINT,container_booking_id BIGINT,status VARCHAR)"); + jdbc.update("INSERT INTO fl_loading_plan VALUES(1,'PLAN',10,'COMPLETED',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)"); + jdbc.update("INSERT INTO fl_container_booking VALUES(10,'CAB','CONTAINER','ACTIVE')"); + jdbc.update("INSERT INTO fl_loading_pick_task VALUES(100,1),(200,2)"); + inbound(1, 11, "TAX_REFUND"); + inbound(2, 22, "TAX_REFUND"); + inbound(3, 11, "BUY_DECLARATION"); + inbound(4, 22, "BUY_DECLARATION"); + item(1, 1, 11, 2, "PICKED", 2, 100); + item(2, 1, 11, 1, "PARTIAL", 1, 100); + item(3, 1, 11, 1, "SKIPPED", 0, 100); // same inbound, skipped SKU + item(4, 2, 22, 1, "SKIPPED", 0, 100); // entirely skipped customer/inbound + item(5, 3, 11, 1, "PICKED", 1, 100); + item(6, 4, 22, 1, "SKIPPED", 0, 100); + item(7, 2, 22, 1, "PICKED", 1, 200); // different task/plan cannot prove loading + item(8, 2, 22, 2, "PARTIAL", 1, 100); // unreconciled allocation + item(9, 2, 22, 1, "PICKED", 1, 100); + jdbc.update("UPDATE fl_loading_plan_item SET deleted=1 WHERE id=9"); + var configuration = new MybatisConfiguration(); + configuration.setMapUnderscoreToCamelCase(true); + configuration.addMapper(ContainerCustomsMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(ds); + factory.setConfiguration(configuration); + mapper = new SqlSessionTemplate(factory.getObject()).getMapper(ContainerCustomsMapper.class); + } + + @Test + void actualCountsAndInboundScopeUseSettledPicksWithCustomerAndTaxIsolation() { + assertThat(mapper.actualBoxCount(1, null, null)).isEqualTo(4); + assertThat(mapper.actualBoxCount(1, 11L, "TAX_REFUND")).isEqualTo(3); + assertThat(mapper.actualBoxCount(1, 22L, "TAX_REFUND")).isZero(); + assertThat(mapper.planInbounds(1)).extracting(ContainerCustomsMapper.InboundRow::inboundId) + .containsExactly(1L, 3L); + assertThat(mapper.completedPlanForOrder(11, 1)).isEqualTo(1); + assertThat(mapper.completedPlanForOrder(22, 2)).isNull(); + } + + @Test + void skippedRefundTicketDoesNotBlockCoverageOrCreateAFalseRefundRequirement() { + assertThat(mapper.taxRefundCount(1)).isEqualTo(1); + assertThat(mapper.missingRefundCount(1, 90)).isEqualTo(1); + jdbc.update("INSERT INTO fl_customs_customer_document VALUES(80,90,'ACTIVE')"); + jdbc.update("INSERT INTO fl_customs_customer_document_inbound VALUES(80,1)"); + assertThat(mapper.missingRefundCount(1, 90)).isZero(); + } + + @Test + void entirelySkippedRefundCargoLeavesARealBuyDeclarationOnlyContainer() { + jdbc.update("UPDATE fl_loading_pick_item SET item_status='SKIPPED',picked_package_count=0 WHERE loading_plan_item_id IN (1,2)"); + assertThat(mapper.taxRefundCount(1)).isZero(); + assertThat(mapper.missingRefundCount(1, 90)).isZero(); + assertThat(mapper.actualBoxCount(1, null, null)).isEqualTo(1); + assertThat(mapper.planInbounds(1)).singleElement().satisfies(row -> + assertThat(row.taxMode()).isEqualTo("BUY_DECLARATION")); + assertThat(mapper.completedPlanForOrder(11, 1)).isNull(); + } + + @Test + void buyExportCursorCountAndRowsExcludeSkippedCargoConsistently() { + assertThat(mapper.maximumBuyItemId(1)).isEqualTo(5); + assertThat(mapper.countBuyRows(1, 100)).isEqualTo(1); + assertThat(mapper.buyRows(1, 0, 100, 20)).singleElement().satisfies(row -> { + assertThat(row.itemId()).isEqualTo(5); + assertThat(row.packageCount()).isEqualTo(1); + }); + assertThat(mapper.buyRows(1, 5, 100, 20)).isEmpty(); + } + + @Test + void pendingDispatchIgnoresSkippedRowsButStillFindsUndispatchedActualCargo() throws Exception { + jdbc.update("INSERT INTO fl_customs_container VALUES(90,1,10,'COMPLETED',CURRENT_TIMESTAMP)"); + // H2 cannot bind the outer alias inside this LEFT JOIN ON. Bind only that + // correlation to this fixture's container; execute the remaining production SQL unchanged. + String sql = String.join("\n", ContainerCustomsMapper.class.getMethod("pendingAutoDispatch", int.class) + .getAnnotation(org.apache.ibatis.annotations.Select.class).value()) + .replace("d.container_booking_id=c.container_booking_id", "d.container_booking_id=10") + .replace("#{limit}", "20"); + assertThat(jdbc.queryForList(sql, Long.class)).containsExactly(10L); + jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(1,11,10,'WAITING_FOR_LOADING'),(3,11,10,'WAITING_FOR_LOADING')"); + assertThat(jdbc.queryForList(sql, Long.class)).isEmpty(); + } + + @Test + void preCompletionInboundPreparationKeepsItsPlannedScopeAndNoActualBoxCount() { + jdbc.update("UPDATE fl_loading_plan SET plan_status='LOADING' WHERE id=1"); + assertThat(mapper.planInbounds(1)).hasSize(4); + assertThat(mapper.actualBoxCount(1, null, null)).isZero(); + assertThat(mapper.completedPlanForOrder(11, 1)).isNull(); + } + + private void inbound(long id, long tenant, String taxMode) { + jdbc.update("INSERT INTO fl_inbound_order VALUES(?,?,?,'ORDER','INBOUND','TEST',?,0)", id, tenant, id, taxMode); + } + + private void item(long id, long inbound, long tenant, int packages, String status, int picked, long task) { + jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,inbound_id,account_id,deleted,package_count,product_quantity) VALUES(?,1,?,?,0,?,?)", + id, inbound, tenant, packages, packages * 10); + jdbc.update("INSERT INTO fl_loading_pick_item VALUES(?,?,?,?)", id, task, status, picked); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PartialLoadingMetricsPersistenceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PartialLoadingMetricsPersistenceTest.java new file mode 100644 index 000000000..a975ee36f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PartialLoadingMetricsPersistenceTest.java @@ -0,0 +1,103 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper; +import org.apache.ibatis.annotations.Update; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.math.BigDecimal; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Persist real SET expressions; adapt only MySQL UPDATE JOIN to H2 MERGE syntax. */ +class PartialLoadingMetricsPersistenceTest { + private JdbcTemplate jdbc; + private String update; + + @BeforeEach + void setup() throws Exception { + var ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:partial-metrics-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"); + jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,package_count INT,product_quantity INT,units_per_carton INT,pallet_count INT,gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3),dimensional_weight_kg DECIMAL(18,3),volume_cbm DECIMAL(18,4),unit_type VARCHAR,pallet_id BIGINT,deleted INT,updated_by BIGINT,updated_by_name VARCHAR,updated_at TIMESTAMP,version INT)"); + jdbc.execute("CREATE TABLE fl_loading_pick_item(loading_plan_item_id BIGINT,planned_package_count INT,picked_package_count INT,item_status VARCHAR)"); + String mysql = String.join("\n", InboundSplitSettlementMapper.class + .getMethod("convergePartialPlanItem", long.class, long.class, String.class) + .getAnnotation(Update.class).value()); + String assignments = mysql.substring(mysql.indexOf("SET ") + 4, mysql.indexOf("WHERE ")); + String conditions = mysql.substring(mysql.indexOf("WHERE ") + 6); + update = ("MERGE INTO fl_loading_plan_item plan_item USING fl_loading_pick_item pick_item " + + "ON pick_item.loading_plan_item_id=plan_item.id WHEN MATCHED AND (" + conditions + + ") THEN UPDATE SET " + assignments) + .replace("#{operatorId}", "9").replace("#{operatorName}", "'TEST'").replace("#{planItemId}", "?"); + } + + @Test + void oneActualCartonUsesItsFrozenUnitsAndHalfTheTwoCartonMetrics() { + seed(1, 2, 1, 12, 24, 2, "25", "22", "18.5", "0.1234"); + assertThat(jdbc.update(update, 1)).isEqualTo(1); + assertMetrics(1, 1, 12, 1, "12.500", "11.000", "9.250", "0.0617"); + } + + @Test + void nonHalfRatioUsesDeclaredUnitsAndDatabasePrecisionWithoutTruncation() { + seed(1, 3, 2, 7, 21, 2, "10.001", "8.002", "6.004", "0.1001"); + assertThat(jdbc.update(update, 1)).isEqualTo(1); + assertMetrics(1, 2, 14, 2, "6.667", "5.335", "4.003", "0.0667"); + } + + @Test + void missingUnitsUsesFrozenQuantityRatioAndPreservesUnknownMeasures() { + seed(1, 3, 2, null, 10, null, null, null, null, null); + assertThat(jdbc.update(update, 1)).isEqualTo(1); + assertMetrics(1, 2, 7, 0, null, null, null, null); + seed(2, 3, 2, null, null, null, null, null, null, null); + assertThat(jdbc.update(update, 2)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT product_quantity FROM fl_loading_plan_item WHERE id=2", Integer.class)).isNull(); + } + + @Test + void alreadyConvergedRowDoesNotScaleAgainOrAdvanceVersion() { + seed(1, 2, 1, 12, 24, 2, "25", "22", "18.5", "0.1234"); + assertThat(jdbc.update(update, 1)).isEqualTo(1); + var after = jdbc.queryForMap("SELECT * FROM fl_loading_plan_item WHERE id=1"); + assertThat(jdbc.update(update, 1)).isZero(); + assertThat(jdbc.queryForMap("SELECT * FROM fl_loading_plan_item WHERE id=1")).isEqualTo(after); + } + + @Test + void pendingSkippedFullAndPalletUnitsCannotEnterPartialConvergence() { + for (int id = 1; id <= 4; id++) seed(id, 2, 1, 12, 24, 2, "25", "22", "18.5", "0.1234"); + jdbc.update("UPDATE fl_loading_pick_item SET item_status='PENDING' WHERE loading_plan_item_id=1"); + jdbc.update("UPDATE fl_loading_pick_item SET item_status='SKIPPED',picked_package_count=0 WHERE loading_plan_item_id=2"); + jdbc.update("UPDATE fl_loading_pick_item SET item_status='PICKED',picked_package_count=2 WHERE loading_plan_item_id=3"); + jdbc.update("UPDATE fl_loading_plan_item SET unit_type='SEALED_PALLET',pallet_id=99 WHERE id=4"); + var before = jdbc.queryForList("SELECT * FROM fl_loading_plan_item ORDER BY id"); + for (int id = 1; id <= 4; id++) assertThat(jdbc.update(update, id)).isZero(); + assertThat(jdbc.queryForList("SELECT * FROM fl_loading_plan_item ORDER BY id")).isEqualTo(before); + } + + private void seed(long id, int planned, int picked, Integer units, Integer quantity, Integer pallets, + String gross, String net, String dimensional, String volume) { + jdbc.update("INSERT INTO fl_loading_plan_item VALUES(?,?,?,?,?,?,?,?,?,'PACKAGE',NULL,0,NULL,NULL,NULL,0)", + id, planned, quantity, units, pallets, decimal(gross), decimal(net), decimal(dimensional), decimal(volume)); + jdbc.update("INSERT INTO fl_loading_pick_item VALUES(?,?,?,'PARTIAL')", id, planned, picked); + } + + private void assertMetrics(long id, int boxes, Integer quantity, Integer pallets, + String gross, String net, String dimensional, String volume) { + var row = jdbc.queryForMap("SELECT * FROM fl_loading_plan_item WHERE id=?", id); + assertThat(row.get("PACKAGE_COUNT")).isEqualTo(boxes); + assertThat(row.get("PRODUCT_QUANTITY")).isEqualTo(quantity); + assertThat(row.get("PALLET_COUNT")).isEqualTo(pallets); + assertThat((BigDecimal) row.get("GROSS_WEIGHT_KG")).isEqualTo(decimal(gross)); + assertThat((BigDecimal) row.get("NET_WEIGHT_KG")).isEqualTo(decimal(net)); + assertThat((BigDecimal) row.get("DIMENSIONAL_WEIGHT_KG")).isEqualTo(decimal(dimensional)); + assertThat((BigDecimal) row.get("VOLUME_CBM")).isEqualTo(decimal(volume)); + } + + private BigDecimal decimal(String value) { return value == null ? null : new BigDecimal(value); } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java index 7d81e6af5..38984f323 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java @@ -16,6 +16,38 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class PdaSplitSettlementSqlContractTest { + @Test + void partialMetricsGuardStaysIdenticalInModuleBaselineAndControlledUpdate() throws Exception { + String module = new ClassPathResource("db/phase3-smart-loading-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); + String update = Files.readString(Path.of("..", "deploy", "sql", "migrations", "cn", + "baiyue_firstleg", "V7__settle_partial_loaded_cargo.sql")); + String trigger = loadingItemGuard(module); + assertThat(loadingItemGuard(unified)).isEqualTo(trigger); + assertThat(loadingItemGuard(update)).isEqualTo(trigger); + assertThat(trigger) + .contains("actual_task.loading_plan_id = OLD.loading_plan_id") + .contains("OLD.unit_type = 'PACKAGE' AND OLD.pallet_id IS NULL") + .contains("OLD.package_count = actual_pick.planned_package_count") + .contains("actual_pick.picked_package_count < actual_pick.planned_package_count") + .contains("NEW.package_count = actual_pick.picked_package_count") + .contains("NEW.product_quantity <=> CASE WHEN OLD.units_per_carton IS NOT NULL") + .contains("NEW.gross_weight_kg <=> ROUND(OLD.gross_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3)") + .contains("NEW.volume_cbm <=> ROUND(OLD.volume_cbm * NEW.package_count / NULLIF(OLD.package_count,0),4)") + .contains("IF OLD.frozen_at IS NOT NULL THEN") + .contains("装柜完成后的明细永久冻结") + .contains("NEW.inbound_detail_id <=> OLD.inbound_detail_id"); + } + + private static String loadingItemGuard(String sql) { + int start = sql.indexOf("CREATE TRIGGER trg_loading_item_before_update"); + assertThat(start).isGreaterThanOrEqualTo(0); + int end = sql.indexOf("END$$", start); + assertThat(end).isGreaterThan(start); + return sql.substring(start, end + 5).replaceAll("\\s+", " ").trim(); + } + @Test void addsPreloadConfirmationAuditColumnsForIncrementalPdaAllocation() throws Exception { String moduleSql = new ClassPathResource("db/phase3-split-preload-schema.sql") @@ -133,7 +165,7 @@ class PdaSplitSettlementSqlContractTest { .getAnnotation(Update.class).value()[0]; assertThat(converge) .contains("plan_item.pallet_count = CEIL") - .contains("pick_item.picked_package_count / NULLIF(plan_item.package_count,0)") + .contains("pick_item.picked_package_count / NULLIF(pick_item.planned_package_count,0)") .contains("plan_item.package_count = pick_item.picked_package_count"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedPreflightDatabaseTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedPreflightDatabaseTest.java new file mode 100644 index 000000000..1ae756746 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedPreflightDatabaseTest.java @@ -0,0 +1,249 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper; +import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row; +import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper; +import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper; +import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +/** Real annotated Mapper query against persisted, not-yet-settled PDA facts. */ +class LoadingCompletedPreflightDatabaseTest { + private JdbcTemplate jdbc; + private SqlSession session; + private LoadingCompletedEventMapper snapshots; + private LoadingCompletedOutboxService service; + private final LoadingPalletLabelService labels = mock(LoadingPalletLabelService.class); + private final InboundOutboxMapper outbox = mock(InboundOutboxMapper.class); + + @BeforeEach void setup() { + var ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:loading-preflight-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"); + jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT,plan_no VARCHAR,container_booking_id BIGINT,version INT)"); + jdbc.execute("CREATE TABLE fl_container_booking(id BIGINT,container_no VARCHAR,cabinet_no VARCHAR,warehouse_id BIGINT,warehouse_name VARCHAR,estimated_port_arrival_at TIMESTAMP,actual_port_arrival_at TIMESTAMP,estimated_warehouse_arrival_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT,tenant_id BIGINT,root_inbound_id BIGINT,order_id BIGINT,order_no VARCHAR,inbound_code VARCHAR,business_type VARCHAR,fulfillment_type VARCHAR,lx_code VARCHAR,destination_type VARCHAR,destination_warehouse_id BIGINT,destination_code VARCHAR,destination_name VARCHAR,deleted INT)"); + jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,loading_plan_id BIGINT,account_id BIGINT,inbound_id BIGINT,inbound_detail_id BIGINT,customer_name VARCHAR,sku_code VARCHAR,product_name_zh VARCHAR,product_name_en VARCHAR,package_count INT,product_quantity INT,units_per_carton INT,pallet_count INT,gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3),dimensional_weight_kg DECIMAL(18,3),volume_cbm DECIMAL(18,4),fba_tracking_no VARCHAR,shipment_no VARCHAR,shipping_mark VARCHAR,customer_remark VARCHAR,internal_remark VARCHAR,warehouse_remark VARCHAR,deleted INT,unit_type VARCHAR,pallet_id BIGINT)"); + jdbc.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT)"); + jdbc.execute("CREATE TABLE fl_loading_pick_item(id BIGINT,loading_plan_item_id BIGINT,pick_task_id BIGINT,item_status VARCHAR,picked_package_count INT,planned_package_count INT)"); + jdbc.execute("ALTER TABLE fl_loading_plan ADD plan_status VARCHAR DEFAULT 'LOADING'"); + jdbc.execute("ALTER TABLE fl_container_booking ADD record_status VARCHAR DEFAULT 'ACTIVE'"); + jdbc.execute("ALTER TABLE fl_inbound_order ADD total_package_count INT"); + jdbc.execute("ALTER TABLE fl_inbound_order ADD pallet_count INT"); + for (String definition : List.of("inbound_code VARCHAR","destination_warehouse_id BIGINT","destination_warehouse_code VARCHAR", + "destination_warehouse_name VARCHAR","load_sequence INT","updated_by BIGINT","updated_by_name VARCHAR","updated_at TIMESTAMP","version INT DEFAULT 0")) { + jdbc.execute("ALTER TABLE fl_loading_plan_item ADD " + definition); + } + jdbc.update("INSERT INTO fl_loading_plan(id,plan_no,container_booking_id,version) VALUES(90,'LP90',91,2)"); + jdbc.update("INSERT INTO fl_container_booking(id,container_no,cabinet_no,warehouse_id,warehouse_name) VALUES(91,'MSCU6639870','D0091',1,'TEST')"); + jdbc.update("INSERT INTO fl_loading_pick_task VALUES(92,90)"); + Configuration config = new Configuration(new Environment("test", new JdbcTransactionFactory(), ds)); + config.setMapUnderscoreToCamelCase(true); + config.addMapper(LoadingCompletedEventMapper.class); + config.addMapper(LoadingPalletLabelMapper.class); + session = new SqlSessionFactoryBuilder().build(config).openSession(true); + snapshots = session.getMapper(LoadingCompletedEventMapper.class); + service = new LoadingCompletedOutboxService(); + ReflectionTestUtils.setField(service,"snapshotMapper",snapshots); + ReflectionTestUtils.setField(service,"cartonManifestMapper",mock(LoadingCartonManifestMapper.class)); + ReflectionTestUtils.setField(service,"palletLabelMapper",mock(LoadingPalletLabelMapper.class)); + ReflectionTestUtils.setField(service,"palletLabelService",labels); + ReflectionTestUtils.setField(service,"outboxMapper",outbox); + ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper().findAndRegisterModules()); + var evidence = mock(CabinetLoadingEvidenceMapper.class); + when(evidence.byContainer(anyLong())).thenReturn(List.of(photo("PHOTO_STEP_1"),photo("PHOTO_STEP_2"),photo("PHOTO_STEP_3"),photo("PHOTO_STEP_4"),photo("SEAL"))); + ReflectionTestUtils.setField(service,"cabinetEvidenceMapper",evidence); + // Unrelated sealed-pallet data is empty; the actual plan and pick rows remain real SQL. + jdbc.execute("CREATE TABLE fl_dropship_pallet(id BIGINT,pallet_status VARCHAR,pallet_no VARCHAR)"); + jdbc.execute("CREATE TABLE fl_dropship_pallet_carton(id BIGINT,pallet_id BIGINT,tenant_id BIGINT,inbound_id BIGINT,inbound_detail_id BIGINT,sku_code VARCHAR,box_code VARCHAR,actual_sku_quantity INT)"); + jdbc.execute("CREATE TABLE fl_dropship_pallet_carton_sku(carton_id BIGINT,inbound_detail_id BIGINT,sku_code VARCHAR,actual_quantity INT)"); + jdbc.execute("CREATE TABLE fl_inbound_pallet_allocation(tenant_id BIGINT,inbound_id BIGINT,pallet_id BIGINT,reservation_id BIGINT,pallet_code VARCHAR,allocation_no INT,picked_package_count INT,allocation_status VARCHAR)"); + for (String definition : List.of("weight_result_id BIGINT","block_id BIGINT","slot_no INT","allocated_package_count INT")) { + jdbc.execute("ALTER TABLE fl_inbound_pallet_allocation ADD " + definition); + } + jdbc.execute("CREATE TABLE fl_inbound_weight_result(id BIGINT,tenant_id BIGINT,inbound_id BIGINT,deleted INT,recognition_status VARCHAR)"); + jdbc.execute("CREATE TABLE fl_loading_pallet_label(id BIGINT,loading_plan_id BIGINT,loading_plan_item_id BIGINT,account_id BIGINT,inbound_id BIGINT,inbound_code VARCHAR,container_no VARCHAR,destination_warehouse_code VARCHAR,destination_warehouse_name VARCHAR,pallet_index INT,pallet_code VARCHAR)"); + jdbc.execute("CREATE TABLE fl_inbound_carton_label(tenant_id BIGINT,inbound_detail_id BIGINT,batch_status VARCHAR,box_code VARCHAR,carton_index INT,id BIGINT)"); + } + + @AfterEach void close() { session.close(); } + + @Test void allPartialItemsAreValidatedBeforeSettlementWithoutWrites() { + seed(1,11,3,2,"PARTIAL","客户A"); + var before = jdbc.queryForList("SELECT * FROM fl_loading_plan_item"); + service.requirePublishable(90); + assertThat(jdbc.queryForList("SELECT * FROM fl_loading_plan_item")).isEqualTo(before); + verify(labels,never()).prepare(anyLong(),anyLong()); + verifyNoInteractions(outbox); + } + + @Test void mixedCustomerPartialCannotDisappearFromReadinessValidation() { + seed(1,11,3,3,"PICKED","客户A"); + seed(2,22,3,2,"PARTIAL",null); + assertThatThrownBy(() -> service.requirePublishable(90)).hasMessageContaining("客户名称快照"); + verify(labels,never()).prepare(anyLong(),anyLong()); + verifyNoInteractions(outbox); + } + + @Test void invalidSecondCustomersRealPartialSnapshotStopsWorkerBeforeAnySettlement() { + seed(1,11,3,3,"PICKED","客户A"); + seed(2,22,3,2,"PARTIAL",null); + var worker = new LoadingPickService(); + var repository = mock(com.baiyue.firstleg.repository.LoadingPickRepository.class); + var settlements = mock(PdaPickSettlementService.class); + var list = new LoadingListService(); + ReflectionTestUtils.setField(list,"loadingCompletedOutboxService",service); + ReflectionTestUtils.setField(worker,"repository",repository); + ReflectionTestUtils.setField(worker,"settlementService",settlements); + ReflectionTestUtils.setField(worker,"loadingListService",list); + ReflectionTestUtils.setField(worker,"inboundHoldGuard",mock(InboundHoldGuard.class)); + ReflectionTestUtils.setField(worker,"cabinetEvidenceService",mock(com.baiyue.firstleg.loading.service.CabinetLoadingEvidenceService.class)); + ReflectionTestUtils.setField(worker,"coloadGroups",mock(com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort.class)); + var task = new com.baiyue.firstleg.entity.LoadingPickTaskEntity(); + task.setId(92L); task.setLoadingPlanId(90L); task.setContainerBookingId(91L); + when(repository.task(92L)).thenReturn(task); + when(repository.inboundOwners(92L)).thenReturn(List.of( + new com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundOwner(11L,1L), + new com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundOwner(22L,2L))); + var command = new com.baiyue.firstleg.entity.LoadingCompletionCommandEntity(); + command.setPickTaskId(92L); + assertThatThrownBy(() -> worker.processCompletion(command,"test")) + .isInstanceOf(LoadingPickService.CompletionProcessingException.class) + .satisfies(error -> assertThat(((LoadingPickService.CompletionProcessingException)error).stage()) + .isEqualTo("COMPLETED_EVENT_READY")); + verifyNoInteractions(settlements,outbox); + } + + @Test void previewUsesFrozenUnitsAndPrecisionButDoesNotScaleSettledRowsTwice() { + seed(1,11,3,2,"PARTIAL","客户A"); + Row row = snapshots.previewRows(90).get(0); + LoadingCompletedOutboxService.projectSettledRow(row); + assertThat(row.getPackageCount()).isEqualTo(2); + assertThat(row.getProductQuantity()).isEqualTo(14); + assertThat(row.getGrossWeightKg()).isEqualByComparingTo("6.667"); + assertThat(row.getNetWeightKg()).isEqualByComparingTo("5.335"); + assertThat(row.getDimensionalWeightKg()).isEqualByComparingTo("4.003"); + assertThat(row.getVolumeCbm()).isEqualByComparingTo("0.0667"); + assertThat(row.getPalletCount()).isEqualTo(2); + LoadingCompletedOutboxService.projectSettledRow(row); + assertThat(row.getProductQuantity()).isEqualTo(14); + assertThat(row.getGrossWeightKg()).isEqualByComparingTo("6.667"); + assertThat(jdbc.queryForObject("SELECT package_count FROM fl_loading_plan_item",Integer.class)).isEqualTo(3); + } + + @Test void previewUsesHalfUpFallbackAndKeepsUnknownMetricsNull() { + seed(1,11,3,2,"PARTIAL","客户A"); + jdbc.update("UPDATE fl_loading_plan_item SET units_per_carton=NULL,product_quantity=10,gross_weight_kg=NULL,net_weight_kg=NULL,dimensional_weight_kg=NULL,volume_cbm=NULL"); + Row row = snapshots.previewRows(90).get(0); + LoadingCompletedOutboxService.projectSettledRow(row); + assertThat(row.getProductQuantity()).isEqualTo(7); + assertThat(row.getGrossWeightKg()).isNull(); + assertThat(row.getNetWeightKg()).isNull(); + assertThat(row.getDimensionalWeightKg()).isNull(); + assertThat(row.getVolumeCbm()).isNull(); + } + + @Test void finalPublicationRejectsMixedUnsettledItemsInsteadOfPublishingOnlyFullOnes() { + seed(1,11,3,3,"PICKED","客户A"); + seed(2,22,3,2,"PARTIAL","客户B"); + assertThat(snapshots.rows(90)).hasSize(1); + assertThatThrownBy(() -> service.publish(90)).hasMessageContaining("尚未完成结算"); + verifyNoInteractions(labels,outbox); + } + + @Test void actualSettlementSqlProducesExactlyThePredictedMetricsAndEnablesFinalEvent() throws Exception { + seed(1,11,3,2,"PARTIAL","客户A"); + Row expected = snapshots.previewRows(90).get(0); + LoadingCompletedOutboxService.projectSettledRow(expected); + String sql = String.join("\n", com.baiyue.firstleg.mapper.InboundSplitSettlementMapper.class + .getMethod("convergePartialPlanItem",long.class,long.class,String.class) + .getAnnotation(org.apache.ibatis.annotations.Update.class).value()); + String update = ("MERGE INTO fl_loading_plan_item plan_item USING fl_loading_pick_item pick_item " + + "ON pick_item.loading_plan_item_id=plan_item.id WHEN MATCHED AND (" + sql.substring(sql.indexOf("WHERE ") + 6) + + ") THEN UPDATE SET " + sql.substring(sql.indexOf("SET ") + 4,sql.indexOf("WHERE "))) + .replace("#{operatorId}","9").replace("#{operatorName}","'TEST'").replace("#{planItemId}","1"); + assertThat(jdbc.update(update)).isEqualTo(1); + session.clearCache(); + Row settled = snapshots.rows(90).get(0); + assertThat(settled).usingRecursiveComparison().isEqualTo(expected); + service.publish(90); + var published = org.mockito.ArgumentCaptor.forClass(com.baiyue.firstleg.entity.InboundOutboxEntity.class); + verify(outbox).insert(published.capture()); + var json = new ObjectMapper().readTree(published.getValue().getPayloadJson()); + assertThat(json.at("/payload/orders/0/packageCount").asInt()).isEqualTo(2); + assertThat(json.at("/payload/orders/0/productQuantity").asInt()).isEqualTo(14); + assertThat(json.at("/payload/orders/0/grossWeightKg").decimalValue()).isEqualByComparingTo("6.667"); + } + + @Test void palletPreviewUsesActualScopeAndMatchesSettledFallbackWithoutWritingLabels() { + seed(1,11,3,2,"PARTIAL","客户A"); + seed(2,22,3,3,"SKIPPED","客户B"); + jdbc.update("UPDATE fl_loading_pick_item SET picked_package_count=0 WHERE loading_plan_item_id=2"); + jdbc.update("UPDATE fl_loading_plan_item SET pallet_count=NULL WHERE id=1"); + jdbc.update("INSERT INTO fl_inbound_weight_result VALUES(1,11,1,0,'CONFIRMED'),(2,11,1,0,'CONFIRMED')"); + var labelMapper = session.getMapper(LoadingPalletLabelMapper.class); + var before = jdbc.queryForList("SELECT * FROM fl_loading_plan_item ORDER BY id"); + var projected = labelMapper.previewSources(90); + assertThat(projected).singleElement().satisfies(row -> { + assertThat(row.getItemId()).isEqualTo(1L); + assertThat(row.getPalletCount()).isEqualTo(2); + }); + assertThat(jdbc.queryForList("SELECT * FROM fl_loading_plan_item ORDER BY id")).isEqualTo(before); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_loading_pallet_label",Integer.class)).isZero(); + jdbc.update("UPDATE fl_loading_plan_item SET package_count=2,pallet_count=0 WHERE id=1"); + jdbc.update("UPDATE fl_loading_plan_item SET deleted=1 WHERE id=2"); + jdbc.update("UPDATE fl_inbound_order SET pallet_count=0 WHERE id=1"); + session.clearCache(); + assertThat(labelMapper.lockSources(90)).usingRecursiveComparison().isEqualTo(projected); + } + + @Test void fallbackPalletPredictionPreservesOriginalInboundDenominatorAfterPartialSettlement() { + seed(1,11,3,1,"PARTIAL","客户A"); + jdbc.update("UPDATE fl_loading_plan_item SET pallet_count=NULL WHERE id=1"); + jdbc.update("INSERT INTO fl_inbound_weight_result VALUES(1,11,1,0,'CONFIRMED'),(2,11,1,0,'CONFIRMED'),(3,11,1,0,'CONFIRMED')"); + var labelMapper = session.getMapper(LoadingPalletLabelMapper.class); + var projected = labelMapper.previewSources(90); + assertThat(projected).singleElement().satisfies(row -> assertThat(row.getPalletCount()).isEqualTo(1)); + // Production settlement changes package_count and resets pallet_count; + // the original declaration total_package_count remains three. + jdbc.update("UPDATE fl_loading_plan_item SET package_count=1,pallet_count=0 WHERE id=1"); + jdbc.update("UPDATE fl_inbound_order SET pallet_count=0 WHERE id=1"); + session.clearCache(); + assertThat(labelMapper.lockSources(90)).usingRecursiveComparison().isEqualTo(projected); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_loading_pallet_label",Integer.class)).isZero(); + } + + private void seed(long id,long account,int planned,int picked,String status,String customer) { + jdbc.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,order_no,inbound_code,business_type,fulfillment_type,destination_type,destination_warehouse_id,destination_code,destination_name,deleted) VALUES(?,?,?,'FL-TEST','IN-TEST','FBA','STANDARD','FBA',7,'ABE8','TEST',0)",id,account,id); + jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,account_id,inbound_id,inbound_detail_id,customer_name,sku_code,product_name_zh,package_count,product_quantity,units_per_carton,pallet_count,gross_weight_kg,net_weight_kg,dimensional_weight_kg,volume_cbm,deleted,unit_type) VALUES(?,90,?,?,? ,?,'SKU-TEST','TEST',?,21,7,2,10.001,8.002,6.004,0.1001,0,'PACKAGE')",id,account,id,id,customer,planned); + jdbc.update("INSERT INTO fl_loading_pick_item VALUES(?,?,92,?,?,?)",id,id,status,picked,planned); + jdbc.update("UPDATE fl_loading_plan_item SET inbound_code='IN-TEST',destination_warehouse_id=7,destination_warehouse_code='ABE8',destination_warehouse_name='TEST',load_sequence=? WHERE id=?",id,id); + jdbc.update("UPDATE fl_inbound_order SET total_package_count=?,pallet_count=2 WHERE id=?",planned,id); + } + + private static CabinetLoadingEvidenceEntity photo(String type) { + var value = new CabinetLoadingEvidenceEntity(); value.setEvidenceType(type); value.setFileId(1L); + value.setFileName(type+".png"); value.setStatus("ACTIVE"); value.setActiveFlag(1); return value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java index 892ca2bcf..1ff24eede 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java @@ -15,6 +15,29 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; class LoadingPalletLabelServiceTest { + @Test + void previewChecksDestinationAndExistingCodesWithoutLocksIdsOrWrites() { + var mapper = mock(LoadingPalletLabelMapper.class); + var service = new LoadingPalletLabelService(); + var warehouses = mock(WarehouseMasterDataClient.class); + ReflectionTestUtils.setField(service,"mapper",mapper); + ReflectionTestUtils.setField(service,"warehouseClient",warehouses); + ReflectionTestUtils.setField(service,"internalToken","test-token"); + when(mapper.previewSources(10L)).thenReturn(List.of(source(3,"LAX9",1,"STANDARD"))); + when(warehouses.active("test-token",0L,101L)).thenReturn(warehouse("LAX9","154")); + var preview = service.preview(10L,20L); + assertThat(preview).singleElement().satisfies(row -> { + assertThat(row.getPalletCode()).isEqualTo("D0002-154-001"); + assertThat(row.getLabelId()).isNull(); + }); + org.mockito.Mockito.verify(mapper,org.mockito.Mockito.never()).lockSources(org.mockito.ArgumentMatchers.anyLong()); + org.mockito.Mockito.verify(mapper,org.mockito.Mockito.never()).insert(any()); + var wrong = new LoadingPalletLabelMapper.LabelRow(); wrong.setItemId(99L); wrong.setPalletCode("D0002-154-001"); + when(mapper.labels(10L)).thenReturn(List.of(wrong)); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.preview(10L,20L)) + .hasMessageContaining("当前装柜明细不一致"); + } + @Test void printsAllAndWarehouseSubsetThroughSharedFbaRendererWithoutRegeneratingCodes() { var service = org.mockito.Mockito.spy(new LoadingPalletLabelService()); diff --git a/scripts/partial-loading-actual-acceptance-plan.md b/scripts/partial-loading-actual-acceptance-plan.md new file mode 100644 index 000000000..69a14da18 --- /dev/null +++ b/scripts/partial-loading-actual-acceptance-plan.md @@ -0,0 +1,50 @@ +# F21/F22 实装与部分装柜验收方案 + +本文件是本阶段的执行方案,不是已通过的 TEST 证据或状态主文档。不直接运行,不访问环境。真实执行必须等总控确认匹配 Firstleg 版本、受控触发器和独占样本窗口;发布、SQL、数据库取证由总控串行负责。凭据只从本轮指定 TEST 环境文件读取,报告不保存密码、Token 或内部服务令牌。 + +## 入口与样本 + +复用 `admin-test-env-modules/phase3-container-loading.mjs` 的合法订单、审核、测量、分柜、RELEASED 订舱、真实柜号、PDA 领取及五类装柜证据链;复用 `phase3-container-customs-fixture.mjs` 的客户资格和报关入口。现有全拣场景不能直接替代本方案的 PARTIAL/SKIPPED 场景,也不能直接运行内部模块当作新验收入口。 + +使用新批次标记 `PARTIAL-ACTUAL-<运行时间>`、独立客户/订单/柜次/任务。一个任务单请求流,只操作本批次样本;已有柜 `TSTU8545690` 仅做只读回查,不回填计划、库存、入仓单或重放历史事件。失败停在原样本,保留原请求和失败报告,不盲目重造样本。 + +最小场景分两柜,沿合法流程分别准备: + +| 柜 | 来源与操作 | 验证目的 | +|---|---|---| +| A | 客户甲退税普通 PACKAGE 行计划 3 箱、每箱 7 件,实拣 2 箱并提交 PARTIAL;甲另一 SKU 零拣 SKIPPED;客户乙买单普通行全拣;客户丙退税整票零拣 SKIPPED | 混合客户/税制、部分数量缩放、同票跳过 SKU 与整客户跳过隔离 | +| B | 只有买单行实际装柜;同计划退税票全部 SKIPPED | 实际全买单柜不因计划历史退税票要求客户资料或退税发送 | + +从预装详情冻结每行 itemId、客户/来源/明细三键、原箱数、unitsPerCarton、productQuantity、palletCount、重量和体积。件数先按每箱件数×实箱;无每箱件数时按原量比例 HALF_UP 至整数。重量 HALF_UP 至 3 位、体积至 4 位;NULL 保持 NULL。托数按原计划托数比例向上取整。托数为空/0 的标签回退另核对原入仓总箱数、合法确认称重/库位证据,不能用本次实拣总数替换原比例分母。样本中的未知值只来自正式接口允许的输入,不以 SQL 修改事实制造样本。 + +## PDA 正式动作 + +接口基址为 `/api/platform/v1/first-leg/pda-loading`,按正式仓绑定会话访问。 + +1. 回读任务、库存和每行版本;通过既有正式扫码/拣货入口取得真实箱码事实。PARTIAL 为最终实箱数,不能把增量当最终数量。 +2. `POST /tasks/{taskId}/items/{itemId}/partial`:提交 `warehouseId, finalPickedPackageCount, version, operationId`。SKIPPED 使用 `POST /tasks/{taskId}/items/{itemId}/skip`,提交 `warehouseId, version, operationId, reason`,仅限零拣行。 +3. 上传并回查 PHOTO_STEP_1–4 与 SEAL 的五类有效照片和锁码;保留任务及计划全量基线、操作历史、预装冻结行。缺证据拒绝场景优先复用已有证据,不额外销毁照片。 +4. 在最终完成前回读标签,确认预检没有新增标签或占用冻结编号。内部数据库/序号核验由总控做只读取证;不存在公网独立预检写入口。 +5. 回读最新任务版本后,只提交一次 `POST /tasks/{taskId}/complete`,载荷 `expectedTaskVersion, operationId, remark`;保存 HTTP 状态、业务 code、requestId/traceId、完整载荷和命令身份,等待实际 COMPLETED,不把受理态当完成。 +6. 完成后原 operationId、原 expectedTaskVersion、原载荷重放,确认返回同一业务结果;另一独立 operationId 不作为原请求重放。 + +## 完成后的独立核对 + +| 对象 | 必须成立的断言 | +|---|---| +| 完成清单 | `/api/platform/v1/first-leg/loading-lists/{planId}?warehouseId=...` 的行集合严格等于有效实拣集合;PARTIAL 行已结算为实箱,SKIPPED/零拣不出现;列表与详情汇总一致 | +| 计划历史/PDA | 计划历史允许保留 SKIPPED;任务保留真实实拣及原计划数量。不能以原计划总箱数与实装箱数相等作为完成断言 | +| 部分指标 | 原 3 箱 21 件变为实 2 箱 14 件;重量/体积按冻结行缩放。例:10.001/8.002/6.004 kg、0.1001 cbm 对应 6.667/5.335/4.003 kg、0.0667 cbm;实际期望以正式冻结值为准 | +| 来源与余量 | 实箱+余量=原可用箱数;余量沿正式分片产生,原订单/入仓编号及 lineage 可追溯。重放后版本、历史、资源结算和分片数量不重复推进 | +| 标签/事件 | 预测与最终冻结托盘码/托数一致,预检零标签写入;最终 V3 只携带各客户实际货物。总控只读核对唯一 Outbox、载荷摘要、ACK 与接收结果,不伪造事件 | +| 报关平台 | `GET /api/platform/v1/customs/loading-plans/{planId}` 的 actualBoxCount 等于实拣之和;退税票/客户范围、资料覆盖要求排除整票跳过;已实装票缺资料仍拒绝开始 | +| 客户资料 | `/api/merchant/v1/customs/orders/{orderId}`:甲只能见自身已实装退税来源;丙整票跳过没有该柜资料入口;乙买单不会获得退税客户入口;外客户拒绝保持原 code 与诊断字段 | +| 买单导出 | 复用正式导出服务,只含乙实际行,不含跳过的买单行;记录分页最大ID、总数、输出行集合与字节摘要,不能只核对 XLSX 魔数 | +| 全买单 B 柜 | 跳过退税不阻断全买单报关开始/完成;拒绝创建退税申报/发送。报关完成后的自动离仓必须满足既有正式前置,不能用本方案绕过 | +| 下游 | Lastleg/入仓计划/在途库存独立读回与事件实际数一致;禁止把正确下游改成原计划总箱数;原柜只读回查保持下游完整快照不变 | + +## 负向预检与证据界限 + +跨客户第二来源缺客户/目的仓快照、非法 PARTIAL、未结算最终发布等服务内非法事实,使用 `LoadingCompletedPreflightDatabaseTest` 的真实 Mapper 场景核验;不得通过 TEST SQL 捏造非法业务数据。有效新样本的跨客户正常完成必须走上述真实 HTTP。H2 对 MySQL UPDATE JOIN 的语法适配与 pendingAutoDispatch 关联替换均需在报告中披露;真实 MySQL 触发器与最终完成另验。 + +证据建议落在 `reports/partial-loading-actual-<批次>/`:版本/健康与触发器就绪证明、请求序列、before/after 全量快照、逐断言结果、事件/接收端只读取证、失败原件、会话退出结果。执行结束退出自己的全部会话,不删除数据或恢复他人资源。HTTP/DOM证据与人工1920×1080、100%缩放及PDA设备验收分别报告,不据服务测试宣布页面或全流程完成。