diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index e2020fb45..0aa5cd3af 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -8571,7 +8571,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_operation ( UNIQUE KEY uk_loading_pick_operation_id (operation_id), KEY idx_loading_pick_operation_task (pick_task_id, created_at), CONSTRAINT fk_loading_pick_operation_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), - CONSTRAINT chk_loading_pick_operation_action CHECK (action_type IN ('CLAIM', 'ASSIGN', 'REVIEW')) + CONSTRAINT chk_loading_pick_operation_action CHECK (action_type IN ('CLAIM', 'ASSIGN', 'ITEM_CLAIM', 'ITEM_ASSIGN', 'REVIEW')) ) ENGINE=InnoDB COMMENT='柜次PDA任务级幂等审计'; DROP TRIGGER IF EXISTS trg_loading_pick_review_before_update; @@ -8708,5 +8708,46 @@ USE baiyue_firstleg; ALTER TABLE fl_loading_pick_item ADD COLUMN IF NOT EXISTS release_mode VARCHAR(10) NULL COMMENT 'AUTO按比例释放,MANUAL按人工选择释放' AFTER item_status, ADD COLUMN IF NOT EXISTS release_location_ids JSON NULL COMMENT '人工选择释放库位ID;空数组表示明确释放0个' AFTER release_mode; +-- PDA 多人协作:领取粒度由整柜任务下沉为单条拣货明细。 +CREATE TABLE IF NOT EXISTS fl_loading_pick_item_assignment ( + pick_item_id BIGINT UNSIGNED NOT NULL, + assignee_id BIGINT UNSIGNED NOT NULL, + assignee_name VARCHAR(64) NOT NULL, + assigned_by BIGINT UNSIGNED NOT NULL, + assigned_by_name VARCHAR(64) NULL, + assignment_type VARCHAR(16) NOT NULL COMMENT 'CLAIMED/ASSIGNED', + version INT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (pick_item_id), + KEY idx_loading_pick_item_assignee (assignee_id, updated_at), + CONSTRAINT fk_loading_pick_item_assignment_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id), + CONSTRAINT chk_loading_pick_item_assignment_type CHECK (assignment_type IN ('CLAIMED', 'ASSIGNED')) +) ENGINE=InnoDB COMMENT='PDA拣货明细领取与作业员分配'; + +ALTER TABLE fl_loading_pick_operation + DROP CHECK IF EXISTS chk_loading_pick_operation_action, + ADD CONSTRAINT chk_loading_pick_operation_action + CHECK (action_type IN ('CLAIM', 'ASSIGN', 'ITEM_CLAIM', 'ITEM_ASSIGN', 'REVIEW')); + +DROP PROCEDURE IF EXISTS assert_phase3_pda_item_assignment_schema; +DELIMITER $$ +CREATE PROCEDURE assert_phase3_pda_item_assignment_schema() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_pick_item_assignment') <> 1 + OR (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_pick_item_assignment' + AND index_name='idx_loading_pick_item_assignee') <> 2 + OR (SELECT COUNT(*) FROM information_schema.referential_constraints + WHERE constraint_schema='baiyue_firstleg' + AND constraint_name='fk_loading_pick_item_assignment_item') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA明细级多人分配表创建失败'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase3_pda_item_assignment_schema(); +DROP PROCEDURE IF EXISTS assert_phase3_pda_item_assignment_schema; + ALTER TABLE fl_inbound_pallet_allocation ADD COLUMN IF NOT EXISTS reservation_id BIGINT UNSIGNED NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分托盘' AFTER location_code; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java index 87d98aa6a..8110dda33 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java @@ -12,9 +12,13 @@ import org.springframework.cloud.openfeign.EnableFeignClients; "com.baiyue.firstleg.mapper", "com.baiyue.firstleg.split.mapper", "com.baiyue.firstleg.loading.mapper", - "com.baiyue.firstleg.oceanbooking.mapper" + "com.baiyue.firstleg.loading.bridge.mapper", + "com.baiyue.firstleg.oceanbooking.mapper", + "com.baiyue.firstleg.outbound.dispatch.mapper", + "com.baiyue.firstleg.portschedule.mapper", + "com.baiyue.firstleg.customs.mapper" }) -@EnableFeignClients(basePackages = "com.baiyue.firstleg.client") +@EnableFeignClients(basePackages = "com.baiyue.firstleg") @SpringBootApplication(scanBasePackages = "com.baiyue") public class BaiyueFirstlegApplication { public static void main(String[] args) { SpringApplication.run(BaiyueFirstlegApplication.class, args); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java index 26bc461fa..4e5c352d5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java @@ -62,6 +62,28 @@ public class PlatformLoadingPdaController { return ApiResponse.success(service.assign(taskId, request, user.userId(), user.username())); } + /** 同一柜按 PDA 明细领取,多个作业员可分别处理不同入库单明细。 */ + @PostMapping("/tasks/{taskId}/items/{itemId}/claim") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ApiResponse claimItem(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @PathVariable @Positive long itemId, + @Valid @RequestBody ClaimRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.claimItem(taskId, itemId, request, user.userId(), user.username())); + } + + /** 管理员可将单条 PDA 明细分配给指定仓库作业员。 */ + @PostMapping("/tasks/{taskId}/items/{itemId}/assign") + @PreAuthorize("hasAuthority('platform:firstleg:pda:assign')") + public ApiResponse assignItem(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @PathVariable @Positive long itemId, + @Valid @RequestBody AssignRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.assignItem(taskId, itemId, request, user.userId(), user.username())); + } + @PostMapping("/tasks/{taskId}/items/{itemId}/pick") @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java index 6f5f1619c..a73e54c31 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java @@ -76,7 +76,7 @@ public final class LoadingPickDtos { public record PickItem(Long id, Long loadingPlanItemId, Long inboundId, Long inboundDetailId, Integer plannedPackageCount, Integer pickedPackageCount, - String itemStatus, Integer version) { + String itemStatus, Integer version, Assignment assignment) { } public record Assignment(Long assigneeId, String assigneeName, Long assignedBy, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickItemAssignmentEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickItemAssignmentEntity.java new file mode 100644 index 000000000..cbaf49495 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickItemAssignmentEntity.java @@ -0,0 +1,25 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** PDA 拣货明细的作业员领取或分配记录。 */ +@Getter +@Setter +@TableName("fl_loading_pick_item_assignment") +public class LoadingPickItemAssignmentEntity { + @TableId + private Long pickItemId; + private Long assigneeId; + private String assigneeName; + private Long assignedBy; + private String assignedByName; + private String assignmentType; + private Integer version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemAssignmentMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemAssignmentMapper.java new file mode 100644 index 000000000..bf2df6098 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemAssignmentMapper.java @@ -0,0 +1,27 @@ +package com.baiyue.firstleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.entity.LoadingPickItemAssignmentEntity; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; + +/** 明细级作业员分配,允许同一柜的不同明细并行领取。 */ +@Mapper +public interface LoadingPickItemAssignmentMapper extends BaseMapper { + @Insert("INSERT INTO fl_loading_pick_item_assignment " + + "(pick_item_id,assignee_id,assignee_name,assigned_by,assigned_by_name,assignment_type,version) " + + "VALUES (#{itemId},#{assigneeId},#{assigneeName},#{operatorId},#{operatorName},#{type},0)") + int create(@Param("itemId") long itemId, @Param("assigneeId") long assigneeId, + @Param("assigneeName") String assigneeName, @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName, @Param("type") String type); + + @Update("UPDATE fl_loading_pick_item_assignment SET assignee_id=#{assigneeId},assignee_name=#{assigneeName}," + + "assigned_by=#{operatorId},assigned_by_name=#{operatorName},assignment_type=#{type},version=version+1 " + + "WHERE pick_item_id=#{itemId} AND version=#{expectedVersion}") + int reassign(@Param("itemId") long itemId, @Param("assigneeId") long assigneeId, + @Param("assigneeName") String assigneeName, @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName, @Param("type") String type, + @Param("expectedVersion") int expectedVersion); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java index d22fdc003..de69fa0f8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java @@ -9,9 +9,11 @@ import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import com.baiyue.firstleg.mapper.LoadingPickItemMapper; import com.baiyue.firstleg.mapper.LoadingPickTaskMapper; import com.baiyue.firstleg.mapper.LoadingPickAssignmentMapper; +import com.baiyue.firstleg.mapper.LoadingPickItemAssignmentMapper; import com.baiyue.firstleg.mapper.LoadingPickOperationMapper; import com.baiyue.firstleg.mapper.LoadingPickReviewMapper; import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; +import com.baiyue.firstleg.entity.LoadingPickItemAssignmentEntity; import com.baiyue.firstleg.entity.LoadingPickOperationEntity; import com.baiyue.firstleg.entity.LoadingPickReviewEntity; import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper; @@ -33,6 +35,8 @@ public class LoadingPickRepository { @Resource private LoadingPickAssignmentMapper assignments; @Resource + private LoadingPickItemAssignmentMapper itemAssignments; + @Resource private LoadingPickReviewMapper reviews; @Resource private LoadingPickOperationMapper operations; @@ -165,6 +169,21 @@ public class LoadingPickRepository { type, expectedVersion); } + public LoadingPickItemAssignmentEntity itemAssignment(long itemId) { + return itemAssignments.selectById(itemId); + } + + public int createItemAssignment(long itemId, long assigneeId, String assigneeName, + long operatorId, String operatorName, String type) { + return itemAssignments.create(itemId, assigneeId, assigneeName, operatorId, operatorName, type); + } + + public int reassignItem(long itemId, long assigneeId, String assigneeName, long operatorId, + String operatorName, String type, int expectedVersion) { + return itemAssignments.reassign(itemId, assigneeId, assigneeName, operatorId, operatorName, + type, expectedVersion); + } + public LoadingPickReviewEntity review(long taskId) { return reviews.selectById(taskId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index e579abbdb..cf84c3c07 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -14,6 +14,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.Operation; import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; +import com.baiyue.firstleg.entity.LoadingPickItemAssignmentEntity; import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; import com.baiyue.firstleg.entity.LoadingPickItemEntity; import com.baiyue.firstleg.entity.LoadingPickOperationEntity; @@ -118,6 +119,38 @@ public class LoadingPickService { return view(task, null); } + /** + * 领取单条 PDA 明细。柜次任务只是汇总容器,作业所有权必须以明细为粒度, + * 才能让多个仓库人员在同一柜中并行处理不同入库单。 + */ + @Transactional + public Task claimItem(long taskId, long itemId, ClaimRequest request, long operatorId, String operatorName) { + String operationId = operation(request.operationId()); + String requestHash = hash("ITEM_CLAIM|" + taskId + '|' + itemId + '|' + + request.expectedAssignmentVersion()); + Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId); + if (replay != null) return replay; + LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId); + LoadingPickItemEntity item = repository.item(taskId, itemId); + requireAssignableItem(item); + LoadingPickItemAssignmentEntity existing = repository.itemAssignment(itemId); + if (existing == null) { + try { + if (request.expectedAssignmentVersion() != 0 || repository.createItemAssignment(itemId, operatorId, + operatorName, operatorId, operatorName, "CLAIMED") != 1) { + throw conflict("PDA拣货明细已被其他作业员领取"); + } + } catch (DuplicateKeyException exception) { + throw conflict("PDA拣货明细已被其他作业员领取"); + } + } else if (!Long.valueOf(operatorId).equals(existing.getAssigneeId()) + || !request.expectedAssignmentVersion().equals(existing.getVersion())) { + throw conflict("PDA拣货明细已分配给其他作业员或分配版本已变化"); + } + insertOperation(taskId, operationId, requestHash, "ITEM_CLAIM", operatorId, operatorName); + return view(task, null); + } + @Transactional public Task assign(long taskId, AssignRequest request, long operatorId, String operatorName) { String operationId = operation(request.operationId()); @@ -139,6 +172,29 @@ public class LoadingPickService { return view(task, null); } + @Transactional + public Task assignItem(long taskId, long itemId, AssignRequest request, long operatorId, String operatorName) { + String operationId = operation(request.operationId()); + String assigneeName = request.assigneeName().trim(); + String requestHash = hash("ITEM_ASSIGN|" + taskId + '|' + itemId + '|' + + request.assigneeId() + '|' + assigneeName + '|' + request.expectedAssignmentVersion()); + Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId); + if (replay != null) return replay; + LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId); + LoadingPickItemEntity item = repository.item(taskId, itemId); + requireAssignableItem(item); + LoadingPickItemAssignmentEntity existing = repository.itemAssignment(itemId); + int changed = existing == null + ? request.expectedAssignmentVersion() == 0 + ? repository.createItemAssignment(itemId, request.assigneeId(), assigneeName, operatorId, + operatorName, "ASSIGNED") : 0 + : repository.reassignItem(itemId, request.assigneeId(), assigneeName, operatorId, operatorName, + "ASSIGNED", request.expectedAssignmentVersion()); + if (changed != 1) throw conflict("PDA拣货明细分配版本已变化"); + insertOperation(taskId, operationId, requestHash, "ITEM_ASSIGN", operatorId, operatorName); + return view(task, null); + } + @Transactional public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) { ReleaseSelection release = releaseSelection(request.releaseMode(), request.locationIds()); @@ -154,11 +210,12 @@ public class LoadingPickService { } return view(requiredTask(taskId), request.warehouseId()); } - LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId); + LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId); LoadingPickItemEntity item = repository.item(taskId, itemId, request.warehouseId()); if (item == null) { throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在"); } + requireItemAssignee(taskId, itemId, operatorId); if (repository.pick(itemId, request.pickedPackageCount(), release.mode(), release.json(), request.version(), request.warehouseId()) != 1) { throw conflict("拣货数量超过计划箱数或明细版本已变化"); @@ -190,8 +247,9 @@ public class LoadingPickService { assertReplay(replay, taskId, itemId, requestHash); return view(requiredTask(taskId), request.warehouseId()); } - LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId); + LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId); requireWarehouseItem(taskId, itemId, request.warehouseId()); + requireItemAssignee(taskId, itemId, operatorId); if (repository.partial(itemId, request.finalPickedPackageCount(), release.mode(), release.json(), request.version(), request.warehouseId()) != 1) { throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化"); @@ -214,8 +272,9 @@ public class LoadingPickService { assertReplay(replay, taskId, itemId, requestHash); return view(requiredTask(taskId), request.warehouseId()); } - LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId); + LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId); LoadingPickItemEntity item = requireWarehouseItem(taskId, itemId, request.warehouseId()); + requireItemAssignee(taskId, itemId, operatorId); if (repository.hasPickedOrPartialItem(taskId, item.getInboundId())) { throw conflict("同一入库单已存在已拣或部分拣货明细,不能跳拣并恢复智能装柜候选"); } @@ -343,14 +402,27 @@ public class LoadingPickService { return task; } - private LoadingPickTaskEntity requiredAssignedLoadingTaskForUpdate(long taskId, long operatorId) { - LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId); - LoadingPickAssignmentEntity assignment = repository.assignment(taskId); - if (assignment == null) throw conflict("请先领取PDA拣货任务"); - if (!Long.valueOf(operatorId).equals(assignment.getAssigneeId())) { - throw conflict("PDA拣货任务已分配给其他作业员"); + private void requireItemAssignee(long taskId, long itemId, long operatorId) { + LoadingPickItemAssignmentEntity assignment = repository.itemAssignment(itemId); + if (assignment != null) { + if (!Long.valueOf(operatorId).equals(assignment.getAssigneeId())) { + throw conflict("PDA拣货明细已分配给其他作业员"); + } + return; + } + // 兼容已上线的整柜领取调用;新作业必须走明细领取接口。 + LoadingPickAssignmentEntity legacy = repository.assignment(taskId); + if (legacy == null || !Long.valueOf(operatorId).equals(legacy.getAssigneeId())) { + throw conflict("请先领取PDA拣货明细"); + } + } + + private static void requireAssignableItem(LoadingPickItemEntity item) { + if (item == null) throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在"); + if ("PICKED".equals(item.getItemStatus()) || "PARTIAL".equals(item.getItemStatus()) + || "SKIPPED".equals(item.getItemStatus())) { + throw conflict("已完成或已跳拣的PDA明细不能再领取或改派"); } - return task; } private LoadingPickItemEntity requireWarehouseItem(long taskId, long itemId, long warehouseId) { @@ -364,10 +436,15 @@ public class LoadingPickService { private Task view(LoadingPickTaskEntity task, Long warehouseId) { List source = warehouseId == null ? repository.items(task.getId()) : repository.items(task.getId(), warehouseId); - List items = source.stream() - .map(item -> new PickItem(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(), + List items = source.stream().map(item -> { + LoadingPickItemAssignmentEntity assignedItem = repository.itemAssignment(item.getId()); + Assignment itemAssignment = assignedItem == null ? null : new Assignment(assignedItem.getAssigneeId(), + assignedItem.getAssigneeName(), assignedItem.getAssignedBy(), assignedItem.getAssignedByName(), + assignedItem.getAssignmentType(), assignedItem.getVersion()); + return new PickItem(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(), item.getInboundDetailId(), item.getPlannedPackageCount(), item.getPickedPackageCount(), - item.getItemStatus(), item.getVersion())).toList(); + item.getItemStatus(), item.getVersion(), itemAssignment); + }).toList(); LoadingPickAssignmentEntity assigned = repository.assignment(task.getId()); LoadingPickReviewEntity reviewed = repository.review(task.getId()); Assignment assignment = assigned == null ? null : new Assignment(assigned.getAssigneeId(), @@ -375,9 +452,11 @@ public class LoadingPickService { assigned.getAssignmentType(), assigned.getVersion()); Review review = reviewed == null ? null : new Review(reviewed.getReviewStatus(), reviewed.getReviewedBy(), reviewed.getReviewedByName(), reviewed.getRemark(), reviewed.getVersion()); + boolean hasPickedItem = source.stream().anyMatch(item -> item.getPickedPackageCount() != null + && item.getPickedPackageCount() > 0); String workflowStatus = "COMPLETED".equals(task.getTaskStatus()) ? "COMPLETED" - : assigned == null ? "UNASSIGNED" - : repository.allTaskItemsResolved(task.getId()) ? "AWAITING_REVIEW" : "IN_PROGRESS"; + : repository.allTaskItemsResolved(task.getId()) ? "AWAITING_REVIEW" + : hasPickedItem ? "IN_PROGRESS" : "PENDING"; return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerNo(), task.getTaskStatus(), workflowStatus, task.getVersion(), assignment, review, items); } diff --git a/firstleg/src/main/resources/db/phase3-pda-convergence-schema.sql b/firstleg/src/main/resources/db/phase3-pda-convergence-schema.sql index a207bbb7e..8ce8f9e99 100644 --- a/firstleg/src/main/resources/db/phase3-pda-convergence-schema.sql +++ b/firstleg/src/main/resources/db/phase3-pda-convergence-schema.sql @@ -45,9 +45,25 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_operation ( UNIQUE KEY uk_loading_pick_operation_id (operation_id), KEY idx_loading_pick_operation_task (pick_task_id, created_at), CONSTRAINT fk_loading_pick_operation_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), - CONSTRAINT chk_loading_pick_operation_action CHECK (action_type IN ('CLAIM', 'ASSIGN', 'REVIEW')) + CONSTRAINT chk_loading_pick_operation_action CHECK (action_type IN ('CLAIM', 'ASSIGN', 'ITEM_CLAIM', 'ITEM_ASSIGN', 'REVIEW')) ) ENGINE=InnoDB COMMENT='柜次PDA任务级幂等审计'; +CREATE TABLE IF NOT EXISTS fl_loading_pick_item_assignment ( + pick_item_id BIGINT UNSIGNED NOT NULL, + assignee_id BIGINT UNSIGNED NOT NULL, + assignee_name VARCHAR(64) NOT NULL, + assigned_by BIGINT UNSIGNED NOT NULL, + assigned_by_name VARCHAR(64) NULL, + assignment_type VARCHAR(16) NOT NULL COMMENT 'CLAIMED/ASSIGNED', + version INT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (pick_item_id), + KEY idx_loading_pick_item_assignee (assignee_id, updated_at), + CONSTRAINT fk_loading_pick_item_assignment_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id), + CONSTRAINT chk_loading_pick_item_assignment_type CHECK (assignment_type IN ('CLAIMED', 'ASSIGNED')) +) ENGINE=InnoDB COMMENT='PDA拣货明细领取与作业员分配'; + DROP TRIGGER IF EXISTS trg_loading_pick_review_before_update; DELIMITER $$ CREATE TRIGGER trg_loading_pick_review_before_update diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index 28d1d2c48..e681be2b6 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -29,6 +29,24 @@ paths: content: {application/json: {schema: {$ref: '#/components/schemas/AssignRequest'}}} responses: '200': {description: Assigned task or A0409 on assignment version conflict} + /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/items/{itemId}/claim: + post: + summary: 领取单条PDA拣货明细;同柜不同明细可由不同仓库人员并行作业 + parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/ItemId'}] + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ClaimRequest'}}} + responses: + '200': {description: 返回含明细作业员信息的PDA任务} + /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/items/{itemId}/assign: + post: + summary: 将单条PDA拣货明细分配或改派给指定仓库人员 + parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/ItemId'}] + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/AssignRequest'}}} + responses: + '200': {description: 返回含明细作业员信息的PDA任务} /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/items/{itemId}/pick: post: summary: Increment a picked item owned by the authenticated assignee diff --git a/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java index 1dbd581c5..0bf27593b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java @@ -2,18 +2,30 @@ package com.baiyue.firstleg; import org.junit.jupiter.api.Test; import org.mybatis.spring.annotation.MapperScan; +import org.springframework.cloud.openfeign.EnableFeignClients; import static org.assertj.core.api.Assertions.assertThat; class BaiyueFirstlegApplicationContractTest { @Test - void scansAllFirstlegMapperPackagesIncludingOceanBooking() { + void scansAllFirstlegMapperPackagesIncludingCustoms() { MapperScan scan = BaiyueFirstlegApplication.class.getAnnotation(MapperScan.class); assertThat(scan.basePackages()) .contains("com.baiyue.firstleg.mapper") .contains("com.baiyue.firstleg.split.mapper") .contains("com.baiyue.firstleg.loading.mapper") - .contains("com.baiyue.firstleg.oceanbooking.mapper"); + .contains("com.baiyue.firstleg.loading.bridge.mapper") + .contains("com.baiyue.firstleg.oceanbooking.mapper") + .contains("com.baiyue.firstleg.outbound.dispatch.mapper") + .contains("com.baiyue.firstleg.portschedule.mapper") + .contains("com.baiyue.firstleg.customs.mapper"); + } + + @Test + void scansAllFirstlegFeignClientPackagesIncludingCustoms() { + EnableFeignClients clients = BaiyueFirstlegApplication.class.getAnnotation(EnableFeignClients.class); + + assertThat(clients.basePackages()).containsExactly("com.baiyue.firstleg"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java index 890a564c5..ed9b9b3a3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java @@ -16,6 +16,8 @@ class PdaConvergenceOpenApiContractTest { .contains("/api/platform/v1/first-leg/pda-loading/tasks:") .contains("/tasks/{taskId}/claim:") .contains("/tasks/{taskId}/assign:") + .contains("/tasks/{taskId}/items/{itemId}/claim:") + .contains("/tasks/{taskId}/items/{itemId}/assign:") .contains("/tasks/{taskId}/items/{itemId}/pick:") .contains("/tasks/{taskId}/items/{itemId}/partial:") .contains("/tasks/{taskId}/items/{itemId}/skip:") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/PdaConvergenceSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/PdaConvergenceSqlContractTest.java index 8bdf3a4ba..76b84d773 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/PdaConvergenceSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/PdaConvergenceSqlContractTest.java @@ -18,8 +18,10 @@ class PdaConvergenceSqlContractTest { .getContentAsString(StandardCharsets.UTF_8); assertThat(sql) .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_assignment") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_item_assignment") .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_review") .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_operation") + .contains("'ITEM_CLAIM', 'ITEM_ASSIGN'") .contains("UNIQUE KEY uk_loading_pick_operation_id") .contains("trg_loading_pick_review_before_update") .contains("trg_loading_pick_operation_before_delete") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index e46e64e7f..49ff7cf7a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -3,11 +3,13 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest; import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; import com.baiyue.firstleg.entity.LoadingPickItemEntity; +import com.baiyue.firstleg.entity.LoadingPickItemAssignmentEntity; import com.baiyue.firstleg.entity.LoadingPickOperationEntity; import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.repository.LoadingPickRepository; @@ -110,6 +112,47 @@ class LoadingPickServiceTest { verify(settlementService, never()).settle(20L, 9L, "operator"); } + @Test + void permitsItemAssigneeWithoutCabinetAssignee() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity item = item(); + when(repository.history("item-owner-pick")).thenReturn(null); + when(repository.task(20L)).thenReturn(task); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.item(20L, 30L, 101L)).thenReturn(item); + when(repository.itemAssignment(30L)).thenReturn(itemAssignment(9L, "operator")); + when(repository.pick(30L, 2, "MANUAL", "[]", 0, 101L)).thenReturn(1); + when(repository.touchTask(20L, 0)).thenReturn(1); + when(repository.items(20L, 101L)).thenReturn(List.of(item)); + + service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "item-owner-pick"), + 9L, "operator"); + + } + + @Test + void permitsTwoWorkersToClaimDifferentItemsInOneCabinet() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity first = item(); + LoadingPickItemEntity second = item(); + second.setId(31L); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.task(20L)).thenReturn(task); + when(repository.item(20L, 30L)).thenReturn(first); + when(repository.item(20L, 31L)).thenReturn(second); + when(repository.itemAssignment(30L)).thenReturn(null); + when(repository.itemAssignment(31L)).thenReturn(null); + when(repository.createItemAssignment(30L, 9L, "worker-a", 9L, "worker-a", "CLAIMED")).thenReturn(1); + when(repository.createItemAssignment(31L, 10L, "worker-b", 10L, "worker-b", "CLAIMED")).thenReturn(1); + when(repository.items(20L)).thenReturn(List.of(first, second)); + + service.claimItem(20L, 30L, new ClaimRequest(0, "claim-a"), 9L, "worker-a"); + service.claimItem(20L, 31L, new ClaimRequest(0, "claim-b"), 10L, "worker-b"); + + verify(repository).createItemAssignment(30L, 9L, "worker-a", 9L, "worker-a", "CLAIMED"); + verify(repository).createItemAssignment(31L, 10L, "worker-b", 10L, "worker-b", "CLAIMED"); + } + @Test void reviewSettlesResolvedItemsAndCompletesPlan() { LoadingPickTaskEntity task = task(); @@ -187,4 +230,13 @@ class LoadingPickServiceTest { assignment.setVersion(0); return assignment; } + + private static LoadingPickItemAssignmentEntity itemAssignment(long assigneeId, String assigneeName) { + LoadingPickItemAssignmentEntity assignment = new LoadingPickItemAssignmentEntity(); + assignment.setPickItemId(30L); + assignment.setAssigneeId(assigneeId); + assignment.setAssigneeName(assigneeName); + assignment.setVersion(0); + return assignment; + } }