This commit is contained in:
2026-08-28 10:38:58 +08:00
parent b34fbb2bd1
commit 1e5ce54858
37 changed files with 256 additions and 159 deletions
@@ -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)));
@@ -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 {
@@ -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");
@@ -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;
@@ -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;
@@ -120,11 +120,11 @@ public class LoadingCompletedOutboxService {
private void validateCustomerSnapshots(List<Row> 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) -> {
@@ -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);
}
}
@@ -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<InboundOutboxEntity> 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));
@@ -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);
@@ -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();
}
+2 -2
View File
@@ -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`。
## 全量验收导航
@@ -254,13 +254,18 @@ public class PlatformUserManagementService {
}
private Long validateWarehouseBinding(List<Long> 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<String> 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;
}
@@ -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;
@@ -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;
+3 -3
View File
@@ -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]
@@ -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);
@@ -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);
};
@@ -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);
+1 -1
View File
@@ -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`,候选跨客户读取,选柜时从柜内计划唯一解析并冻结真实客户。未完成会话按平台操作账号和柜号分别全局唯一,后续照片、扫码、实收、库存与完成操作只使用会话客户范围;平台管理员和客户账号不得进入该作业入口。
## 查询与租户约束
@@ -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;
@@ -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;
@@ -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;
@@ -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");
@@ -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;
@@ -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;
@@ -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,
@@ -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:当前绑定仓无匹配库存 } }
@@ -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"));
@@ -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<String> paths = Arrays.stream(PdaPalletOperationController.class.getDeclaredMethods())
.map(Method::getDeclaredAnnotations).flatMap(Arrays::stream)
.filter(PostMapping.class::isInstance).map(PostMapping.class::cast)
@@ -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
@@ -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
@@ -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
+1 -1
View File
@@ -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。
@@ -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)
@@ -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'/)
})
@@ -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' },
@@ -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