From 1e5ce54858b0ae3052ddad99268adb07ae21e8eb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 28 Aug 2026 10:38:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/AuthServiceSmallJwtTest.java | 4 +- .../OverseasSessionValidationServiceTest.java | 2 +- .../service/OverseasTokenServiceTest.java | 10 +-- deploy/sql/头程数据库统一增量-MySQL8.sql | 77 +++++++++++-------- .../mapper/LoadingCompletedEventMapper.java | 6 +- .../LoadingCompletedOutboxService.java | 6 +- .../service/LoadingCompletionWorker.java | 8 +- .../LoadingCompletedOutboxServiceTest.java | 22 ++++-- .../service/LoadingCompletionWorkerTest.java | 18 +++++ .../PdaSessionValidationFilterTest.java | 2 +- iam/AGENTS.md | 4 +- .../PlatformUserManagementService.java | 17 ++-- .../main/resources/db/lastleg-permissions.sql | 14 ++-- .../resources/db/phase3-warehouse-roles.sql | 62 +++++++++------ iam/src/main/resources/openapi/iam-v1.yaml | 6 +- ...erseasPhase4PermissionSqlContractTest.java | 10 +-- .../WarehouseRoleSqlContractTest.java | 26 ++++--- .../PlatformUserManagementServiceTest.java | 26 ++++++- lastleg-service/AGENTS.md | 2 +- .../ReceivingContainerController.java | 2 +- .../PdaPalletOperationController.java | 2 +- .../PdaInventoryMoveController.java | 2 +- .../pda/security/PdaWarehouseIdentity.java | 4 +- .../controller/PdaStocktakeController.java | 2 +- .../controller/PdaWarehouseController.java | 2 +- .../pda/controller/PdaRelabelController.java | 4 +- .../openapi/overseas-business-v1.yaml | 4 +- .../pda/ReceivingContainerContractTest.java | 4 +- ...PalletOperationControllerContractTest.java | 2 +- .../PdaInventoryOperationContractTest.java | 4 +- .../warehouse/PdaWarehouseContractTest.java | 2 +- .../controller/PdaRelabelContractTest.java | 4 +- scripts/admin-test-env-modules/README.md | 2 +- .../phase3-warehouse-rbac.mjs | 30 +++++--- ...canonical-warehouse-rbac-contract.test.mjs | 9 ++- .../phase4-overseas-pda-contract.test.mjs | 6 +- .../phase4-overseas-pda.mjs | 8 +- 37 files changed, 256 insertions(+), 159 deletions(-) diff --git a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java index 11db26bf7..4f0a36bc5 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java @@ -63,7 +63,7 @@ class AuthServiceSmallJwtTest { account.setDefaultAppCode("PLATFORM"); account.setTokenVersion(1); AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", - "WEB", Set.of("WAREHOUSE_PICKER"), Set.of("platform:order:list"), 1L, 900L); + "WEB", Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("platform:order:list"), 1L, 900L); Jwt encoded = org.mockito.Mockito.mock(Jwt.class); when(repository.findByUsername("operator")).thenReturn(account); @@ -91,7 +91,7 @@ class AuthServiceSmallJwtTest { assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256); assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull(); verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L), - org.mockito.ArgumentMatchers.eq(Set.of("WAREHOUSE_PICKER")), + org.mockito.ArgumentMatchers.eq(Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR")), org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java index b98b7b637..c076584bf 100644 --- a/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java @@ -66,7 +66,7 @@ class OverseasSessionValidationServiceTest { private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) { return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA", - Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), version, warehouseId); + Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), version, warehouseId); } private void inject(String fieldName, Object value) throws Exception { diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java index 805788918..ad4f82bf0 100644 --- a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java @@ -52,7 +52,7 @@ class OverseasTokenServiceTest { String lastlegAuthority = "lastleg:pda:operate"; String domesticAuthority = "platform:firstleg:order:list"; AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB", - Set.of("WAREHOUSE_PICKER"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L, 900L); + Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L, 900L); Jwt encoded = org.mockito.Mockito.mock(Jwt.class); when(accounts.findById(42L)).thenReturn(account); when(authorizationLoader.load(account)).thenReturn(snapshot); @@ -74,7 +74,7 @@ class OverseasTokenServiceTest { .containsEntry("permission_version", 7L) .containsEntry("token_version", 0) .containsEntry("overseas_warehouse_id", 900L) - .containsEntry("roles", Set.of("WAREHOUSE_PICKER")) + .containsEntry("roles", Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR")) .containsEntry("authorities", Set.of(authority, lastlegAuthority)) .doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion"); assertThat(response.expiresIn()).isEqualTo(900); @@ -87,9 +87,9 @@ class OverseasTokenServiceTest { account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM"); account.setTokenVersion(8); AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA", - Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 8L, 900L); + Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 8L, 900L); AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA", - Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 9L, 901L); + Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 9L, 901L); Jwt first = org.mockito.Mockito.mock(Jwt.class); Jwt second = org.mockito.Mockito.mock(Jwt.class); when(encoder.encode(any())).thenReturn(first, second); @@ -117,7 +117,7 @@ class OverseasTokenServiceTest { AuthAccount account = new AuthAccount(); account.setId(44L); account.setAccountStatus("ACTIVE"); AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA", - Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 10L, null); + Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 10L, null); Jwt encoded = org.mockito.Mockito.mock(Jwt.class); when(encoder.encode(any())).thenReturn(encoded); when(encoded.getTokenValue()).thenReturn("overseas-unbound"); diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index d1abd3ecd..067c1cf16 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -10817,11 +10817,18 @@ CALL ensure_carrier_schedule_retry_check(); DROP PROCEDURE IF EXISTS ensure_carrier_schedule_retry_check; -- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql --- 三期仓库常用系统角色:仓库操作员、仓库拣货员、仓库理货员。 +-- 三期仓库系统角色:国内仓后台、国内头程 PDA、海外仓 PDA、国内仓理货复核。 -- 本脚本只组合已经存在的权限资源,需在三期 Firstleg/IAM 权限脚本之后执行,可重复部署。 USE baiyue_iam; SET NAMES utf8mb4; +UPDATE iam_role + SET role_code = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', + role_name = '海外仓 PDA 作业员', + description = '负责使用海外仓 PDA 执行接柜、入仓和库内作业,必须绑定一个海外仓' + WHERE id = 4000005 AND tenant_id = 0 AND app_code = 'PLATFORM' + AND role_code = 'WAREHOUSE_PICKER'; + DROP PROCEDURE IF EXISTS assert_phase3_warehouse_role_ids_available; DELIMITER $$ CREATE PROCEDURE assert_phase3_warehouse_role_ids_available() @@ -10830,14 +10837,16 @@ BEGIN 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 = 4000005 AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR role_code <> 'OVERSEAS_WAREHOUSE_PDA_OPERATOR')) OR (id = 4000006 AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR role_code <> 'WAREHOUSE_TALLY')) + OR (id = 4000007 AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR role_code <> 'CN_FIRSTLEG_PDA_OPERATOR')) 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 = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' AND id <> 4000005) OR (tenant_id = 0 AND app_code = 'PLATFORM' AND role_code = 'WAREHOUSE_TALLY' AND id <> 4000006) + OR (tenant_id = 0 AND app_code = 'PLATFORM' AND role_code = 'CN_FIRSTLEG_PDA_OPERATOR' AND id <> 4000007) ) THEN SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '仓库角色初始化失败:4000004-4000006 或角色编码已被占用'; + SET MESSAGE_TEXT = '仓库角色初始化失败:4000004-4000007 或角色编码已被占用'; END IF; IF EXISTS ( SELECT 1 @@ -10845,12 +10854,14 @@ BEGIN WHERE (id = 6000004 AND role_id <> 4000004) OR (id = 6000005 AND role_id <> 4000005) OR (id = 6000006 AND role_id <> 4000006) + OR (id = 6000007 AND role_id <> 4000007) OR (role_id = 4000004 AND id <> 6000004) OR (role_id = 4000005 AND id <> 6000005) OR (role_id = 4000006 AND id <> 6000006) + OR (role_id = 4000007 AND id <> 6000007) ) THEN SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '仓库角色初始化失败:6000004-6000006 数据范围ID已被占用'; + SET MESSAGE_TEXT = '仓库角色初始化失败:6000004-6000007 数据范围ID已被占用'; END IF; END$$ DELIMITER ; @@ -10861,12 +10872,14 @@ 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', + (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) + (4000005, 0, 'PLATFORM', 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', '海外仓 PDA 作业员', 'SYSTEM', 'ACTIVE', + '负责使用海外仓 PDA 执行接柜、入仓和库内作业,必须绑定一个海外仓', 0, 0), + (4000006, 0, 'PLATFORM', 'WAREHOUSE_TALLY', '国内仓理货复核员', 'SYSTEM', 'ACTIVE', + '负责国内入库测量、PDA复核和装柜证据维护', 0, 0), + (4000007, 0, 'PLATFORM', 'CN_FIRSTLEG_PDA_OPERATOR', '国内头程 PDA 作业员', 'SYSTEM', 'ACTIVE', + '负责使用国内 PDA 执行柜次拣货、现场取证和装柜完成', 0, 0) ON DUPLICATE KEY UPDATE role_name = VALUES(role_name), role_type = 'SYSTEM', status = 'ACTIVE', description = VALUES(description), deleted = 0; @@ -10886,10 +10899,10 @@ INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VA ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:view'), ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:edit'); --- 拣货员:只允许PDA任务查询、作业和现场照片,不授予后台页面、任务分派或复核权限。 +-- 国内头程 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'); + ('CN_FIRSTLEG_PDA_OPERATOR', 'platform:firstleg:pda:view'), + ('CN_FIRSTLEG_PDA_OPERATOR', 'platform:firstleg:pda:work'); -- 理货员:负责到仓测量、装柜证据维护和PDA复核,不允许完成装柜或确认出仓。 INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VALUES @@ -10911,15 +10924,15 @@ DELIMITER $$ CREATE PROCEDURE assert_phase3_warehouse_role_permissions_available() BEGIN DECLARE operator_permission_count INT DEFAULT 0; - DECLARE picker_permission_count INT DEFAULT 0; + DECLARE domestic_pda_permission_count INT DEFAULT 0; DECLARE tally_permission_count INT DEFAULT 0; SELECT COALESCE(SUM(role_code = 'WAREHOUSE_OPERATOR'), 0), - COALESCE(SUM(role_code = 'WAREHOUSE_PICKER'), 0), + COALESCE(SUM(role_code = 'CN_FIRSTLEG_PDA_OPERATOR'), 0), COALESCE(SUM(role_code = 'WAREHOUSE_TALLY'), 0) - INTO operator_permission_count, picker_permission_count, tally_permission_count + INTO operator_permission_count, domestic_pda_permission_count, tally_permission_count FROM tmp_phase3_warehouse_role_permission; IF operator_permission_count <> 5 - OR picker_permission_count <> 2 + OR domestic_pda_permission_count <> 2 OR tally_permission_count <> 12 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仓库角色初始化失败:角色权限组合数量不符合预期'; @@ -10943,7 +10956,7 @@ 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 @@ -10958,8 +10971,8 @@ DELETE role_permission 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 NOT (role.role_code = 'WAREHOUSE_PICKER' AND permission.permission_code = 'lastleg:pda:operate') + AND role.role_code IN ('WAREHOUSE_OPERATOR', 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', 'WAREHOUSE_TALLY', 'CN_FIRSTLEG_PDA_OPERATOR') + AND NOT (role.role_code = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' AND permission.permission_code = 'lastleg:pda:operate') AND expected.permission_code IS NULL; INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) @@ -10981,7 +10994,8 @@ SELECT role.id, permission.id, 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) + (6000006, 4000006, 0, 'TENANT', NULL), + (6000007, 4000007, 0, 'TENANT', NULL) ON DUPLICATE KEY UPDATE tenant_id = 0, scope_type = 'TENANT', scope_expression = NULL; @@ -10996,7 +11010,7 @@ BEGIN AND role_type = 'SYSTEM' AND status = 'ACTIVE' AND deleted = 0 - AND role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY')) <> 3 THEN + AND role_code IN ('WAREHOUSE_OPERATOR', 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', 'WAREHOUSE_TALLY', 'CN_FIRSTLEG_PDA_OPERATOR')) <> 4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仓库角色初始化失败:系统角色未完整启用'; END IF; @@ -11022,9 +11036,9 @@ BEGIN 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 role.role_code IN ('WAREHOUSE_OPERATOR', 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', 'WAREHOUSE_TALLY', 'CN_FIRSTLEG_PDA_OPERATOR') AND scope_row.tenant_id = 0 - AND scope_row.scope_type = 'TENANT') <> 3 THEN + AND scope_row.scope_type = 'TENANT') <> 4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仓库角色初始化失败:数据范围未完整写入'; END IF; @@ -11036,7 +11050,6 @@ DROP PROCEDURE assert_phase3_warehouse_roles_ready; DROP TEMPORARY TABLE IF EXISTS tmp_phase3_warehouse_role_permission; - -- BEGIN FINANCE FEE SOURCE MODULE MIGRATIONS -- 来源:finance/src/main/resources/db/finance.sql CREATE DATABASE IF NOT EXISTS baiyue_finance @@ -16340,14 +16353,14 @@ DELETE role_permission FROM baiyue_iam.iam_role_permission role_permission JOIN baiyue_iam.iam_role role ON role.id=role_permission.role_id AND role.tenant_id=role_permission.tenant_id JOIN baiyue_iam.iam_permission permission ON permission.id=role_permission.permission_id AND permission.tenant_id=role_permission.tenant_id WHERE permission.permission_code='lastleg:pda:operate' - AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER'); + AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='OVERSEAS_WAREHOUSE_PDA_OPERATOR'); INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, role.id, permission.id, NOW(3) FROM baiyue_iam.iam_role role JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' AND permission.permission_code='lastleg:pda:operate' -WHERE role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER' +WHERE role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='OVERSEAS_WAREHOUSE_PDA_OPERATOR' AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; DROP PROCEDURE IF EXISTS baiyue_iam.assert_overseas_pda_picker_permission; @@ -16358,15 +16371,15 @@ BEGIN JOIN baiyue_iam.iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id JOIN baiyue_iam.iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id WHERE permission.permission_code='lastleg:pda:operate' - AND role.id=4000005 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER') <> 1 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:WAREHOUSE_PICKER未精确授权'; + AND role.id=4000005 AND role.app_code='PLATFORM' AND role.role_code='OVERSEAS_WAREHOUSE_PDA_OPERATOR') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:海外仓 PDA 作业员未精确授权'; END IF; IF EXISTS (SELECT 1 FROM baiyue_iam.iam_role_permission grant_row JOIN baiyue_iam.iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id JOIN baiyue_iam.iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id WHERE permission.permission_code='lastleg:pda:operate' - AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER')) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:存在非仓库拣货员授权'; + AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='OVERSEAS_WAREHOUSE_PDA_OPERATOR')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:存在非海外仓 PDA 作业员授权'; END IF; END$$ DELIMITER ; @@ -16409,7 +16422,7 @@ JOIN baiyue_iam.iam_permission permission ON permission.id=grant_row.permission_ LEFT JOIN tmp_phase4_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 role.role_code IN ('WAREHOUSE_OPERATOR','OVERSEAS_WAREHOUSE_PDA_OPERATOR','WAREHOUSE_TALLY','CN_FIRSTLEG_PDA_OPERATOR') AND permission.permission_code LIKE 'platform:overseas-%' AND expected.permission_code IS NULL; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index e0cab29ac..8760d0ce1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -16,7 +16,7 @@ public interface LoadingCompletedEventMapper { @Select(""" SELECT p.id AS loading_plan_id, p.plan_no, p.container_booking_id, p.version AS aggregate_version, b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name, - b.customer_id AS booking_customer_id, b.customer_name, + i.customer_name, b.estimated_port_arrival_at, b.actual_port_arrival_at, b.estimated_warehouse_arrival_at, i.account_id, i.inbound_id AS source_inbound_id, @@ -84,8 +84,8 @@ public interface LoadingCompletedEventMapper { class Row { private Long loadingPlanId; private String planNo; private Long containerBookingId; private Integer aggregateVersion; private String containerNo; private String cabinetNo; - private Long warehouseId; private String warehouseName; private Long bookingCustomerId; - private String customerName; private Long accountId; + private Long warehouseId; private String warehouseName; private String customerName; + private Long accountId; private LocalDateTime estimatedPortArrivalAt; private LocalDateTime actualPortArrivalAt; private LocalDateTime estimatedWarehouseArrivalAt; private Long sourceInboundId; private Long rootInboundId; private Long sourceOrderId; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 6dd1b3330..96083caea 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -120,11 +120,11 @@ public class LoadingCompletedOutboxService { private void validateCustomerSnapshots(List rows) { for (Row row : rows) { - if (row.getAccountId() == null || !Objects.equals(row.getAccountId(), row.getBookingCustomerId())) { - throw new BizException(ResultCode.CONFLICT, "装柜计划客户与柜次客户快照不一致,不能生成尾程交接事件"); + if (row.getAccountId() == null || row.getAccountId() <= 0) { + throw new BizException(ResultCode.CONFLICT, "装柜明细缺少有效客户标识,不能生成尾程交接事件"); } if (row.getCustomerName() == null || row.getCustomerName().isBlank()) { - throw new BizException(ResultCode.CONFLICT, "柜次缺少客户名称快照,不能生成尾程交接事件"); + throw new BizException(ResultCode.CONFLICT, "装柜明细缺少客户名称快照,不能生成尾程交接事件"); } } rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId)).forEach((accountId, values) -> { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java index 412ebbfcc..39659b1ef 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java @@ -50,7 +50,7 @@ public class LoadingCompletionWorker { String traceId = cause instanceof PdaPickSettlementService.SettlementFailure remote ? remote.traceId() : null; int attempt = command.getAttemptCount() == null ? 1 : command.getAttemptCount() + 1; - boolean terminal = attempt >= MAX_ATTEMPTS; + boolean terminal = isDeterministicLocalBusinessFailure(stage, cause) || attempt >= MAX_ATTEMPTS; LocalDateTime retryAt = LocalDateTime.now().plusSeconds(Math.min(300L, 15L << Math.min(attempt - 1, 4))); boolean recorded = commands.fail(command.getId(), workerId, terminal ? "FAILED" : "RETRYABLE", retryAt, stage, code, requestId, traceId); @@ -61,4 +61,10 @@ public class LoadingCompletionWorker { + "requestId={} traceId={} attempt={} terminal={}", command.getId(), command.getPickTaskId(), command.getOperationId(), stage, code, requestId, traceId, attempt, terminal, cause); } + + private static boolean isDeterministicLocalBusinessFailure(String stage, Throwable cause) { + return cause instanceof BizException && java.util.Set.of( + "OWNER_DISCOVERY", "OWNER_READY", "PLAN_GATE", "TASK_REVIEW", + "PLAN_COMPLETE", "COMPLETED_EVENT").contains(stage); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 8128fcda0..2fe47c380 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -85,7 +85,7 @@ class LoadingCompletedOutboxServiceTest { .hasMessageContaining("缺少 SKU"); } - @Test void rejectsMissingTrustedBookingCustomerName() { + @Test void rejectsMissingTrustedPlanItemCustomerName() { Row value = row(11, 100, 101); value.setCustomerName(" "); when(snapshots.rows(90L)).thenReturn(List.of(value)); @@ -93,12 +93,19 @@ class LoadingCompletedOutboxServiceTest { assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("缺少客户名称快照"); } - @Test void rejectsBookingCustomerIdThatDoesNotMatchAccountEvent() { + @Test void publishesFrozenPlanItemCustomerSnapshotWithoutDependingOnBookingOwner() throws Exception { Row value = row(11, 100, 101); - value.setBookingCustomerId(22L); + value.setCustomerName("南玻万"); + value.setDestinationType("FBA"); when(snapshots.rows(90L)).thenReturn(List.of(value)); - assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("客户与柜次客户快照不一致"); + service.publish(90L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox).insert(captor.capture()); + EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class); + assertThat(event.tenantId()).isEqualTo("11"); + assertThat(event.payload().at("/orders/0/customerName").asText()).isEqualTo("南玻万"); } @Test void fbaFreezesPackageCountWithoutRequiringCartonLabels() throws Exception { @@ -209,8 +216,9 @@ class LoadingCompletedOutboxServiceTest { assertThat(rowsSql).contains("p.plan_status = 'COMPLETED'") .contains("o.process_status = 'LOADED'") - .contains("b.customer_id AS booking_customer_id") - .contains("b.customer_name") + .contains("i.customer_name") + .doesNotContain("b.customer_id AS booking_customer_id") + .doesNotContain("b.customer_name") .contains("b.estimated_port_arrival_at", "b.actual_port_arrival_at") .contains("b.estimated_warehouse_arrival_at"); for (String sql : List.of(rowsSql, palletsSql)) { @@ -288,7 +296,7 @@ class LoadingCompletedOutboxServiceTest { private Row row(long account,long inbound,long detail) { Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L); r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L); - r.setWarehouseName("WH"); r.setBookingCustomerId(account); r.setCustomerName("客户-" + account); + r.setWarehouseName("WH"); r.setCustomerName("客户-" + account); r.setEstimatedPortArrivalAt(java.time.LocalDateTime.of(2026, 9, 30, 12, 0)); r.setActualPortArrivalAt(java.time.LocalDateTime.of(2026, 10, 1, 8, 30)); r.setEstimatedWarehouseArrivalAt(java.time.LocalDateTime.of(2026, 10, 3, 9, 0)); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java index 230dd7c41..519c17fc1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java @@ -63,6 +63,24 @@ class LoadingCompletionWorkerTest { verify(picks).failCompletion(20L); } + @Test + void completedEventBusinessFailureIsTerminalOnFirstAttempt() { + LoadingCompletionCommandEntity command = command(0); + when(commands.due(10)).thenReturn(List.of(command)); + when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true); + doThrow(new LoadingPickService.CompletionProcessingException("COMPLETED_EVENT", + new BizException(ResultCode.CONFLICT, "customer snapshot conflict"))) + .when(loadingPicks).processCompletion(eq(command), any(String.class)); + when(commands.fail(eq(900L), any(String.class), eq("FAILED"), any(LocalDateTime.class), + eq("COMPLETED_EVENT"), eq(ResultCode.CONFLICT.code()), eq(null), eq(null))).thenReturn(true); + + worker.dispatch(); + + verify(commands).fail(eq(900L), any(String.class), eq("FAILED"), any(LocalDateTime.class), + eq("COMPLETED_EVENT"), eq(ResultCode.CONFLICT.code()), eq(null), eq(null)); + verify(picks).failCompletion(20L); + } + private static LoadingCompletionCommandEntity command(int attemptCount) { LoadingCompletionCommandEntity command = new LoadingCompletionCommandEntity(); command.setId(900L); diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java index ae9d3aa24..73155dbd8 100644 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java @@ -101,7 +101,7 @@ class PdaSessionValidationFilterTest { MockServerHttpRequest.get(path).build()); return exchange.mutate() .principal(Mono.just(new JwtAuthenticationToken(jwt.build(), - List.of(new SimpleGrantedAuthority("ROLE_WAREHOUSE_PICKER"))))) + List.of(new SimpleGrantedAuthority("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"))))) .build(); } diff --git a/iam/AGENTS.md b/iam/AGENTS.md index be2a0b5e5..67500993c 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -14,7 +14,7 @@ | 客户资料/审核 | `controller/PlatformTenantController.java`、`service/TenantService.java` | | 客户默认尺寸初始化、重试与补偿 | `service/CustomerDimensionRuleInitializationService.java`、`CustomerDimensionInitializationRelay.java`、`repository/CustomerDimensionInitializationRepository.java`、`client/CustomerDimensionRuleClient.java`、`model/CustomerDimensionInitializationTask.java`;持久任务异步调用 Firstleg `/internal/firstleg/v1/customer-dimension-configs/defaults` | | 商户成员与个人资料 | `controller/MerchantTenantController.java`、`service/MemberService.java`、`service/PersonalProfileService.java` | -| 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`WAREHOUSE_PICKER` 必须且只能绑定一个 `overseasWarehouseId` | +| 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 必须且只能绑定一个 `overseasWarehouseId` | | 角色/权限/RBAC | `controller/RbacController.java`、`service/RbacService.java` | | 授权快照/内部资格 | `controller/AuthorizationController.java`、`InternalAuthorizationController.java`、`service/AuthorizationSnapshotService.java` | | 海外安全状态事件 Outbox | `service/SecurityEventOutboxService.java`、`SecurityEventOutboxRelay.java`、`client/SecurityEventBridgeClient.java`;账号、授权或客户状态变更经版本化事件投影到 `../lastleg-service/security/` | @@ -30,7 +30,7 @@ - 成员/账号跨 IAM/Auth 保持幂等补偿;资质跨 IAM/File 不保存文件内容。 - 客户建档只在 IAM 事务内落持久初始化意图,远端投递由带租约、退避和最大次数的 Relay 完成;只有状态为 `READY` 才能审核通过。注册补偿必须投递 `COMPENSATE`,不得在建档事务中同步依赖 Firstleg。 - 权限资源 code/type 不可变;按钮隐藏不能替代后端权限校验。 -- 只有平台 `WAREHOUSE_PICKER` 可保存 `overseasWarehouseId`,且必须为单个有效海外仓;非拣货员必须为空。仓绑定、账号状态、角色权限或客户状态变化要在同一业务事务内推进安全事件 Outbox,不得依赖海外服务回查国内 IAM。 +- 国内头程 PDA 使用 `CN_FIRSTLEG_PDA_OPERATOR`,海外仓 PDA 使用 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`;两个角色互斥且权限不得交叉。只有海外角色可保存 `overseasWarehouseId`,且必须为单个有效海外仓;国内角色及其他角色必须为空。仓绑定、账号状态、角色权限或客户状态变化要在同一业务事务内推进安全事件 Outbox,不得依赖海外服务回查国内 IAM。 - `IamTenantStatusChanged` 是安全可用性快照,不暴露客户建档/审核流程状态:仅 IAM 业务状态 `ACTIVE` 发布 `ACTIVE`,`PROFILE_REQUIRED`、`PENDING`、`REJECTED`、`DISABLED`、`ARCHIVED` 及其他不可用状态统一发布 `DISABLED`。 ## 全量验收导航 diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java index b7e719747..812c131a9 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -254,13 +254,18 @@ public class PlatformUserManagementService { } private Long validateWarehouseBinding(List roleIds, Long overseasWarehouseId) { - boolean picker = roleIds.stream().map(roleMapper::selectById).filter(Objects::nonNull) - .anyMatch(role -> "WAREHOUSE_PICKER".equals(role.getRoleCode())); - if (picker && (overseasWarehouseId == null || overseasWarehouseId <= 0)) { - throw new BizException(ResultCode.BAD_REQUEST, "海外仓拣货员必须绑定一个有效海外仓"); + Set roleCodes = roleIds.stream().map(roleMapper::selectById).filter(Objects::nonNull) + .map(IamRole::getRoleCode).collect(Collectors.toSet()); + boolean overseasPda = roleCodes.contains("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); + boolean domesticPda = roleCodes.contains("CN_FIRSTLEG_PDA_OPERATOR"); + if (overseasPda && domesticPda) { + throw new BizException(ResultCode.BAD_REQUEST, "国内头程 PDA 与海外仓 PDA 角色不能同时分配"); } - if (!picker && overseasWarehouseId != null) { - throw new BizException(ResultCode.BAD_REQUEST, "仅海外仓拣货员可绑定海外仓"); + if (overseasPda && (overseasWarehouseId == null || overseasWarehouseId <= 0)) { + throw new BizException(ResultCode.BAD_REQUEST, "海外仓 PDA 作业员必须绑定一个有效海外仓"); + } + if (!overseasPda && overseasWarehouseId != null) { + throw new BizException(ResultCode.BAD_REQUEST, "仅海外仓 PDA 作业员可绑定海外仓"); } return overseasWarehouseId; } diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index dc8e69d70..77c2b9c9c 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -80,14 +80,14 @@ 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 WHERE permission.permission_code='lastleg:pda:operate' - AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER'); + AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='OVERSEAS_WAREHOUSE_PDA_OPERATOR'); INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, role.id, permission.id, NOW(3) FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' AND permission.permission_code='lastleg:pda:operate' -WHERE role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER' +WHERE role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='OVERSEAS_WAREHOUSE_PDA_OPERATOR' AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; DROP PROCEDURE IF EXISTS assert_overseas_pda_picker_permission; @@ -98,15 +98,15 @@ BEGIN JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id JOIN iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id WHERE permission.permission_code='lastleg:pda:operate' - AND role.id=4000005 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER') <> 1 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:WAREHOUSE_PICKER未精确授权'; + AND role.id=4000005 AND role.app_code='PLATFORM' AND role.role_code='OVERSEAS_WAREHOUSE_PDA_OPERATOR') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:海外仓 PDA 作业员未精确授权'; END IF; IF EXISTS (SELECT 1 FROM iam_role_permission grant_row JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id JOIN iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id WHERE permission.permission_code='lastleg:pda:operate' - AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER')) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:存在非仓库拣货员授权'; + AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='OVERSEAS_WAREHOUSE_PDA_OPERATOR')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:存在非海外仓 PDA 作业员授权'; END IF; END$$ DELIMITER ; @@ -148,7 +148,7 @@ JOIN iam_permission permission ON permission.id=grant_row.permission_id AND perm LEFT JOIN tmp_phase4_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 role.role_code IN ('WAREHOUSE_OPERATOR','OVERSEAS_WAREHOUSE_PDA_OPERATOR','WAREHOUSE_TALLY','CN_FIRSTLEG_PDA_OPERATOR') AND permission.permission_code LIKE 'platform:overseas-%' AND expected.permission_code IS NULL; diff --git a/iam/src/main/resources/db/phase3-warehouse-roles.sql b/iam/src/main/resources/db/phase3-warehouse-roles.sql index 5f39d36cf..63424653a 100644 --- a/iam/src/main/resources/db/phase3-warehouse-roles.sql +++ b/iam/src/main/resources/db/phase3-warehouse-roles.sql @@ -1,8 +1,15 @@ --- 三期仓库常用系统角色:仓库操作员、仓库拣货员、仓库理货员。 +-- 三期仓库系统角色:国内仓后台、国内头程 PDA、海外仓 PDA、国内仓理货复核。 -- 本脚本只组合已经存在的权限资源,需在三期 Firstleg/IAM 权限脚本之后执行,可重复部署。 USE baiyue_iam; SET NAMES utf8mb4; +UPDATE iam_role + SET role_code = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', + role_name = '海外仓 PDA 作业员', + description = '负责使用海外仓 PDA 执行接柜、入仓和库内作业,必须绑定一个海外仓' + WHERE id = 4000005 AND tenant_id = 0 AND app_code = 'PLATFORM' + AND role_code = 'WAREHOUSE_PICKER'; + DROP PROCEDURE IF EXISTS assert_phase3_warehouse_role_ids_available; DELIMITER $$ CREATE PROCEDURE assert_phase3_warehouse_role_ids_available() @@ -11,14 +18,16 @@ BEGIN 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 = 4000005 AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR role_code <> 'OVERSEAS_WAREHOUSE_PDA_OPERATOR')) OR (id = 4000006 AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR role_code <> 'WAREHOUSE_TALLY')) + OR (id = 4000007 AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR role_code <> 'CN_FIRSTLEG_PDA_OPERATOR')) 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 = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' AND id <> 4000005) OR (tenant_id = 0 AND app_code = 'PLATFORM' AND role_code = 'WAREHOUSE_TALLY' AND id <> 4000006) + OR (tenant_id = 0 AND app_code = 'PLATFORM' AND role_code = 'CN_FIRSTLEG_PDA_OPERATOR' AND id <> 4000007) ) THEN SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '仓库角色初始化失败:4000004-4000006 或角色编码已被占用'; + SET MESSAGE_TEXT = '仓库角色初始化失败:4000004-4000007 或角色编码已被占用'; END IF; IF EXISTS ( SELECT 1 @@ -26,12 +35,14 @@ BEGIN WHERE (id = 6000004 AND role_id <> 4000004) OR (id = 6000005 AND role_id <> 4000005) OR (id = 6000006 AND role_id <> 4000006) + OR (id = 6000007 AND role_id <> 4000007) OR (role_id = 4000004 AND id <> 6000004) OR (role_id = 4000005 AND id <> 6000005) OR (role_id = 4000006 AND id <> 6000006) + OR (role_id = 4000007 AND id <> 6000007) ) THEN SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '仓库角色初始化失败:6000004-6000006 数据范围ID已被占用'; + SET MESSAGE_TEXT = '仓库角色初始化失败:6000004-6000007 数据范围ID已被占用'; END IF; END$$ DELIMITER ; @@ -42,12 +53,14 @@ 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', + (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) + (4000005, 0, 'PLATFORM', 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', '海外仓 PDA 作业员', 'SYSTEM', 'ACTIVE', + '负责使用海外仓 PDA 执行接柜、入仓和库内作业,必须绑定一个海外仓', 0, 0), + (4000006, 0, 'PLATFORM', 'WAREHOUSE_TALLY', '国内仓理货复核员', 'SYSTEM', 'ACTIVE', + '负责国内入库测量、PDA复核和装柜证据维护', 0, 0), + (4000007, 0, 'PLATFORM', 'CN_FIRSTLEG_PDA_OPERATOR', '国内头程 PDA 作业员', 'SYSTEM', 'ACTIVE', + '负责使用国内 PDA 执行柜次拣货、现场取证和装柜完成', 0, 0) ON DUPLICATE KEY UPDATE role_name = VALUES(role_name), role_type = 'SYSTEM', status = 'ACTIVE', description = VALUES(description), deleted = 0; @@ -67,10 +80,10 @@ INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VA ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:view'), ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:edit'); --- 拣货员:只允许PDA任务查询、作业和现场照片,不授予后台页面、任务分派或复核权限。 +-- 国内头程 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'); + ('CN_FIRSTLEG_PDA_OPERATOR', 'platform:firstleg:pda:view'), + ('CN_FIRSTLEG_PDA_OPERATOR', 'platform:firstleg:pda:work'); -- 理货员:负责到仓测量、装柜证据维护和PDA复核,不允许完成装柜或确认出仓。 INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VALUES @@ -92,15 +105,15 @@ DELIMITER $$ CREATE PROCEDURE assert_phase3_warehouse_role_permissions_available() BEGIN DECLARE operator_permission_count INT DEFAULT 0; - DECLARE picker_permission_count INT DEFAULT 0; + DECLARE domestic_pda_permission_count INT DEFAULT 0; DECLARE tally_permission_count INT DEFAULT 0; SELECT COALESCE(SUM(role_code = 'WAREHOUSE_OPERATOR'), 0), - COALESCE(SUM(role_code = 'WAREHOUSE_PICKER'), 0), + COALESCE(SUM(role_code = 'CN_FIRSTLEG_PDA_OPERATOR'), 0), COALESCE(SUM(role_code = 'WAREHOUSE_TALLY'), 0) - INTO operator_permission_count, picker_permission_count, tally_permission_count + INTO operator_permission_count, domestic_pda_permission_count, tally_permission_count FROM tmp_phase3_warehouse_role_permission; IF operator_permission_count <> 5 - OR picker_permission_count <> 2 + OR domestic_pda_permission_count <> 2 OR tally_permission_count <> 12 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仓库角色初始化失败:角色权限组合数量不符合预期'; @@ -124,7 +137,7 @@ 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 @@ -139,8 +152,8 @@ DELETE role_permission 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 NOT (role.role_code = 'WAREHOUSE_PICKER' AND permission.permission_code = 'lastleg:pda:operate') + AND role.role_code IN ('WAREHOUSE_OPERATOR', 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', 'WAREHOUSE_TALLY', 'CN_FIRSTLEG_PDA_OPERATOR') + AND NOT (role.role_code = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' AND permission.permission_code = 'lastleg:pda:operate') AND expected.permission_code IS NULL; INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) @@ -162,7 +175,8 @@ SELECT role.id, permission.id, 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) + (6000006, 4000006, 0, 'TENANT', NULL), + (6000007, 4000007, 0, 'TENANT', NULL) ON DUPLICATE KEY UPDATE tenant_id = 0, scope_type = 'TENANT', scope_expression = NULL; @@ -177,7 +191,7 @@ BEGIN AND role_type = 'SYSTEM' AND status = 'ACTIVE' AND deleted = 0 - AND role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY')) <> 3 THEN + AND role_code IN ('WAREHOUSE_OPERATOR', 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', 'WAREHOUSE_TALLY', 'CN_FIRSTLEG_PDA_OPERATOR')) <> 4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仓库角色初始化失败:系统角色未完整启用'; END IF; @@ -203,9 +217,9 @@ BEGIN 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 role.role_code IN ('WAREHOUSE_OPERATOR', 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', 'WAREHOUSE_TALLY', 'CN_FIRSTLEG_PDA_OPERATOR') AND scope_row.tenant_id = 0 - AND scope_row.scope_type = 'TENANT') <> 3 THEN + AND scope_row.scope_type = 'TENANT') <> 4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仓库角色初始化失败:数据范围未完整写入'; END IF; diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index a3d146541..fa52a8d25 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -645,7 +645,7 @@ paths: get: tags: [RBAC] summary: List roles / 查询角色 - description: List roles in the selected application and current customer or platform technical scope. The platform scope includes the built-in WAREHOUSE_OPERATOR, WAREHOUSE_PICKER and WAREHOUSE_TALLY roles after the phase-three role migration. / 查询指定应用及当前客户范围或平台技术作用域内的角色;三期角色迁移后,平台作用域包含仓库操作员、仓库拣货员和仓库理货员三个内置角色。 + description: List roles in the selected application and current customer or platform technical scope. Domestic first-leg PDA and overseas warehouse PDA operators are separate, mutually exclusive roles. / 查询指定应用及当前客户范围或平台技术作用域内的角色;国内头程 PDA 作业员与海外仓 PDA 作业员是两批独立人员,对应角色互斥且权限不交叉。 responses: '200': {$ref: '#/components/responses/Success'} '403': {$ref: '#/components/responses/Forbidden'} @@ -1323,7 +1323,7 @@ components: email: {type: string, format: email, maxLength: 128} tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0} roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} - overseasWarehouseId: {type: integer, format: int64, nullable: true, description: 选择WAREHOUSE_PICKER角色时必填且只能绑定一个实际海外仓;其他角色必须为空} + overseasWarehouseId: {type: integer, format: int64, nullable: true, description: 选择 OVERSEAS_WAREHOUSE_PDA_OPERATOR 时必填且只能绑定一个实际海外仓;CN_FIRSTLEG_PDA_OPERATOR 与其他角色必须为空;两个 PDA 角色不能同时分配} password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true} status: {type: string, enum: [ACTIVE, DISABLED]} UpdatePlatformManagedUserRequest: @@ -1335,7 +1335,7 @@ components: email: {type: string, format: email, maxLength: 128} tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0} roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} - overseasWarehouseId: {type: integer, format: int64, nullable: true, description: 选择WAREHOUSE_PICKER角色时必填且只能绑定一个实际海外仓;其他角色必须为空} + overseasWarehouseId: {type: integer, format: int64, nullable: true, description: 选择 OVERSEAS_WAREHOUSE_PDA_OPERATOR 时必填且只能绑定一个实际海外仓;CN_FIRSTLEG_PDA_OPERATOR 与其他角色必须为空;两个 PDA 角色不能同时分配} CreateInternalUserRequest: type: object required: [displayName] diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index 2182c43f1..e10060315 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -82,10 +82,10 @@ class OverseasPhase4PermissionSqlContractTest { "platform:overseas-business:purchase:list", "platform:overseas-business:truck:list", "platform:overseas-business:looseboard:list"); - assertThat(matrix).doesNotContain("WAREHOUSE_PICKER"); + assertThat(matrix).doesNotContain("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); assertThat(matrix).doesNotContain(":create'", ":edit'", ":status'", ":export'", ":adjust'"); assertThat(sql).contains("assert_phase4_warehouse_role_minimum", "<> 20", - "role.role_code IN ('WAREHOUSE_OPERATOR','WAREHOUSE_PICKER','WAREHOUSE_TALLY')"); + "role.role_code IN ('WAREHOUSE_OPERATOR','OVERSEAS_WAREHOUSE_PDA_OPERATOR','WAREHOUSE_TALLY','CN_FIRSTLEG_PDA_OPERATOR')"); } } @@ -125,12 +125,12 @@ class OverseasPhase4PermissionSqlContractTest { "permission.permission_code<>'lastleg:pda:operate'", "role.id=4000005", "assert_overseas_pda_picker_permission", - "存在非仓库拣货员授权", - "role.role_code='WAREHOUSE_PICKER'", + "存在非海外仓 PDA 作业员授权", + "role.role_code='OVERSEAS_WAREHOUSE_PDA_OPERATOR'", "permission.permission_code='lastleg:pda:operate'", "role.role_code='PLATFORM_ADMIN'", "DELETE role_permission FROM"); - int picker = sql.indexOf("role.role_code='WAREHOUSE_PICKER'"); + int picker = sql.indexOf("role.role_code='OVERSEAS_WAREHOUSE_PDA_OPERATOR'"); int grantStart = sql.lastIndexOf("INSERT IGNORE INTO", picker); int grantEnd = sql.indexOf(';', picker); assertThat(grantStart).isGreaterThanOrEqualTo(0); 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 36786af3e..1a4aaeb7d 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java @@ -30,33 +30,35 @@ class WarehouseRoleSqlContractTest { private static void assertWarehouseRoleContract(String sql) { assertThat(sql) - .contains("(4000004, 0, 'PLATFORM', 'WAREHOUSE_OPERATOR', '仓库操作员', 'SYSTEM'") - .contains("(4000005, 0, 'PLATFORM', 'WAREHOUSE_PICKER', '仓库拣货员', 'SYSTEM'") - .contains("(4000006, 0, 'PLATFORM', 'WAREHOUSE_TALLY', '仓库理货员', 'SYSTEM'") + .contains("(4000004, 0, 'PLATFORM', 'WAREHOUSE_OPERATOR', '国内仓后台操作员', 'SYSTEM'") + .contains("(4000005, 0, 'PLATFORM', 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', '海外仓 PDA 作业员', 'SYSTEM'") + .contains("(4000006, 0, 'PLATFORM', 'WAREHOUSE_TALLY', '国内仓理货复核员', 'SYSTEM'") + .contains("(4000007, 0, 'PLATFORM', 'CN_FIRSTLEG_PDA_OPERATOR', '国内头程 PDA 作业员', 'SYSTEM'") .contains("ON DUPLICATE KEY UPDATE") .contains("INSERT IGNORE INTO iam_role_permission") - .contains("'WAREHOUSE_PICKER', 'platform:firstleg:pda:work'") + .contains("'CN_FIRSTLEG_PDA_OPERATOR', 'platform:firstleg:pda:work'") .contains("'WAREHOUSE_TALLY', 'platform:firstleg:pda:review'") .contains("'WAREHOUSE_TALLY', 'platform:firstleg:inbound:loading:edit'") .contains("'WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:edit'") .contains("operator_permission_count <> 5") - .contains("picker_permission_count <> 2") + .contains("domestic_pda_permission_count <> 2") .contains("tally_permission_count <> 12") .contains("依赖的三期平台权限尚未完整部署") .contains("DELETE role_permission") .contains("expected.permission_code IS NULL") - .contains("role.role_code = 'WAREHOUSE_PICKER' AND permission.permission_code = 'lastleg:pda:operate'") + .contains("role.role_code = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' AND permission.permission_code = 'lastleg:pda:operate'") .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)"); + .contains("(6000006, 4000006, 0, 'TENANT', NULL)") + .contains("(6000007, 4000007, 0, 'TENANT', NULL)"); - assertRoleGrant(sql, "WAREHOUSE_PICKER", "platform:firstleg:pda:work"); + assertRoleGrant(sql, "CN_FIRSTLEG_PDA_OPERATOR", "platform:firstleg:pda:work"); assertRoleGrant(sql, "WAREHOUSE_TALLY", "platform:firstleg:pda:review"); assertRoleGrant(sql, "WAREHOUSE_OPERATOR", "platform:firstleg:inbound:loading:edit"); - assertThat(roleGrantBlock(sql, "WAREHOUSE_PICKER")) + assertThat(roleGrantBlock(sql, "CN_FIRSTLEG_PDA_OPERATOR")) .doesNotContain("platform:order") .doesNotContain("platform:master-data") .doesNotContain("platform:warehouse:list") @@ -76,9 +78,9 @@ class WarehouseRoleSqlContractTest { .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("'CN_FIRSTLEG_PDA_OPERATOR', 'platform:firstleg:smart-loading:delete'") .doesNotContain("'WAREHOUSE_TALLY', 'firstleg:inbound:fee-detail:edit'") - .doesNotContain("'WAREHOUSE_PICKER', 'merchant:") + .doesNotContain("'CN_FIRSTLEG_PDA_OPERATOR', 'merchant:") .doesNotContain("'WAREHOUSE_OPERATOR', 'merchant:") .doesNotContain("'WAREHOUSE_TALLY', 'merchant:"); } @@ -89,7 +91,7 @@ class WarehouseRoleSqlContractTest { private static String roleGrantBlock(String sql, String roleCode) { String marker = "-- " + switch (roleCode) { - case "WAREHOUSE_PICKER" -> "拣货员:"; + case "CN_FIRSTLEG_PDA_OPERATOR" -> "国内头程 PDA 作业员:"; case "WAREHOUSE_TALLY" -> "理货员:"; default -> throw new IllegalArgumentException("unsupported role " + roleCode); }; diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java index fb4949914..292703777 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -146,7 +146,7 @@ class PlatformUserManagementServiceTest { @Test void warehousePickerRequiresAndPersistsSingleOverseasWarehouseBinding() { - platformRole.setRoleCode("WAREHOUSE_PICKER"); + platformRole.setRoleCode("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); UpdateUserRequest missing = new UpdateUserRequest("张伟", "13800138000", null, 0L, List.of(40001L), null); assertThrows(BizException.class, @@ -161,9 +161,31 @@ class PlatformUserManagementServiceTest { verify(auth).revokeSessions(eq("internal-token"), any()); } + @Test + void domesticAndOverseasPdaRolesAreMutuallyExclusive() { + IamRole domestic = new IamRole(); + domestic.setId(40007L); domestic.setTenantId(0L); domestic.setAppCode("PLATFORM"); + domestic.setRoleCode("CN_FIRSTLEG_PDA_OPERATOR"); domestic.setRoleName("国内头程 PDA 作业员"); + domestic.setStatus("ACTIVE"); + IamRole overseas = new IamRole(); + overseas.setId(40005L); overseas.setTenantId(0L); overseas.setAppCode("PLATFORM"); + overseas.setRoleCode("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); overseas.setRoleName("海外仓 PDA 作业员"); + overseas.setStatus("ACTIVE"); + when(roles.selectById(40007L)).thenReturn(domestic); + when(roles.selectById(40005L)).thenReturn(overseas); + + UpdateUserRequest request = new UpdateUserRequest("张伟", "13800138000", null, + 0L, List.of(40007L, 40005L), 900L); + + BizException error = assertThrows(BizException.class, + () -> service.update(30L, request, Set.of("platform:user:grant-role"))); + assertTrue(error.getMessage().contains("不能同时分配")); + verify(auth, never()).scope(anyString(), anyLong(), any()); + } + @Test void changingWarehouseBindingRevokesAllAuthSessionsSoAccountVersionIsBumped() { - platformRole.setRoleCode("WAREHOUSE_PICKER"); + platformRole.setRoleCode("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); membership.setOverseasWarehouseId(900L); UpdateUserRequest changed = new UpdateUserRequest("张伟", "13800138000", null, 0L, List.of(40001L), 901L); diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index df6034517..4cdc99bc5 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -83,7 +83,7 @@ - Lastleg 安全投影只接受 `ACTIVE`/`DISABLED` 两态安全语义;客户建档和审核的 `PROFILE_REQUIRED`/`PENDING`/`REJECTED`/`ARCHIVED` 由 IAM 在发布前统一折叠为 `DISABLED`,Lastleg 不兼容或解释国内工作流状态。 - 开发基线只为国内固定 `authUserId=1000001`、`tenantId=0` 的 `platform_admin` 写 USER/AUTHORIZATION version=0 投影;使用 `INSERT IGNORE`,后续 IAM version>=1 事件单调覆盖。不得扩大为历史账号运行时补齐或缺失投影默认放行。 - PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。 -- 海外 PDA 柜号签收只允许平台 `WAREHOUSE_PICKER` 使用 `lastleg:pda:operate`;PDA 不接收 `customerId`,候选跨客户读取,选柜时从柜内计划唯一解析并冻结真实客户。未完成会话按平台操作账号和柜号分别全局唯一,后续照片、扫码、实收、库存与完成操作只使用会话客户范围;平台管理员和客户账号不得进入该作业入口。 +- 海外 PDA 柜号签收只允许平台 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 使用 `lastleg:pda:operate`;国内头程 PDA 角色不得进入。PDA 不接收 `customerId`,候选跨客户读取,选柜时从柜内计划唯一解析并冻结真实客户。未完成会话按平台操作账号和柜号分别全局唯一,后续照片、扫码、实收、库存与完成操作只使用会话客户范围;平台管理员和客户账号不得进入该作业入口。 ## 查询与租户约束 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java index f5aa7e7d8..2cb0fa4c2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -34,7 +34,7 @@ import java.util.List; @RestController @RequestMapping("/api/pda/v1/last-leg/receiving-containers") @Tag(name = "Overseas PDA container receiving") -@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") public class ReceivingContainerController { public static final String OPERATE_PERMISSION = PdaWarehouseIdentity.OPERATE_PERMISSION; @Resource private ReceivingContainerService service; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java index def4fce41..22d51660f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java @@ -17,7 +17,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/pda/v1/last-leg/pallet-operations") -@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") public class PdaPalletOperationController { @Resource private PdaPalletOperationService service; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java index 6bfdf88b7..3d9c5ef94 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java @@ -17,7 +17,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/pda/v1/last-leg/inventory-moves") -@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") public class PdaInventoryMoveController { @Resource private PdaInventoryMoveService service; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/security/PdaWarehouseIdentity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/security/PdaWarehouseIdentity.java index cbbf5139b..6d4f653e6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/security/PdaWarehouseIdentity.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/security/PdaWarehouseIdentity.java @@ -6,7 +6,7 @@ import org.springframework.web.server.ResponseStatusException; public record PdaWarehouseIdentity(String operatorKey, String operatorName, long overseasWarehouseId) { public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; - public static final String PICKER_ROLE = "ROLE_WAREHOUSE_PICKER"; + public static final String PICKER_ROLE = "ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"; public static PdaWarehouseIdentity require(AuthenticatedUser user) { if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0 @@ -15,7 +15,7 @@ public record PdaWarehouseIdentity(String operatorKey, String operatorName, long "Authenticated platform warehouse PDA identity is required"); } if (!user.permissions().contains(PICKER_ROLE)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "WAREHOUSE_PICKER role is required"); + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "OVERSEAS_WAREHOUSE_PDA_OPERATOR role is required"); } if (!user.permissions().contains(OPERATE_PERMISSION)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java index fb103115a..e95b37819 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java @@ -20,7 +20,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/pda/v1/last-leg/stocktakes") -@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") public class PdaStocktakeController { @Resource private PdaStocktakeService service; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java index 874bb0e17..439a11fb7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java @@ -19,7 +19,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/pda/v1/last-leg") -@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") public class PdaWarehouseController { @Resource private PdaWarehouseService service; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java index 6d89ac391..f01660cc8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java @@ -28,7 +28,7 @@ import java.util.List; @PreAuthorize("hasAuthority('lastleg:pda:operate')") public class PdaRelabelController { public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; - private static final String PICKER_ROLE = "ROLE_WAREHOUSE_PICKER"; + private static final String PICKER_ROLE = "ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"; @Resource private PdaRelabelService service; @@ -111,7 +111,7 @@ public class PdaRelabelController { if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION) || !user.permissions().contains(PICKER_ROLE)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, - "WAREHOUSE_PICKER and " + OPERATE_PERMISSION + " are required"); + "OVERSEAS_WAREHOUSE_PDA_OPERATOR and " + OPERATE_PERMISSION + " are required"); } if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0L) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 478471b24..0247d96a1 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -6,7 +6,7 @@ paths: /api/pda/v1/last-leg/receiving-containers/candidates: get: summary: 查询当前客户待签收柜号 - description: 权限 lastleg:pda:operate;仅 PLATFORM WAREHOUSE_PICKER 可用;仅返回已单调消费 PICKUP_READY(80) 或更高实际船节点、状态为 RECEIVING_AVAILABLE 的柜,不接收 customerId。 + description: 权限 lastleg:pda:operate;仅 PLATFORM OVERSEAS_WAREHOUSE_PDA_OPERATOR 可用;仅返回已单调消费 PICKUP_READY(80) 或更高实际船节点、状态为 RECEIVING_AVAILABLE 的柜,不接收 customerId。 security: [{ bearerAuth: [] }] responses: { '200': { description: 柜号候选及箱数进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCandidateListApiResponse' } } } }, '403': { description: 缺少 PDA 操作权限 } } /api/pda/v1/last-leg/receiving-containers/warehouse-profile: @@ -107,7 +107,7 @@ paths: /api/pda/v1/last-leg/inventory/resolve: post: summary: PDA按SKU、箱唛或入仓码扫码查询库存 - description: 仅 WAREHOUSE_PICKER + lastleg:pda:operate;海外实际仓只取Token overseasWarehouseId,不接收客户或仓库参数。 + description: 仅 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate;海外实际仓只取Token overseasWarehouseId,不接收客户或仓库参数。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PdaWarehouseScan' } } } } responses: { '200': { description: 同仓跨客户库存与物理仓位事实, content: { application/json: { schema: { $ref: '#/components/schemas/PdaInventoryScanApiResponse' } } } }, '404': { description: A0404:当前绑定仓无匹配库存 } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java index 65b4ae086..f95f2cdf4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -51,7 +51,7 @@ class ReceivingContainerContractTest { void everyBrowserReachableReceivingMethodUsesOnlyPdaOperatePermission() { PreAuthorize permission = ReceivingContainerController.class.getAnnotation(PreAuthorize.class); assertTrue(permission != null); - assertTrue("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')" + assertTrue("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')" .equals(permission.value())); } @@ -138,7 +138,7 @@ class ReceivingContainerContractTest { assertEquals(12, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count()); assertEquals(4, successResponses.stream().filter(line -> line.contains("/schemas/Pda")).count()); assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜")); - assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用")); + assertTrue(receivingPaths.contains("仅 PLATFORM OVERSEAS_WAREHOUSE_PDA_OPERATOR 可用")); assertFalse(receivingPaths.contains("客户与操作账号均从 PDA Token 获取")); assertTrue(yaml.contains("enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN]")); assertTrue(yaml.contains("photos: { type: array, minItems: 3, maxItems: 3")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java index fc8416556..0e4efce25 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java @@ -19,7 +19,7 @@ class PdaPalletOperationControllerContractTest { RequestMapping mapping = PdaPalletOperationController.class.getAnnotation(RequestMapping.class); assertArrayEquals(new String[]{"/api/pda/v1/last-leg/pallet-operations"}, mapping.value()); PreAuthorize authorize = PdaPalletOperationController.class.getAnnotation(PreAuthorize.class); - assertEquals("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')", authorize.value()); + assertEquals("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')", authorize.value()); Set paths = Arrays.stream(PdaPalletOperationController.class.getDeclaredMethods()) .map(Method::getDeclaredAnnotations).flatMap(Arrays::stream) .filter(PostMapping.class::isInstance).map(PostMapping.class::cast) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java index b67a31d65..7518d9cd0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java @@ -25,9 +25,9 @@ class PdaInventoryOperationContractTest { assertThat(PdaInventoryMoveController.class.getAnnotation(RequestMapping.class).value()) .containsExactly("/api/pda/v1/last-leg/inventory-moves"); assertThat(PdaStocktakeController.class.getAnnotation(PreAuthorize.class).value()) - .isEqualTo("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')"); + .isEqualTo("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')"); assertThat(PdaInventoryMoveController.class.getAnnotation(PreAuthorize.class).value()) - .isEqualTo("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')"); + .isEqualTo("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')"); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java index dfb7bdde8..175cd0ba7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java @@ -34,7 +34,7 @@ class PdaWarehouseContractTest { void controllerRequiresPickerRoleAndPdaAuthority() { PreAuthorize permission = PdaWarehouseController.class.getAnnotation(PreAuthorize.class); assertThat(permission.value()).isEqualTo( - "hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')"); + "hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')"); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelContractTest.java index 188b70adf..6a912d10c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelContractTest.java @@ -43,12 +43,12 @@ class PdaRelabelContractTest { @Test void identityRequiresPlatformPickerPermissionAndFrozenWarehouse() { - AuthenticatedUser valid = user(Set.of("lastleg:pda:operate", "ROLE_WAREHOUSE_PICKER"), 900L); + AuthenticatedUser valid = user(Set.of("lastleg:pda:operate", "ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"), 900L); assertEquals(900L, PdaRelabelController.identity(valid).overseasWarehouseId()); assertThrows(ResponseStatusException.class, () -> PdaRelabelController.identity(user(Set.of("lastleg:pda:operate"), 900L))); assertThrows(ResponseStatusException.class, - () -> PdaRelabelController.identity(user(Set.of("lastleg:pda:operate", "ROLE_WAREHOUSE_PICKER"), null))); + () -> PdaRelabelController.identity(user(Set.of("lastleg:pda:operate", "ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"), null))); } @Test diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 69b2297d4..8e283a35d 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -84,7 +84,7 @@ export async function run(context) { 表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。 -四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 WAREHOUSE_PICKER 海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、整托移库、托盘操作和换标;新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。 +四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 OVERSEAS_WAREHOUSE_PDA_OPERATOR 海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、整托移库、托盘操作和换标;新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。 节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定经过本轮 `LOADED` 柜次的 `DESTINATION_CLEARANCE`、`CUSTOMS_DECLARATION`、`DELIVERY_ORDER`、`PICKUP_ORDER` 四类资料生命周期,再确认国内出仓、核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。四类资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。 diff --git a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs index 92896b776..b4ed078ef 100644 --- a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs +++ b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs @@ -36,22 +36,30 @@ export async function run(context) { 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)) + .filter((item) => ['WAREHOUSE_OPERATOR', 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', 'WAREHOUSE_TALLY', 'CN_FIRSTLEG_PDA_OPERATOR'].includes(item.roleCode)) .map((item) => [item.roleCode, item])) - context.assert(MODULE, '操作员、拣货员、理货员三个系统角色已启用', rolesResponse, - warehouseRoles.size === 3 && [...warehouseRoles.values()] + context.assert(MODULE, '国内后台、国内PDA、海外PDA、理货复核四个系统角色已启用', rolesResponse, + warehouseRoles.size === 4 && [...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查询作业与海外PDA入仓操作权限', rolesResponse, - pickerCodes.size === 3 - && pickerCodes.has('platform:firstleg:pda:view') - && pickerCodes.has('platform:firstleg:pda:work') - && pickerCodes.has('lastleg:pda:operate'), - `permissions=${[...pickerCodes].sort().join(',')}`, OPTIONS) + const domesticPdaCodes = roleCodes('CN_FIRSTLEG_PDA_OPERATOR') + context.assert(MODULE, '国内头程PDA角色只具备国内任务查询与作业权限', rolesResponse, + domesticPdaCodes.size === 2 + && domesticPdaCodes.has('platform:firstleg:pda:view') + && domesticPdaCodes.has('platform:firstleg:pda:work') + && !domesticPdaCodes.has('lastleg:pda:operate'), + `permissions=${[...domesticPdaCodes].sort().join(',')}`, OPTIONS) + + const overseasPdaCodes = roleCodes('OVERSEAS_WAREHOUSE_PDA_OPERATOR') + context.assert(MODULE, '海外仓PDA角色只具备海外PDA作业权限', rolesResponse, + overseasPdaCodes.size === 1 + && overseasPdaCodes.has('lastleg:pda:operate') + && !overseasPdaCodes.has('platform:firstleg:pda:view') + && !overseasPdaCodes.has('platform:firstleg:pda:work'), + `permissions=${[...overseasPdaCodes].sort().join(',')}`, OPTIONS) const operatorCodes = roleCodes('WAREHOUSE_OPERATOR') context.assert(MODULE, '操作员具备国内入仓权限与四期十项只读权限', rolesResponse, @@ -88,7 +96,7 @@ export async function run(context) { `phase4ReadCodes=${PHASE4_READ_CODES.length}`, OPTIONS) context.assert(MODULE, '仓库系统角色不包含任何客户侧权限', rolesResponse, - [...operatorCodes, ...pickerCodes, ...tallyCodes] + [...operatorCodes, ...domesticPdaCodes, ...overseasPdaCodes, ...tallyCodes] .every((code) => code.startsWith('platform:') || code === 'lastleg:pda:operate'), 'warehouse roles may use platform:* plus the single overseas service authority lastleg:pda:operate', OPTIONS) diff --git a/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs b/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs index d36246ae5..14406d161 100644 --- a/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs @@ -32,10 +32,11 @@ test('warehouse operator and tally use the exact phase-four list/detail permissi assert.match(iamSql, /permission_code REGEXP ':\(create\|edit\|status\|export\|adjust\|delete\|review\)\$'/) }) -test('picker stays isolated to the dedicated overseas PDA authority', () => { - assert.match(acceptance, /pickerCodes\.size === 3/) - assert.match(acceptance, /pickerCodes\.has\('lastleg:pda:operate'\)/) - assert.match(iamSql, /role\.role_code='WAREHOUSE_PICKER'/) +test('domestic and overseas PDA operators use isolated authorities', () => { + assert.match(acceptance, /domesticPdaCodes\.size === 2/) + assert.match(acceptance, /overseasPdaCodes\.size === 1/) + assert.match(acceptance, /overseasPdaCodes\.has\('lastleg:pda:operate'\)/) + assert.match(iamSql, /role\.role_code='OVERSEAS_WAREHOUSE_PDA_OPERATOR'/) assert.match(iamSql, /permission\.permission_code='lastleg:pda:operate'/) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 07efd5faa..4cd77d951 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -33,7 +33,7 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /同键异载荷冲突/) assert.match(source, /跨柜切换锁返回409/) assert.match(source, /第二仓库操作员抢占同柜返回409/) - assert.match(source, /WAREHOUSE_PICKER/) + assert.match(source, /OVERSEAS_WAREHOUSE_PDA_OPERATOR/) assert.match(source, /lastleg:pda:operate/) assert.match(source, /安全投影已收敛/) assert.match(source, /projectionReady = success\(projectionResponse\) \|\| notFound\(projectionResponse\)/) @@ -157,7 +157,7 @@ test('PDA receiving lifecycle is ordered from candidate claim through photos, mo ]) assert.ok(source.includes(token), token) }) -test('missing WAREHOUSE_PICKER overseas authority is a deployment blocker', async () => { +test('missing OVERSEAS_WAREHOUSE_PDA_OPERATOR overseas authority is a deployment blocker', async () => { const calls = []; const gaps = []; const steps = [] const context = { marker: 'PDA-CONTRACT', context: {}, createdResources: {}, @@ -192,7 +192,7 @@ test('empty candidate response cannot become a zero-business-request pass', asyn async request(method, requestPath, body) { calls.push({ channel: 'domestic', method, requestPath }) if (method === 'GET' && requestPath.endsWith('/roles')) return response([ - { id: roleId, roleCode: 'WAREHOUSE_PICKER', status: 'ACTIVE', permissionIds: [permissionId] }, + { id: roleId, roleCode: 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', status: 'ACTIVE', permissionIds: [permissionId] }, ], requestPath, method) if (method === 'GET' && requestPath.endsWith('/permissions')) return response([ { id: permissionId, permissionCode: 'lastleg:pda:operate' }, diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index ba0a70019..af628bcaf 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -90,15 +90,15 @@ async function loginMerchantForNegativeProbe(context) { async function resolvePickerAccess(context) { const rolesResponse = await context.request('GET', '/api/platform/v1/iam/roles') const roles = context.apiSuccess(MODULE, '查询海外PDA仓库操作员角色', rolesResponse, '', OPTIONS) - const role = (Array.isArray(roles) ? roles : []).find((item) => item?.roleCode === 'WAREHOUSE_PICKER' && item?.status === 'ACTIVE') + const role = (Array.isArray(roles) ? roles : []).find((item) => item?.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && item?.status === 'ACTIVE') const permissionsResponse = await context.request('GET', '/api/platform/v1/iam/permissions') const permissions = context.apiSuccess(MODULE, '查询海外PDA权限资源', permissionsResponse, '', OPTIONS) const operate = (Array.isArray(permissions) ? permissions : []).find((item) => item?.permissionCode === 'lastleg:pda:operate') const assigned = Boolean(role && operate && (role.permissionIds || []).map(id).includes(id(operate.id))) - context.assert(MODULE, 'WAREHOUSE_PICKER精确具备海外PDA权限', rolesResponse, assigned, + context.assert(MODULE, 'OVERSEAS_WAREHOUSE_PDA_OPERATOR精确具备海外PDA权限', rolesResponse, assigned, `roleId=${id(role?.id) || 'missing'}, permissionId=${id(operate?.id) || 'missing'}`, OPTIONS) if (!assigned) { - blockerGap(context, '海外PDA仓库操作员权限', 'WAREHOUSE_PICKER未获得lastleg:pda:operate;必须先执行最新国内统一增量SQL并部署IAM') + blockerGap(context, '海外PDA仓库操作员权限', 'OVERSEAS_WAREHOUSE_PDA_OPERATOR未获得lastleg:pda:operate;必须先执行最新国内统一增量SQL并部署IAM') return null } return { role, operate } @@ -662,7 +662,7 @@ export async function run(context) { } try { if (!primary?.token || !secondary?.token) { - blockerGap(context, '海外PDA双操作员账号', '未能创建并登录两个WAREHOUSE_PICKER平台账号,无法验证全局一柜锁') + blockerGap(context, '海外PDA双操作员账号', '未能创建并登录两个OVERSEAS_WAREHOUSE_PDA_OPERATOR平台账号,无法验证全局一柜锁') return } const matrix = context.createdResources?.phase4InboundProducerFixtures