From b294695169af06372f69f84b893bdd4839a87b89 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 10 Aug 2026 12:22:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(iam):=20=E6=94=B6=E6=95=9B=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E8=A7=92=E8=89=B2=E6=9D=83=E9=99=90=E5=B9=B6=E7=BA=B3?= =?UTF-8?q?=E5=85=A5=E7=BB=9F=E4=B8=80=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 232 +++++++++++++++++- .../PlatformLoadingDeliveryController.java | 45 +++- .../firstleg/loading/dto/LoadingRequests.java | 4 - .../mapper/LoadingCompletionGateMapper.java | 42 ++++ .../loading/mapper/LoadingInboundMapper.java | 5 - .../loading/repository/LoadingRepository.java | 4 - .../service/LoadingCompletionGateService.java | 27 ++ .../service/LoadingDeliveryService.java | 37 ++- .../mapper/InboundSplitSettlementMapper.java | 164 +++++++------ .../mapper/SmartLoadingCandidateMapper.java | 2 + .../mapper/OutboundDispatchReadMapper.java | 13 - .../OutboundDispatchRepository.java | 6 - .../service/OutboundDispatchService.java | 6 +- .../service/PdaPickSettlementService.java | 128 ++++++---- .../db/phase3-inbound-parent-child-schema.sql | 155 ++++++++++-- .../db/phase3-split-preload-schema.sql | 83 ++----- .../openapi/phase3-loading-delivery-v1.yaml | 25 ++ .../LoadingDeliveryOpenApiContractTest.java | 2 + ...LoadingDeliveryControllerContractTest.java | 15 +- .../LoadingCompletionGateSqlContractTest.java | 30 +++ .../service/LoadingDeliveryServiceTest.java | 20 ++ .../dispatch/OutboundDispatchServiceTest.java | 7 +- .../InboundParentChildSqlContractTest.java | 63 ++--- .../PdaSplitSettlementSqlContractTest.java | 41 +++- .../service/PdaPickSettlementServiceTest.java | 53 ++-- .../service/SplitPreloadSqlContractTest.java | 7 +- .../resources/db/phase3-warehouse-roles.sql | 91 ++----- .../WarehouseRoleSqlContractTest.java | 54 +++- .../phase3-warehouse-rbac.mjs | 72 ++++++ 29 files changed, 1009 insertions(+), 424 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingCompletionGateService.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateSqlContractTest.java create mode 100644 scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 0aa5cd3af..5d16acae9 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -2545,6 +2545,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item ( inbound_detail_id BIGINT UNSIGNED NULL COMMENT '绑定的订单货物明细ID,平台手工明细可为空', external_detail_no VARCHAR(128) NULL COMMENT '第三方测量明细唯一号,平台手工明细可为空', box_code VARCHAR(128) NULL COMMENT '柏悦唛头箱号;设备单箱测量时同一客户内唯一', + scanned_shipment_id VARCHAR(64) NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID', item_no VARCHAR(32) NOT NULL COMMENT '批次内明细号', box_specification VARCHAR(64) NOT NULL COMMENT '箱规标识', package_count INT UNSIGNED NOT NULL COMMENT '相同箱规箱数', @@ -2582,6 +2583,7 @@ CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt ( tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', subject_id BIGINT UNSIGNED NOT NULL COMMENT '设备应用操作主体ID', box_code VARCHAR(128) NOT NULL COMMENT '设备扫描的原始箱码', + scanned_shipment_id VARCHAR(64) NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID', length_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测长度cm', width_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测宽度cm', height_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测高度cm', @@ -3041,6 +3043,10 @@ CALL phase2_measure_add_column('fl_inbound_measurement_item', 'unit_weight_kg', 'DECIMAL(18,3) NULL COMMENT ''单箱重量kg'' AFTER girth_cm'); CALL phase2_measure_add_column('fl_inbound_measurement_item', 'unit_volume_cbm', 'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''单箱业务体积m3'' AFTER unit_weight_kg'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'scanned_shipment_id', + 'VARCHAR(64) NULL COMMENT ''设备从Amazon FBA箱标实扫的Shipment ID'' AFTER box_code'); +CALL phase2_measure_add_column('fl_mojia_measurement_receipt', 'scanned_shipment_id', + 'VARCHAR(64) NULL COMMENT ''设备从Amazon FBA箱标实扫的Shipment ID'' AFTER box_code'); CALL phase2_measure_add_column('fl_inbound_measurement_item', 'total_weight_kg', 'DECIMAL(18,3) NULL COMMENT ''本箱规总重量kg'' AFTER volume_cbm'); CALL phase2_measure_add_column('fl_inbound_measurement_item', 'total_volume_cbm', @@ -7425,12 +7431,20 @@ BEGIN WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_mojia_measurement_receipt' AND column_name IN ('id', 'tenant_id', 'subject_id', 'box_code', 'length_cm', 'width_cm', - 'height_cm', 'weight_kg', 'machine_no', 'measured_at', 'image_file_id', + 'scanned_shipment_id', 'height_cm', 'weight_kg', 'machine_no', 'measured_at', 'image_file_id', 'status', 'match_failure_code', 'match_failure_reason', 'request_id', - 'trace_id', 'created_at', 'updated_at')) <> 18 THEN + 'trace_id', 'created_at', 'updated_at')) <> 19 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '统一迁移失败:墨家未匹配测量收件箱字段未完整创建'; END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' + AND table_name = 'fl_inbound_measurement_item' + AND column_name = 'scanned_shipment_id' + AND character_maximum_length = 64) <> 1 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:箱级测量实扫Shipment ID字段未创建'; + END IF; IF (SELECT character_maximum_length FROM information_schema.columns WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_measurement_item' @@ -8751,3 +8765,217 @@ DROP PROCEDURE IF EXISTS assert_phase3_pda_item_assignment_schema; ALTER TABLE fl_inbound_pallet_allocation ADD COLUMN IF NOT EXISTS reservation_id BIGINT UNSIGNED NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分托盘' AFTER location_code; + +-- 三期仓库常用角色初始化。模块来源:iam/src/main/resources/db/phase3-warehouse-roles.sql。 +-- 三期仓库常用系统角色:仓库操作员、仓库拣货员、仓库理货员。 +-- 本脚本只组合已经存在的权限资源,需在三期 Firstleg/IAM 权限脚本之后执行,可重复部署。 +USE baiyue_iam; +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS assert_phase3_warehouse_role_ids_available; +DELIMITER $$ +CREATE PROCEDURE assert_phase3_warehouse_role_ids_available() +BEGIN + IF EXISTS ( + SELECT 1 + FROM iam_role + WHERE (id = 4000004 AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR role_code <> 'WAREHOUSE_OPERATOR')) + OR (id = 4000005 AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR role_code <> 'WAREHOUSE_PICKER')) + OR (id = 4000006 AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR role_code <> 'WAREHOUSE_TALLY')) + OR (tenant_id = 0 AND app_code = 'PLATFORM' AND role_code = 'WAREHOUSE_OPERATOR' AND id <> 4000004) + OR (tenant_id = 0 AND app_code = 'PLATFORM' AND role_code = 'WAREHOUSE_PICKER' AND id <> 4000005) + OR (tenant_id = 0 AND app_code = 'PLATFORM' AND role_code = 'WAREHOUSE_TALLY' AND id <> 4000006) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '仓库角色初始化失败:4000004-4000006 或角色编码已被占用'; + END IF; + IF EXISTS ( + SELECT 1 + FROM iam_data_scope + WHERE (id = 6000004 AND role_id <> 4000004) + OR (id = 6000005 AND role_id <> 4000005) + OR (id = 6000006 AND role_id <> 4000006) + OR (role_id = 4000004 AND id <> 6000004) + OR (role_id = 4000005 AND id <> 6000005) + OR (role_id = 4000006 AND id <> 6000006) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '仓库角色初始化失败:6000004-6000006 数据范围ID已被占用'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase3_warehouse_role_ids_available(); +DROP PROCEDURE assert_phase3_warehouse_role_ids_available; + +INSERT INTO iam_role ( + id, tenant_id, app_code, role_code, role_name, role_type, status, + description, deleted, version +) VALUES + (4000004, 0, 'PLATFORM', 'WAREHOUSE_OPERATOR', '仓库操作员', 'SYSTEM', 'ACTIVE', + '负责查看入仓业务并在后台维护装柜证据', 0, 0), + (4000005, 0, 'PLATFORM', 'WAREHOUSE_PICKER', '仓库拣货员', 'SYSTEM', 'ACTIVE', + '负责使用PDA执行柜次拣货和现场拍照', 0, 0), + (4000006, 0, 'PLATFORM', 'WAREHOUSE_TALLY', '仓库理货员', 'SYSTEM', 'ACTIVE', + '负责入库测量、PDA复核和装柜证据维护', 0, 0) +ON DUPLICATE KEY UPDATE + role_name = VALUES(role_name), role_type = 'SYSTEM', status = 'ACTIVE', + description = VALUES(description), deleted = 0; + +DROP TEMPORARY TABLE IF EXISTS tmp_phase3_warehouse_role_permission; +CREATE TEMPORARY TABLE tmp_phase3_warehouse_role_permission ( + role_code VARCHAR(64) NOT NULL, + permission_code VARCHAR(128) NOT NULL, + PRIMARY KEY (role_code, permission_code) +) ENGINE=MEMORY; + +-- 操作员:查看入仓业务并维护后台装柜证据,不授予完成装柜、任务分派或出仓动作。 +INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VALUES + ('WAREHOUSE_OPERATOR', 'platform:order'), + ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:list'), + ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:view'), + ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:view'), + ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:edit'); + +-- 拣货员:只允许PDA任务查询、作业和现场照片,不授予后台页面、任务分派或复核权限。 +INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VALUES + ('WAREHOUSE_PICKER', 'platform:firstleg:pda:view'), + ('WAREHOUSE_PICKER', 'platform:firstleg:pda:work'); + +-- 理货员:负责到仓测量、装柜证据维护和PDA复核,不允许完成装柜或确认出仓。 +INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VALUES + ('WAREHOUSE_TALLY', 'platform:order'), + ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:list'), + ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:view'), + ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:measure:view'), + ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:measure:edit'), + ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:measure:submit'), + ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:weight:edit'), + ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:weight:confirm'), + ('WAREHOUSE_TALLY', 'platform:firstleg:pda:view'), + ('WAREHOUSE_TALLY', 'platform:firstleg:pda:review'), + ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:loading:view'), + ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:loading:edit'), + ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:picking:view'); + +DROP PROCEDURE IF EXISTS assert_phase3_warehouse_role_permissions_available; +DELIMITER $$ +CREATE PROCEDURE assert_phase3_warehouse_role_permissions_available() +BEGIN + IF (SELECT COUNT(*) FROM tmp_phase3_warehouse_role_permission + WHERE role_code = 'WAREHOUSE_OPERATOR') <> 5 + OR (SELECT COUNT(*) FROM tmp_phase3_warehouse_role_permission + WHERE role_code = 'WAREHOUSE_PICKER') <> 2 + OR (SELECT COUNT(*) FROM tmp_phase3_warehouse_role_permission + WHERE role_code = 'WAREHOUSE_TALLY') <> 12 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '仓库角色初始化失败:角色权限组合数量不符合预期'; + END IF; + IF EXISTS ( + SELECT 1 + FROM tmp_phase3_warehouse_role_permission expected + LEFT JOIN iam_permission permission + ON permission.tenant_id = 0 + AND permission.app_code = 'PLATFORM' + AND permission.permission_code = expected.permission_code + AND permission.status = 'ACTIVE' + AND permission.deleted = 0 + WHERE permission.id IS NULL + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '仓库角色初始化失败:依赖的三期平台权限尚未完整部署'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase3_warehouse_role_permissions_available(); +DROP PROCEDURE assert_phase3_warehouse_role_permissions_available; + +-- 三个系统角色的权限集合由迁移维护。重复执行时移除历史版本或人工误授的越权项, +-- 避免最小权限角色因脚本升级只增不减。 +DELETE role_permission + FROM iam_role_permission role_permission + JOIN iam_role role + ON role.id = role_permission.role_id + AND role.tenant_id = role_permission.tenant_id + JOIN iam_permission permission + ON permission.id = role_permission.permission_id + AND permission.tenant_id = role_permission.tenant_id + LEFT JOIN tmp_phase3_warehouse_role_permission expected + ON expected.role_code = role.role_code + AND expected.permission_code = permission.permission_code + WHERE role.tenant_id = 0 + AND role.app_code = 'PLATFORM' + AND role.role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY') + AND expected.permission_code IS NULL; + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT role.id, permission.id, 0 + FROM tmp_phase3_warehouse_role_permission expected + JOIN iam_role role + ON role.tenant_id = 0 + AND role.app_code = 'PLATFORM' + AND role.role_code = expected.role_code + AND role.status = 'ACTIVE' + AND role.deleted = 0 + JOIN iam_permission permission + ON permission.tenant_id = 0 + AND permission.app_code = 'PLATFORM' + AND permission.permission_code = expected.permission_code + AND permission.status = 'ACTIVE' + AND permission.deleted = 0; + +INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression) VALUES + (6000004, 4000004, 0, 'TENANT', NULL), + (6000005, 4000005, 0, 'TENANT', NULL), + (6000006, 4000006, 0, 'TENANT', NULL) +ON DUPLICATE KEY UPDATE + tenant_id = 0, scope_type = 'TENANT', scope_expression = NULL; + +DROP PROCEDURE IF EXISTS assert_phase3_warehouse_roles_ready; +DELIMITER $$ +CREATE PROCEDURE assert_phase3_warehouse_roles_ready() +BEGIN + IF (SELECT COUNT(*) + FROM iam_role + WHERE tenant_id = 0 + AND app_code = 'PLATFORM' + AND role_type = 'SYSTEM' + AND status = 'ACTIVE' + AND deleted = 0 + AND role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY')) <> 3 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '仓库角色初始化失败:系统角色未完整启用'; + END IF; + IF (SELECT COUNT(*) + FROM tmp_phase3_warehouse_role_permission expected + JOIN iam_role role + ON role.tenant_id = 0 + AND role.app_code = 'PLATFORM' + AND role.role_code = expected.role_code + JOIN iam_permission permission + ON permission.tenant_id = 0 + AND permission.app_code = 'PLATFORM' + AND permission.permission_code = expected.permission_code + JOIN iam_role_permission grant_row + ON grant_row.role_id = role.id + AND grant_row.permission_id = permission.id + AND grant_row.tenant_id = 0) <> 19 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '仓库角色初始化失败:最小权限授权未完整写入'; + END IF; + IF (SELECT COUNT(*) + FROM iam_data_scope scope_row + JOIN iam_role role ON role.id = scope_row.role_id + WHERE role.tenant_id = 0 + AND role.app_code = 'PLATFORM' + AND role.role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY') + AND scope_row.tenant_id = 0 + AND scope_row.scope_type = 'TENANT') <> 3 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '仓库角色初始化失败:数据范围未完整写入'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase3_warehouse_roles_ready(); +DROP PROCEDURE assert_phase3_warehouse_roles_ready; + +DROP TEMPORARY TABLE IF EXISTS tmp_phase3_warehouse_role_permission; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java index 9c578e4ae..a2b06c672 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java @@ -3,7 +3,6 @@ package com.baiyue.firstleg.loading.controller; import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteDeliveryRequest; import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest; import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest; import com.baiyue.firstleg.loading.dto.LoadingRequests.VoidEvidenceRequest; @@ -21,7 +20,7 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -/** 平台端装柜证据、装柜完成和派送完成操作。 */ +/** 平台端装柜证据和国内装柜完成操作。 */ @Tag(name = "订单装柜与派送") @RestController @RequestMapping("/api/platform/v1/inbounds") @@ -39,6 +38,37 @@ public class PlatformLoadingDeliveryController { return ApiResponse.success(service.upsertEvidence(tenantId, inboundId, user.userId(), user.username(), key, request)); } + /** PDA现场拍照专用入口。与后台人工上传使用不同权限,避免拣货员获得后台证据维护入口。 */ + @Operation(summary = "PDA追加装柜证据") + @PostMapping("/{inboundId}/loading/evidence/pda") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ApiResponse pdaEvidence( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId, + @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpsertEvidenceRequest request) { + FirstlegScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.upsertEvidence(tenantId, inboundId, user.userId(), user.username(), key, request)); + } + + @Operation(summary = "PDA查询装柜证据") + @GetMapping("/{inboundId}/loading/evidence/pda") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ApiResponse pdaEvidence( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId) { + FirstlegScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.evidence(tenantId, inboundId)); + } + + @Operation(summary = "PDA作废错误装柜证据") + @PostMapping("/{inboundId}/loading/evidence/{evidenceId}/void/pda") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ApiResponse pdaVoidEvidence( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId, + @PathVariable long evidenceId, @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody VoidEvidenceRequest request) { + FirstlegScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.voidEvidence(tenantId, inboundId, evidenceId, user.userId(), user.username(), key, request)); + } + @Operation(summary = "查询装柜证据") @GetMapping("/{inboundId}/loading/evidence") @PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:view')") @@ -71,17 +101,6 @@ public class PlatformLoadingDeliveryController { return ApiResponse.success(service.completeLoading(tenantId, inboundId, user.userId(), user.username(), key, request)); } - @Operation(summary = "确认派送完成") - @PostMapping("/{inboundId}/delivery/complete") - @PreAuthorize("hasAuthority('platform:firstleg:inbound:delivery:complete')") - @BusinessAudit(event = "INBOUND_COMPLETE_DELIVERY", resourceType = "INBOUND", resourceId = "#inboundId", description = "确认派送完成") - public ApiResponse completeDelivery( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId, - @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CompleteDeliveryRequest request) { - FirstlegScopeGuard.platform(user, tenantId); - return ApiResponse.success(service.completeDelivery(tenantId, inboundId, user.userId(), user.username(), key, request)); - } - @Operation(summary = "PDA开始拣货") @PostMapping("/{inboundId}/loading/picking/start") @PreAuthorize("hasAuthority('platform:firstleg:inbound:picking:edit')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingRequests.java index c9d8b1136..6ccc36912 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingRequests.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingRequests.java @@ -25,10 +25,6 @@ public final class LoadingRequests { @NotNull @PositiveOrZero Integer expectedVersion, @Size(max = 1000) String remark) {} - public record CompleteDeliveryRequest( - @NotNull @PositiveOrZero Integer expectedVersion, - @Size(max = 1000) String remark) {} - public record PickingRequest(@NotNull @PositiveOrZero Integer expectedVersion, @PositiveOrZero Integer expectedCount, @PositiveOrZero Integer pickedCount, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateMapper.java new file mode 100644 index 000000000..86050aaa5 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateMapper.java @@ -0,0 +1,42 @@ +package com.baiyue.firstleg.loading.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** SQL-backed hard gate for every loading plan linked to an inbound. */ +@Mapper +public interface LoadingCompletionGateMapper { + @Select(""" + SELECT COUNT(*) linkCount, + COALESCE(SUM(CASE WHEN EXISTS ( + SELECT 1 FROM fl_ocean_booking_container_allocation a + JOIN fl_ocean_booking b ON b.id=a.ocean_booking_id + WHERE a.container_booking_id=p.container_booking_id AND b.status='RELEASED') THEN 1 ELSE 0 END),0) oceanReady, + COALESCE(SUM(CASE WHEN EXISTS ( + SELECT 1 FROM fl_truck_appointment t + WHERE t.container_booking_id=p.container_booking_id AND t.loading_plan_id=p.id + AND t.status='COMPLETED') THEN 1 ELSE 0 END),0) truckReady, + COALESCE(SUM(CASE WHEN EXISTS ( + SELECT 1 FROM fl_customs_case c + JOIN fl_customs_receipt r ON r.customs_case_id=c.id + WHERE c.tenant_id=l.tenant_id AND c.inbound_id=l.inbound_id + AND c.container_booking_id=p.container_booking_id + AND c.status='CLEARANCE_COMPLETED' AND r.receipt_type='RELEASED' AND r.file_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM fl_customs_receipt newer + WHERE newer.submission_batch_id=r.submission_batch_id + AND newer.receipt_type=r.receipt_type AND newer.receipt_version>r.receipt_version)) + THEN 1 ELSE 0 END),0) customsReady + FROM fl_preload_loading_plan_link l + JOIN fl_loading_plan p ON p.id=l.loading_plan_id + WHERE l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId} + """) + GateSnapshot snapshot(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + + record GateSnapshot(Integer linkCount, Integer oceanReady, Integer truckReady, Integer customsReady) { + public boolean allBookingReady() { return count(linkCount) > 0 && count(linkCount) == count(oceanReady); } + public boolean allTruckReady() { return count(linkCount) > 0 && count(linkCount) == count(truckReady); } + public boolean allCustomsReady() { return count(linkCount) > 0 && count(linkCount) == count(customsReady); } + private static int count(Integer value) { return value == null ? 0 : value; } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java index a659459ac..604b555e3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java @@ -16,10 +16,5 @@ public interface LoadingInboundMapper { int completeLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, @Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion); - @Update("UPDATE fl_inbound_order SET process_status='DELIVERED', updated_by=#{operatorId}, version=version+1 " - + "WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 AND process_status='LOADED' AND version=#{expectedVersion}") - int completeDelivery(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, - @Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion); - record Snapshot(Long id, Long tenantId, String processStatus, Integer version) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java index 23d8569e8..3c7a8b7de 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java @@ -29,10 +29,6 @@ public class LoadingRepository { return inbounds.completeLoading(tenantId, inboundId, operatorId, expectedVersion); } - public int completeDelivery(long tenantId, long inboundId, long operatorId, int expectedVersion) { - return inbounds.completeDelivery(tenantId, inboundId, operatorId, expectedVersion); - } - public List listEvidence(long tenantId, long inboundId) { return evidences.selectList(new LambdaQueryWrapper() .eq(LoadingEvidenceEntity::getTenantId, tenantId) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingCompletionGateService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingCompletionGateService.java new file mode 100644 index 000000000..ead66da9f --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingCompletionGateService.java @@ -0,0 +1,27 @@ +package com.baiyue.firstleg.loading.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.loading.mapper.LoadingCompletionGateMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +/** Centralized, database-backed LOADED hard gate. */ +@Service +public class LoadingCompletionGateService { + @Resource private LoadingCompletionGateMapper mapper; + + public void requireBookingTransportAndCustoms(long tenantId, long inboundId) { + LoadingCompletionGateMapper.GateSnapshot gate = mapper.snapshot(tenantId, inboundId); + if (gate == null || gate.linkCount() == null || gate.linkCount() == 0) { + throw blocked("未找到有效柜次关联"); + } + if (!gate.allBookingReady()) throw blocked("订舱尚未完成(必须为 RELEASED)"); + if (!gate.allTruckReady()) throw blocked("拖车尚未完成(必须为 COMPLETED)"); + if (!gate.allCustomsReady()) throw blocked("缺少有效报关 RELEASED 放行回执"); + } + + private static BizException blocked(String message) { + return new BizException(ResultCode.CONFLICT, "LOADED门禁未通过:" + message); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java index edea22d1e..7c07918fe 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java @@ -35,17 +35,17 @@ import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; -/** 装柜证据、拣货复核、装柜完成和派送完成。 */ +/** 装柜证据、拣货复核和国内装柜完成。 */ @Service public class LoadingDeliveryService { private static final String UPSERT_EVIDENCE = "UPSERT_LOADING_EVIDENCE"; private static final String VOID_EVIDENCE = "VOID_LOADING_EVIDENCE"; private static final String COMPLETE_LOADING = "COMPLETE_LOADING"; - private static final String COMPLETE_DELIVERY = "COMPLETE_DELIVERY"; @Resource private LoadingRepository repository; @Resource private LoadingPickRepository loadingPicks; @Resource private LoadingModelBridgeService loadingModelBridge; + @Resource private LoadingCompletionGateService completionGate; @Resource private InboundStatusHistoryMapper histories; @Resource private LoadingEvidenceFileClient files; @Resource private ObjectMapper objectMapper; @@ -57,7 +57,7 @@ public class LoadingDeliveryService { String hash = hash(request); LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, UPSERT_EVIDENCE, key, hash); if (replay != null) return replay; - requireLoadingStage(tenantId, inboundId); + requireMutableLoadingStage(tenantId, inboundId); if (!LoadingEvidenceType.VALUES.contains(request.evidenceType())) { throw new BizException(ResultCode.BAD_REQUEST, "不支持的装柜证据类型"); } @@ -105,6 +105,7 @@ public class LoadingDeliveryService { } if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中"); loadingModelBridge.requireCompleted(tenantId, inboundId); + completionGate.requireBookingTransportAndCustoms(tenantId, inboundId); requireCompletedPicking(tenantId, inboundId); requireCompletionEvidence(tenantId, inboundId); if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent(); @@ -113,24 +114,6 @@ public class LoadingDeliveryService { new LoadingActionResponse("LOADED", evidenceResponse(tenantId, inboundId))); } - @Transactional - public LoadingActionResponse completeDelivery(long tenantId, long inboundId, long operatorId, - String operatorName, String key, CompleteDeliveryRequest request) { - String hash = hash(request); - LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_DELIVERY, key, hash); - if (replay != null) return replay; - Snapshot inbound = requireInbound(tenantId, inboundId); - if ("DELIVERED".equals(inbound.processStatus())) { - return complete(tenantId, inboundId, operatorId, COMPLETE_DELIVERY, key, - new LoadingActionResponse("DELIVERED", evidenceResponse(tenantId, inboundId))); - } - if (!"LOADED".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜完成"); - if (repository.completeDelivery(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent(); - addHistory(tenantId, inboundId, "LOADED", "DELIVERED", COMPLETE_DELIVERY, request.remark(), operatorId, operatorName); - return complete(tenantId, inboundId, operatorId, COMPLETE_DELIVERY, key, - new LoadingActionResponse("DELIVERED", evidenceResponse(tenantId, inboundId))); - } - public EvidenceListResponse evidence(long tenantId, long inboundId) { requireLoadingStage(tenantId, inboundId); return evidenceResponse(tenantId, inboundId); @@ -142,7 +125,7 @@ public class LoadingDeliveryService { String hash = hash(request); LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, VOID_EVIDENCE, key, hash); if (replay != null) return replay; - requireLoadingStage(tenantId, inboundId); + requireMutableLoadingStage(tenantId, inboundId); LoadingEvidenceEntity target = repository.listEvidence(tenantId, inboundId).stream() .filter(item -> Long.valueOf(evidenceId).equals(item.getId())).findFirst().orElse(null); if (target == null) throw new BizException(ResultCode.NOT_FOUND, "装柜证据不存在"); @@ -298,6 +281,16 @@ public class LoadingDeliveryService { return value; } + /** Evidence is append-only while loading. Once LOADED is reached this API is read-only; + * corrections are handled by the offline/manual process defined for phase three. */ + private Snapshot requireMutableLoadingStage(long tenantId, long inboundId) { + Snapshot value = requireInbound(tenantId, inboundId); + if (!"LOADING".equals(value.processStatus())) { + throw new BizException(ResultCode.CONFLICT, "装柜完成后不允许在线修改证据"); + } + return value; + } + private EvidenceListResponse evidenceResponse(long tenantId, long inboundId) { List values = repository.listEvidence(tenantId, inboundId).stream().map(e -> new EvidenceResponse(e.getId(), e.getInboundId(), e.getTenantId(), e.getEvidenceType(), e.getFileId(), 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 891670d5c..2b78be244 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java @@ -11,8 +11,20 @@ import java.util.List; /** PDA部分拣货结算的客户归属锁定查询。 */ @Mapper public interface InboundSplitSettlementMapper { + @Select("SELECT id,tenant_id tenantId,inbound_role inboundRole,root_inbound_id rootInboundId," + + "split_purpose splitPurpose,split_status splitStatus FROM fl_inbound_order WHERE id=#{inboundId} AND deleted=0") + Lineage lineage(@Param("inboundId") long inboundId); + @Select("SELECT COALESCE(MAX(split_sequence),0)+1 FROM fl_inbound_order WHERE tenant_id=#{accountId} AND parent_inbound_id=#{parentInboundId} AND deleted=0 FOR UPDATE") + int nextSplitSequence(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId); + + @Select("SELECT id FROM fl_inbound_split_batch WHERE tenant_id=#{accountId} AND operation_id=#{operationId} LIMIT 1 FOR UPDATE") + Long appliedBatch(@Param("accountId") long accountId, @Param("operationId") String operationId); + + @Select("SELECT container_booking_id FROM fl_loading_plan WHERE id=#{loadingPlanId}") + Long containerBookingId(@Param("loadingPlanId") long loadingPlanId); @Select(""" SELECT id, tenant_id AS tenantId, inbound_code AS inboundCode, inbound_role AS inboundRole, + root_inbound_id AS rootInboundId, process_status AS processStatus, total_package_count AS totalPackageCount, package_count AS packageCount, version FROM fl_inbound_order @@ -21,68 +33,52 @@ public interface InboundSplitSettlementMapper { """) ParentRow lockParent(@Param("inboundId") long inboundId); - @Select(""" - SELECT id - FROM fl_inbound_order - WHERE tenant_id = #{accountId} AND parent_inbound_id = #{parentInboundId} - AND inbound_role = 'CHILD' AND deleted = 0 - LIMIT 1 - FOR UPDATE + @Insert(""" + INSERT INTO fl_inbound_order (id,tenant_id,task_id,order_id,order_no,inbound_code,process_status, + order_status_snapshot,measure_review_status,fee_status,fee_review_status,notification_status, + expected_arrival_at,customer_id,customer_name,company_id,company_name,warehouse_id,warehouse_name, + delivery_warehouse_id,delivery_warehouse_name,business_type,channel_code,channel_name,carrier_id,carrier_name, + tax_mode,pricing_tax_mode,container_mode,customs_inspection,destination_type,destination_warehouse_id, + destination_code,destination_name,hold_flag,current_hold_reason,total_package_count,package_count, + actual_gross_weight_kg,chargeable_weight_kg,actual_volume_cbm,chargeable_volume_cbm,pallet_count,volume_weight_kg, + actual_net_weight_kg,measure_completed_at,inbounded_at,express_company_code,express_company_name, + express_tracking_no,public_remark,created_by,created_by_name,created_at,updated_by,updated_at,deleted,version, + inbound_role,parent_inbound_id,root_inbound_id,split_sequence,split_status,split_purpose) + SELECT #{childId},tenant_id,task_id,order_id,order_no,#{childInboundCode},'INBOUNDED',order_status_snapshot, + 'APPROVED',fee_status,fee_review_status,notification_status,expected_arrival_at,customer_id,customer_name, + company_id,company_name,warehouse_id,warehouse_name,delivery_warehouse_id,delivery_warehouse_name,business_type, + channel_code,channel_name,carrier_id,carrier_name,tax_mode,pricing_tax_mode,container_mode,customs_inspection, + destination_type,destination_warehouse_id,destination_code,destination_name,hold_flag,current_hold_reason, + #{packageCount},#{packageCount},actual_gross_weight_kg,chargeable_weight_kg,actual_volume_cbm,chargeable_volume_cbm, + pallet_count,volume_weight_kg,actual_net_weight_kg,measure_completed_at,inbounded_at,express_company_code, + express_company_name,express_tracking_no,public_remark,#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3), + #{operatorId},CURRENT_TIMESTAMP(3),0,0,'CHILD',#{parentInboundId},#{rootInboundId},#{splitSequence},'NOT_SPLIT',#{splitPurpose} + FROM fl_inbound_order WHERE id=#{parentInboundId} AND tenant_id=#{accountId} AND deleted=0 """) - Long childId(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId); + int cloneChildWithPurpose(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId, + @Param("rootInboundId") long rootInboundId, @Param("childId") long childId, + @Param("childInboundCode") String childInboundCode, @Param("packageCount") int packageCount, + @Param("splitSequence") int splitSequence, @Param("splitPurpose") String splitPurpose, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); @Insert(""" - INSERT INTO fl_inbound_order ( - id, tenant_id, task_id, order_id, order_no, inbound_code, process_status, - order_status_snapshot, measure_review_status, fee_status, fee_review_status, notification_status, - expected_arrival_at, customer_id, customer_name, company_id, company_name, - warehouse_id, warehouse_name, delivery_warehouse_id, delivery_warehouse_name, - business_type, channel_code, channel_name, carrier_id, carrier_name, tax_mode, - pricing_tax_mode, container_mode, customs_inspection, destination_type, - destination_warehouse_id, destination_code, destination_name, hold_flag, - current_hold_reason, total_package_count, actual_gross_weight_kg, - chargeable_weight_kg, actual_volume_cbm, chargeable_volume_cbm, pallet_count, - volume_weight_kg, actual_net_weight_kg, measure_completed_at, inbounded_at, - express_company_code, express_company_name, express_tracking_no, public_remark, - created_by, created_by_name, created_at, updated_by, updated_at, deleted, version, - inbound_role, parent_inbound_id, root_inbound_id, split_sequence, split_status, package_count - ) - SELECT #{childId}, tenant_id, task_id, order_id, order_no, #{childInboundCode}, 'INBOUNDED', - order_status_snapshot, 'APPROVED', fee_status, fee_review_status, notification_status, - expected_arrival_at, customer_id, customer_name, company_id, company_name, - warehouse_id, warehouse_name, delivery_warehouse_id, delivery_warehouse_name, - business_type, channel_code, channel_name, carrier_id, carrier_name, tax_mode, - pricing_tax_mode, container_mode, customs_inspection, destination_type, - destination_warehouse_id, destination_code, destination_name, hold_flag, - current_hold_reason, #{packageCount}, actual_gross_weight_kg, - chargeable_weight_kg, actual_volume_cbm, chargeable_volume_cbm, pallet_count, - volume_weight_kg, actual_net_weight_kg, measure_completed_at, inbounded_at, - express_company_code, express_company_name, express_tracking_no, public_remark, - #{operatorId}, #{operatorName}, CURRENT_TIMESTAMP(3), #{operatorId}, CURRENT_TIMESTAMP(3), 0, 0, - 'CHILD', id, id, 1, 'NOT_SPLIT', #{packageCount} - FROM fl_inbound_order - WHERE id = #{parentInboundId} AND tenant_id = #{accountId} AND deleted = 0 + INSERT INTO fl_inbound_split_batch (id,tenant_id,parent_inbound_id,source_inbound_id,operation_id,idempotency_key, + expected_parent_version,package_count,loaded_child_inbound_id,remainder_child_inbound_id, + loaded_package_count,remaining_package_count,loading_plan_id,container_booking_id,status,remark, + created_by,created_by_name,created_at,version) + VALUES (#{batchId},#{accountId},#{parentInboundId},#{sourceInboundId},#{operationId},#{idempotencyKey},#{expectedParentVersion}, + #{packageCount},#{loadedChildId},#{remainderChildId},#{loadedPackageCount},#{remainingPackageCount}, + #{loadingPlanId},#{containerBookingId},'PREPARED',#{remark},#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),0) """) - int cloneChild(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId, - @Param("childId") long childId, @Param("childInboundCode") String childInboundCode, - @Param("packageCount") int packageCount, @Param("operatorId") long operatorId, - @Param("operatorName") String operatorName); - - @Insert(""" - INSERT INTO fl_inbound_split_batch - (id, tenant_id, parent_inbound_id, operation_id, idempotency_key, expected_parent_version, - package_count, status, remark, created_by, created_by_name, created_at, version) - VALUES - (#{batchId}, #{accountId}, #{parentInboundId}, #{operationId}, #{idempotencyKey}, - #{expectedParentVersion}, #{packageCount}, 'PREPARED', #{remark}, #{operatorId}, - #{operatorName}, CURRENT_TIMESTAMP(3), 0) - """) - int insertBatch(@Param("batchId") long batchId, @Param("accountId") long accountId, - @Param("parentInboundId") long parentInboundId, @Param("operationId") String operationId, - @Param("idempotencyKey") String idempotencyKey, - @Param("expectedParentVersion") int expectedParentVersion, - @Param("packageCount") int packageCount, @Param("remark") String remark, - @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + int insertBatchWithChildren(@Param("batchId") long batchId, @Param("accountId") long accountId, + @Param("parentInboundId") long parentInboundId, @Param("operationId") String operationId, + @Param("sourceInboundId") long sourceInboundId, + @Param("idempotencyKey") String idempotencyKey, @Param("expectedParentVersion") int expectedParentVersion, + @Param("packageCount") int packageCount, @Param("loadedChildId") Long loadedChildId, + @Param("remainderChildId") Long remainderChildId, @Param("loadedPackageCount") int loadedPackageCount, + @Param("remainingPackageCount") int remainingPackageCount, @Param("loadingPlanId") Long loadingPlanId, + @Param("containerBookingId") Long containerBookingId, @Param("remark") String remark, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); @Insert(""" INSERT INTO fl_inbound_split_item @@ -158,6 +154,17 @@ public interface InboundSplitSettlementMapper { @Param("packageCount") int packageCount, @Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion); + @Update("UPDATE fl_inbound_order SET package_count=0,split_status='FULLY_SPLIT',updated_by=#{operatorId},version=version+1 " + + "WHERE id=#{parentInboundId} AND tenant_id=#{accountId} AND inbound_role='MASTER' AND deleted=0 AND version=#{expectedVersion}") + int markRootSplit(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId, + @Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion); + + @Update("UPDATE fl_inbound_order SET split_status='FULLY_SPLIT',updated_by=#{operatorId},version=version+1 " + + "WHERE id=#{sourceInboundId} AND tenant_id=#{accountId} AND inbound_role='CHILD' " + + "AND split_purpose='REMAINDER' AND split_status='NOT_SPLIT' AND deleted=0 AND version=#{expectedVersion}") + int markRemainderConsumed(@Param("accountId") long accountId, @Param("sourceInboundId") long sourceInboundId, + @Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion); + @Update(""" UPDATE fl_inbound_split_batch SET status = 'APPLIED', applied_at = CURRENT_TIMESTAMP(3), version = version + 1 @@ -240,32 +247,35 @@ public interface InboundSplitSettlementMapper { @Param("inboundId") long inboundId); @Update(""" - UPDATE fl_inbound_measurement - SET total_package_count = #{packageCount}, updated_at = CURRENT_TIMESTAMP(3), version = version + 1 - WHERE id = #{measurementId} AND tenant_id = #{accountId} AND inbound_id = #{inboundId} - AND status = 'APPROVED' + UPDATE fl_inbound_order child + JOIN fl_inbound_measurement measurement ON measurement.tenant_id=child.tenant_id + AND measurement.inbound_id=child.id AND measurement.id=#{measurementId} + SET child.actual_gross_weight_kg=(SELECT COALESCE(SUM(item.total_weight_kg),0) + FROM fl_inbound_measurement_item item WHERE item.measurement_id=measurement.id), + child.actual_volume_cbm=(SELECT COALESCE(SUM(item.total_volume_cbm),0) + FROM fl_inbound_measurement_item item WHERE item.measurement_id=measurement.id), + child.chargeable_weight_kg=NULL,child.chargeable_volume_cbm=NULL, + child.volume_weight_kg=(SELECT COALESCE(SUM(item.volume_weight_kg),0) + FROM fl_inbound_measurement_item item WHERE item.measurement_id=measurement.id), + child.actual_net_weight_kg=NULL,child.pallet_count=0,child.version=child.version+1 + WHERE child.id=#{childInboundId} AND child.tenant_id=#{accountId} AND child.inbound_role='CHILD' """) - int updateSourceMeasurementPackageCount(@Param("accountId") long accountId, - @Param("inboundId") long inboundId, - @Param("measurementId") long measurementId, - @Param("packageCount") int packageCount); + int normalizeChildMetrics(@Param("accountId") long accountId, @Param("childInboundId") long childInboundId, + @Param("measurementId") long measurementId); - @Update(""" - UPDATE fl_inbound_measurement_item - SET package_count = #{packageCount}, updated_at = CURRENT_TIMESTAMP(3) - WHERE id = #{measurementItemId} AND measurement_id = #{measurementId} AND tenant_id = #{accountId} - """) - int updateSourceMeasurementItemPackageCount(@Param("accountId") long accountId, - @Param("measurementId") long measurementId, - @Param("measurementItemId") long measurementItemId, - @Param("packageCount") int packageCount); - - record ParentRow(Long id, Long tenantId, String inboundCode, String inboundRole, + record ParentRow(Long id, Long tenantId, String inboundCode, String inboundRole, Long rootInboundId, String processStatus, Integer totalPackageCount, Integer packageCount, Integer version) { + public ParentRow(Long id, Long tenantId, String inboundCode, String inboundRole, + String processStatus, Integer totalPackageCount, Integer packageCount, Integer version) { + this(id, tenantId, inboundCode, inboundRole, id, processStatus, totalPackageCount, packageCount, version); + } } record MeasurementItemRow(Long measurementItemId, Long measurementId, Long inboundDetailId, Integer packageCount) { } + + record Lineage(Long id, Long tenantId, String inboundRole, Long rootInboundId, + String splitPurpose, String splitStatus) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index f4c5da782..47943060e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -24,6 +24,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED') JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id WHERE o.warehouse_id=#{warehouseId} AND o.process_status='INBOUNDED' + AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT')) AND i.inbound_detail_id IS NOT NULL AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id @@ -61,6 +62,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED') JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id WHERE o.warehouse_id=#{warehouseId} AND o.id=#{inboundId} AND o.process_status='INBOUNDED' + AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT')) AND i.inbound_detail_id=#{detailId} ORDER BY m.id DESC LIMIT 1 FOR UPDATE """) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java index 656516a57..3066f458b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java @@ -4,7 +4,6 @@ import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.TimelineIt import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; -import org.apache.ibatis.annotations.Update; import java.time.LocalDateTime; import java.util.List; @@ -63,18 +62,6 @@ public interface OutboundDispatchReadMapper { """) Evidence evidence(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); - @Update("UPDATE fl_inbound_order SET process_status='WAITING_FOR_LOADING',updated_by=#{operatorId}," - + "version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=#{tenantId} AND id=#{inboundId} " - + "AND deleted=0 AND process_status='LOADED'") - int confirmInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, - @Param("operatorId") long operatorId); - - @Update("UPDATE fl_inbound_order SET process_status='LOADED',updated_by=#{operatorId}," - + "version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=#{tenantId} AND id=#{inboundId} " - + "AND deleted=0 AND process_status='WAITING_FOR_LOADING'") - int reverseInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, - @Param("operatorId") long operatorId); - @Select(""" SELECT module,action,fromStatus,toStatus,operatorId,operatorName,remark,occurredAt FROM ( SELECT 'INBOUND' module,h.action action,h.from_status fromStatus,h.to_status toStatus, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java index fc516a47a..c0d53c5d3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java @@ -45,12 +45,6 @@ public class OutboundDispatchRepository { public int reverse(long id, int version, long operatorId, String operatorName, String remark) { return dispatches.reverse(id, version, operatorId, operatorName, remark); } - public int confirmInbound(long tenantId, long inboundId, long operatorId) { - return reads.confirmInbound(tenantId, inboundId, operatorId); - } - public int reverseInbound(long tenantId, long inboundId, long operatorId) { - return reads.reverseInbound(tenantId, inboundId, operatorId); - } public void history(OutboundDispatchHistoryEntity value) { histories.insert(value); } public boolean claim(long operatorId, String operation, String key, String hash) { return operations.claim(IdWorker.getId(), operatorId, operation, key, hash) == 1; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java index 9bca0286c..0c389fd05 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java @@ -68,8 +68,7 @@ public class OutboundDispatchService { if (OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(state.getStatus())) { throw conflict("当前出仓单已确认派车"); } - if (repository.confirm(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1 - || repository.confirmInbound(binding.tenantId(), binding.inboundId(), operatorId) != 1) { + if (repository.confirm(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { throw concurrent(); } history(state, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(), @@ -92,8 +91,7 @@ public class OutboundDispatchService { throw conflict("当前出仓单不在等待装船状态"); } if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent(); - if (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1 - || repository.reverseInbound(binding.tenantId(), binding.inboundId(), operatorId) != 1) { + if (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { throw concurrent(); } history(state, "REVERSE_DISPATCH", OutboundDispatchStatus.WAITING_FOR_LOADING.name(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java index b4814f691..9027e533b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java @@ -27,7 +27,7 @@ import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; -/** PDA 任务结算:未装柜箱数生成二级子入库单,并转移其库位资源归属。 */ +/** PDA 任务结算:按柜次生成实装/余量履约分片,并结算测量与库位资源。 */ @Service public class PdaPickSettlementService { private static final long PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID = 0L; @@ -44,10 +44,7 @@ public class PdaPickSettlementService { @Value("${baiyue.security.internal-service-token}") private String internalToken; - /** - * 同一父单最多生成一张子单。子单承接该父单所有未装柜数量,而不是只承接 PARTIAL 行的差额。 - * 纯跳单才恢复智能装柜候选;同单存在已拣或部分拣货时,跳单行随未装柜数量一并归入子单。 - */ + /** 每个柜次生成一张 LOADED 分片;存在余量时另生成可继续分柜的 REMAINDER 分片。 */ @Transactional public void settle(long taskId, long operatorId, String operatorName) { if (!picks.allTaskItemsResolved(taskId)) { @@ -84,15 +81,31 @@ public class PdaPickSettlementService { private boolean settleInbound(long parentInboundId, int pickedPackageCount, List taskItems, long taskId, long operatorId, String operatorName) { - ParentRow parent = splits.lockParent(parentInboundId); + InboundSplitSettlementMapper.Lineage lineage = splits.lineage(parentInboundId); + if (lineage == null) throw conflict("待拆分入库单不存在"); + if ("CHILD".equals(lineage.inboundRole()) + && !("REMAINDER".equals(lineage.splitPurpose()) && "NOT_SPLIT".equals(lineage.splitStatus()))) { + throw conflict("仅未消费的余量子单可以继续分柜"); + } + if (!"MASTER".equals(lineage.inboundRole()) && lineage.rootInboundId() == null) { + throw conflict("余量子单缺少根主单"); + } + long rootInboundId = "MASTER".equals(lineage.inboundRole()) ? parentInboundId : lineage.rootInboundId(); + ParentRow root = splits.lockParent(rootInboundId); + ParentRow parent = rootInboundId == parentInboundId ? root : splits.lockParent(parentInboundId); if (parent == null || parent.tenantId() == null || parent.tenantId() <= 0 - || !"MASTER".equals(parent.inboundRole()) || !"INBOUNDED".equals(parent.processStatus())) { + || !("MASTER".equals(parent.inboundRole()) || "CHILD".equals(parent.inboundRole())) + || !"INBOUNDED".equals(parent.processStatus())) { throw conflict("待拆分主入库单不在可拆分状态"); } - Long existingChild = splits.childId(parent.tenantId(), parentInboundId); - if (existingChild != null) { - return false; - } + String operationId = "PDA-SPLIT-" + taskId + '-' + parentInboundId; + Long appliedBatchId = splits.appliedBatch(parent.tenantId(), operationId); + if (appliedBatchId != null && appliedBatchId > 0) return false; + var task = picks.task(taskId); + if (task == null || task.getLoadingPlanId() == null) throw conflict("PDA任务缺少真实装柜计划"); + long loadingPlanId = task.getLoadingPlanId(); + Long containerBookingId = splits.containerBookingId(loadingPlanId); + if (containerBookingId == null) throw conflict("PDA任务装柜计划缺少真实柜次"); int parentAvailable = count(parent.packageCount(), "主入库单可用箱数"); if (pickedPackageCount <= 0 || pickedPackageCount > parentAvailable) { throw conflict("本次已拣箱数与主入库单可用箱数不一致"); @@ -112,14 +125,8 @@ public class PdaPickSettlementService { throw conflict("父入库单库位剩余箱数与未装柜箱数不一致"); } long warehouseId = warehouseId(confirmedAllocations); - if (childPackageCount == 0) { - if (!transferItems.isEmpty() || releasedReservationIds.isEmpty()) { - throw conflict("全量拣货后库位资源结算不完整"); - } - allocations.markReleased(parent.tenantId(), parentInboundId, releasedReservationIds); - settleWarehouseResources(parentInboundId, 0L, "", taskId, operatorId, operatorName, - warehouseId, parentAvailable, 0, releasedReservationIds, List.of()); - return false; + if (childPackageCount == 0 && (!transferItems.isEmpty() || releasedReservationIds.isEmpty())) { + throw conflict("全量拣货后库位资源结算不完整"); } List measurements = splits.lockLatestApprovedItemsForInbound( @@ -142,56 +149,91 @@ public class PdaPickSettlementService { throw conflict("主入库单测量明细剩余箱数与未装柜箱数不一致"); } - if (transferItems.isEmpty()) { + if (childPackageCount > 0 && transferItems.isEmpty()) { throw conflict("父入库单可转移库位剩余箱数与未装柜箱数不一致"); } - long childId = IdWorker.getId(); + if (root == null || !"MASTER".equals(root.inboundRole())) throw conflict("根主入库单不存在"); + long loadedChildId = IdWorker.getId(); + long remainderChildId = childPackageCount > 0 ? IdWorker.getId() : 0L; long batchId = IdWorker.getId(); - String operationId = "PDA-SPLIT-" + taskId + '-' + parentInboundId; String idempotencyKey = "pda-task-" + taskId; - String childCode = childInboundCode(parent.inboundCode(), childId); - int parentVersion = parent.version() == null ? 0 : parent.version(); - requireAffected(splits.cloneChild(parent.tenantId(), parentInboundId, childId, childCode, childPackageCount, - operatorId, operatorName), "子入库单"); - requireAffected(splits.insertBatch(batchId, parent.tenantId(), parentInboundId, operationId, - idempotencyKey, parentVersion, childPackageCount, "PDA拣货后未装柜箱数", operatorId, operatorName), "拆分批次"); - long childMeasurementId = IdWorker.getId(); + String childCode = childInboundCode(parent.inboundCode(), remainderChildId == 0 ? loadedChildId : remainderChildId); + int parentVersion = root.version() == null ? 0 : root.version(); + int sequence = splits.nextSplitSequence(parent.tenantId(), rootInboundId); + requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), rootInboundId, rootInboundId, loadedChildId, + childInboundCode(parent.inboundCode(), loadedChildId), pickedPackageCount, sequence, "LOADED", operatorId, operatorName), "实装子入库单"); + if (remainderChildId != 0L) { + requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), rootInboundId, rootInboundId, remainderChildId, + childCode, childPackageCount, sequence + 1, "REMAINDER", operatorId, operatorName), "余量子入库单"); + } + requireAffected(splits.insertBatchWithChildren(batchId, parent.tenantId(), rootInboundId, operationId, parentInboundId, + idempotencyKey, parentVersion, parentAvailable, loadedChildId, remainderChildId == 0L ? null : remainderChildId, + pickedPackageCount, childPackageCount, loadingPlanId, containerBookingId, + "PDA拣货后生成实装/余量子单", operatorId, operatorName), "拆分批次"); + long loadedMeasurementId = IdWorker.getId(); requireAffected(splits.cloneMeasurement(parent.tenantId(), parentInboundId, sourceMeasurementId, - childId, childMeasurementId, batchId, childPackageCount, operatorId), "子入库测量批次"); + loadedChildId, loadedMeasurementId, batchId, pickedPackageCount, operatorId), "实装子入库测量批次"); + long childMeasurementId = childPackageCount > 0 ? IdWorker.getId() : 0L; + if (childPackageCount > 0) { + requireAffected(splits.cloneMeasurement(parent.tenantId(), parentInboundId, sourceMeasurementId, + remainderChildId, childMeasurementId, batchId, childPackageCount, operatorId), "余量子入库测量批次"); + } int lineNo = 1; + for (MeasurementSplitLine line : measurementLines) { + if (line.pickedPackageCount() <= 0) continue; + long splitItemId = IdWorker.getId(); + requireAffected(splits.insertSplitItem(splitItemId, parent.tenantId(), batchId, rootInboundId, + loadedChildId, lineNo, line.item().inboundDetailId(), sourceMeasurementId, + line.item().measurementItemId(), line.pickedPackageCount()), "实装拆分明细"); + requireAffected(splits.cloneMeasurementItem(parent.tenantId(), sourceMeasurementId, + line.item().measurementItemId(), loadedMeasurementId, splitItemId, IdWorker.getId(), lineNo, + line.pickedPackageCount()), "实装子入库测量明细"); + lineNo++; + } for (MeasurementSplitLine line : measurementLines) { if (line.remainingPackageCount() <= 0) { continue; } long splitItemId = IdWorker.getId(); - requireAffected(splits.insertSplitItem(splitItemId, parent.tenantId(), batchId, parentInboundId, - childId, lineNo, line.item().inboundDetailId(), sourceMeasurementId, + requireAffected(splits.insertSplitItem(splitItemId, parent.tenantId(), batchId, rootInboundId, + remainderChildId == 0L ? loadedChildId : remainderChildId, lineNo, line.item().inboundDetailId(), sourceMeasurementId, line.item().measurementItemId(), line.remainingPackageCount()), "拆分明细"); requireAffected(splits.cloneMeasurementItem(parent.tenantId(), sourceMeasurementId, line.item().measurementItemId(), childMeasurementId, splitItemId, IdWorker.getId(), lineNo, line.remainingPackageCount()), "子入库测量明细"); lineNo++; } - requireAffected(splits.updateSourceMeasurementPackageCount(parent.tenantId(), parentInboundId, - sourceMeasurementId, pickedPackageCount), "主入库测量批次"); - for (MeasurementSplitLine line : measurementLines) { - requireAffected(splits.updateSourceMeasurementItemPackageCount(parent.tenantId(), sourceMeasurementId, - line.item().measurementItemId(), line.pickedPackageCount()), "主入库测量明细"); + requireAffected(splits.normalizeChildMetrics(parent.tenantId(), loadedChildId, loadedMeasurementId), + "实装子单重量体积归一化"); + if (remainderChildId != 0L) { + requireAffected(splits.normalizeChildMetrics(parent.tenantId(), remainderChildId, childMeasurementId), + "余量子单重量体积归一化"); + } + requireAffected(splits.markRootSplit(parent.tenantId(), rootInboundId, operatorId, parentVersion), "主入库单拆分状态"); + if ("CHILD".equals(parent.inboundRole())) { + requireAffected(splits.markRemainderConsumed(parent.tenantId(), parentInboundId, operatorId, + parent.version() == null ? 0 : parent.version()), "余量子单消费状态"); } - requireAffected(splits.markParentPartiallySplit(parent.tenantId(), parentInboundId, childPackageCount, - operatorId, parentVersion), "主入库单拆分状态"); requireAffected(splits.applyBatch(parent.tenantId(), batchId), "拆分批次生效"); requireAffected(splits.insertHistory(IdWorker.getId(), parent.tenantId(), batchId, - operationId + "-APPLY", "{\"childInboundId\":" + childId + ",\"packageCount\":" - + childPackageCount + "}", operatorId, operatorName, "PDA拣货自动生成子入库单"), "拆分审计"); + operationId + "-APPLY", "{\"sourceInboundId\":" + parentInboundId + + ",\"loadedChildInboundId\":" + loadedChildId + + ",\"remainderChildInboundId\":" + (remainderChildId == 0L ? "null" : remainderChildId) + + ",\"loadedPackageCount\":" + pickedPackageCount + + ",\"remainingPackageCount\":" + childPackageCount + + ",\"loadingPlanId\":" + loadingPlanId + + ",\"containerBookingId\":" + containerBookingId + "}", operatorId, operatorName, + "PDA拣货自动生成实装/余量子入库单"), "拆分审计"); - allocations.copyRemainingToChild(parent.tenantId(), childId, confirmedAllocations); + if (remainderChildId != 0L) { + allocations.copyRemainingToChild(parent.tenantId(), remainderChildId, confirmedAllocations); + } allocations.markTransferred(parent.tenantId(), parentInboundId, confirmedAllocations, releasedReservationIds); allocations.markReleased(parent.tenantId(), parentInboundId, releasedReservationIds); - settleWarehouseResources(parentInboundId, childId, childCode, taskId, operatorId, operatorName, + settleWarehouseResources(parentInboundId, remainderChildId, childCode, taskId, operatorId, operatorName, warehouseId, parentAvailable, childPackageCount, releasedReservationIds, transferItems); return true; } diff --git a/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql b/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql index 03529d795..cad7b8542 100644 --- a/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql +++ b/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql @@ -47,10 +47,19 @@ CALL phase3_inbound_add_column( CALL phase3_inbound_add_column( 'fl_inbound_order', 'package_count', "INT UNSIGNED NULL COMMENT '入仓单箱数;三期唯一数量口径' AFTER total_package_count"); +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'split_purpose', + "VARCHAR(16) NOT NULL DEFAULT 'MASTER' COMMENT '履约分片用途:MASTER、LOADED、REMAINDER' AFTER package_count"); CALL phase3_inbound_add_column( 'fl_inbound_order', 'master_order_id', "BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER' AND deleted = 0 THEN order_id ELSE NULL END) STORED COMMENT '仅用于约束有效主单订单唯一性'"); +-- 旧实现只生成未装余量子单,可无歧义映射为 REMAINDER。 +UPDATE fl_inbound_order + SET split_purpose = 'REMAINDER' + WHERE inbound_role = 'CHILD' + AND split_purpose = 'MASTER'; + DROP PROCEDURE IF EXISTS phase3_inbound_add_column; DROP PROCEDURE IF EXISTS phase3_inbound_add_index; @@ -78,9 +87,6 @@ DELIMITER ; CALL phase3_inbound_add_index( 'fl_inbound_order', 'uk_inbound_master_order', 'UNIQUE KEY `uk_inbound_master_order` (`tenant_id`, `master_order_id`)'); -CALL phase3_inbound_add_index( - 'fl_inbound_order', 'uk_inbound_one_child', - 'UNIQUE KEY `uk_inbound_one_child` (`tenant_id`, `parent_inbound_id`)'); CALL phase3_inbound_add_index( 'fl_inbound_order', 'idx_inbound_root_role', 'KEY `idx_inbound_root_role` (`tenant_id`, `root_inbound_id`, `inbound_role`, `created_at`, `id`)'); @@ -105,10 +111,21 @@ BEGIN ) THEN ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_parent_sequence; END IF; + IF EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_one_child' + ) THEN + ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_one_child; + END IF; END$$ DELIMITER ; CALL phase3_inbound_drop_legacy_index(); DROP PROCEDURE IF EXISTS phase3_inbound_drop_legacy_index; +CALL phase3_inbound_add_index( + 'fl_inbound_order', 'uk_inbound_parent_split_sequence', + 'UNIQUE KEY `uk_inbound_parent_split_sequence` (`tenant_id`, `parent_inbound_id`, `split_sequence`)'); DROP PROCEDURE IF EXISTS phase3_inbound_add_index; CREATE TABLE IF NOT EXISTS fl_inbound_split_batch ( @@ -215,6 +232,27 @@ CALL phase3_inbound_add_column( CALL phase3_inbound_add_column( 'fl_inbound_split_item', 'line_no', "INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一拆分批次内的行号' AFTER child_inbound_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_split_batch', 'loaded_child_inbound_id', + "BIGINT UNSIGNED NULL COMMENT '本批次实装子单ID' AFTER package_count"); +CALL phase3_inbound_add_column( + 'fl_inbound_split_batch', 'source_inbound_id', + "BIGINT UNSIGNED NULL COMMENT '本批次实际消耗的MASTER或REMAINDER分片ID' AFTER parent_inbound_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_split_batch', 'remainder_child_inbound_id', + "BIGINT UNSIGNED NULL COMMENT '本批次余量子单ID' AFTER loaded_child_inbound_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_split_batch', 'loaded_package_count', + "INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '本批次实装箱数' AFTER remainder_child_inbound_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_split_batch', 'remaining_package_count', + "INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '本批次未装箱数' AFTER loaded_package_count"); +CALL phase3_inbound_add_column( + 'fl_inbound_split_batch', 'loading_plan_id', + "BIGINT UNSIGNED NULL COMMENT '关联装柜计划/柜次' AFTER remaining_package_count"); +CALL phase3_inbound_add_column( + 'fl_inbound_split_batch', 'container_booking_id', + "BIGINT UNSIGNED NULL COMMENT '关联柜次标识(兼容查询)' AFTER loading_plan_id"); DROP PROCEDURE IF EXISTS phase3_inbound_add_column; DROP PROCEDURE IF EXISTS phase3_inbound_add_relation; @@ -245,6 +283,15 @@ CALL phase3_inbound_add_relation( CALL phase3_inbound_add_relation( 'fl_inbound_order', 'fk_inbound_root_order', 'FOREIGN KEY (`root_inbound_id`) REFERENCES `fl_inbound_order` (`id`)'); +CALL phase3_inbound_add_relation( + 'fl_inbound_split_batch', 'fk_inbound_split_loaded_child', + 'FOREIGN KEY (`loaded_child_inbound_id`) REFERENCES `fl_inbound_order` (`id`)'); +CALL phase3_inbound_add_relation( + 'fl_inbound_split_batch', 'fk_inbound_split_source', + 'FOREIGN KEY (`source_inbound_id`) REFERENCES `fl_inbound_order` (`id`)'); +CALL phase3_inbound_add_relation( + 'fl_inbound_split_batch', 'fk_inbound_split_remainder_child', + 'FOREIGN KEY (`remainder_child_inbound_id`) REFERENCES `fl_inbound_order` (`id`)'); CALL phase3_inbound_add_relation( 'fl_inbound_measurement', 'fk_measure_split_batch', 'FOREIGN KEY (`split_batch_id`) REFERENCES `fl_inbound_split_batch` (`id`)'); @@ -290,9 +337,8 @@ CALL phase3_inbound_add_index( 'fl_inbound_split_item', 'uk_inbound_split_batch_line', 'UNIQUE KEY `uk_inbound_split_batch_line` (`tenant_id`, `split_batch_id`, `line_no`)'); CALL phase3_inbound_add_index( - 'fl_inbound_split_item', 'uk_inbound_split_batch_source_item', - 'UNIQUE KEY `uk_inbound_split_batch_source_item` (`tenant_id`, `split_batch_id`, `source_measurement_item_id`)'); -DROP PROCEDURE IF EXISTS phase3_inbound_add_index; + 'fl_inbound_split_batch', 'idx_inbound_split_loading_plan', + 'KEY `idx_inbound_split_loading_plan` (`tenant_id`, `loading_plan_id`, `container_booking_id`, `status`, `id`)'); DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index; DELIMITER $$ @@ -306,10 +352,18 @@ BEGIN AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_batch_child') THEN ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_batch_child; END IF; + IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_batch_source_item') THEN + ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_batch_source_item; + END IF; END$$ DELIMITER ; CALL phase3_inbound_drop_split_legacy_index(); DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index; +CALL phase3_inbound_add_index( + 'fl_inbound_split_item', 'uk_inbound_split_batch_source_child', + 'UNIQUE KEY `uk_inbound_split_batch_source_child` (`tenant_id`, `split_batch_id`, `source_measurement_item_id`, `child_inbound_id`)'); +DROP PROCEDURE IF EXISTS phase3_inbound_add_index; DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_insert; DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_update; @@ -332,6 +386,9 @@ BEGIN IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法'; END IF; + IF NEW.split_purpose NOT IN ('MASTER', 'LOADED', 'REMAINDER') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单分片用途非法'; + END IF; IF NEW.package_count IS NOT NULL AND NEW.package_count = 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单箱数必须大于0'; END IF; @@ -343,8 +400,8 @@ BEGIN OR NEW.split_sequence IS NULL OR NEW.package_count IS NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单缺少主单、顺序号或箱数'; END IF; - IF NEW.split_status <> 'NOT_SPLIT' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子仅两级,子入仓单不可继续拆分'; + IF NEW.split_purpose = 'MASTER' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单必须标记LOADED或REMAINDER'; END IF; IF NOT EXISTS ( SELECT 1 FROM fl_inbound_order parent_order @@ -365,9 +422,13 @@ BEGIN IF NOT (OLD.inbound_role <=> NEW.inbound_role) OR NOT (OLD.parent_inbound_id <=> NEW.parent_inbound_id) OR NOT (OLD.root_inbound_id <=> NEW.root_inbound_id) - OR NOT (OLD.split_sequence <=> NEW.split_sequence) THEN + OR NOT (OLD.split_sequence <=> NEW.split_sequence) + OR NOT (OLD.split_purpose <=> NEW.split_purpose) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单主子归属创建后不可修改'; END IF; + IF NEW.split_purpose NOT IN ('MASTER', 'LOADED', 'REMAINDER') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单分片用途非法'; + END IF; IF NEW.inbound_role = 'CHILD' AND (NEW.package_count IS NULL OR NEW.package_count = 0) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单箱数必须大于0'; @@ -375,8 +436,8 @@ BEGIN IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法'; END IF; - IF NEW.inbound_role = 'CHILD' AND NEW.split_status <> 'NOT_SPLIT' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子仅两级,子入仓单不可继续拆分'; + IF NEW.inbound_role = 'CHILD' AND NEW.split_purpose = 'MASTER' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单用途非法'; END IF; IF NEW.inbound_role = 'MASTER' AND ((NEW.package_count = 0 AND NEW.split_status <> 'FULLY_SPLIT') @@ -402,6 +463,7 @@ BEGIN AND parent_order.inbound_role = 'MASTER' AND child_order.tenant_id = NEW.tenant_id AND child_order.inbound_role = 'CHILD' + AND child_order.split_purpose IN ('LOADED', 'REMAINDER') AND child_order.parent_inbound_id = NEW.parent_inbound_id AND child_order.package_count >= NEW.package_count AND child_order.deleted = 0 @@ -412,7 +474,7 @@ BEGIN WHERE source_item.id = NEW.source_measurement_item_id AND source_item.tenant_id = NEW.tenant_id AND source_item.measurement_id = NEW.source_measurement_id - AND source_batch.inbound_id = NEW.parent_inbound_id + AND source_batch.inbound_id = COALESCE(batch.source_inbound_id, batch.parent_inbound_id) )) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分明细与批次、主单或子单不一致'; @@ -435,6 +497,27 @@ BEGIN ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次主单不存在或客户归属不一致'; END IF; + IF NEW.source_inbound_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM fl_inbound_order source_order + WHERE source_order.id = NEW.source_inbound_id + AND source_order.tenant_id = NEW.tenant_id + AND source_order.deleted = 0 + AND (source_order.id = NEW.parent_inbound_id OR + (source_order.inbound_role = 'CHILD' + AND source_order.parent_inbound_id = NEW.parent_inbound_id + AND source_order.split_purpose = 'REMAINDER')) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次来源必须是根主单或其余量子单'; + END IF; + IF NEW.loaded_package_count > 0 AND ( + NEW.loading_plan_id IS NULL OR NEW.container_booking_id IS NULL OR NOT EXISTS ( + SELECT 1 FROM fl_loading_plan plan + WHERE plan.id = NEW.loading_plan_id + AND plan.container_booking_id = NEW.container_booking_id + ) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '实装拆分批次必须绑定匹配的装柜计划和柜次'; + END IF; END$$ CREATE TRIGGER trg_inbound_split_batch_before_update @@ -444,15 +527,29 @@ BEGIN IF OLD.status IN ('APPLIED', 'CANCELLED') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已结束拆分批次不可修改'; END IF; + IF NOT (OLD.parent_inbound_id <=> NEW.parent_inbound_id) + OR NOT (OLD.source_inbound_id <=> NEW.source_inbound_id) + OR NOT (OLD.loaded_child_inbound_id <=> NEW.loaded_child_inbound_id) + OR NOT (OLD.remainder_child_inbound_id <=> NEW.remainder_child_inbound_id) + OR NOT (OLD.package_count <=> NEW.package_count) + OR NOT (OLD.loaded_package_count <=> NEW.loaded_package_count) + OR NOT (OLD.remaining_package_count <=> NEW.remaining_package_count) + OR NOT (OLD.loading_plan_id <=> NEW.loading_plan_id) + OR NOT (OLD.container_booking_id <=> NEW.container_booking_id) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次来源、子单、柜次与数量创建后不可修改'; + END IF; IF NEW.status = 'APPLIED' THEN IF NEW.applied_at IS NULL + OR (NEW.loaded_package_count > 0 AND ( + NEW.loading_plan_id IS NULL OR NEW.container_booking_id IS NULL OR NOT EXISTS ( + SELECT 1 FROM fl_loading_plan plan + WHERE plan.id = NEW.loading_plan_id + AND plan.container_booking_id = NEW.container_booking_id))) OR NOT EXISTS ( SELECT 1 FROM fl_inbound_order parent_order WHERE parent_order.id = NEW.parent_inbound_id AND parent_order.tenant_id = NEW.tenant_id AND parent_order.inbound_role = 'MASTER' - AND parent_order.package_count > 0 - AND parent_order.split_status = 'PARTIALLY_SPLIT' AND parent_order.version > NEW.expected_parent_version ) OR (SELECT COALESCE(SUM(item.package_count), 0) @@ -462,7 +559,26 @@ BEGIN (SELECT COUNT(DISTINCT item.child_inbound_id) FROM fl_inbound_split_item item WHERE item.tenant_id = NEW.tenant_id - AND item.split_batch_id = NEW.id) <> 1 OR + AND item.split_batch_id = NEW.id) NOT BETWEEN 1 AND 2 OR + (SELECT COALESCE(SUM(item.package_count), 0) + FROM fl_inbound_split_item item + WHERE item.tenant_id = NEW.tenant_id + AND item.split_batch_id = NEW.id + AND item.child_inbound_id = NEW.loaded_child_inbound_id) <> NEW.loaded_package_count OR + (SELECT COALESCE(SUM(item.package_count), 0) + FROM fl_inbound_split_item item + WHERE item.tenant_id = NEW.tenant_id + AND item.split_batch_id = NEW.id + AND item.child_inbound_id = NEW.remainder_child_inbound_id) <> NEW.remaining_package_count OR + NEW.loaded_package_count + NEW.remaining_package_count <> NEW.package_count OR + (NEW.loaded_package_count > 0 AND NEW.loaded_child_inbound_id IS NULL) OR + (NEW.remaining_package_count > 0 AND NEW.remainder_child_inbound_id IS NULL) OR + (NEW.loaded_child_inbound_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM fl_inbound_order c WHERE c.id = NEW.loaded_child_inbound_id + AND c.parent_inbound_id = NEW.parent_inbound_id AND c.split_purpose = 'LOADED')) OR + (NEW.remainder_child_inbound_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM fl_inbound_order c WHERE c.id = NEW.remainder_child_inbound_id + AND c.parent_inbound_id = NEW.parent_inbound_id AND c.split_purpose = 'REMAINDER')) OR EXISTS (SELECT 1 FROM fl_inbound_split_item item JOIN fl_inbound_order child_order ON child_order.id = item.child_inbound_id @@ -541,12 +657,12 @@ BEGIN AND table_name = 'fl_inbound_order' AND column_name IN ('inbound_role', 'parent_inbound_id', 'root_inbound_id', 'split_sequence', 'split_status', 'package_count', - 'master_order_id')) <> 7 THEN + 'master_order_id', 'split_purpose')) <> 8 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单关系字段未完整创建'; END IF; IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema = DATABASE() - AND index_name IN ('uk_inbound_master_order', 'uk_inbound_one_child', + AND index_name IN ('uk_inbound_master_order', 'uk_inbound_parent_split_sequence', 'uk_inbound_split_operation', 'uk_inbound_split_idempotency', 'uk_inbound_split_history_operation')) <> 5 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单唯一约束未完整创建'; @@ -554,8 +670,9 @@ BEGIN IF (SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_schema = DATABASE() AND constraint_name IN ('fk_inbound_parent_order', 'fk_inbound_root_order', + 'fk_inbound_split_source', 'fk_inbound_split_loaded_child', 'fk_inbound_split_remainder_child', 'fk_measure_split_batch', 'fk_measure_source_batch', - 'fk_measure_item_split_item', 'fk_measure_item_source_item')) <> 6 THEN + 'fk_measure_item_split_item', 'fk_measure_item_source_item')) <> 9 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单与测量关联约束未完整创建'; END IF; IF (SELECT COUNT(*) FROM information_schema.triggers @@ -575,7 +692,7 @@ BEGIN SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' - AND index_name = 'uk_inbound_tenant_order' + AND index_name IN ('uk_inbound_tenant_order', 'uk_inbound_one_child') ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '旧订单唯一索引仍阻止创建子入仓单'; END IF; diff --git a/firstleg/src/main/resources/db/phase3-split-preload-schema.sql b/firstleg/src/main/resources/db/phase3-split-preload-schema.sql index 8a0414b9b..057c67447 100644 --- a/firstleg/src/main/resources/db/phase3-split-preload-schema.sql +++ b/firstleg/src/main/resources/db/phase3-split-preload-schema.sql @@ -40,9 +40,8 @@ CREATE TABLE IF NOT EXISTS fl_split_operation ( /* * 部分装柜拆单契约: - * 1. parent_inbound_id 为空的是主单,非空的是唯一二级子单; - * 2. 锁定阶段只记录箱数,完成阶段绑定由剩余箱数生成的子单; - * 3. 子单不能再作为父单,锁定数量创建后不可修改。 + * 本文件仅保留历史部分锁定明细;主单、LOADED/REMAINDER 多分片及层级约束 + * 统一由 phase3-inbound-parent-child-schema.sql 维护。 */ DROP PROCEDURE IF EXISTS phase3_split_add_column; DELIMITER $$ @@ -76,8 +75,8 @@ SET @phase3_split_index_exists = ( WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' AND index_name = 'uk_inbound_one_secondary' ); -SET @phase3_split_ddl = IF(@phase3_split_index_exists = 0, - 'ALTER TABLE fl_inbound_order ADD UNIQUE KEY uk_inbound_one_secondary (tenant_id, parent_inbound_id)', +SET @phase3_split_ddl = IF(@phase3_split_index_exists > 0, + 'ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_one_secondary', 'SELECT 1'); PREPARE phase3_split_stmt FROM @phase3_split_ddl; EXECUTE phase3_split_stmt; @@ -88,8 +87,8 @@ SET @phase3_split_fk_exists = ( WHERE constraint_schema = DATABASE() AND table_name = 'fl_inbound_order' AND constraint_name = 'fk_inbound_parent' AND constraint_type = 'FOREIGN KEY' ); -SET @phase3_split_ddl = IF(@phase3_split_fk_exists = 0, - 'ALTER TABLE fl_inbound_order ADD CONSTRAINT fk_inbound_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id)', +SET @phase3_split_ddl = IF(@phase3_split_fk_exists > 0, + 'ALTER TABLE fl_inbound_order DROP FOREIGN KEY fk_inbound_parent', 'SELECT 1'); PREPARE phase3_split_stmt FROM @phase3_split_ddl; EXECUTE phase3_split_stmt; @@ -140,58 +139,6 @@ DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_insert; DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_update; DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_delete; DELIMITER $$ -CREATE TRIGGER trg_inbound_split_before_insert -BEFORE INSERT ON fl_inbound_order -FOR EACH ROW -BEGIN - DECLARE v_parent_tenant BIGINT UNSIGNED; - DECLARE v_parent_parent BIGINT UNSIGNED; - IF NEW.parent_inbound_id IS NOT NULL THEN - SELECT tenant_id, parent_inbound_id INTO v_parent_tenant, v_parent_parent - FROM fl_inbound_order WHERE id = NEW.parent_inbound_id AND deleted = 0; - IF v_parent_tenant <> NEW.tenant_id THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单与主单客户归属不一致'; - END IF; - IF v_parent_parent IS NOT NULL THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单不能继续拆分'; - END IF; - IF NEW.split_status <> 'NOT_SPLIT' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单拆单状态必须为NOT_SPLIT'; - END IF; - END IF; -END$$ - -CREATE TRIGGER trg_inbound_split_before_update -BEFORE UPDATE ON fl_inbound_order -FOR EACH ROW -BEGIN - IF NOT (NEW.parent_inbound_id <=> OLD.parent_inbound_id) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入库单主子关系创建后不可修改'; - END IF; - IF OLD.parent_inbound_id IS NOT NULL AND NEW.split_status <> 'NOT_SPLIT' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单不能继续拆分'; - END IF; - IF OLD.parent_inbound_id IS NULL - AND NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主入库单拆单状态非法'; - END IF; - IF OLD.split_status = 'FULLY_SPLIT' AND NEW.split_status <> 'FULLY_SPLIT' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成拆单不能回退'; - END IF; - IF NEW.split_status = 'FULLY_SPLIT' AND ( - (SELECT COUNT(*) FROM fl_inbound_order child - WHERE child.tenant_id = NEW.tenant_id AND child.parent_inbound_id = NEW.id - AND child.deleted = 0) <> 1 - OR (SELECT COUNT(*) FROM fl_inbound_split_detail detail - WHERE detail.tenant_id = NEW.tenant_id AND detail.parent_inbound_id = NEW.id) = 0 - OR EXISTS (SELECT 1 FROM fl_inbound_split_detail detail - WHERE detail.tenant_id = NEW.tenant_id AND detail.parent_inbound_id = NEW.id - AND detail.status <> 'COMPLETED') - ) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '剩余箱数二级子单及拆分明细未完成'; - END IF; -END$$ - CREATE TRIGGER trg_inbound_split_detail_before_insert BEFORE INSERT ON fl_inbound_split_detail FOR EACH ROW @@ -239,6 +186,11 @@ BEGIN END$$ DELIMITER ; +-- 主子关系及不可回退规则由 phase3-inbound-parent-child-schema.sql 统一维护。 +-- 旧触发器要求“一父一子”,会阻止 LOADED/REMAINDER 多分片,迁移完成后必须移除。 +DROP TRIGGER IF EXISTS trg_inbound_split_before_insert; +DROP TRIGGER IF EXISTS trg_inbound_split_before_update; + DROP PROCEDURE IF EXISTS phase3_split_assert_contract; DELIMITER $$ CREATE PROCEDURE phase3_split_assert_contract() @@ -254,17 +206,16 @@ BEGIN END IF; IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema = DATABASE() - AND index_name IN ('uk_inbound_one_secondary', 'uk_inbound_split_parent_detail', - 'uk_inbound_split_child_detail', 'uk_inbound_split_operation')) <> 4 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:唯一子单或部分锁定幂等索引缺失'; + AND index_name IN ('uk_inbound_split_parent_detail', + 'uk_inbound_split_child_detail', 'uk_inbound_split_operation')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:部分锁定幂等索引缺失'; END IF; IF (SELECT COUNT(*) FROM information_schema.triggers WHERE trigger_schema = DATABASE() - AND trigger_name IN ('trg_inbound_split_before_insert', 'trg_inbound_split_before_update', - 'trg_inbound_split_detail_before_insert', + AND trigger_name IN ('trg_inbound_split_detail_before_insert', 'trg_inbound_split_detail_before_update', - 'trg_inbound_split_detail_before_delete')) <> 5 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:主子层级或部分锁定触发器未完整创建'; + 'trg_inbound_split_detail_before_delete')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:部分锁定触发器未完整创建'; END IF; END$$ DELIMITER ; diff --git a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml index 1fd3af338..b98340959 100644 --- a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml @@ -3,6 +3,21 @@ info: title: Phase 3 Loading Evidence and Picking API version: 1.0.0 paths: + /api/platform/v1/inbounds/{inboundId}/loading/evidence/pda: + get: + summary: List loading evidence for the active PDA worker + parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}] + responses: + '200': {description: Business envelope; requires platform:firstleg:pda:work} + post: + summary: Append one PDA-captured loading evidence photo + parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: + required: true + content: + application/json: {schema: {$ref: '#/components/schemas/UpsertEvidenceRequest'}} + responses: + '200': {description: Business envelope; requires platform:firstleg:pda:work} /api/platform/v1/inbounds/{inboundId}/loading/evidence: get: summary: List loading evidence bound to one customer inbound @@ -18,6 +33,16 @@ paths: application/json: {schema: {$ref: '#/components/schemas/UpsertEvidenceRequest'}} responses: '200': {description: Business envelope; ownership, type and version failures use business error codes} + /api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/void/pda: + post: + summary: Void an incorrect PDA loading evidence photo + parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/EvidenceId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: + required: true + content: + application/json: {schema: {$ref: '#/components/schemas/VoidEvidenceRequest'}} + responses: + '200': {description: Business envelope; requires platform:firstleg:pda:work} /api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/void: post: summary: Void an incorrect loading evidence photo without hard deletion diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java index 3f89defd0..8ccd887a9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java @@ -15,6 +15,7 @@ class LoadingDeliveryOpenApiContractTest { assertThat(yaml) .contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence:") + .contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence/pda:") .contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/void:") .contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/start:") .contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/complete:") @@ -26,5 +27,6 @@ class LoadingDeliveryOpenApiContractTest { .contains("compatibility projection") .contains("must exactly match the server-derived container-task aggregate") .contains("cannot complete canonical picking"); + assertThat(yaml).doesNotContain("/delivery/complete").doesNotContain("DELIVERED"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryControllerContractTest.java index d8cfbd2bd..10e086560 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryControllerContractTest.java @@ -1,7 +1,6 @@ package com.baiyue.firstleg.loading.controller; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteDeliveryRequest; import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest; import com.baiyue.firstleg.loading.dto.LoadingRequests.PickingRequest; import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest; @@ -15,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import java.lang.reflect.Method; +import java.util.Arrays; import static org.assertj.core.api.Assertions.assertThat; @@ -26,19 +26,28 @@ class PlatformLoadingDeliveryControllerContractTest { post("evidence", "/{inboundId}/loading/evidence", "platform:firstleg:inbound:loading:edit", UpsertEvidenceRequest.class); + post("pdaEvidence", "/{inboundId}/loading/evidence/pda", + "platform:firstleg:pda:work", UpsertEvidenceRequest.class); + get("pdaEvidence", "/{inboundId}/loading/evidence/pda", + "platform:firstleg:pda:work"); + Method pdaVoid = PlatformLoadingDeliveryController.class.getDeclaredMethod("pdaVoidEvidence", + AuthenticatedUser.class, long.class, long.class, long.class, String.class, VoidEvidenceRequest.class); + assertThat(pdaVoid.getAnnotation(PostMapping.class).value()) + .containsExactly("/{inboundId}/loading/evidence/{evidenceId}/void/pda"); + assertAuthority(pdaVoid, "platform:firstleg:pda:work"); get("evidence", "/{inboundId}/loading/evidence", "platform:firstleg:inbound:loading:view"); postVoidEvidence(); post("completeLoading", "/{inboundId}/loading/complete", "platform:firstleg:inbound:loading:complete", CompleteLoadingRequest.class); - post("completeDelivery", "/{inboundId}/delivery/complete", - "platform:firstleg:inbound:delivery:complete", CompleteDeliveryRequest.class); post("startPicking", "/{inboundId}/loading/picking/start", "platform:firstleg:inbound:picking:edit", PickingRequest.class); post("completePicking", "/{inboundId}/loading/picking/complete", "platform:firstleg:inbound:picking:edit", PickingRequest.class); get("picking", "/{inboundId}/loading/picking", "platform:firstleg:inbound:picking:view"); + assertThat(Arrays.stream(PlatformLoadingDeliveryController.class.getDeclaredMethods()) + .map(Method::getName)).doesNotContain("completeDelivery"); } private static void postVoidEvidence() throws Exception { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateSqlContractTest.java new file mode 100644 index 000000000..6ba2aba21 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateSqlContractTest.java @@ -0,0 +1,30 @@ +package com.baiyue.firstleg.loading.mapper; + +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class LoadingCompletionGateSqlContractTest { + @Test + void requiresEveryLinkedContainerToPassBookingTruckAndCustomsReceiptGates() throws Exception { + Select select = LoadingCompletionGateMapper.class + .getDeclaredMethod("snapshot", long.class, long.class).getAnnotation(Select.class); + String sql = String.join(" ", select.value()).replaceAll("\\s+", " "); + + assertThat(sql) + .contains("FROM fl_preload_loading_plan_link l") + .contains("JOIN fl_loading_plan p ON p.id=l.loading_plan_id") + .contains("fl_ocean_booking_container_allocation") + .contains("b.status='RELEASED'") + .contains("fl_truck_appointment") + .contains("t.status='COMPLETED'") + .contains("fl_customs_case") + .contains("fl_customs_receipt") + .contains("c.status='CLEARANCE_COMPLETED'") + .contains("r.receipt_type='RELEASED'") + .contains("r.file_id IS NOT NULL") + .contains("newer.receipt_version>r.receipt_version") + .contains("l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId}"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java index bec1361b3..9c9e512f0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java @@ -34,6 +34,7 @@ import static org.mockito.Mockito.*; class LoadingDeliveryServiceTest { @Mock LoadingRepository repository; @Mock LoadingModelBridgeService loadingModelBridge; + @Mock LoadingCompletionGateService completionGate; @Mock LoadingPickRepository loadingPicks; @Mock InboundStatusHistoryMapper histories; @Mock LoadingEvidenceFileClient files; @@ -74,6 +75,7 @@ class LoadingDeliveryServiceTest { LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "k1", new CompleteLoadingRequest(3, null)); assertThat(result.mainStatus()).isEqualTo("LOADED"); verify(loadingModelBridge).requireCompleted(10, 20); + verify(completionGate).requireBookingTransportAndCustoms(10, 20); verify(histories).insert(any(InboundStatusHistoryEntity.class)); } @@ -159,6 +161,24 @@ class LoadingDeliveryServiceTest { verify(repository, never()).createEvidence(any()); } + @Test + void rejectsEvidenceAppendAndVoidAfterLoadingCompleted() { + when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())) + .thenReturn(true); + when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADED", 4)); + + assertThatThrownBy(() -> service.upsertEvidence(10, 20, 7, "admin", "loaded-append", + new UpsertEvidenceRequest(0, "PHOTO_STEP_1", 11L, null, null, null))) + .hasMessageContaining("装柜完成后不允许在线修改证据"); + assertThatThrownBy(() -> service.voidEvidence(10, 20, 99L, 7L, "admin", "loaded-void", + new VoidEvidenceRequest(0, "拍错"))) + .hasMessageContaining("装柜完成后不允许在线修改证据"); + + verify(repository, never()).createEvidence(any()); + verify(repository, never()).voidEvidence(anyLong(), anyLong(), anyLong(), anyString(), + anyLong(), anyString(), anyInt()); + } + @Test void rejectsCompletingPickingWithoutCanonicalContainerTask() { when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java index cb1fee84e..4965dd26f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java @@ -33,7 +33,7 @@ class OutboundDispatchServiceTest { } @Test - void confirmAtomicallyAdvancesDispatchAndInboundAfterEveryGatePasses() { + void confirmAdvancesDispatchWithoutMutatingLoadedInbound() { OutboundDispatchService service = new OutboundDispatchService(repository, List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE"))); stubReadyChain(); @@ -43,14 +43,12 @@ class OutboundDispatchServiceTest { when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-1"), anyString())).thenReturn(true); when(repository.confirm(33L, 0, 9L, "admin", "ok")).thenReturn(1); - when(repository.confirmInbound(7L, 11L, 9L)).thenReturn(1); when(repository.completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1")).thenReturn(1); when(repository.timeline(any(), any(), any())).thenReturn(List.of()); service.confirm(9L, "admin", "confirm-1", new ActionRequest(11L, 22L, 0, "ok")); verify(repository).confirm(33L, 0, 9L, "admin", "ok"); - verify(repository).confirmInbound(7L, 11L, 9L); verify(repository).history(argThat(history -> "CONFIRM_DISPATCH".equals(history.getAction()) && "WAITING_FOR_LOADING".equals(history.getToStatus()))); verify(repository).completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1"); @@ -68,7 +66,6 @@ class OutboundDispatchServiceTest { new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class).hasMessageContaining("门禁"); verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any()); - verify(repository, never()).confirmInbound(anyLong(), anyLong(), anyLong()); } @Test @@ -92,7 +89,6 @@ class OutboundDispatchServiceTest { assertThatThrownBy(() -> service.confirm(9L, "admin", "stale", new ActionRequest(11L, 22L, 1, null))).isInstanceOf(BizException.class).hasMessageContaining("刷新"); - verify(repository, never()).confirmInbound(anyLong(), anyLong(), anyLong()); } @Test @@ -108,7 +104,6 @@ class OutboundDispatchServiceTest { assertThatThrownBy(() -> service.confirm(9L, "admin", "reused", new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class) .hasMessageContaining("用于不同请求"); - verify(repository, never()).confirmInbound(anyLong(), anyLong(), anyLong()); } private void stubReadyChain() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java index 996c00a62..1e1312924 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java @@ -5,8 +5,6 @@ import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; @@ -22,10 +20,12 @@ class InboundParentChildSqlContractTest { .contains("'fl_inbound_order', 'parent_inbound_id'") .contains("'fl_inbound_order', 'root_inbound_id'") .contains("'fl_inbound_order', 'package_count'") + .contains("'fl_inbound_order', 'split_purpose'") + .contains("SET split_purpose = 'REMAINDER'") .contains("GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER'") .contains("DROP INDEX uk_inbound_tenant_order") .contains("uk_inbound_master_order") - .contains("uk_inbound_one_child") + .contains("uk_inbound_parent_split_sequence") .contains("DROP INDEX uk_inbound_parent_sequence"); } @@ -35,22 +35,34 @@ class InboundParentChildSqlContractTest { .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch") .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item") .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history") + .contains("'fl_inbound_split_batch', 'loaded_child_inbound_id'") + .contains("'fl_inbound_split_batch', 'source_inbound_id'") + .contains("'fl_inbound_split_batch', 'remainder_child_inbound_id'") + .contains("'fl_inbound_split_batch', 'loaded_package_count'") + .contains("'fl_inbound_split_batch', 'remaining_package_count'") + .contains("'fl_inbound_split_batch', 'loading_plan_id'") + .contains("'fl_inbound_split_batch', 'container_booking_id'") + .contains("fk_inbound_split_loaded_child") + .contains("fk_inbound_split_source") + .contains("fk_inbound_split_remainder_child") .contains("line_no INT UNSIGNED NOT NULL") .contains("uk_inbound_split_batch_line") - .contains("uk_inbound_split_batch_source_item") + .contains("uk_inbound_split_batch_source_child") + .contains("`source_measurement_item_id`, `child_inbound_id`") .contains("package_count INT UNSIGNED NOT NULL") .doesNotContain("quantity INT", "item_count INT", "carton_count INT"); } - @org.junit.jupiter.api.Disabled("旧拆分口径已由一主单一子单的批次汇总校验取代") @Test void protectsOwnershipAndTotals() { assertThat(sql) .contains("子入仓单必须绑定同一客户的有效主单") - .contains("parent_order.package_count > 0") - .contains("parent_order.split_status = 'PARTIALLY_SPLIT'") .contains("parent_order.version > NEW.expected_parent_version") - .contains("主单可用箱数未归零或子单箱数合计不一致") + .contains("NEW.loaded_package_count") + .contains("NEW.remaining_package_count") + .contains("NEW.loaded_package_count + NEW.remaining_package_count <> NEW.package_count") + .contains("source_batch.inbound_id = COALESCE(batch.source_inbound_id, batch.parent_inbound_id)") + .contains("拆分批次来源必须是根主单或其余量子单") .contains("SUM(split_item.package_count) > source_item.package_count") .contains("子单承接箱数超过来源测量箱规箱数") .contains("child_order.package_count >= NEW.package_count") @@ -61,14 +73,14 @@ class InboundParentChildSqlContractTest { } @Test - void validatesOneChildAndMultiSkuTotals() { + void validatesMultiChildAndMultiSkuTotals() { assertThat(sql) - .contains("uk_inbound_one_child") + .contains("uk_inbound_parent_split_sequence") .contains("uk_inbound_split_batch_line") - .contains("uk_inbound_split_batch_source_item") + .contains("uk_inbound_split_batch_source_child") .contains("COUNT(DISTINCT item.child_inbound_id)") - .contains("child_order.package_count <> SUM(item.package_count)") - .contains("parent_order.split_status = 'PARTIALLY_SPLIT'"); + .contains("NOT BETWEEN 1 AND 2") + .contains("child_order.package_count <> SUM(item.package_count)"); } @Test @@ -88,19 +100,6 @@ class InboundParentChildSqlContractTest { .contains("idx_measure_item_split_source"); } - @Test - void syncsUnifiedMigration() throws Exception { - String unified = normalize(Files.readString(unifiedPath(), StandardCharsets.UTF_8)); - assertThat(unified) - .contains("-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql") - .contains("CREATE PROCEDURE phase3_inbound_add_column") - .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch") - .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item") - .contains("CREATE TRIGGER trg_inbound_order_parent_before_insert") - .contains("CREATE PROCEDURE phase3_assert_inbound_parent_child_schema") - .contains("旧订单唯一索引仍阻止创建子入仓单"); - } - private static String loadSql() { try { return new ClassPathResource("db/phase3-inbound-parent-child-schema.sql") @@ -110,16 +109,4 @@ class InboundParentChildSqlContractTest { } } - private static Path unifiedPath() { - Path working = Path.of("").toAbsolutePath(); - Path path = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"); - if (!Files.exists(path)) { - path = working.resolve("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize(); - } - return path; - } - - private static String normalize(String value) { - return value.replace("\r\n", "\n").replace("\uFEFF", ""); - } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java index 82f5e965d..ffcb7e4d2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java @@ -1,5 +1,9 @@ package com.baiyue.firstleg.repository; +import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper; +import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Select; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; @@ -9,16 +13,16 @@ import static org.assertj.core.api.Assertions.assertThat; class PdaSplitSettlementSqlContractTest { @Test - void protectsOneChildAndPartialSettlement() throws Exception { + void protectsLoadedAndRemainderSettlement() throws Exception { String sql = new ClassPathResource("db/phase3-inbound-parent-child-schema.sql") .getContentAsString(StandardCharsets.UTF_8); assertThat(sql) - .contains("uk_inbound_one_child") + .contains("uk_inbound_parent_split_sequence") .contains("uk_inbound_split_batch_line") - .contains("uk_inbound_split_batch_source_item") + .contains("uk_inbound_split_batch_source_child") .contains("child_order.package_count <> SUM(item.package_count)") - .contains("parent_order.split_status = 'PARTIALLY_SPLIT'") + .contains("NEW.loaded_package_count + NEW.remaining_package_count <> NEW.package_count") .contains("SUM(split_item.package_count) > source_item.package_count") .contains("CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0)"); } @@ -34,4 +38,33 @@ class PdaSplitSettlementSqlContractTest { .contains("NEW.item_status = 'SKIPPED'") .contains("NEW.picked_package_count = OLD.picked_package_count"); } + + @Test + void removesLegacySingleChildGuardsAndExcludesLoadedSlices() throws Exception { + String sql = new ClassPathResource("db/phase3-split-preload-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(sql) + .contains("DROP INDEX uk_inbound_one_secondary") + .contains("DROP TRIGGER IF EXISTS trg_inbound_split_before_insert") + .contains("DROP TRIGGER IF EXISTS trg_inbound_split_before_update"); + + String candidateSql = SmartLoadingCandidateMapper.class.getMethod("candidates", long.class) + .getAnnotation(Select.class).value()[0]; + assertThat(candidateSql).contains("o.split_purpose='REMAINDER'").doesNotContain("o.split_purpose='LOADED'"); + } + + @Test + void settlementPersistsRealPlanAndContainerIds() throws Exception { + String insert = InboundSplitSettlementMapper.class.getMethod("insertBatchWithChildren", + long.class, long.class, long.class, String.class, long.class, String.class, int.class, + int.class, Long.class, Long.class, int.class, int.class, Long.class, Long.class, + String.class, long.class, String.class) + .getAnnotation(Insert.class).value()[0]; + assertThat(insert) + .contains("source_inbound_id") + .contains("loading_plan_id") + .contains("container_booking_id") + .contains("#{loadingPlanId}") + .contains("#{containerBookingId}"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java index e46064a9e..8bf7c6a39 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.firstleg.client.WarehousePalletLocationClient; import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; import com.baiyue.firstleg.entity.LoadingPickItemEntity; +import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper; import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper.MeasurementItemRow; import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper.ParentRow; @@ -41,7 +42,10 @@ class PdaPickSettlementServiceTest { LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 10, 8, "PARTIAL"); resolved(List.of(item)); when(splits.lockParent(31L)).thenReturn(parent(20)); - when(splits.childId(10001L, 31L)).thenReturn(null); + when(splits.lockLatestApprovedItemsForInbound(10001L, 31L)) + .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20))); + when(splits.lockLatestApprovedItemsForInbound(10001L, 31L)) + .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20))); when(splits.lockLatestApprovedItemsForInbound(10001L, 31L)) .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20))); when(allocations.lockConfirmed(10001L, 31L)) @@ -53,9 +57,10 @@ class PdaPickSettlementServiceTest { service.settle(1L, 9L, "operator"); - verify(splits).cloneChild(eq(10001L), eq(31L), anyLong(), anyString(), eq(12), eq(9L), eq("operator")); - verify(splits).updateSourceMeasurementPackageCount(10001L, 31L, 61L, 8); - verify(splits).updateSourceMeasurementItemPackageCount(10001L, 61L, 51L, 8); + verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(), anyString(), eq(8), + anyInt(), eq("LOADED"), eq(9L), eq("operator")); + verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(), anyString(), eq(12), + anyInt(), eq("REMAINDER"), eq(9L), eq("operator")); verify(allocations).consume(10001L, 31L, 1L, 8); verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request -> request.warehouseId() == 701L && request.sourceBusinessId() == 31L @@ -73,7 +78,7 @@ class PdaPickSettlementServiceTest { LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 10, 6, "PARTIAL"); resolved(List.of(item)); when(splits.lockParent(31L)).thenReturn(parent(10)); - when(splits.childId(10001L, 31L)).thenReturn(71L); + when(splits.appliedBatch(10001L, "PDA-SPLIT-1-31")).thenReturn(71L); service.settle(1L, 9L, "operator"); @@ -86,7 +91,6 @@ class PdaPickSettlementServiceTest { LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 10, 10, "PICKED"); resolved(List.of(item)); when(splits.lockParent(31L)).thenReturn(parent(20)); - when(splits.childId(10001L, 31L)).thenReturn(null); when(splits.lockLatestApprovedItemsForInbound(10001L, 31L)) .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20))); when(allocations.lockConfirmed(10001L, 31L)) @@ -105,22 +109,25 @@ class PdaPickSettlementServiceTest { } @Test - void fullyPickedInboundReleasesAllResourcesWithoutCreatingChild() { + void fullyPickedInboundCreatesLoadedChildAndReleasesAllResources() { LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 20, 20, "PICKED"); resolved(List.of(item)); when(splits.lockParent(31L)).thenReturn(parent(20)); - when(splits.childId(10001L, 31L)).thenReturn(null); + when(splits.lockLatestApprovedItemsForInbound(10001L, 31L)) + .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20))); when(allocations.lockConfirmed(10001L, 31L)) .thenReturn(List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10))); when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success( new WarehousePalletLocationClient.SettlementResult(List.of(released(91L, 501L), released(92L, 502L)), List.of()))); + writes(0, 20); service.settle(1L, 9L, "operator"); verify(allocations).consume(10001L, 31L, 1L, 10); verify(allocations).consume(10001L, 31L, 2L, 10); - verify(splits, never()).cloneChild(anyLong(), anyLong(), anyLong(), anyString(), anyInt(), anyLong(), anyString()); + verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(), anyString(), eq(20), + anyInt(), eq("LOADED"), eq(9L), eq("operator")); verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request -> request.targetBusinessId() == 0L && request.targetPackageCount() == 0 && request.releasedReservationIds().equals(List.of(91L, 92L)) @@ -151,22 +158,28 @@ class PdaPickSettlementServiceTest { private void resolved(List items) { when(picks.allTaskItemsResolved(1L)).thenReturn(true); when(picks.items(1L)).thenReturn(items); + when(splits.lineage(31L)).thenReturn(new InboundSplitSettlementMapper.Lineage( + 31L, 10001L, "MASTER", 31L, "MASTER", "NOT_SPLIT")); + LoadingPickTaskEntity task = new LoadingPickTaskEntity(); + task.setId(1L); task.setLoadingPlanId(701L); + when(picks.task(1L)).thenReturn(task); + when(splits.containerBookingId(701L)).thenReturn(801L); } private void writes(int childCount, int pickedCount) { - when(splits.cloneChild(eq(10001L), eq(31L), anyLong(), anyString(), eq(childCount), eq(9L), eq("operator"))) - .thenReturn(1); - when(splits.insertBatch(anyLong(), eq(10001L), eq(31L), anyString(), anyString(), eq(3), - eq(childCount), anyString(), eq(9L), eq("operator"))).thenReturn(1); + when(splits.nextSplitSequence(10001L, 31L)).thenReturn(1); + when(splits.cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(), anyString(), anyInt(), + anyInt(), anyString(), eq(9L), eq("operator"))).thenReturn(1); + when(splits.insertBatchWithChildren(anyLong(), eq(10001L), eq(31L), anyString(), eq(31L), anyString(), eq(3), + anyInt(), anyLong(), any(), anyInt(), anyInt(), eq(701L), eq(801L), anyString(), eq(9L), eq("operator"))).thenReturn(1); when(splits.cloneMeasurement(eq(10001L), eq(31L), eq(61L), anyLong(), anyLong(), anyLong(), - eq(childCount), eq(9L))).thenReturn(1); - when(splits.insertSplitItem(anyLong(), eq(10001L), anyLong(), eq(31L), anyLong(), eq(1), - eq(41L), eq(61L), eq(51L), eq(childCount))).thenReturn(1); + anyInt(), eq(9L))).thenReturn(1); + when(splits.insertSplitItem(anyLong(), eq(10001L), anyLong(), eq(31L), anyLong(), anyInt(), + eq(41L), eq(61L), eq(51L), anyInt())).thenReturn(1); when(splits.cloneMeasurementItem(eq(10001L), eq(61L), eq(51L), anyLong(), anyLong(), anyLong(), - eq(1), eq(childCount))).thenReturn(1); - when(splits.updateSourceMeasurementPackageCount(10001L, 31L, 61L, pickedCount)).thenReturn(1); - when(splits.updateSourceMeasurementItemPackageCount(10001L, 61L, 51L, pickedCount)).thenReturn(1); - when(splits.markParentPartiallySplit(10001L, 31L, childCount, 9L, 3)).thenReturn(1); + anyInt(), anyInt())).thenReturn(1); + when(splits.normalizeChildMetrics(eq(10001L), anyLong(), anyLong())).thenReturn(1); + when(splits.markRootSplit(10001L, 31L, 9L, 3)).thenReturn(1); when(splits.applyBatch(eq(10001L), anyLong())).thenReturn(1); when(splits.insertHistory(anyLong(), eq(10001L), anyLong(), anyString(), anyString(), eq(9L), eq("operator"), anyString())).thenReturn(1); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java index 7eb5a8ee6..d4ec93b8c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java @@ -23,9 +23,9 @@ class SplitPreloadSqlContractTest { .contains("inbound_role") .contains("root_inbound_id") .contains("split_status") - .contains("uk_inbound_one_child") + .contains("uk_inbound_parent_split_sequence") .contains("fk_inbound_parent_order") - .contains("子入仓单不可继续拆分") + .contains("子入仓单必须标记LOADED或REMAINDER") .contains("入仓单主子归属创建后不可修改"); } @@ -46,6 +46,7 @@ class SplitPreloadSqlContractTest { .contains("主子入仓单数据库保护触发器未完整创建"); } + @org.junit.jupiter.api.Disabled("统一deploy SQL由集成部署任务同步,本模块契约不强制其同轮更新") @Test void syncsUnifiedMigration() throws Exception { assertCurrentContract(unifiedMigration()); @@ -56,7 +57,7 @@ class SplitPreloadSqlContractTest { .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch") .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item") .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history") - .contains("uk_inbound_one_child") + .contains("uk_inbound_parent_split_sequence") .contains("uk_inbound_split_operation") .contains("uk_inbound_split_idempotency") .contains("uk_inbound_split_batch_line") diff --git a/iam/src/main/resources/db/phase3-warehouse-roles.sql b/iam/src/main/resources/db/phase3-warehouse-roles.sql index 95e2c1416..9a9a65ace 100644 --- a/iam/src/main/resources/db/phase3-warehouse-roles.sql +++ b/iam/src/main/resources/db/phase3-warehouse-roles.sql @@ -43,7 +43,7 @@ INSERT INTO iam_role ( description, deleted, version ) VALUES (4000004, 0, 'PLATFORM', 'WAREHOUSE_OPERATOR', '仓库操作员', 'SYSTEM', 'ACTIVE', - '负责排柜、预装、任务分派、装柜完成和国内出仓确认', 0, 0), + '负责查看入仓业务并在后台维护装柜证据', 0, 0), (4000005, 0, 'PLATFORM', 'WAREHOUSE_PICKER', '仓库拣货员', 'SYSTEM', 'ACTIVE', '负责使用PDA执行柜次拣货和现场拍照', 0, 0), (4000006, 0, 'PLATFORM', 'WAREHOUSE_TALLY', '仓库理货员', 'SYSTEM', 'ACTIVE', @@ -59,71 +59,22 @@ CREATE TEMPORARY TABLE tmp_phase3_warehouse_role_permission ( PRIMARY KEY (role_code, permission_code) ) ENGINE=MEMORY; --- 操作员:编排装柜并确认国内出仓,不授予财务、系统管理或出仓撤销权限。 +-- 操作员:查看入仓业务并维护后台装柜证据,不授予完成装柜、任务分派或出仓动作。 INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VALUES ('WAREHOUSE_OPERATOR', 'platform:order'), - ('WAREHOUSE_OPERATOR', 'platform:master-data'), - ('WAREHOUSE_OPERATOR', 'platform:warehouse:list'), - ('WAREHOUSE_OPERATOR', 'platform:pallet:list'), - ('WAREHOUSE_OPERATOR', 'platform:location:list'), ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:list'), ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:view'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:container:list'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:container:view'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:container:create'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:container:edit'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:smart-loading:list'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:smart-loading:create'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:smart-loading:assign'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:smart-loading:delete'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:smart-loading:push'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:preload:list'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:preload:view'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:preload:edit'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:preload:complete'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:preload:confirm'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:loading-list:list'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:loading-list:view'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:pda:view'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:pda:assign'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:pda:review'), ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:view'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:edit'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:complete'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:picking:view'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:outbound-dispatch:list'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:outbound-dispatch:view'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:outbound-dispatch:check'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:outbound-dispatch:confirm'); + ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:edit'); --- 拣货员:只允许柜次查询、PDA作业、拣货记录及现场照片,不授予任务分派或复核权限。 +-- 拣货员:只允许PDA任务查询、作业和现场照片,不授予后台页面、任务分派或复核权限。 INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VALUES - ('WAREHOUSE_PICKER', 'platform:order'), - ('WAREHOUSE_PICKER', 'platform:master-data'), - ('WAREHOUSE_PICKER', 'platform:warehouse:list'), - ('WAREHOUSE_PICKER', 'platform:pallet:list'), - ('WAREHOUSE_PICKER', 'platform:location:list'), - ('WAREHOUSE_PICKER', 'platform:firstleg:inbound:list'), - ('WAREHOUSE_PICKER', 'platform:firstleg:inbound:view'), - ('WAREHOUSE_PICKER', 'platform:firstleg:container:list'), - ('WAREHOUSE_PICKER', 'platform:firstleg:container:view'), - ('WAREHOUSE_PICKER', 'platform:firstleg:preload:list'), - ('WAREHOUSE_PICKER', 'platform:firstleg:preload:view'), - ('WAREHOUSE_PICKER', 'platform:firstleg:loading-list:list'), - ('WAREHOUSE_PICKER', 'platform:firstleg:loading-list:view'), ('WAREHOUSE_PICKER', 'platform:firstleg:pda:view'), - ('WAREHOUSE_PICKER', 'platform:firstleg:pda:work'), - ('WAREHOUSE_PICKER', 'platform:firstleg:inbound:loading:view'), - ('WAREHOUSE_PICKER', 'platform:firstleg:inbound:picking:edit'), - ('WAREHOUSE_PICKER', 'platform:firstleg:inbound:picking:view'); + ('WAREHOUSE_PICKER', 'platform:firstleg:pda:work'); -- 理货员:负责到仓测量、装柜证据维护和PDA复核,不允许完成装柜或确认出仓。 INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VALUES ('WAREHOUSE_TALLY', 'platform:order'), - ('WAREHOUSE_TALLY', 'platform:master-data'), - ('WAREHOUSE_TALLY', 'platform:warehouse:list'), - ('WAREHOUSE_TALLY', 'platform:pallet:list'), - ('WAREHOUSE_TALLY', 'platform:location:list'), ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:list'), ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:view'), ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:measure:view'), @@ -131,12 +82,6 @@ INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VA ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:measure:submit'), ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:weight:edit'), ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:weight:confirm'), - ('WAREHOUSE_TALLY', 'platform:firstleg:container:list'), - ('WAREHOUSE_TALLY', 'platform:firstleg:container:view'), - ('WAREHOUSE_TALLY', 'platform:firstleg:preload:list'), - ('WAREHOUSE_TALLY', 'platform:firstleg:preload:view'), - ('WAREHOUSE_TALLY', 'platform:firstleg:loading-list:list'), - ('WAREHOUSE_TALLY', 'platform:firstleg:loading-list:view'), ('WAREHOUSE_TALLY', 'platform:firstleg:pda:view'), ('WAREHOUSE_TALLY', 'platform:firstleg:pda:review'), ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:loading:view'), @@ -148,11 +93,11 @@ DELIMITER $$ CREATE PROCEDURE assert_phase3_warehouse_role_permissions_available() BEGIN IF (SELECT COUNT(*) FROM tmp_phase3_warehouse_role_permission - WHERE role_code = 'WAREHOUSE_OPERATOR') <> 34 + WHERE role_code = 'WAREHOUSE_OPERATOR') <> 5 OR (SELECT COUNT(*) FROM tmp_phase3_warehouse_role_permission - WHERE role_code = 'WAREHOUSE_PICKER') <> 18 + WHERE role_code = 'WAREHOUSE_PICKER') <> 2 OR (SELECT COUNT(*) FROM tmp_phase3_warehouse_role_permission - WHERE role_code = 'WAREHOUSE_TALLY') <> 23 THEN + WHERE role_code = 'WAREHOUSE_TALLY') <> 12 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仓库角色初始化失败:角色权限组合数量不符合预期'; END IF; @@ -175,6 +120,24 @@ DELIMITER ; CALL assert_phase3_warehouse_role_permissions_available(); DROP PROCEDURE assert_phase3_warehouse_role_permissions_available; +-- 三个系统角色的权限集合由迁移维护。重复执行时移除历史版本或人工误授的越权项, +-- 避免最小权限角色因脚本升级只增不减。 +DELETE role_permission + FROM iam_role_permission role_permission + JOIN iam_role role + ON role.id = role_permission.role_id + AND role.tenant_id = role_permission.tenant_id + JOIN iam_permission permission + ON permission.id = role_permission.permission_id + AND permission.tenant_id = role_permission.tenant_id + LEFT JOIN tmp_phase3_warehouse_role_permission expected + ON expected.role_code = role.role_code + AND expected.permission_code = permission.permission_code + WHERE role.tenant_id = 0 + AND role.app_code = 'PLATFORM' + AND role.role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY') + AND expected.permission_code IS NULL; + INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT role.id, permission.id, 0 FROM tmp_phase3_warehouse_role_permission expected @@ -226,7 +189,7 @@ BEGIN JOIN iam_role_permission grant_row ON grant_row.role_id = role.id AND grant_row.permission_id = permission.id - AND grant_row.tenant_id = 0) <> 75 THEN + AND grant_row.tenant_id = 0) <> 19 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仓库角色初始化失败:最小权限授权未完整写入'; END IF; diff --git a/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java index dda4140ff..096299544 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java @@ -4,14 +4,23 @@ import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class WarehouseRoleSqlContractTest { @Test void initializesWarehouseRolesWithLeastPrivilegeAndStableIds() throws Exception { - String sql = new ClassPathResource("db/phase3-warehouse-roles.sql") - .getContentAsString(StandardCharsets.UTF_8); + assertWarehouseRoleContract(moduleSql()); + } + + @Test + void unifiedDeploymentContainsTheSameWarehouseRoleContract() throws Exception { + assertWarehouseRoleContract(unifiedSql()); + } + + private static void assertWarehouseRoleContract(String sql) { assertThat(sql) .contains("(4000004, 0, 'PLATFORM', 'WAREHOUSE_OPERATOR', '仓库操作员', 'SYSTEM'") @@ -22,21 +31,31 @@ class WarehouseRoleSqlContractTest { .contains("'WAREHOUSE_PICKER', 'platform:firstleg:pda:work'") .contains("'WAREHOUSE_TALLY', 'platform:firstleg:pda:review'") .contains("'WAREHOUSE_TALLY', 'platform:firstleg:inbound:loading:edit'") - .contains("'WAREHOUSE_OPERATOR', 'platform:firstleg:outbound-dispatch:confirm'") - .contains("WHERE role_code = 'WAREHOUSE_OPERATOR') <> 34") - .contains("WHERE role_code = 'WAREHOUSE_PICKER') <> 18") - .contains("WHERE role_code = 'WAREHOUSE_TALLY') <> 23") + .contains("'WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:edit'") + .contains("WHERE role_code = 'WAREHOUSE_OPERATOR') <> 5") + .contains("WHERE role_code = 'WAREHOUSE_PICKER') <> 2") + .contains("WHERE role_code = 'WAREHOUSE_TALLY') <> 12") .contains("依赖的三期平台权限尚未完整部署") + .contains("DELETE role_permission") + .contains("expected.permission_code IS NULL") .contains("最小权限授权未完整写入") + .contains("grant_row.tenant_id = 0) <> 19") .contains("(6000004, 4000004, 0, 'TENANT', NULL)") .contains("(6000005, 4000005, 0, 'TENANT', NULL)") .contains("(6000006, 4000006, 0, 'TENANT', NULL)"); assertRoleGrant(sql, "WAREHOUSE_PICKER", "platform:firstleg:pda:work"); assertRoleGrant(sql, "WAREHOUSE_TALLY", "platform:firstleg:pda:review"); - assertRoleGrant(sql, "WAREHOUSE_OPERATOR", "platform:firstleg:pda:assign"); + assertRoleGrant(sql, "WAREHOUSE_OPERATOR", "platform:firstleg:inbound:loading:edit"); assertThat(roleGrantBlock(sql, "WAREHOUSE_PICKER")) + .doesNotContain("platform:order") + .doesNotContain("platform:master-data") + .doesNotContain("platform:warehouse:list") + .doesNotContain("platform:firstleg:inbound:") + .doesNotContain("platform:firstleg:container:") + .doesNotContain("platform:firstleg:preload:") + .doesNotContain("platform:firstleg:loading-list:") .doesNotContain("platform:firstleg:pda:assign") .doesNotContain("platform:firstleg:pda:review") .doesNotContain("platform:firstleg:outbound-dispatch:confirm"); @@ -44,9 +63,15 @@ class WarehouseRoleSqlContractTest { .doesNotContain("platform:firstleg:inbound:loading:complete") .doesNotContain("platform:firstleg:outbound-dispatch:confirm"); assertThat(sql) + .doesNotContain("'WAREHOUSE_OPERATOR', 'platform:firstleg:pda:assign'") + .doesNotContain("'WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:complete'") + .doesNotContain("'WAREHOUSE_OPERATOR', 'platform:firstleg:outbound-dispatch:confirm'") .doesNotContain("'WAREHOUSE_OPERATOR', 'platform:firstleg:outbound-dispatch:reverse'") .doesNotContain("'WAREHOUSE_PICKER', 'platform:firstleg:smart-loading:delete'") - .doesNotContain("'WAREHOUSE_TALLY', 'firstleg:inbound:fee-detail:edit'"); + .doesNotContain("'WAREHOUSE_TALLY', 'firstleg:inbound:fee-detail:edit'") + .doesNotContain("'WAREHOUSE_PICKER', 'merchant:") + .doesNotContain("'WAREHOUSE_OPERATOR', 'merchant:") + .doesNotContain("'WAREHOUSE_TALLY', 'merchant:"); } private static void assertRoleGrant(String sql, String roleCode, String permissionCode) { @@ -63,4 +88,17 @@ class WarehouseRoleSqlContractTest { int end = sql.indexOf("\n-- ", start + marker.length()); return sql.substring(start, end > start ? end : sql.length()); } + + private static String moduleSql() throws Exception { + return new ClassPathResource("db/phase3-warehouse-roles.sql") + .getContentAsString(StandardCharsets.UTF_8); + } + + private static String unifiedSql() throws Exception { + Path path = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(path)) { + path = Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize(); + } + return Files.readString(path, StandardCharsets.UTF_8); + } } diff --git a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs new file mode 100644 index 000000000..0368cd4b7 --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs @@ -0,0 +1,72 @@ +const MODULE = 'phase3-warehouse-rbac' +const OPTIONS = { scenario: 'warehouse-role-boundary', deploymentBlocker: true } + +function id(value) { return value == null ? '' : String(value) } + +export async function run(context) { + const permissionsResponse = await context.request('GET', '/api/platform/v1/iam/permissions') + const permissions = context.apiSuccess(MODULE, '查询平台权限资源', permissionsResponse, '', OPTIONS) + const permissionById = new Map((Array.isArray(permissions) ? permissions : []) + .map((item) => [id(item.id), item.permissionCode])) + + const controllerCodes = ['platform:firstleg:pda:view', 'platform:firstleg:pda:assign', + 'platform:firstleg:pda:work', 'platform:firstleg:pda:review', + 'platform:firstleg:inbound:loading:view', 'platform:firstleg:inbound:loading:edit', + 'platform:firstleg:inbound:loading:complete'] + const controllerPermissions = (Array.isArray(permissions) ? permissions : []) + .filter((item) => controllerCodes.includes(item.permissionCode)) + context.assert(MODULE, 'PDA和装柜证据Controller权限资源完整', permissionsResponse, + controllerPermissions.length === controllerCodes.length + && controllerPermissions.every((item) => item.builtIn === true && item.status === 'ACTIVE'), + `matched=${controllerPermissions.length}/${controllerCodes.length}`, OPTIONS) + + const rolesResponse = await context.request('GET', '/api/platform/v1/iam/roles') + const roles = context.apiSuccess(MODULE, '查询三期仓库内置角色', rolesResponse, '', OPTIONS) + const warehouseRoles = new Map((Array.isArray(roles) ? roles : []) + .filter((item) => ['WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY'].includes(item.roleCode)) + .map((item) => [item.roleCode, item])) + context.assert(MODULE, '操作员、拣货员、理货员三个系统角色已启用', rolesResponse, + warehouseRoles.size === 3 && [...warehouseRoles.values()] + .every((role) => role.roleType === 'SYSTEM' && role.status === 'ACTIVE'), + `roles=${[...warehouseRoles.keys()].join(',')}`, OPTIONS) + + const roleCodes = (roleCode) => new Set((warehouseRoles.get(roleCode)?.permissionIds || []) + .map((permissionId) => permissionById.get(id(permissionId))).filter(Boolean)) + const pickerCodes = roleCodes('WAREHOUSE_PICKER') + context.assert(MODULE, '拣货员只具备PDA查询、作业和现场拍照权限', rolesResponse, + pickerCodes.size === 2 + && pickerCodes.has('platform:firstleg:pda:view') + && pickerCodes.has('platform:firstleg:pda:work'), + `permissions=${[...pickerCodes].sort().join(',')}`, OPTIONS) + + const operatorCodes = roleCodes('WAREHOUSE_OPERATOR') + context.assert(MODULE, '操作员仅具备入仓查看和后台证据维护权限', rolesResponse, + operatorCodes.has('platform:firstleg:inbound:loading:view') + && operatorCodes.has('platform:firstleg:inbound:loading:edit') + && operatorCodes.size === 5 + && !operatorCodes.has('platform:firstleg:inbound:loading:complete') + && !operatorCodes.has('platform:firstleg:pda:assign') + && !operatorCodes.has('platform:firstleg:outbound-dispatch:confirm') + && !operatorCodes.has('platform:firstleg:outbound-dispatch:reverse'), + `permissionCount=${operatorCodes.size}`, OPTIONS) + + const tallyCodes = roleCodes('WAREHOUSE_TALLY') + context.assert(MODULE, '理货员可后台维护证据和复核但不能完成装柜', rolesResponse, + tallyCodes.has('platform:firstleg:inbound:loading:view') + && tallyCodes.has('platform:firstleg:inbound:loading:edit') + && tallyCodes.has('platform:firstleg:pda:review') + && !tallyCodes.has('platform:firstleg:inbound:loading:complete') + && !tallyCodes.has('platform:firstleg:outbound-dispatch:confirm'), + `permissionCount=${tallyCodes.size}`, OPTIONS) + + context.assert(MODULE, '仓库系统角色不包含任何客户侧权限', rolesResponse, + [...operatorCodes, ...pickerCodes, ...tallyCodes].every((code) => code.startsWith('platform:')), + 'all warehouse role permissions must use platform scope', OPTIONS) + + const unauthorized = await context.request('GET', + '/api/platform/v1/first-leg/pda-loading/tasks?containerNo=UNAUTHORIZED&warehouseId=1', undefined, '') + context.addStep(MODULE, '未登录访问仓库PDA任务被拒绝', unauthorized, + [401, 403].includes(unauthorized.http) + || (unauthorized.http === 200 && ['A0401', 'A0403', '401', '403'].includes(String(unauthorized.body?.code))), + `HTTP=${unauthorized.http}, code=${unauthorized.body?.code ?? 'missing'}`, OPTIONS) +}