feat(firstleg): converge phase3 loading models and pda

This commit is contained in:
2026-08-09 11:21:38 +08:00
parent 9b88fb4b9e
commit ed5b9fe77f
46 changed files with 2028 additions and 96 deletions
@@ -5,6 +5,11 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Operation;
import java.util.List;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.LoadingPickService;
@@ -31,7 +36,7 @@ public class PlatformLoadingPdaController {
private LoadingPickService service;
@GetMapping("/tasks")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<Task> task(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String containerNo,
@RequestParam @Positive long warehouseId) {
@@ -39,8 +44,26 @@ public class PlatformLoadingPdaController {
return ApiResponse.success(service.task(containerNo, warehouseId));
}
@PostMapping("/tasks/{taskId}/claim")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> claim(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@Valid @RequestBody ClaimRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.claim(taskId, request, user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/assign")
@PreAuthorize("hasAuthority('platform:firstleg:pda:assign')")
public ApiResponse<Task> assign(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@Valid @RequestBody AssignRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.assign(taskId, request, user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/items/{itemId}/pick")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> pick(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long itemId,
@@ -50,7 +73,7 @@ public class PlatformLoadingPdaController {
}
@PostMapping("/tasks/{taskId}/items/{itemId}/partial")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> partial(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long itemId,
@@ -60,7 +83,7 @@ public class PlatformLoadingPdaController {
}
@PostMapping("/tasks/{taskId}/items/{itemId}/skip")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> skip(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long itemId,
@@ -68,4 +91,21 @@ public class PlatformLoadingPdaController {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.skip(taskId, itemId, request, user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/review")
@PreAuthorize("hasAuthority('platform:firstleg:pda:review')")
public ApiResponse<Task> review(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@Valid @RequestBody ReviewRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.review(taskId, request, user.userId(), user.username()));
}
@GetMapping("/tasks/{taskId}/operations")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<List<Operation>> operations(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.operations(taskId));
}
}
@@ -7,6 +7,7 @@ import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
import java.time.LocalDateTime;
/** PDA 装柜拣货接口模型。 */
public final class LoadingPickDtos {
@@ -41,12 +42,46 @@ public final class LoadingPickDtos {
) {
}
public record ClaimRequest(
@NotNull @Min(0) Integer expectedAssignmentVersion,
@NotBlank @Size(max = 128) String operationId
) {
}
public record AssignRequest(
@NotNull @Min(1) Long assigneeId,
@NotBlank @Size(max = 64) String assigneeName,
@NotNull @Min(0) Integer expectedAssignmentVersion,
@NotBlank @Size(max = 128) String operationId
) {
}
public record ReviewRequest(
@NotNull @Min(0) Integer expectedTaskVersion,
@NotBlank @Size(max = 128) String operationId,
@Size(max = 500) String remark
) {
}
public record PickItem(Long id, Long loadingPlanItemId, Long inboundId, Long inboundDetailId,
Integer plannedPackageCount, Integer pickedPackageCount,
String itemStatus, Integer version) {
}
public record Assignment(Long assigneeId, String assigneeName, Long assignedBy,
String assignedByName, String assignmentType, Integer version) {
}
public record Review(String status, Long reviewedBy, String reviewedByName,
String remark, Integer version) {
}
public record Operation(Long id, Long pickTaskId, String operationId, String actionType,
Long operatorId, String operatorName, LocalDateTime createdAt) {
}
public record Task(Long id, Long loadingPlanId, String containerNo, String taskStatus,
Integer version, List<PickItem> items) {
String workflowStatus, Integer version, Assignment assignment,
Review review, List<PickItem> items) {
}
}
@@ -0,0 +1,23 @@
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;
@Getter
@Setter
@TableName("fl_loading_pick_assignment")
public class LoadingPickAssignmentEntity {
@TableId private Long pickTaskId;
private Long assigneeId;
private String assigneeName;
private Long assignedBy;
private String assignedByName;
private String assignmentType;
private Integer version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_loading_pick_operation")
public class LoadingPickOperationEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long pickTaskId;
private String operationId;
private String requestHash;
private String actionType;
private Long operatorId;
private String operatorName;
private LocalDateTime createdAt;
}
@@ -0,0 +1,22 @@
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;
@Getter
@Setter
@TableName("fl_loading_pick_review")
public class LoadingPickReviewEntity {
@TableId private Long pickTaskId;
private String reviewStatus;
private Long reviewedBy;
private String reviewedByName;
private String remark;
private Integer version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,52 @@
package com.baiyue.firstleg.loading.bridge.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.BindRequest;
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Validated
@RestController
public class PlatformLoadingModelBridgeController {
@Resource private LoadingModelBridgeService service;
@PostMapping("/api/platform/v1/inbounds/{inboundId}/preload/loading-plans/{loadingPlanId}")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Link> bind(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @Positive long tenantId,
@PathVariable @Positive long inboundId, @PathVariable @Positive long loadingPlanId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody BindRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.bind(tenantId, inboundId, loadingPlanId, user.userId(),
user.username(), idempotencyKey, request));
}
@GetMapping("/api/platform/v1/inbounds/{inboundId}/preload/loading-plans")
@PreAuthorize("hasAuthority('firstleg:inbound:preload-view')")
public ApiResponse<List<Link>> byInbound(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @Positive long tenantId,
@PathVariable @Positive long inboundId) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.byInbound(tenantId, inboundId));
}
@GetMapping("/api/platform/v1/first-leg/smart-loading/plans/{loadingPlanId}/preloads")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<List<Link>> byLoadingPlan(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long loadingPlanId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.byLoadingPlan(loadingPlanId));
}
}
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.loading.bridge.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDateTime;
public final class LoadingModelBridgeDtos {
private LoadingModelBridgeDtos() {}
public record BindRequest(
@NotNull @Min(0) Integer expectedPreloadVersion,
@NotNull @Min(0) Integer expectedLoadingPlanVersion) {}
public record Link(
Long id,
Long tenantId,
Long inboundId,
Long preloadPlanId,
String preloadStatus,
Integer preloadVersion,
Long loadingPlanId,
Long containerBookingId,
String containerNo,
String loadingPlanStatus,
Integer loadingPlanVersion,
Integer allocatedPackageCount,
LocalDateTime linkedAt) {}
}
@@ -0,0 +1,26 @@
package com.baiyue.firstleg.loading.bridge.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_preload_loading_plan_link")
public class PreloadLoadingPlanLinkEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Long preloadPlanId;
private Long loadingPlanId;
private String idempotencyKey;
private String requestHash;
private Long createdBy;
private String createdByName;
private LocalDateTime createdAt;
}
@@ -0,0 +1,73 @@
package com.baiyue.firstleg.loading.bridge.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.loading.bridge.entity.PreloadLoadingPlanLinkEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface LoadingModelBridgeMapper extends BaseMapper<PreloadLoadingPlanLinkEntity> {
@Select("SELECT id,tenant_id tenantId,inbound_id inboundId,status,version "
+ "FROM fl_preload_plan WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId}")
PreloadSnapshot preload(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Select("SELECT p.id,p.container_booking_id containerBookingId,b.container_no containerNo,"
+ "p.plan_status status,p.version FROM fl_loading_plan p JOIN fl_container_booking b "
+ "ON b.id=p.container_booking_id WHERE p.id=#{loadingPlanId}")
LoadingPlanSnapshot loadingPlan(@Param("loadingPlanId") long loadingPlanId);
@Select("SELECT COALESCE(SUM(package_count),0) FROM fl_loading_plan_item "
+ "WHERE loading_plan_id=#{loadingPlanId} AND account_id=#{tenantId} "
+ "AND inbound_id=#{inboundId} AND deleted=0")
int allocatedPackages(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("loadingPlanId") long loadingPlanId);
@Update("UPDATE fl_preload_plan SET updated_by=#{operatorId},updated_by_name=#{operatorName},"
+ "version=version+1 WHERE id=#{preloadPlanId} AND tenant_id=#{tenantId} "
+ "AND inbound_id=#{inboundId} AND status='PENDING' AND version=#{expectedVersion}")
int touchPreload(@Param("preloadPlanId") long preloadPlanId, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("UPDATE fl_loading_plan SET updated_by=#{operatorId},updated_by_name=#{operatorName},"
+ "version=version+1 WHERE id=#{loadingPlanId} AND plan_status='EDITING' "
+ "AND version=#{expectedVersion}")
int touchLoadingPlan(@Param("loadingPlanId") long loadingPlanId,
@Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Select(LINK_SELECT + " WHERE link.tenant_id=#{tenantId} AND link.inbound_id=#{inboundId}"
+ " ORDER BY link.created_at,link.id")
List<Link> byInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Select(LINK_SELECT + " WHERE link.loading_plan_id=#{loadingPlanId}"
+ " ORDER BY link.tenant_id,link.inbound_id,link.id")
List<Link> byLoadingPlan(@Param("loadingPlanId") long loadingPlanId);
String LINK_SELECT = """
SELECT link.id,link.tenant_id tenantId,link.inbound_id inboundId,
link.preload_plan_id preloadPlanId,preload.status preloadStatus,
preload.version preloadVersion,link.loading_plan_id loadingPlanId,
plan.container_booking_id containerBookingId,booking.container_no containerNo,
plan.plan_status loadingPlanStatus,plan.version loadingPlanVersion,
COALESCE((SELECT SUM(item.package_count) FROM fl_loading_plan_item item
WHERE item.loading_plan_id=link.loading_plan_id AND item.account_id=link.tenant_id
AND item.inbound_id=link.inbound_id AND item.deleted=0),0) allocatedPackageCount,
link.created_at linkedAt
FROM fl_preload_loading_plan_link link
JOIN fl_preload_plan preload ON preload.id=link.preload_plan_id
AND preload.tenant_id=link.tenant_id AND preload.inbound_id=link.inbound_id
JOIN fl_loading_plan plan ON plan.id=link.loading_plan_id
JOIN fl_container_booking booking ON booking.id=plan.container_booking_id
""";
record PreloadSnapshot(Long id, Long tenantId, Long inboundId, String status, Integer version) {}
record LoadingPlanSnapshot(Long id, Long containerBookingId, String containerNo,
String status, Integer version) {}
}
@@ -0,0 +1,54 @@
package com.baiyue.firstleg.loading.bridge.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.loading.bridge.entity.PreloadLoadingPlanLinkEntity;
import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class LoadingModelBridgeRepository {
@Resource private LoadingModelBridgeMapper mapper;
public LoadingModelBridgeMapper.PreloadSnapshot preload(long tenantId, long inboundId) {
return mapper.preload(tenantId, inboundId);
}
public LoadingModelBridgeMapper.LoadingPlanSnapshot loadingPlan(long loadingPlanId) {
return mapper.loadingPlan(loadingPlanId);
}
public int allocatedPackages(long tenantId, long inboundId, long loadingPlanId) {
return mapper.allocatedPackages(tenantId, inboundId, loadingPlanId);
}
public PreloadLoadingPlanLinkEntity byPair(long tenantId, long inboundId, long loadingPlanId) {
return mapper.selectOne(new LambdaQueryWrapper<PreloadLoadingPlanLinkEntity>()
.eq(PreloadLoadingPlanLinkEntity::getTenantId, tenantId)
.eq(PreloadLoadingPlanLinkEntity::getInboundId, inboundId)
.eq(PreloadLoadingPlanLinkEntity::getLoadingPlanId, loadingPlanId));
}
public PreloadLoadingPlanLinkEntity byIdempotency(long operatorId, String key) {
return mapper.selectOne(new LambdaQueryWrapper<PreloadLoadingPlanLinkEntity>()
.eq(PreloadLoadingPlanLinkEntity::getCreatedBy, operatorId)
.eq(PreloadLoadingPlanLinkEntity::getIdempotencyKey, key));
}
public void insert(PreloadLoadingPlanLinkEntity value) { mapper.insert(value); }
public int touchPreload(long preloadPlanId, long tenantId, long inboundId, int version,
long operatorId, String operatorName) {
return mapper.touchPreload(preloadPlanId, tenantId, inboundId, version, operatorId, operatorName);
}
public int touchLoadingPlan(long loadingPlanId, int version, long operatorId, String operatorName) {
return mapper.touchLoadingPlan(loadingPlanId, version, operatorId, operatorName);
}
public List<Link> byInbound(long tenantId, long inboundId) { return mapper.byInbound(tenantId, inboundId); }
public List<Link> byLoadingPlan(long loadingPlanId) { return mapper.byLoadingPlan(loadingPlanId); }
}
@@ -0,0 +1,119 @@
package com.baiyue.firstleg.loading.bridge.service;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.BindRequest;
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.loading.bridge.entity.PreloadLoadingPlanLinkEntity;
import com.baiyue.firstleg.loading.bridge.repository.LoadingModelBridgeRepository;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Set;
@Service
public class LoadingModelBridgeService {
@Resource private LoadingModelBridgeRepository repository;
@Transactional
@BusinessAudit(event = "PRELOAD_LOADING_PLAN_BIND", resourceType = "LOADING_PLAN",
resourceId = "#loadingPlanId", description = "关联入库预装与柜次装柜计划")
public Link bind(long tenantId, long inboundId, long loadingPlanId, long operatorId,
String operatorName, String idempotencyKey, BindRequest request) {
String key = validateKey(idempotencyKey);
String requestHash = hash(tenantId + "|" + inboundId + "|" + loadingPlanId + "|"
+ request.expectedPreloadVersion() + "|" + request.expectedLoadingPlanVersion());
PreloadLoadingPlanLinkEntity replay = repository.byIdempotency(operatorId, key);
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency-Key已用于不同关联请求");
return requireLink(tenantId, inboundId, loadingPlanId);
}
if (repository.byPair(tenantId, inboundId, loadingPlanId) != null) {
return requireLink(tenantId, inboundId, loadingPlanId);
}
var preload = repository.preload(tenantId, inboundId);
if (preload == null) throw new BizException(ResultCode.NOT_FOUND, "入库单预装计划不存在");
var loadingPlan = repository.loadingPlan(loadingPlanId);
if (loadingPlan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在");
if (!"PENDING".equals(preload.status()) || !"EDITING".equals(loadingPlan.status())) {
throw conflict("仅待确认预装和编辑中柜次计划可以建立关联");
}
if (repository.allocatedPackages(tenantId, inboundId, loadingPlanId) <= 0) {
throw conflict("柜次计划尚未分配当前入库单货物");
}
PreloadLoadingPlanLinkEntity link = new PreloadLoadingPlanLinkEntity();
link.setTenantId(tenantId); link.setInboundId(inboundId); link.setPreloadPlanId(preload.id());
link.setLoadingPlanId(loadingPlanId); link.setIdempotencyKey(key); link.setRequestHash(requestHash);
link.setCreatedBy(operatorId); link.setCreatedByName(operatorName);
try {
repository.insert(link);
} catch (DuplicateKeyException exception) {
throw conflict("相同预装与柜次关联正在处理中,请刷新后重试");
}
if (repository.touchPreload(preload.id(), tenantId, inboundId, request.expectedPreloadVersion(),
operatorId, operatorName) != 1) throw concurrent();
if (repository.touchLoadingPlan(loadingPlanId, request.expectedLoadingPlanVersion(),
operatorId, operatorName) != 1) throw concurrent();
return requireLink(tenantId, inboundId, loadingPlanId);
}
public List<Link> byInbound(long tenantId, long inboundId) { return repository.byInbound(tenantId, inboundId); }
public List<Link> byLoadingPlan(long loadingPlanId) { return repository.byLoadingPlan(loadingPlanId); }
public void requireStarted(long tenantId, long inboundId) {
List<Link> links = requireLinks(tenantId, inboundId);
if (links.stream().anyMatch(link -> link.allocatedPackageCount() == null || link.allocatedPackageCount() <= 0
|| !Set.of("LOADING", "COMPLETED").contains(link.loadingPlanStatus()))) {
throw conflict("关联柜次尚未全部开始装柜");
}
}
public void requireCompleted(long tenantId, long inboundId) {
List<Link> links = requireLinks(tenantId, inboundId);
if (links.stream().anyMatch(link -> link.allocatedPackageCount() == null || link.allocatedPackageCount() <= 0
|| !"COMPLETED".equals(link.loadingPlanStatus()))) {
throw conflict("关联柜次尚未全部完成装柜");
}
}
private List<Link> requireLinks(long tenantId, long inboundId) {
List<Link> links = repository.byInbound(tenantId, inboundId);
if (links.isEmpty()) throw conflict("入库单尚未关联柜次装柜计划");
return links;
}
private Link requireLink(long tenantId, long inboundId, long loadingPlanId) {
return repository.byInbound(tenantId, inboundId).stream()
.filter(link -> Objects.equals(link.loadingPlanId(), loadingPlanId)).findFirst()
.orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "预装与柜次关联不存在"));
}
private static String validateKey(String key) {
String value = key == null ? null : key.trim();
if (!StringUtils.hasText(value) || value.length() > 128) {
throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空且长度不能超过128");
}
return value;
}
private static String hash(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (Exception impossible) { throw new IllegalStateException(impossible); }
}
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); }
}
@@ -5,9 +5,21 @@ import com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import org.apache.ibatis.annotations.Insert;
@Mapper
public interface InboundLoadingPickTaskMapper extends BaseMapper<InboundLoadingPickTaskEntity> {
@Insert("INSERT INTO fl_inbound_loading_pick_task "
+ "(id,tenant_id,inbound_id,status,version,expected_count,picked_count,remark,updated_by,updated_by_name) "
+ "VALUES (#{id},#{tenantId},#{inboundId},#{status},0,#{expectedCount},#{pickedCount},'柜次PDA自动汇总',#{operatorId},#{operatorName}) "
+ "ON DUPLICATE KEY UPDATE status=VALUES(status),expected_count=VALUES(expected_count),"
+ "picked_count=VALUES(picked_count),remark='柜次PDA自动汇总',updated_by=VALUES(updated_by),"
+ "updated_by_name=VALUES(updated_by_name),version=version+1")
int upsertProjection(@Param("id") long id, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("status") String status,
@Param("expectedCount") int expectedCount, @Param("pickedCount") int pickedCount,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("UPDATE fl_inbound_loading_pick_task SET status=#{toStatus}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, "
+ "remark=COALESCE(#{remark}, remark), expected_count=COALESCE(#{expectedCount}, expected_count), "
+ "picked_count=COALESCE(#{pickedCount}, picked_count), version=version+1 WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} "
@@ -14,7 +14,9 @@ import com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity;
import com.baiyue.firstleg.loading.domain.PickingStatus;
import com.baiyue.firstleg.loading.dto.LoadingResponses.PickingResponse;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.loading.repository.LoadingRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
@@ -41,6 +43,8 @@ public class LoadingDeliveryService {
private static final String COMPLETE_DELIVERY = "COMPLETE_DELIVERY";
@Resource private LoadingRepository repository;
@Resource private LoadingPickRepository loadingPicks;
@Resource private LoadingModelBridgeService loadingModelBridge;
@Resource private InboundStatusHistoryMapper histories;
@Resource private LoadingEvidenceFileClient files;
@Resource private ObjectMapper objectMapper;
@@ -100,10 +104,12 @@ public class LoadingDeliveryService {
if (replay != null) return replay;
Snapshot inbound = requireInbound(tenantId, inboundId);
if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) {
loadingModelBridge.requireCompleted(tenantId, inboundId);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse(inbound.processStatus(), evidenceResponse(tenantId, inboundId)));
}
if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中");
loadingModelBridge.requireCompleted(tenantId, inboundId);
requireCompletedPicking(tenantId, inboundId);
requireCompletionEvidence(tenantId, inboundId);
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
@@ -142,11 +148,8 @@ public class LoadingDeliveryService {
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "START_PICKING", key, hash);
if (replay != null) return pickResponse(tenantId, inboundId);
requireLoadingStage(tenantId, inboundId);
if (request.expectedCount() == null || request.expectedCount() <= 0
|| request.pickedCount() == null || request.pickedCount() < 0
|| request.pickedCount() > request.expectedCount()) {
throw new BizException(ResultCode.BAD_REQUEST, "拣货计划数量必须大于0且已拣数量不能超过计划数量");
}
CanonicalPicking canonical = canonicalPicking(tenantId, inboundId);
requireMatchingPickingRequest(request, canonical);
InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
if (task == null) {
if (request.expectedVersion() != 0) throw concurrent();
@@ -172,10 +175,9 @@ public class LoadingDeliveryService {
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key, hash);
if (replay != null) return pickResponse(tenantId, inboundId);
requireLoadingStage(tenantId, inboundId);
if (request.expectedCount() == null || request.expectedCount() <= 0
|| request.pickedCount() == null || !request.expectedCount().equals(request.pickedCount())) {
throw new BizException(ResultCode.CONFLICT, "拣货复核数量必须与计划数量一致");
}
CanonicalPicking canonical = canonicalPicking(tenantId, inboundId);
requireMatchingPickingRequest(request, canonical);
if (!canonical.completed()) throw new BizException(ResultCode.CONFLICT, "柜次PDA任务尚未完成复核");
InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
if (task == null || !PickingStatus.IN_PROGRESS.equals(task.getStatus())) {
throw new BizException(ResultCode.CONFLICT, task == null ? "拣货任务尚未开始" : "拣货任务不在作业中");
@@ -204,14 +206,40 @@ public class LoadingDeliveryService {
}
private void requireCompletedPicking(long tenantId, long inboundId) {
InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
if (task == null || !PickingStatus.COMPLETED.equals(task.getStatus())
|| task.getExpectedCount() == null || task.getExpectedCount() <= 0
|| !task.getExpectedCount().equals(task.getPickedCount())) {
if (!canonicalPicking(tenantId, inboundId).completed()) {
throw new BizException(ResultCode.CONFLICT, "PDA拣货复核尚未完成");
}
}
private CanonicalPicking canonicalPicking(long tenantId, long inboundId) {
var progress = loadingPicks.inboundProgress(tenantId, inboundId);
int expected = progress == null || progress.getExpectedCount() == null ? 0 : progress.getExpectedCount();
int picked = progress == null || progress.getPickedCount() == null ? 0 : progress.getPickedCount();
int tasks = progress == null || progress.getTaskCount() == null ? 0 : progress.getTaskCount();
int completed = progress == null || progress.getCompletedTaskCount() == null
? 0 : progress.getCompletedTaskCount();
int unresolved = progress == null || progress.getUnresolvedCount() == null
? 0 : progress.getUnresolvedCount();
if (expected <= 0 || tasks <= 0) throw new BizException(ResultCode.CONFLICT, "未找到真实柜次PDA拣货任务");
return new CanonicalPicking(expected, picked, tasks, completed, unresolved);
}
private static void requireMatchingPickingRequest(PickingRequest request, CanonicalPicking canonical) {
if (request.expectedCount() == null || request.pickedCount() == null
|| request.expectedCount() != canonical.expectedCount()
|| request.pickedCount() != canonical.pickedCount()) {
throw new BizException(ResultCode.CONFLICT, "拣货数量与柜次PDA真实汇总不一致");
}
}
private record CanonicalPicking(int expectedCount, int pickedCount, int taskCount,
int completedTaskCount, int unresolvedCount) {
boolean completed() {
return expectedCount > 0 && expectedCount == pickedCount && taskCount > 0
&& completedTaskCount == taskCount && unresolvedCount == 0;
}
}
private void requireCompletionEvidence(long tenantId, long inboundId) {
Map<String, LoadingEvidenceEntity> values = repository.listEvidence(tenantId, inboundId).stream()
.collect(Collectors.toMap(LoadingEvidenceEntity::getEvidenceType, Function.identity(), (left, right) -> right));
@@ -0,0 +1,26 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
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 LoadingPickAssignmentMapper extends BaseMapper<LoadingPickAssignmentEntity> {
@Insert("INSERT INTO fl_loading_pick_assignment "
+ "(pick_task_id,assignee_id,assignee_name,assigned_by,assigned_by_name,assignment_type,version) "
+ "VALUES (#{taskId},#{assigneeId},#{assigneeName},#{operatorId},#{operatorName},#{type},0)")
int create(@Param("taskId") long taskId, @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_assignment SET assignee_id=#{assigneeId},assignee_name=#{assigneeName},"
+ "assigned_by=#{operatorId},assigned_by_name=#{operatorName},assignment_type=#{type},version=version+1 "
+ "WHERE pick_task_id=#{taskId} AND version=#{expectedVersion}")
int reassign(@Param("taskId") long taskId, @Param("assigneeId") long assigneeId,
@Param("assigneeName") String assigneeName, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName, @Param("type") String type,
@Param("expectedVersion") int expectedVersion);
}
@@ -9,6 +9,63 @@ import org.apache.ibatis.annotations.Update;
@Mapper
public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity> {
@Update("UPDATE fl_loading_plan_item SET package_count=#{pickedCount},updated_by=#{operatorId},"
+ "updated_by_name=#{operatorName},version=version+1 WHERE id=#{planItemId} AND deleted=0 "
+ "AND package_count=#{plannedCount}")
int settlePartialPlanItem(@Param("planItemId") long planItemId,
@Param("pickedCount") int pickedCount,
@Param("plannedCount") int plannedCount,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("UPDATE fl_loading_pick_item SET planned_package_count=picked_package_count,"
+ "item_status='PICKED',version=version+1 WHERE id=#{itemId} AND item_status='PARTIAL' "
+ "AND picked_package_count>0 AND picked_package_count<planned_package_count")
int settlePartialPickItem(@Param("itemId") long itemId);
@Update("UPDATE fl_loading_plan_item SET deleted=1,removed_by=#{operatorId},"
+ "removed_by_name=#{operatorName},removed_at=CURRENT_TIMESTAMP(3),updated_by=#{operatorId},"
+ "updated_by_name=#{operatorName},version=version+1 WHERE id=#{planItemId} AND deleted=0 "
+ "AND EXISTS (SELECT 1 FROM fl_loading_pick_item pick_item WHERE pick_item.loading_plan_item_id=#{planItemId} "
+ "AND pick_item.item_status='SKIPPED' AND pick_item.picked_package_count=0)")
int settleSkippedPlanItem(@Param("planItemId") long planItemId,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Select("""
SELECT COALESCE(SUM(plan_item.package_count),0) expectedCount,
COALESCE(SUM(pick_item.picked_package_count),0) pickedCount,
COUNT(DISTINCT pick_task.id) taskCount,
COUNT(DISTINCT CASE WHEN pick_task.task_status='COMPLETED' THEN pick_task.id END) completedTaskCount,
SUM(CASE WHEN pick_item.id IS NULL OR pick_item.picked_package_count<>plan_item.package_count THEN 1 ELSE 0 END) unresolvedCount
FROM fl_loading_plan_item plan_item
JOIN fl_inbound_order inbound_order ON inbound_order.id=plan_item.inbound_id
AND inbound_order.tenant_id=#{tenantId} AND inbound_order.deleted=0
LEFT JOIN fl_loading_pick_item pick_item ON pick_item.loading_plan_item_id=plan_item.id
LEFT JOIN fl_loading_pick_task pick_task ON pick_task.id=pick_item.pick_task_id
WHERE plan_item.account_id=#{tenantId} AND plan_item.inbound_id=#{inboundId} AND plan_item.deleted=0
""")
InboundProgress selectInboundProgress(@Param("tenantId") long tenantId,
@Param("inboundId") long inboundId);
interface InboundProgress {
Integer getExpectedCount();
Integer getPickedCount();
Integer getTaskCount();
Integer getCompletedTaskCount();
Integer getUnresolvedCount();
}
@Select("""
SELECT DISTINCT plan_item.account_id tenantId,plan_item.inbound_id inboundId
FROM fl_loading_pick_item pick_item
JOIN fl_loading_plan_item plan_item ON plan_item.id=pick_item.loading_plan_item_id
WHERE pick_item.pick_task_id=#{taskId}
""")
java.util.List<InboundOwner> selectInboundOwners(@Param("taskId") long taskId);
record InboundOwner(Long tenantId, Long inboundId) {}
@Select("""
SELECT pick_item.*
FROM fl_loading_pick_item pick_item
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingPickOperationEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface LoadingPickOperationMapper extends BaseMapper<LoadingPickOperationEntity> {
}
@@ -0,0 +1,16 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingPickReviewEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface LoadingPickReviewMapper extends BaseMapper<LoadingPickReviewEntity> {
@Insert("INSERT INTO fl_loading_pick_review "
+ "(pick_task_id,review_status,reviewed_by,reviewed_by_name,remark,version) "
+ "VALUES (#{taskId},'APPROVED',#{operatorId},#{operatorName},#{remark},0)")
int approve(@Param("taskId") long taskId, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName, @Param("remark") String remark);
}
@@ -5,9 +5,17 @@ import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> {
@Select("SELECT * FROM fl_loading_pick_task WHERE id=#{taskId} FOR UPDATE")
LoadingPickTaskEntity selectForUpdate(@Param("taskId") long taskId);
@Update("UPDATE fl_loading_pick_task SET version=version+1 WHERE id=#{taskId} "
+ "AND task_status='LOADING' AND version=#{expectedVersion}")
int touch(@Param("taskId") long taskId, @Param("expectedVersion") int expectedVersion);
@Select("""
SELECT DISTINCT task.*
FROM fl_loading_pick_task task
@@ -8,6 +8,14 @@ import com.baiyue.firstleg.mapper.LoadingPickHistoryMapper;
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.LoadingPickOperationMapper;
import com.baiyue.firstleg.mapper.LoadingPickReviewMapper;
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
import com.baiyue.firstleg.entity.LoadingPickOperationEntity;
import com.baiyue.firstleg.entity.LoadingPickReviewEntity;
import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
@@ -22,11 +30,27 @@ public class LoadingPickRepository {
private LoadingPickItemMapper items;
@Resource
private LoadingPickHistoryMapper histories;
@Resource
private LoadingPickAssignmentMapper assignments;
@Resource
private LoadingPickReviewMapper reviews;
@Resource
private LoadingPickOperationMapper operations;
@Resource
private InboundLoadingPickTaskMapper inboundProjections;
public LoadingPickTaskEntity task(long taskId) {
return tasks.selectById(taskId);
}
public LoadingPickTaskEntity taskForUpdate(long taskId) {
return tasks.selectForUpdate(taskId);
}
public int touchTask(long taskId, int expectedVersion) {
return tasks.touch(taskId, expectedVersion);
}
public LoadingPickTaskEntity taskByPlan(long planId) {
return tasks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
.eq(LoadingPickTaskEntity::getLoadingPlanId, planId));
@@ -111,4 +135,78 @@ public class LoadingPickRepository {
public void deleteItem(long itemId) {
items.deleteById(itemId);
}
public LoadingPickAssignmentEntity assignment(long taskId) {
return assignments.selectById(taskId);
}
public int createAssignment(long taskId, long assigneeId, String assigneeName,
long operatorId, String operatorName, String type) {
return assignments.create(taskId, assigneeId, assigneeName, operatorId, operatorName, type);
}
public int reassign(long taskId, long assigneeId, String assigneeName, long operatorId,
String operatorName, String type, int expectedVersion) {
return assignments.reassign(taskId, assigneeId, assigneeName, operatorId, operatorName,
type, expectedVersion);
}
public LoadingPickReviewEntity review(long taskId) {
return reviews.selectById(taskId);
}
public int approve(long taskId, long operatorId, String operatorName, String remark) {
return reviews.approve(taskId, operatorId, operatorName, remark);
}
public LoadingPickOperationEntity operation(String operationId) {
return operations.selectOne(new LambdaQueryWrapper<LoadingPickOperationEntity>()
.eq(LoadingPickOperationEntity::getOperationId, operationId));
}
public void insert(LoadingPickOperationEntity operation) {
operations.insert(operation);
}
public List<LoadingPickOperationEntity> operations(long taskId) {
return operations.selectList(new LambdaQueryWrapper<LoadingPickOperationEntity>()
.eq(LoadingPickOperationEntity::getPickTaskId, taskId)
.orderByAsc(LoadingPickOperationEntity::getCreatedAt));
}
public LoadingPickItemMapper.InboundProgress inboundProgress(long tenantId, long inboundId) {
return items.selectInboundProgress(tenantId, inboundId);
}
public int settlePartial(long planItemId, long pickItemId, int pickedCount, int plannedCount,
long operatorId, String operatorName) {
if (items.settlePartialPlanItem(planItemId, pickedCount, plannedCount,
operatorId, operatorName) != 1) {
return 0;
}
return items.settlePartialPickItem(pickItemId);
}
public int settleSkipped(long planItemId, long operatorId, String operatorName) {
return items.settleSkippedPlanItem(planItemId, operatorId, operatorName);
}
public void syncInboundProjections(long taskId, long operatorId, String operatorName) {
for (LoadingPickItemMapper.InboundOwner owner : items.selectInboundOwners(taskId)) {
LoadingPickItemMapper.InboundProgress progress = inboundProgress(owner.tenantId(), owner.inboundId());
int taskCount = value(progress == null ? null : progress.getTaskCount());
int completed = value(progress == null ? null : progress.getCompletedTaskCount());
int unresolved = value(progress == null ? null : progress.getUnresolvedCount());
int expected = value(progress == null ? null : progress.getExpectedCount());
int picked = value(progress == null ? null : progress.getPickedCount());
String status = taskCount > 0 && completed == taskCount && unresolved == 0 && expected == picked
? "COMPLETED" : picked > 0 ? "IN_PROGRESS" : "PENDING";
inboundProjections.upsertProjection(IdWorker.getId(), owner.tenantId(), owner.inboundId(), status,
expected, picked, operatorId, operatorName);
}
}
private static int value(Integer value) {
return value == null ? 0 : value;
}
}
@@ -7,8 +7,17 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.dto.LoadingPickDtos.Assignment;
import com.baiyue.firstleg.dto.LoadingPickDtos.Review;
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
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.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import com.baiyue.firstleg.entity.LoadingPickOperationEntity;
import com.baiyue.firstleg.entity.LoadingPickReviewEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.repository.LoadingPickRepository;
@@ -55,6 +64,7 @@ public class LoadingPickService {
for (LoadingPlanItemEntity planItem : planItems) {
syncItem(task.getId(), planItem);
}
repository.syncInboundProjections(task.getId(), operatorId, operatorName);
}
@Transactional
@@ -79,11 +89,59 @@ public class LoadingPickService {
return view(task, warehouseId);
}
@Transactional
public Task claim(long taskId, ClaimRequest request, long operatorId, String operatorName) {
String operationId = operation(request.operationId());
String requestHash = hash("CLAIM|" + taskId + '|' + request.expectedAssignmentVersion());
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
LoadingPickAssignmentEntity existing = repository.assignment(taskId);
if (existing == null) {
if (request.expectedAssignmentVersion() != 0
|| repository.createAssignment(taskId, operatorId, operatorName, operatorId,
operatorName, "CLAIMED") != 1) {
throw conflict("PDA任务已被其他作业员领取");
}
} else {
if (!Long.valueOf(operatorId).equals(existing.getAssigneeId())) {
throw conflict("PDA任务已分配给其他作业员");
}
if (!request.expectedAssignmentVersion().equals(existing.getVersion())) {
throw conflict("PDA任务分配版本已变化");
}
}
insertOperation(taskId, operationId, requestHash, "CLAIM", operatorId, operatorName);
return view(task, null);
}
@Transactional
public Task assign(long taskId, AssignRequest request, long operatorId, String operatorName) {
String operationId = operation(request.operationId());
String assigneeName = request.assigneeName().trim();
String requestHash = hash("ASSIGN|" + taskId + '|' + request.assigneeId() + '|'
+ assigneeName + '|' + request.expectedAssignmentVersion());
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
LoadingPickAssignmentEntity existing = repository.assignment(taskId);
int changed = existing == null
? request.expectedAssignmentVersion() == 0
? repository.createAssignment(taskId, request.assigneeId(), assigneeName, operatorId,
operatorName, "ASSIGNED") : 0
: repository.reassign(taskId, request.assigneeId(), assigneeName, operatorId, operatorName,
"ASSIGNED", request.expectedAssignmentVersion());
if (changed != 1) throw conflict("PDA任务分配版本已变化");
insertOperation(taskId, operationId, requestHash, "ASSIGN", operatorId, operatorName);
return view(task, null);
}
@Transactional
public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) {
String operationId = request.operationId().trim();
String requestHash = hash(taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + request.pickedPackageCount());
requireNoTaskOperation(operationId);
LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) {
if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId)
@@ -92,10 +150,7 @@ public class LoadingPickService {
}
return view(requiredTask(taskId), request.warehouseId());
}
LoadingPickTaskEntity task = requiredTask(taskId);
if (!"LOADING".equals(task.getTaskStatus())) {
throw conflict("装柜任务已完成,不能继续拣货");
}
LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId);
LoadingPickItemEntity item = repository.item(taskId, itemId, request.warehouseId());
if (item == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在");
@@ -113,7 +168,7 @@ public class LoadingPickService {
history.setOperatorId(operatorId);
history.setOperatorName(operatorName);
repository.insert(history);
finishIfResolved(task, operatorId, operatorName);
touchAndSync(task, operatorId, operatorName);
return view(requiredTask(taskId), request.warehouseId());
}
@@ -123,19 +178,20 @@ public class LoadingPickService {
String operationId = request.operationId().trim();
String requestHash = hash("PARTIAL|" + taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + request.finalPickedPackageCount());
requireNoTaskOperation(operationId);
LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) {
assertReplay(replay, taskId, itemId, requestHash);
return view(requiredTask(taskId), request.warehouseId());
}
LoadingPickTaskEntity task = requiredLoadingTask(taskId);
LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId);
requireWarehouseItem(taskId, itemId, request.warehouseId());
if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(), request.warehouseId()) != 1) {
throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化");
}
insertHistory(taskId, itemId, operationId, requestHash, "PARTIAL",
request.finalPickedPackageCount(), null, operatorId, operatorName);
finishIfResolved(task, operatorId, operatorName);
touchAndSync(task, operatorId, operatorName);
return view(requiredTask(taskId), request.warehouseId());
}
@@ -145,26 +201,68 @@ public class LoadingPickService {
String reason = trim(request.reason());
String requestHash = hash("SKIP|" + taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + reason);
requireNoTaskOperation(operationId);
LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) {
assertReplay(replay, taskId, itemId, requestHash);
return view(requiredTask(taskId), request.warehouseId());
}
LoadingPickTaskEntity task = requiredLoadingTask(taskId);
LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId);
requireWarehouseItem(taskId, itemId, request.warehouseId());
if (repository.skip(itemId, request.version(), request.warehouseId()) != 1) {
throw conflict("跳拣仅允许未开始拣货的待拣明细,或明细版本已变化");
}
insertHistory(taskId, itemId, operationId, requestHash, "SKIP", 0, reason,
operatorId, operatorName);
finishIfResolved(task, operatorId, operatorName);
touchAndSync(task, operatorId, operatorName);
return view(requiredTask(taskId), request.warehouseId());
}
private void finishIfResolved(LoadingPickTaskEntity task, long operatorId, String operatorName) {
if (repository.allPlanItemsPicked(task.getLoadingPlanId())) {
loadingListService.finishFromPda(task.getLoadingPlanId(), task.getId(), operatorId, operatorName);
@Transactional
public Task review(long taskId, ReviewRequest request, long operatorId, String operatorName) {
String operationId = operation(request.operationId());
String remark = trim(request.remark());
String requestHash = hash("REVIEW|" + taskId + '|' + request.expectedTaskVersion() + '|' + remark);
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
if (!request.expectedTaskVersion().equals(task.getVersion())) {
throw conflict("PDA任务版本已变化,请刷新后复核");
}
if (repository.review(taskId) != null) throw conflict("PDA任务已经复核");
List<LoadingPickItemEntity> taskItems = repository.items(taskId);
PdaPickSettlementPlan.Resolution resolution = PdaPickSettlementPlan.resolve(taskItems);
for (List<PdaPickSettlementPlan.SplitLine> lines : resolution.partialByInbound().values()) {
for (PdaPickSettlementPlan.SplitLine line : lines) {
if (repository.settlePartial(line.loadingPlanItemId(), line.pickItemId(),
line.pickedPackageCount(), line.pickedPackageCount() + line.remainingPackageCount(),
operatorId, operatorName) != 1) {
throw conflict("部分拣货结算版本已变化");
}
}
}
for (Long planItemId : resolution.skippedPlanItemIds()) {
if (repository.settleSkipped(planItemId, operatorId, operatorName) != 1) {
throw conflict("跳拣结算版本已变化");
}
}
if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) {
throw conflict("柜次仍有未完成PDA拣货的箱数");
}
if (repository.approve(taskId, operatorId, operatorName, remark) != 1) {
throw conflict("PDA任务已经复核");
}
insertOperation(taskId, operationId, requestHash, "REVIEW", operatorId, operatorName);
loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName);
repository.syncInboundProjections(taskId, operatorId, operatorName);
return view(requiredTask(taskId), null);
}
public List<Operation> operations(long taskId) {
requiredTask(taskId);
return repository.operations(taskId).stream().map(item -> new Operation(item.getId(), item.getPickTaskId(),
item.getOperationId(), item.getActionType(), item.getOperatorId(), item.getOperatorName(),
item.getCreatedAt())).toList();
}
@Transactional
@@ -218,19 +316,89 @@ public class LoadingPickService {
return task;
}
private LoadingPickTaskEntity requiredLoadingTaskForUpdate(long taskId) {
LoadingPickTaskEntity task = repository.taskForUpdate(taskId);
if (task == null) throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在");
if (!"LOADING".equals(task.getTaskStatus())) throw conflict("装柜任务已完成,不能继续拣货");
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拣货任务已分配给其他作业员");
}
return task;
}
private void requireWarehouseItem(long taskId, long itemId, long warehouseId) {
if (repository.item(taskId, itemId, warehouseId) == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在或不属于当前仓库");
}
}
private Task view(LoadingPickTaskEntity task, long warehouseId) {
List<PickItem> items = repository.items(task.getId(), warehouseId).stream()
private Task view(LoadingPickTaskEntity task, Long warehouseId) {
List<LoadingPickItemEntity> source = warehouseId == null
? repository.items(task.getId()) : repository.items(task.getId(), warehouseId);
List<PickItem> items = source.stream()
.map(item -> new PickItem(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(),
item.getInboundDetailId(), item.getPlannedPackageCount(), item.getPickedPackageCount(),
item.getItemStatus(), item.getVersion())).toList();
LoadingPickAssignmentEntity assigned = repository.assignment(task.getId());
LoadingPickReviewEntity reviewed = repository.review(task.getId());
Assignment assignment = assigned == null ? null : new Assignment(assigned.getAssigneeId(),
assigned.getAssigneeName(), assigned.getAssignedBy(), assigned.getAssignedByName(),
assigned.getAssignmentType(), assigned.getVersion());
Review review = reviewed == null ? null : new Review(reviewed.getReviewStatus(),
reviewed.getReviewedBy(), reviewed.getReviewedByName(), reviewed.getRemark(), reviewed.getVersion());
String workflowStatus = "COMPLETED".equals(task.getTaskStatus()) ? "COMPLETED"
: assigned == null ? "UNASSIGNED"
: repository.allTaskItemsResolved(task.getId()) ? "AWAITING_REVIEW" : "IN_PROGRESS";
return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerNo(), task.getTaskStatus(),
task.getVersion(), items);
workflowStatus, task.getVersion(), assignment, review, items);
}
private void touchAndSync(LoadingPickTaskEntity task, long operatorId, String operatorName) {
if (repository.touchTask(task.getId(), task.getVersion()) != 1) {
throw conflict("PDA任务版本已变化");
}
repository.syncInboundProjections(task.getId(), operatorId, operatorName);
}
private Task replayTaskOperation(long taskId, String operationId, String requestHash, long operatorId) {
if (repository.history(operationId) != null) throw conflict("operationId已用于明细拣货操作");
LoadingPickOperationEntity replay = repository.operation(operationId);
if (replay == null) return null;
if (!Long.valueOf(taskId).equals(replay.getPickTaskId())
|| !Long.valueOf(operatorId).equals(replay.getOperatorId())
|| !requestHash.equals(replay.getRequestHash())) {
throw conflict("operationId已用于不同PDA任务操作");
}
return view(requiredTask(taskId), null);
}
private void insertOperation(long taskId, String operationId, String requestHash, String actionType,
long operatorId, String operatorName) {
LoadingPickOperationEntity entity = new LoadingPickOperationEntity();
entity.setPickTaskId(taskId); entity.setOperationId(operationId); entity.setRequestHash(requestHash);
entity.setActionType(actionType); entity.setOperatorId(operatorId); entity.setOperatorName(operatorName);
try {
repository.insert(entity);
} catch (DuplicateKeyException exception) {
throw conflict("operationId已被并发使用");
}
}
private void requireNoTaskOperation(String operationId) {
if (repository.operation(operationId) != null) throw conflict("operationId已用于PDA任务操作");
}
private static String operation(String value) {
String result = trim(value);
if (!StringUtils.hasText(result)) throw new BizException(ResultCode.BAD_REQUEST, "operationId不能为空");
return result;
}
private void insertHistory(long taskId, long itemId, String operationId, String requestHash,
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.split.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.split.domain.PreloadStatus;
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
@@ -33,6 +34,7 @@ public class SplitPreloadService {
private static final String CONFIRM_PRELOAD = "CONFIRM_PRELOAD";
@Resource private SplitRepository repository;
@Resource private LoadingModelBridgeService loadingModelBridge;
@Resource private InboundStatusHistoryMapper histories;
@Resource private ObjectMapper objectMapper;
@@ -100,6 +102,7 @@ public class SplitPreloadService {
if (replay != null) return replay;
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
if ("LOADING".equals(inbound.processStatus())) {
loadingModelBridge.requireStarted(tenantId, inboundId);
return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key,
new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId))));
}
@@ -108,6 +111,7 @@ public class SplitPreloadService {
if (PreloadStatus.CONFIRMED.equals(plan.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "预装清单已确认但订单状态尚未推进,请刷新重试");
}
loadingModelBridge.requireStarted(tenantId, inboundId);
if (repository.changePlanStatus(tenantId, inboundId, plan.getStatus(), PreloadStatus.CONFIRMED,
operatorId, operatorName, request.expectedVersion()) != 1) {
throw concurrent();