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