货柜pda 添加操作人

This commit is contained in:
2026-09-22 17:27:32 +08:00
parent abca6882c3
commit 2555eda797
31 changed files with 384 additions and 59 deletions
@@ -22,11 +22,19 @@ public class AuthorizationSnapshot {
private Set<String> permissions;
private Long permissionVersion;
private Long overseasWarehouseId;
private Long domesticWarehouseId;
public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode,
String clientType, Set<String> roles, Set<String> permissions,
Long permissionVersion) {
this(userId, tenantId, membershipId, appCode, clientType, roles, permissions,
permissionVersion, null);
permissionVersion, null, null);
}
public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode,
String clientType, Set<String> roles, Set<String> permissions,
Long permissionVersion, Long overseasWarehouseId) {
this(userId, tenantId, membershipId, appCode, clientType, roles, permissions,
permissionVersion, overseasWarehouseId, null);
}
}
@@ -135,6 +135,9 @@ public class AuthService {
if (authorization.getOverseasWarehouseId() != null) {
claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId());
}
if (authorization.getDomesticWarehouseId() != null) {
claimsBuilder.claim("domestic_warehouse_id", authorization.getDomesticWarehouseId());
}
JwtClaimsSet claims = claimsBuilder.build();
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
signingHeader(), claims)).getTokenValue();
@@ -124,6 +124,7 @@ CREATE TABLE iam_membership (
member_type varchar(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型:EMPLOYEE企业员工等',
member_no varchar(64) DEFAULT NULL COMMENT '客户企业内部成员编号',
overseas_warehouse_id bigint DEFAULT NULL COMMENT '海外仓PDA操作员绑定的唯一实际海外仓ID',
domestic_warehouse_id bigint DEFAULT NULL COMMENT '国内头程PDA操作员绑定的唯一国内作业仓ID',
status varchar(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态:ACTIVE在职、DISABLED停用、LEFT已离开',
joined_at datetime(3) DEFAULT NULL COMMENT '加入客户企业时间',
left_at datetime(3) DEFAULT NULL COMMENT '离开客户企业时间',
@@ -6814,6 +6815,9 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5102031,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5102124,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5102125,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5102100,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5102103,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5102191,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107000,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107010,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107016,0,CURRENT_TIMESTAMP(3));
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = "iam-service", contextId = "firstlegDomesticPdaOperatorClient",
url = "${baiyue.clients.iam.url:}", path = "/internal/iam")
public interface DomesticPdaOperatorClient {
@GetMapping("/domestic-pda-operators/{authUserId}")
ApiResponse<Operator> required(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long authUserId,
@RequestParam long warehouseId);
record Operator(Long userId, String displayName) { }
}
@@ -92,9 +92,9 @@ public class PlatformLoadingListController {
planId, warehouseId, user.userId(), user.username(), idempotencyKey, request));
}
@Operation(summary = "确认整份预装计划", description = "按 loadingPlanId 原子确认全部已分配入库预装票据;要求关联订舱已放舱,真实集装箱号可在PDA确认装柜完成前补录。成功后唯一创建待领取 PDA 任务,claim 后才进入 LOADING。")
@Operation(summary = "确认预装并推送指定 PDA 作业员", description = "按 loadingPlanId 原子确认预装、创建任务并分配给启用的国内头程 PDA 作业员;仅被指定人员可在 PDA 查看和领取。")
@PostMapping("/loading-plans/{planId}/confirm")
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit') and hasAuthority('platform:firstleg:pda:assign')")
public ApiResponse<PreloadConfirmationResponse> confirmPreload(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long planId,
@@ -64,7 +64,9 @@ public class PlatformLoadingPdaController {
@RequestParam(required = false) Long loadingPlanId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.task(cabinetNo, loadingPlanId, warehouseId));
Task result = service.task(cabinetNo, loadingPlanId, warehouseId);
service.requireVisibleTo(result.id(), user.userId(), canManage(user));
return ApiResponse.success(result);
}
@GetMapping("/tasks/pending")
@@ -90,6 +92,7 @@ public class PlatformLoadingPdaController {
@PathVariable @Positive long taskId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
service.requireVisibleTo(taskId, user.userId(), canManage(user));
return ApiResponse.success(service.task(taskId, warehouseId));
}
@@ -143,6 +146,7 @@ public class PlatformLoadingPdaController {
@PathVariable @Positive long taskId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
service.requireVisibleTo(taskId, user.userId(), canManage(user));
return ApiResponse.success(service.inventorySnapshot(taskId, warehouseId));
}
@@ -238,7 +242,13 @@ public class PlatformLoadingPdaController {
public ApiResponse<List<Operation>> operations(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId) {
FirstlegScopeGuard.platform(user);
service.requireVisibleTo(taskId, user.userId(), canManage(user));
return ApiResponse.success(service.operations(taskId));
}
private static boolean canManage(AuthenticatedUser user) {
return user.permissions().contains("platform:firstleg:pda:assign")
|| user.permissions().contains("platform:firstleg:pda:review");
}
}
@@ -102,7 +102,8 @@ public final class LoadingListDtos {
}
}
public record ConfirmPreloadRequest(@NotNull @Min(0) Integer expectedVersion) {
public record ConfirmPreloadRequest(@NotNull @Min(0) Integer expectedVersion,
@NotNull @Min(1) Long assigneeId) {
}
public record PreloadConfirmationResponse(
@@ -108,7 +108,7 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
LEFT JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id = task.id
WHERE task.task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED') AND booking.record_status = 'ACTIVE'
AND booking.warehouse_id = #{warehouseId}
AND (assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId})
AND assignment.assignee_id = #{operatorId}
<if test="keyword != null and keyword != ''">
AND (booking.container_no LIKE CONCAT('%', #{keyword}, '%')
OR booking.warehouse_name LIKE CONCAT('%', #{keyword}, '%'))
@@ -18,6 +18,7 @@ import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.LifecycleResolution;
import com.baiyue.firstleg.client.LoadingDocumentFileClient;
import com.baiyue.firstleg.client.DomesticPdaOperatorClient;
import com.baiyue.firstleg.entity.ContainerDocumentEntity;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
@@ -78,6 +79,8 @@ public class LoadingListService {
private ObjectMapper objectMapper;
@Resource
private LoadingDocumentFileClient documentFiles;
@Resource
private DomesticPdaOperatorClient domesticPdaOperators;
@Value("${baiyue.security.internal-service-token:}")
private String internalToken;
@@ -509,12 +512,21 @@ public class LoadingListService {
String idempotencyKey,
ConfirmPreloadRequest request) {
inboundHoldGuard.requirePlanNotHeld(planId);
String requestHash = sha256("CONFIRM_PRELOAD|" + planId + '|' + request.expectedVersion());
String requestHash = sha256("CONFIRM_PRELOAD|" + planId + '|' + request.expectedVersion()
+ '|' + request.assigneeId());
String operationId = operationId("CONFIRM_PRELOAD", planId, idempotencyKey);
HistoryRow replay = repository.history(operationId);
if (replay != null) return confirmationReplay(replay, requestHash);
AggregateRow before = requirePlan(planId);
var operatorResponse = domesticPdaOperators.required(internalToken, request.assigneeId(), before.getWarehouseId());
var assignee = operatorResponse == null ? null : operatorResponse.data();
if (operatorResponse == null || !operatorResponse.successful() || assignee == null
|| !request.assigneeId().equals(assignee.userId())
|| !StringUtils.hasText(assignee.displayName())) {
throw badRequest("所选国内头程 PDA 作业员无效");
}
List<Link> links = loadingPickService.requirePreloadConfirmationReady(planId);
requireContainerFeesReadyForPreloadConfirmation(links, operationId);
if (repository.touchForPreloadConfirmation(planId, request.expectedVersion(),
@@ -533,6 +545,12 @@ public class LoadingListService {
.forEach(containerBookingId -> loadingPickService.beginIfReady(
containerBookingId, operatorId, operatorName));
var pickTask = pickRepository.taskByPlan(planId);
if (pickTask == null || pickRepository.createAssignment(pickTask.getId(), assignee.userId(),
assignee.displayName().trim(), operatorId, trim(operatorName), "ASSIGNED") != 1) {
throw conflict("PDA任务创建或分配失败,请刷新后重试");
}
AggregateRow after = requirePlan(planId);
PreloadDetail detail = repository.preloadDetail(after);
PreloadConfirmationResponse response = new PreloadConfirmationResponse(planId,
@@ -24,6 +24,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot;
import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryUnit;
import com.baiyue.firstleg.dto.LoadingPickDtos.PendingTask;
import com.baiyue.firstleg.domain.Iso6346ContainerNumber;
import com.baiyue.firstleg.client.DomesticPdaOperatorClient;
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
@@ -54,6 +55,7 @@ import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -110,6 +112,10 @@ public class LoadingPickService {
private InboundHoldGuard inboundHoldGuard;
@Resource
private com.baiyue.firstleg.mapper.DropshipPalletizationMapper dropshipPalletizationMapper;
@Resource
private DomesticPdaOperatorClient domesticPdaOperators;
@Value("${baiyue.security.internal-service-token:}")
private String internalToken;
/** Opens the field task after planning and ocean release; the real container number may be filled later. */
@Transactional
@@ -238,6 +244,15 @@ public class LoadingPickService {
return view(requiredTask(taskId), warehouseId);
}
/** PDA reads require the actual assigned operator; backstage dispatch/review retains oversight. */
public void requireVisibleTo(long taskId, long operatorId, boolean dispatcherOrReviewer) {
if (dispatcherOrReviewer) return;
var assignment = repository.assignment(taskId);
if (assignment == null || !Long.valueOf(operatorId).equals(assignment.getAssigneeId())) {
throw new BizException(ResultCode.NOT_FOUND, "PDA任务不存在或未分配给当前作业员");
}
}
public List<PendingTask> pendingTasks(long operatorId, long warehouseId, String keyword) {
String normalizedKeyword = StringUtils.hasText(keyword) ? keyword.trim() : null;
return repository.pendingTasks(operatorId, warehouseId, normalizedKeyword).stream()
@@ -267,11 +282,7 @@ public class LoadingPickService {
if (replay != null) return replay;
LoadingPickAssignmentEntity existing = repository.assignment(taskId);
if (existing == null) {
if (request.expectedAssignmentVersion() != 0
|| repository.createAssignment(taskId, operatorId, operatorName, operatorId,
operatorName, "CLAIMED") != 1) {
throw conflict("PDA任务已被其他作业员领取");
}
throw conflict("PDA任务尚未指定作业员,请联系客服或仓管分配");
} else {
if (!Long.valueOf(operatorId).equals(existing.getAssigneeId())) {
throw conflict("PDA任务已分配给其他作业员");
@@ -289,12 +300,20 @@ public class LoadingPickService {
public Task assign(long taskId, AssignRequest request, long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
String operationId = operation(request.operationId());
String assigneeName = request.assigneeName().trim();
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
var booking = repository.activeBookingByTask(taskId);
var operatorResponse = domesticPdaOperators.required(internalToken, request.assigneeId(), booking == null ? 0L : booking.getWarehouseId());
var assignee = operatorResponse == null ? null : operatorResponse.data();
if (operatorResponse == null || !operatorResponse.successful() || assignee == null
|| !request.assigneeId().equals(assignee.userId())
|| !StringUtils.hasText(assignee.displayName())) {
throw new BizException(ResultCode.BAD_REQUEST, "所选国内头程 PDA 作业员无效");
}
String assigneeName = assignee.displayName().trim();
String requestHash = hash("ASSIGN|" + taskId + '|' + request.assigneeId() + '|'
+ assigneeName + '|' + request.expectedAssignmentVersion());
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
replay = replayTaskOperationForUpdate(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
LoadingPickAssignmentEntity existing = repository.assignment(taskId);
@@ -65,9 +65,9 @@ paths:
'200': {$ref: '#/components/responses/PreloadDetailResponse'}
/api/platform/v1/first-leg/loading-plans/{planId}/confirm:
post:
summary: 原子确认整份预装计划
description: 以 loadingPlanId 为唯一范围,原子确认全部已分配入库预装票据。要求计划已 push 且仍为 EDITING、稳定 bridge 完整、真实订舱已关联并为 RELEASED,且真实集装箱号已经补录。任一条件不满足均不写入,也不创建 PDA task。成功后唯一创建待领取 PDA task,claim 后才推进 LOADING。同一 Idempotency-Key 与相同请求精确重放原响应,不同 Key 重复确认返回业务冲突。
x-permission: platform:firstleg:preload:edit
summary: 原子确认预装并分配国内 PDA 作业员
description: 以 loadingPlanId 为唯一范围,校验指定人员为启用的国内头程 PDA 作业员,原子确认预装、创建任务并分配。未分配任务不向 PDA 展示;同一 Idempotency-Key 与相同请求精确重放原响应。
x-permission: platform:firstleg:preload:edit, platform:firstleg:pda:assign
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
@@ -336,10 +336,11 @@ components:
items: {type: string, enum: [LOADING_PLAN_NOT_EDITING, PRELOAD_LINK_MISSING, CONTAINER_NO_MISSING, OCEAN_BOOKING_NOT_LINKED, OCEAN_BOOKING_NOT_RELEASED, PDA_TASK_ALREADY_CREATED, PRELOAD_ALREADY_CONFIRMED]}
ConfirmPreloadRequest:
type: object
required: [expectedVersion, operationReason]
required: [expectedVersion, assigneeId]
additionalProperties: false
properties:
expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前装柜计划 version}
assigneeId: {$ref: '#/components/schemas/LongId'}
PreloadConfirmation:
type: object
required: [planId, containerBookingId, confirmationStatus, version, readiness]
@@ -33,8 +33,10 @@ class LoadingListControllerContractTest {
authority("lifecycle", "platform:firstleg:preload:list", AuthenticatedUser.class, long.class, long.class);
authority("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.class,
long.class, long.class, String.class, TransportUpdateRequest.class);
authority("confirmPreload", "platform:firstleg:preload:edit", AuthenticatedUser.class,
long.class, String.class, ConfirmPreloadRequest.class);
Method confirm = PlatformLoadingListController.class.getDeclaredMethod("confirmPreload",
AuthenticatedUser.class, long.class, String.class, ConfirmPreloadRequest.class);
assertThat(confirm.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:preload:edit') and hasAuthority('platform:firstleg:pda:assign')");
authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class,
ConfirmedQuery.class);
authority("confirmedDetail", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class);
@@ -35,7 +35,7 @@ class LoadingPickPdaTaskSqlContractTest {
}
@Test
void pendingTasksAreLimitedToUnassignedOrCurrentOperatorWithoutCustomerInput() throws Exception {
void pendingTasksAreLimitedToAssignedOperatorWithoutCustomerInput() throws Exception {
String sql = LoadingPickTaskMapper.class
.getDeclaredMethod("selectPendingForOperator", long.class, long.class, String.class)
.getAnnotation(Select.class).value()[0];
@@ -54,7 +54,8 @@ class LoadingPickPdaTaskSqlContractTest {
.contains("pick_item.item_status IN ('PENDING', 'PICKING')")
.contains("THEN 'AWAITING_REVIEW'")
.contains("ELSE 'IN_PROGRESS' END AS workflowStatus")
.contains("assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId}")
.contains("AND assignment.assignee_id = #{operatorId}")
.doesNotContain("assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId}")
.contains("booking.warehouse_id AS warehouseId")
.doesNotContain("tenantId");
}
@@ -12,6 +12,8 @@ import com.baiyue.firstleg.dto.LoadingListDtos.PreloadReadiness;
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest;
import com.baiyue.firstleg.client.LoadingDocumentFileClient;
import com.baiyue.firstleg.client.DomesticPdaOperatorClient;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.entity.ContainerDocumentEntity;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
@@ -63,6 +65,8 @@ class LoadingListServiceTest {
@MockBean
private LoadingDocumentFileClient documentFiles;
@MockBean
private DomesticPdaOperatorClient domesticPdaOperators;
@MockBean
private LoadingCompletedOutboxService loadingCompletedOutboxService;
@MockBean
private FbaPricingService fbaPricingService;
@@ -784,6 +788,18 @@ class LoadingListServiceTest {
}
@Test
void rejectsInvalidPdaOperatorBeforeConfirmingAnyPreload() {
when(domesticPdaOperators.required(any(), eq(77L)))
.thenReturn(ApiResponse.success(new DomesticPdaOperatorClient.Operator(88L, "其他人")));
assertThatThrownBy(() -> service.confirmPreload(10L, 9L, "operator", "invalid-operator",
new ConfirmPreloadRequest(3, 77L)))
.isInstanceOf(BizException.class).hasMessageContaining("作业员无效");
verify(repository, never()).touchForPreloadConfirmation(anyLong(), anyInt(), anyLong(), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
void confirmsEveryLinkedPreloadAndCreatesSinglePdaTaskAtomically() {
AggregateRow before = row("EDITING", 3);
@@ -810,9 +826,16 @@ class LoadingListServiceTest {
when(repository.confirmLinkedPreload(301L, "PENDING", 2, 9L, "operator")).thenReturn(1);
when(repository.confirmLinkedPreload(302L, "CONFIRMING", 5, 9L, "operator")).thenReturn(1);
when(repository.preloadDetail(after)).thenReturn(detail);
when(domesticPdaOperators.required(any(), eq(77L)))
.thenReturn(ApiResponse.success(new DomesticPdaOperatorClient.Operator(77L, "作业员")));
LoadingPickTaskEntity pickTask = new LoadingPickTaskEntity();
pickTask.setId(80L);
when(pickRepository.taskByPlan(10L)).thenReturn(pickTask);
when(pickRepository.createAssignment(80L, 77L, "作业员", 9L, "operator", "ASSIGNED"))
.thenReturn(1);
PreloadConfirmationResponse response = service.confirmPreload(
10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3));
10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3, 77L));
assertThat(response.confirmationStatus()).isEqualTo("CONFIRMED");
assertThat(response.version()).isEqualTo(4);
@@ -822,6 +845,7 @@ class LoadingListServiceTest {
verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(30L), eq(101L), anyString());
verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(30L), eq(102L), anyString());
verify(loadingPickService, times(1)).beginIfReady(30L, 9L, "operator");
verify(pickRepository).createAssignment(80L, 77L, "作业员", 9L, "operator", "ASSIGNED");
verify(repository).appendHistory(eq(10L), anyString(), eq("CONFIRM_PRELOAD"),
eq("EDITING"), eq("EDITING"), anyString(), anyString(), eq(9L),
eq("operator"), org.mockito.ArgumentMatchers.isNull());
@@ -838,9 +862,11 @@ class LoadingListServiceTest {
when(fbaPricingService.recalculateUnlockedContainerCustomerFees(eq(30L), eq(101L), anyString()))
.thenReturn(new FbaPricingService.ContainerFeeRecalculationResult(
FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY, 0));
when(domesticPdaOperators.required(any(), eq(77L)))
.thenReturn(ApiResponse.success(new DomesticPdaOperatorClient.Operator(77L, "作业员")));
assertThatThrownBy(() -> service.confirmPreload(
10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3)))
10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3, 77L)))
.isInstanceOf(BizException.class).hasMessageContaining("初始费用");
verify(repository, never()).touchForPreloadConfirmation(anyLong(), anyInt(), anyLong(), anyString());
@@ -852,7 +878,7 @@ class LoadingListServiceTest {
void exactConfirmationReplayReturnsFrozenResponseWithoutTouchingPlan() throws Exception {
PreloadConfirmationResponse expected = new PreloadConfirmationResponse(
10L, 30L, "CONFIRMED", 4, readiness("CONFIRMED", false, true, 80L));
String requestHash = sha256("CONFIRM_PRELOAD|10|3");
String requestHash = sha256("CONFIRM_PRELOAD|10|3|77");
HistoryRow replay = new HistoryRow();
ObjectMapper mapper = new ObjectMapper();
replay.setAfterSnapshot(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(
@@ -860,7 +886,7 @@ class LoadingListServiceTest {
when(repository.history(anyString())).thenReturn(replay);
PreloadConfirmationResponse actual = service.confirmPreload(
10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3));
10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3, 77L));
assertThat(actual).isEqualTo(expected);
verify(loadingPickService, never()).requirePreloadConfirmationReady(anyLong());
@@ -876,14 +902,14 @@ class LoadingListServiceTest {
void rejectsConfirmationReplayWhenExpectedVersionDiffers() throws Exception {
PreloadConfirmationResponse expected = new PreloadConfirmationResponse(
10L, 30L, "CONFIRMED", 4, readiness("CONFIRMED", false, true, 80L));
String originalHash = sha256("CONFIRM_PRELOAD|10|3");
String originalHash = sha256("CONFIRM_PRELOAD|10|3|77");
HistoryRow replay = new HistoryRow();
replay.setAfterSnapshot(new ObjectMapper().writeValueAsString(
java.util.Map.of("requestHash", originalHash, "response", expected)));
when(repository.history(anyString())).thenReturn(replay);
assertThatThrownBy(() -> service.confirmPreload(
10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(4)))
10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(4, 77L)))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
@@ -898,11 +924,13 @@ class LoadingListServiceTest {
AggregateRow confirmed = row("EDITING", 4);
when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(confirmed);
when(domesticPdaOperators.required(any(), eq(77L)))
.thenReturn(ApiResponse.success(new DomesticPdaOperatorClient.Operator(77L, "作业员")));
when(loadingPickService.requirePreloadConfirmationReady(10L))
.thenThrow(new BizException(ResultCode.CONFLICT, "预装计划已确认"));
assertThatThrownBy(() -> service.confirmPreload(
10L, 9L, "operator", "new-confirm-key", new ConfirmPreloadRequest(4)))
10L, 9L, "operator", "new-confirm-key", new ConfirmPreloadRequest(4, 77L)))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
@@ -919,11 +947,13 @@ class LoadingListServiceTest {
AggregateRow before = row("EDITING", 3);
when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(before);
when(domesticPdaOperators.required(any(), eq(77L)))
.thenReturn(ApiResponse.success(new DomesticPdaOperatorClient.Operator(77L, "作业员")));
when(loadingPickService.requirePreloadConfirmationReady(10L))
.thenThrow(new BizException(ResultCode.CONFLICT, "第二票订舱尚未放舱"));
assertThatThrownBy(() -> service.confirmPreload(
10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3)))
10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3, 77L)))
.isInstanceOf(BizException.class).hasMessageContaining("第二票");
verify(repository, never()).touchForPreloadConfirmation(anyLong(),
@@ -940,7 +970,7 @@ class LoadingListServiceTest {
when(repository.history(anyString())).thenReturn(replay);
assertThatThrownBy(() -> service.confirmPreload(
10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(4)))
10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(4, 77L)))
.isInstanceOf(BizException.class).hasMessageContaining("不同请求");
verify(loadingPickService, never()).requirePreloadConfirmationReady(anyLong());
@@ -11,6 +11,8 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.client.DomesticPdaOperatorClient;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
@@ -106,6 +108,8 @@ class LoadingPickServiceTest {
private InboundHoldGuard inboundHoldGuard;
@MockBean
private DropshipPalletizationMapper dropshipPallets;
@MockBean
private DomesticPdaOperatorClient domesticPdaOperators;
@Resource
private LoadingPickService service;
@@ -565,8 +569,7 @@ class LoadingPickServiceTest {
when(repository.operation("claim-2")).thenReturn(null);
when(repository.task(20L)).thenReturn(task);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(null, assignment);
when(repository.createAssignment(20L, 9L, "operator", 9L, "operator", "CLAIMED")).thenReturn(1);
when(repository.assignment(20L)).thenReturn(assignment);
when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(1, 0);
doAnswer(invocation -> {
LoadingPickOperationEntity value = invocation.getArgument(0);
@@ -574,9 +577,9 @@ class LoadingPickServiceTest {
return null;
}).when(repository).insert(any(LoadingPickOperationEntity.class));
service.claim(20L, new ClaimRequest(0, "claim-1"), 9L, "operator");
service.claim(20L, new ClaimRequest(0, "claim-1"), 9L, "operator");
service.claim(20L, new ClaimRequest(0, "claim-2"), 9L, "operator");
service.claim(20L, new ClaimRequest(assignment.getVersion(), "claim-1"), 9L, "operator");
service.claim(20L, new ClaimRequest(assignment.getVersion(), "claim-1"), 9L, "operator");
service.claim(20L, new ClaimRequest(assignment.getVersion(), "claim-2"), 9L, "operator");
verify(smartLoadingRepository, times(2)).startPicking(10L, 9L, "operator");
verify(smartLoadingRepository, times(1)).insert(argThat(
@@ -585,7 +588,8 @@ class LoadingPickServiceTest {
&& "EDITING".equals(history.getFromStatus())
&& "LOADING".equals(history.getToStatus())
&& "PDA-START-20".equals(history.getOperationId())));
verify(repository, times(1)).createAssignment(20L, 9L, "operator", 9L, "operator", "CLAIMED");
verify(repository, never()).createAssignment(anyLong(), anyLong(), anyString(), anyLong(),
anyString(), anyString());
}
@Test
@@ -606,6 +610,17 @@ class LoadingPickServiceTest {
verify(smartLoadingRepository, never()).startPicking(anyLong(), anyLong(), anyString());
}
@Test
void unassignedTaskCannotBeClaimedFromPda() {
when(repository.taskForUpdate(20L)).thenReturn(task());
assertThatThrownBy(() -> service.claim(20L, new ClaimRequest(0, "claim-unassigned"),
9L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("尚未指定作业员");
verify(repository, never()).createAssignment(anyLong(), anyLong(), anyString(),
anyLong(), anyString(), anyString());
}
@Test
void assignRechecksSameOperationAfterWaitingForTaskLock() {
LoadingPickTaskEntity task = task();
@@ -615,6 +630,8 @@ class LoadingPickServiceTest {
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L)).thenReturn(List.of());
when(domesticPdaOperators.required(any(), eq(11L)))
.thenReturn(ApiResponse.success(new DomesticPdaOperatorClient.Operator(11L, "picker")));
Task replay = service.assign(20L, new AssignRequest(11L, "picker", 0, "assign-race"),
9L, "operator");
@@ -18,6 +18,8 @@ import com.baiyue.iam.service.TenantService;
import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.iam.dto.TenantDtos.FinanceCustomerSummary;
import com.baiyue.iam.dto.DomesticPdaOperatorDtos.OperatorOption;
import com.baiyue.iam.service.DomesticPdaOperatorService;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
@@ -38,6 +40,7 @@ public class InternalTenantController {
@Resource private MerchantOnboardingService onboardingService;
@Resource private ProfileResourceService profileResourceService;
@Resource private TenantService tenantService;
@Resource private DomesticPdaOperatorService domesticPdaOperatorService;
@Value("${baiyue.security.internal-service-token}") private String internalTokenValue;
private byte[] internalToken;
@@ -128,6 +131,15 @@ public class InternalTenantController {
return ApiResponse.success(tenantService.pageFinanceCustomers(page, size, keyword, status, customerId));
}
@GetMapping("/domestic-pda-operators/{authUserId}")
public ApiResponse<OperatorOption> domesticPdaOperator(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long authUserId,
@RequestParam long warehouseId) {
verify(token);
return ApiResponse.success(domesticPdaOperatorService.required(authUserId, warehouseId));
}
@GetMapping("/exports/customers")
public ApiResponse<CustomerExportBatch> exportCustomers(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -6,6 +6,9 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.iam.dto.PlatformUserManagementDtos.*;
import com.baiyue.iam.service.PlatformUserManagementService;
import com.baiyue.iam.service.TenantGuard;
import com.baiyue.iam.service.DomesticPdaOperatorService;
import com.baiyue.iam.dto.DomesticPdaOperatorDtos.OperatorOption;
import java.util.List;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@@ -26,6 +29,19 @@ import org.springframework.web.bind.annotation.*;
public class PlatformUserManagementController {
@Resource
private PlatformUserManagementService service;
@Resource
private DomesticPdaOperatorService domesticPdaOperators;
@GetMapping("/domestic-pda-operators")
@PreAuthorize("hasAuthority('platform:firstleg:pda:assign')")
@Operation(summary = "查询启用的国内头程 PDA 作业员候选")
public ApiResponse<List<OperatorOption>> domesticPdaOperators(
@RequestParam long warehouseId,
@RequestParam(required = false) String keyword,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(domesticPdaOperators.options(warehouseId, keyword));
}
@GetMapping
@PreAuthorize("hasAuthority('platform:user:list')")
@@ -41,12 +41,13 @@ public final class AuthorizationDtos {
private Set<String> permissions;
private Long permissionVersion;
private Long overseasWarehouseId;
private Long domesticWarehouseId;
public SnapshotResponse(Long userId, Long tenantId, Long membershipId, String appCode,
String clientType, Set<String> roles, Set<String> permissions,
Long permissionVersion) {
this(userId, tenantId, membershipId, appCode, clientType, roles, permissions,
permissionVersion, null);
permissionVersion, null, null);
}
}
}
@@ -0,0 +1,8 @@
package com.baiyue.iam.dto;
/** 国内头程 PDA 作业员候选契约。 */
public final class DomesticPdaOperatorDtos {
private DomesticPdaOperatorDtos() { }
public record OperatorOption(Long userId, String displayName) { }
}
@@ -23,12 +23,13 @@ public final class PlatformUserManagementDtos {
@NotNull Long tenantId,
@NotNull List<Long> roleIds,
Long overseasWarehouseId,
Long domesticWarehouseId,
@NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password,
@NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) {
public CreateUserRequest(String username, String displayName, String phone, String email,
Long tenantId, List<Long> roleIds, String password, String status) {
this(username, displayName, phone, email, tenantId, roleIds, null, password, status);
this(username, displayName, phone, email, tenantId, roleIds, null, null, password, status);
}
}
@@ -39,10 +40,15 @@ public final class PlatformUserManagementDtos {
@Email @Size(max = 128) String email,
@NotNull Long tenantId,
@NotNull List<Long> roleIds,
Long overseasWarehouseId) {
Long overseasWarehouseId,
Long domesticWarehouseId) {
public UpdateUserRequest(String displayName, String phone, String email,
Long tenantId, List<Long> roleIds, Long overseasWarehouseId) {
this(displayName, phone, email, tenantId, roleIds, overseasWarehouseId, null);
}
public UpdateUserRequest(String displayName, String phone, String email,
Long tenantId, List<Long> roleIds) {
this(displayName, phone, email, tenantId, roleIds, null);
this(displayName, phone, email, tenantId, roleIds, null, null);
}
}
@@ -55,7 +61,7 @@ public final class PlatformUserManagementDtos {
String displayName, String phone, String email, Long tenantId,
String tenantName, String memberType, String memberNo, String status,
List<Long> roleIds, List<String> roleNames, LocalDateTime lastLoginAt,
boolean protectedAccount, Long overseasWarehouseId) { }
boolean protectedAccount, Long overseasWarehouseId, Long domesticWarehouseId) { }
public record TenantOption(Long id, String name, String status) { }
public record RoleOption(Long id, Long tenantId, String name, String status) { }
@@ -9,6 +9,6 @@ import com.baiyue.common.core.model.BaseEntity; import com.baomidou.mybatisplus.
@Getter
@Setter
@TableName("iam_membership") public class IamMembership extends BaseEntity {
@TableId private Long id; private Long tenantId; private Long userId; private String memberType; private String memberNo; private Long overseasWarehouseId; private String status; private LocalDateTime joinedAt; private LocalDateTime leftAt;
@TableId private Long id; private Long tenantId; private Long userId; private String memberType; private String memberNo; private Long overseasWarehouseId; private Long domesticWarehouseId; private String status; private LocalDateTime joinedAt; private LocalDateTime leftAt;
}
@@ -105,4 +105,8 @@ public interface AuthorizationQueryMapper {
@Select("SELECT overseas_warehouse_id FROM iam_membership WHERE id=#{membershipId} AND tenant_id=#{tenantId} AND status='ACTIVE' AND deleted=0")
Long findOverseasWarehouseId(@Param("tenantId") long tenantId,
@Param("membershipId") long membershipId);
@Select("SELECT domestic_warehouse_id FROM iam_membership WHERE id=#{membershipId} AND tenant_id=#{tenantId} AND status='ACTIVE' AND deleted=0")
Long findDomesticWarehouseId(@Param("tenantId") long tenantId,
@Param("membershipId") long membershipId);
}
@@ -10,6 +10,7 @@ import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import com.baiyue.iam.dto.DomesticPdaOperatorDtos.OperatorOption;
@Mapper
public interface IamMembershipRoleMapper extends BaseMapper<IamMembershipRole> {
@@ -26,4 +27,31 @@ public interface IamMembershipRoleMapper extends BaseMapper<IamMembershipRole> {
""")
List<Long> findAssignedAuthUserIds(@Param("tenantId") long tenantId,
@Param("roleId") Long roleId);
@Select("""
<script>
SELECT DISTINCT u.auth_user_id AS userId, u.display_name AS displayName
FROM iam_membership_role mr
JOIN iam_role role ON role.id = mr.role_id AND role.tenant_id = mr.tenant_id
JOIN iam_membership m ON m.id = mr.membership_id AND m.tenant_id = mr.tenant_id
JOIN iam_user u ON u.id = m.user_id
WHERE mr.tenant_id = #{tenantId}
AND role.role_code = #{roleCode} AND role.app_code = 'PLATFORM'
AND role.status = 'ACTIVE' AND role.deleted = 0
AND m.status = 'ACTIVE' AND m.deleted = 0
AND u.status = 'ACTIVE' AND u.deleted = 0
<if test="warehouseId != null">AND m.domestic_warehouse_id = #{warehouseId}</if>
<if test="authUserId != null">AND u.auth_user_id = #{authUserId}</if>
<if test="keyword != null and keyword != ''">
AND u.display_name LIKE CONCAT('%', #{keyword}, '%')
</if>
ORDER BY u.display_name, u.auth_user_id
LIMIT 200
</script>
""")
List<OperatorOption> findActiveUsersByRoleCode(@Param("tenantId") long tenantId,
@Param("roleCode") String roleCode,
@Param("keyword") String keyword,
@Param("authUserId") Long authUserId,
@Param("warehouseId") Long warehouseId);
}
@@ -48,7 +48,8 @@ public class AuthorizationSnapshotService {
return new SnapshotResponse(request.getUserId(), request.getTenantId(), request.getMembershipId(),
appCode, appCode, Set.copyOf(roles), Set.copyOf(permissions),
mapper.findPermissionVersion(request.getUserId()),
mapper.findOverseasWarehouseId(request.getTenantId(), request.getMembershipId()));
mapper.findOverseasWarehouseId(request.getTenantId(), request.getMembershipId()),
mapper.findDomesticWarehouseId(request.getTenantId(), request.getMembershipId()));
}
/**
@@ -0,0 +1,32 @@
package com.baiyue.iam.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.dto.DomesticPdaOperatorDtos.OperatorOption;
import com.baiyue.iam.mapper.IamMembershipRoleMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/** 提供启用的国内头程 PDA 作业员,并作为派单时的权威角色校验。 */
@Service
public class DomesticPdaOperatorService {
@Resource
private IamMembershipRoleMapper membershipRoles;
@Transactional(readOnly = true)
public List<OperatorOption> options(long warehouseId, String keyword) {
String normalized = keyword == null || keyword.isBlank() ? null : keyword.trim();
return membershipRoles.findActiveUsersByRoleCode(0L, "CN_FIRSTLEG_PDA_OPERATOR", normalized, null, warehouseId);
}
@Transactional(readOnly = true)
public OperatorOption required(long authUserId, long warehouseId) {
return membershipRoles.findActiveUsersByRoleCode(
0L, "CN_FIRSTLEG_PDA_OPERATOR", null, authUserId, warehouseId).stream().findFirst()
.orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST,
"所选人员不是该仓库启用的国内头程 PDA 作业员"));
}
}
@@ -75,7 +75,7 @@ public class PlatformUserManagementService {
long tenantId = request.tenantId();
requirePlatformTenant(tenantId);
List<Long> roleIds = validateRoles(tenantId, request.roleIds());
Long overseasWarehouseId = validateWarehouseBinding(roleIds, request.overseasWarehouseId());
WarehouseBindings warehouses = validateWarehouseBinding(roleIds, request.overseasWarehouseId(), request.domesticWarehouseId());
long authUserId = IdWorker.getId();
long userId = IdWorker.getId();
long membershipId = IdWorker.getId();
@@ -100,7 +100,8 @@ public class PlatformUserManagementService {
membership.setUserId(userId);
membership.setMemberType("EMPLOYEE");
membership.setMemberNo(memberNo(tenantId, membershipId));
membership.setOverseasWarehouseId(overseasWarehouseId);
membership.setOverseasWarehouseId(warehouses.overseasWarehouseId());
membership.setDomesticWarehouseId(warehouses.domesticWarehouseId());
membership.setStatus(request.status());
membership.setJoinedAt(LocalDateTime.now());
membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null);
@@ -125,7 +126,7 @@ public class PlatformUserManagementService {
long newTenantId = request.tenantId();
requirePlatformTenant(newTenantId);
List<Long> roleIds = validateRoles(newTenantId, request.roleIds());
Long overseasWarehouseId = validateWarehouseBinding(roleIds, request.overseasWarehouseId());
WarehouseBindings warehouses = validateWarehouseBinding(roleIds, request.overseasWarehouseId(), request.domesticWarehouseId());
List<Long> oldRoleIds = members.findRoleIds(oldTenantId, membershipId);
if (isProtectedPlatformAdmin(membership, oldRoleIds)
&& (newTenantId != PLATFORM_TENANT_ID || !containsPlatformAdminRole(roleIds))) {
@@ -140,7 +141,8 @@ public class PlatformUserManagementService {
boolean phoneChanged = !Objects.equals(user.getPhone(), request.phone().trim());
boolean scopeChanged = oldTenantId != newTenantId;
boolean rolesChanged = !new java.util.HashSet<>(oldRoleIds).equals(new java.util.HashSet<>(roleIds));
boolean warehouseChanged = !Objects.equals(membership.getOverseasWarehouseId(), overseasWarehouseId);
boolean warehouseChanged = !Objects.equals(membership.getOverseasWarehouseId(), warehouses.overseasWarehouseId())
|| !Objects.equals(membership.getDomesticWarehouseId(), warehouses.domesticWarehouseId());
if (profileChanged) call(() -> authClient.profile(internalToken, user.getAuthUserId(),
new AuthAccountClient.ProfileRequest(request.displayName().trim())));
@@ -154,7 +156,8 @@ public class PlatformUserManagementService {
user.setEmail(trimToNull(request.email()));
user.setUserType(userType(newTenantId));
membership.setTenantId(newTenantId);
membership.setOverseasWarehouseId(overseasWarehouseId);
membership.setOverseasWarehouseId(warehouses.overseasWarehouseId());
membership.setDomesticWarehouseId(warehouses.domesticWarehouseId());
if (scopeChanged && members.existsMemberNo(newTenantId, membership.getMemberNo(), membershipId)) {
membership.setMemberNo(memberNo(newTenantId, membershipId));
}
@@ -239,7 +242,7 @@ public class PlatformUserManagementService {
user.getDisplayName(), user.getPhone(), user.getEmail(), membership.getTenantId(), tenantName,
membership.getMemberType(), membership.getMemberNo(), membership.getStatus(), roleIds,
roleNames, account.lastLoginAt(), isProtectedPlatformAdmin(membership, roleIds),
membership.getOverseasWarehouseId());
membership.getOverseasWarehouseId(), membership.getDomesticWarehouseId());
}
private List<IamRole> platformRoles() {
@@ -262,7 +265,8 @@ public class PlatformUserManagementService {
return roleIds;
}
private Long validateWarehouseBinding(List<Long> roleIds, Long overseasWarehouseId) {
private WarehouseBindings validateWarehouseBinding(List<Long> roleIds, Long overseasWarehouseId,
Long domesticWarehouseId) {
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");
@@ -276,9 +280,17 @@ public class PlatformUserManagementService {
if (!overseasPda && overseasWarehouseId != null) {
throw new BizException(ResultCode.BAD_REQUEST, "仅海外仓 PDA 作业员可绑定海外仓");
}
return overseasWarehouseId;
if (domesticPda && (domesticWarehouseId == null || domesticWarehouseId <= 0)) {
throw new BizException(ResultCode.BAD_REQUEST, "国内头程 PDA 作业员必须绑定一个有效国内仓");
}
if (!domesticPda && domesticWarehouseId != null) {
throw new BizException(ResultCode.BAD_REQUEST, "仅国内头程 PDA 作业员可绑定国内仓");
}
return new WarehouseBindings(overseasWarehouseId, domesticWarehouseId);
}
private record WarehouseBindings(Long overseasWarehouseId, Long domesticWarehouseId) { }
private boolean isProtectedPlatformAdmin(IamMembership membership, List<Long> roleIds) {
return membership.getTenantId() == PLATFORM_TENANT_ID && containsPlatformAdminRole(roleIds);
}
+1
View File
@@ -149,6 +149,7 @@ CREATE TABLE IF NOT EXISTS iam_membership (
member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型:EMPLOYEE企业员工等',
member_no VARCHAR(64) NULL COMMENT '客户企业内部成员编号',
overseas_warehouse_id BIGINT NULL COMMENT '海外仓PDA操作员绑定的唯一实际海外仓ID',
domestic_warehouse_id BIGINT NULL COMMENT '国内头程PDA操作员绑定的唯一国内作业仓ID',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态:ACTIVE在职、DISABLED停用、LEFT已离开',
joined_at DATETIME(3) NULL COMMENT '加入客户企业时间',
left_at DATETIME(3) NULL COMMENT '离开客户企业时间',
@@ -3,6 +3,26 @@
USE baiyue_iam;
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS ensure_domestic_pda_operator_warehouse_column;
DELIMITER $$
CREATE PROCEDURE ensure_domestic_pda_operator_warehouse_column()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'iam_membership'
AND column_name = 'domestic_warehouse_id'
) THEN
ALTER TABLE iam_membership
ADD COLUMN domestic_warehouse_id BIGINT NULL
COMMENT '国内头程PDA操作员绑定的唯一国内作业仓ID'
AFTER overseas_warehouse_id,
ADD KEY idx_membership_domestic_warehouse (tenant_id, domestic_warehouse_id, status);
END IF;
END$$
DELIMITER ;
CALL ensure_domestic_pda_operator_warehouse_column();
DROP PROCEDURE ensure_domestic_pda_operator_warehouse_column;
UPDATE iam_role
SET role_code = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR',
role_name = '海外仓 PDA 作业员',
@@ -72,13 +92,16 @@ CREATE TEMPORARY TABLE tmp_phase3_warehouse_role_permission (
PRIMARY KEY (role_code, permission_code)
) ENGINE=MEMORY;
-- 操作员:查看入仓业务并维护后台装柜证据,不授予完成装柜、任务分派或出仓动作。
-- 仓管:可查看并确认预装、指定国内 PDA 作业员,不授予 PDA 执行或出仓动作。
INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VALUES
('WAREHOUSE_OPERATOR', 'platform:order'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:list'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:view'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:view'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:edit');
('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:edit'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:preload:list'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:preload:edit'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:pda:assign');
-- 国内头程 PDA 作业员:只允许国内任务查询、作业和现场照片,不授予后台、海外、分派或复核权限。
INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VALUES
@@ -112,7 +135,7 @@ BEGIN
COALESCE(SUM(role_code = 'WAREHOUSE_TALLY'), 0)
INTO operator_permission_count, domestic_pda_permission_count, tally_permission_count
FROM tmp_phase3_warehouse_role_permission;
IF operator_permission_count <> 5
IF operator_permission_count <> 8
OR domestic_pda_permission_count <> 2
OR tally_permission_count <> 12 THEN
SIGNAL SQLSTATE '45000'
@@ -208,7 +231,7 @@ BEGIN
JOIN iam_role_permission grant_row
ON grant_row.role_id = role.id
AND grant_row.permission_id = permission.id
AND grant_row.tenant_id = 0) <> 19 THEN
AND grant_row.tenant_id = 0) <> 22 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '仓库角色初始化失败:最小权限授权未完整写入';
END IF;
@@ -435,6 +435,18 @@ paths:
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/platform/v1/iam/users/domestic-pda-operators:
get:
tags: [IAM Resources / IAM 资源]
summary: 查询启用的国内头程 PDA 作业员候选
description: 仅返回具有 CN_FIRSTLEG_PDA_OPERATOR 角色且账号、成员、角色均启用的平台人员。
x-permission: platform:firstleg:pda:assign
parameters:
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1, description: 仅返回绑定到该国内仓的国内头程 PDA 作业员}}
- {name: keyword, in: query, required: false, schema: {type: string}}
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/platform/v1/iam/users/{membershipId}:
parameters:
- {$ref: '#/components/parameters/MembershipId'}
@@ -993,6 +1005,19 @@ paths:
total: {type: integer, format: int64}
pages: {type: integer}
'403': {$ref: '#/components/responses/Forbidden'}
/internal/iam/domestic-pda-operators/{authUserId}:
get:
tags: [IAM Resources / IAM 资源]
summary: 校验并返回启用的国内头程 PDA 作业员
security: []
parameters:
- {$ref: '#/components/parameters/InternalServiceToken'}
- {name: authUserId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
/internal/exports/v1/CUSTOMER_LIST/batches:
post:
tags: [Internal Export Provider]
@@ -1550,6 +1575,7 @@ components:
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: 选择 OVERSEAS_WAREHOUSE_PDA_OPERATOR 时必填且只能绑定一个实际海外仓;CN_FIRSTLEG_PDA_OPERATOR 与其他角色必须为空;两个 PDA 角色不能同时分配}
domesticWarehouseId: {type: integer, format: int64, nullable: true, description: 选择 CN_FIRSTLEG_PDA_OPERATOR 时必填且只能绑定一个有效国内作业仓;非国内头程 PDA 角色时必须为空;两个 PDA 角色不能同时分配}
password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true}
status: {type: string, enum: [ACTIVE, DISABLED]}
UpdatePlatformManagedUserRequest:
@@ -1562,6 +1588,7 @@ components:
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: 选择 OVERSEAS_WAREHOUSE_PDA_OPERATOR 时必填且只能绑定一个实际海外仓;CN_FIRSTLEG_PDA_OPERATOR 与其他角色必须为空;两个 PDA 角色不能同时分配}
domesticWarehouseId: {type: integer, format: int64, nullable: true, description: 选择 CN_FIRSTLEG_PDA_OPERATOR 时必填且只能绑定一个有效国内作业仓;非国内头程 PDA 角色时必须为空;两个 PDA 角色不能同时分配}
CreateInternalUserRequest:
type: object
required: [displayName]
@@ -1635,6 +1662,7 @@ components:
lastLoginAt: {type: string, format: date-time, nullable: true}
protectedAccount: {type: boolean}
overseasWarehouseId: {type: string, pattern: '^\d+$', nullable: true, description: 海外仓PDA操作员绑定的唯一实际海外仓ID}
domesticWarehouseId: {type: string, pattern: '^\d+$', nullable: true, description: 国内头程PDA操作员绑定的唯一国内作业仓ID}
CreateMemberRequest:
type: object
required: [username, password, displayName, memberType]
@@ -0,0 +1,20 @@
package com.baiyue.iam.repository;
import com.baiyue.iam.mapper.IamMembershipRoleMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class DomesticPdaOperatorSqlContractTest {
@Test
void candidatesUseActivePlatformRoleAndAuthAccountIdentity() throws Exception {
String sql = IamMembershipRoleMapper.class.getDeclaredMethod("findActiveUsersByRoleCode",
long.class, String.class, String.class, Long.class).getAnnotation(Select.class).value()[0];
assertThat(sql).contains("u.auth_user_id AS userId", "role.role_code = #{roleCode}",
"role.app_code = 'PLATFORM'", "role.status = 'ACTIVE'", "m.status = 'ACTIVE'",
"u.status = 'ACTIVE'", "u.auth_user_id = #{authUserId}")
.doesNotContain("baiyue_auth");
}
}
@@ -46,7 +46,7 @@ class WarehouseRoleSqlContractTest {
.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("operator_permission_count <> 8")
.contains("domestic_pda_permission_count <> 2")
.contains("tally_permission_count <> 12")
.contains("依赖的三期平台权限尚未完整部署")
@@ -54,7 +54,7 @@ class WarehouseRoleSqlContractTest {
.contains("expected.permission_code IS NULL")
.contains("role.role_code = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' AND permission.permission_code = 'lastleg:pda:operate'")
.contains("最小权限授权未完整写入")
.contains("grant_row.tenant_id = 0) <> 19")
.contains("grant_row.tenant_id = 0) <> 22")
.contains("(6000004, 4000004, 0, 'TENANT', NULL)")
.contains("(6000005, 4000005, 0, 'TENANT', NULL)")
.contains("(6000006, 4000006, 0, 'TENANT', NULL)")
@@ -80,7 +80,7 @@ class WarehouseRoleSqlContractTest {
.doesNotContain("platform:firstleg:inbound:picking:view")
.doesNotContain("platform:firstleg:outbound-dispatch:confirm");
assertThat(sql)
.doesNotContain("'WAREHOUSE_OPERATOR', 'platform:firstleg:pda:assign'")
.contains("'WAREHOUSE_OPERATOR', 'platform:firstleg:pda:assign'")
.doesNotContain("'WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:complete'")
.doesNotContain("'WAREHOUSE_OPERATOR', 'platform:firstleg:outbound-dispatch:confirm'")
.doesNotContain("'WAREHOUSE_OPERATOR', 'platform:firstleg:outbound-dispatch:reverse'")