feat(firstleg): converge phase3 loading models and pda
This commit is contained in:
+44
-4
@@ -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;
|
||||
}
|
||||
+52
@@ -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));
|
||||
}
|
||||
}
|
||||
+29
@@ -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) {}
|
||||
}
|
||||
+26
@@ -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;
|
||||
}
|
||||
+73
@@ -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) {}
|
||||
}
|
||||
+54
@@ -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); }
|
||||
}
|
||||
+119
@@ -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("数据已被其他人修改,请刷新后重试"); }
|
||||
}
|
||||
+12
@@ -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} "
|
||||
|
||||
+41
-13
@@ -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();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
SET NAMES utf8mb4;
|
||||
USE baiyue_firstleg;
|
||||
|
||||
/* 入库单级预装与柜次级装柜计划的多对多关联;一单多柜和拼柜均通过关联行表达。 */
|
||||
CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '关联ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '入库单客户隔离ID',
|
||||
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
|
||||
preload_plan_id BIGINT UNSIGNED NOT NULL COMMENT '入库单级预装计划ID',
|
||||
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '柜次级装柜计划ID',
|
||||
idempotency_key VARCHAR(128) NOT NULL COMMENT '建立关联幂等键',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '建立关联请求SHA-256',
|
||||
created_by BIGINT UNSIGNED NOT NULL,
|
||||
created_by_name VARCHAR(64) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_preload_loading_pair (tenant_id, inbound_id, loading_plan_id),
|
||||
UNIQUE KEY uk_preload_loading_idempotency (created_by, idempotency_key),
|
||||
KEY idx_preload_loading_preload (preload_plan_id, loading_plan_id),
|
||||
KEY idx_preload_loading_plan (loading_plan_id, tenant_id, inbound_id),
|
||||
CONSTRAINT fk_preload_loading_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id),
|
||||
CONSTRAINT fk_preload_loading_preload FOREIGN KEY (preload_plan_id) REFERENCES fl_preload_plan (id),
|
||||
CONSTRAINT fk_preload_loading_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
|
||||
CONSTRAINT chk_preload_loading_tenant CHECK (tenant_id > 0)
|
||||
) ENGINE=InnoDB COMMENT='入库单预装与柜次装柜计划多对多关联';
|
||||
|
||||
/* 兼容已存在且可由活动装柜明细无歧义推导的关联。 */
|
||||
INSERT IGNORE INTO fl_preload_loading_plan_link
|
||||
(id, tenant_id, inbound_id, preload_plan_id, loading_plan_id, idempotency_key,
|
||||
request_hash, created_by, created_by_name, created_at)
|
||||
SELECT CAST(CONV(SUBSTRING(SHA2(CONCAT('PRELOAD_LOADING|', item.account_id, '|', item.inbound_id,
|
||||
'|', item.loading_plan_id), 256), 1, 15), 16, 10) AS UNSIGNED),
|
||||
item.account_id, item.inbound_id, preload.id, item.loading_plan_id,
|
||||
CONCAT('BACKFILL-', item.loading_plan_id, '-', item.inbound_id),
|
||||
SHA2(CONCAT('BACKFILL|', item.account_id, '|', item.inbound_id, '|', item.loading_plan_id), 256),
|
||||
preload.created_by, preload.created_by_name, LEAST(preload.created_at, plan.created_at)
|
||||
FROM (SELECT DISTINCT account_id, inbound_id, loading_plan_id
|
||||
FROM fl_loading_plan_item WHERE deleted = 0) item
|
||||
JOIN fl_preload_plan preload ON preload.tenant_id = item.account_id
|
||||
AND preload.inbound_id = item.inbound_id
|
||||
JOIN fl_loading_plan plan ON plan.id = item.loading_plan_id;
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_phase3_loading_model_bridge;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_phase3_loading_model_bridge()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name = 'fl_preload_loading_plan_link') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜模型桥接表创建失败';
|
||||
END IF;
|
||||
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE() AND table_name = 'fl_preload_loading_plan_link'
|
||||
AND index_name IN ('uk_preload_loading_pair', 'uk_preload_loading_idempotency',
|
||||
'idx_preload_loading_preload', 'idx_preload_loading_plan')) <> 4 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜模型桥接索引不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.key_column_usage
|
||||
WHERE constraint_schema = DATABASE() AND table_name = 'fl_preload_loading_plan_link'
|
||||
AND referenced_table_name IN ('fl_inbound_order', 'fl_preload_plan', 'fl_loading_plan')) <> 3 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜模型桥接外键不完整';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_phase3_loading_model_bridge();
|
||||
DROP PROCEDURE IF EXISTS assert_phase3_loading_model_bridge;
|
||||
@@ -0,0 +1,133 @@
|
||||
SET NAMES utf8mb4;
|
||||
USE baiyue_firstleg;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_pick_assignment (
|
||||
pick_task_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_task_id),
|
||||
KEY idx_loading_pick_assignee (assignee_id, updated_at),
|
||||
CONSTRAINT fk_loading_pick_assignment_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
|
||||
CONSTRAINT chk_loading_pick_assignment_type CHECK (assignment_type IN ('CLAIMED', 'ASSIGNED'))
|
||||
) ENGINE=InnoDB COMMENT='柜次PDA任务领取与作业员分配';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_pick_review (
|
||||
pick_task_id BIGINT UNSIGNED NOT NULL,
|
||||
review_status VARCHAR(16) NOT NULL DEFAULT 'APPROVED',
|
||||
reviewed_by BIGINT UNSIGNED NOT NULL,
|
||||
reviewed_by_name VARCHAR(64) NULL,
|
||||
remark VARCHAR(500) NULL,
|
||||
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_task_id),
|
||||
KEY idx_loading_pick_reviewer (reviewed_by, created_at),
|
||||
CONSTRAINT fk_loading_pick_review_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
|
||||
CONSTRAINT chk_loading_pick_review_status CHECK (review_status = 'APPROVED')
|
||||
) ENGINE=InnoDB COMMENT='柜次PDA任务完成复核';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_pick_operation (
|
||||
id BIGINT UNSIGNED NOT NULL,
|
||||
pick_task_id BIGINT UNSIGNED NOT NULL,
|
||||
operation_id VARCHAR(128) NOT NULL,
|
||||
request_hash CHAR(64) NOT NULL,
|
||||
action_type VARCHAR(16) NOT NULL COMMENT 'CLAIM/ASSIGN/REVIEW',
|
||||
operator_id BIGINT UNSIGNED NOT NULL,
|
||||
operator_name VARCHAR(64) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
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'))
|
||||
) ENGINE=InnoDB COMMENT='柜次PDA任务级幂等审计';
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_pick_review_before_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_pick_review_before_update
|
||||
BEFORE UPDATE ON fl_loading_pick_review
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA复核记录不可修改';
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_pick_review_before_delete;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_pick_review_before_delete
|
||||
BEFORE DELETE ON fl_loading_pick_review
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA复核记录不可删除';
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_pick_operation_before_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_pick_operation_before_update
|
||||
BEFORE UPDATE ON fl_loading_pick_operation
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA任务操作历史不可修改';
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_loading_pick_operation_before_delete;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_loading_pick_operation_before_delete
|
||||
BEFORE DELETE ON fl_loading_pick_operation
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA任务操作历史不可删除';
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
USE baiyue_iam;
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_phase3_pda_permission_ids;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_phase3_pda_permission_ids()
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM iam_permission
|
||||
WHERE id BETWEEN 5102190 AND 5102193
|
||||
AND permission_code NOT IN (
|
||||
'platform:firstleg:pda:view', 'platform:firstleg:pda:assign',
|
||||
'platform:firstleg:pda:work', 'platform:firstleg:pda:review'
|
||||
)
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA权限ID已被其他资源占用';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_phase3_pda_permission_ids();
|
||||
DROP PROCEDURE assert_phase3_pda_permission_ids;
|
||||
|
||||
INSERT INTO iam_permission
|
||||
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
|
||||
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
|
||||
VALUES
|
||||
(5102190, 0, 'PLATFORM', 'MENU', 5102094, 'platform:firstleg:pda:view',
|
||||
'PDA装柜拣货', '/first-leg/pda-loading', 'GET', 10, 'ACTIVE', 1, 0),
|
||||
(5102191, 0, 'PLATFORM', 'BUTTON', 5102190, 'platform:firstleg:pda:assign',
|
||||
'分配PDA任务', NULL, 'POST', 20, 'ACTIVE', 1, 0),
|
||||
(5102192, 0, 'PLATFORM', 'BUTTON', 5102190, 'platform:firstleg:pda:work',
|
||||
'执行PDA拣货', NULL, 'POST', 30, 'ACTIVE', 1, 0),
|
||||
(5102193, 0, 'PLATFORM', 'BUTTON', 5102190, 'platform:firstleg:pda:review',
|
||||
'复核PDA任务', NULL, 'POST', 40, 'ACTIVE', 1, 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
permission_name=VALUES(permission_name), parent_id=VALUES(parent_id), route_path=VALUES(route_path),
|
||||
http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
|
||||
|
||||
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||
SELECT role.id, permission.id, 0
|
||||
FROM iam_role role
|
||||
JOIN iam_permission permission ON permission.id BETWEEN 5102190 AND 5102193
|
||||
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')
|
||||
AND role.deleted=0 AND permission.deleted=0;
|
||||
@@ -20,7 +20,9 @@ paths:
|
||||
'200': {description: Business envelope; ownership, type and version failures use business error codes}
|
||||
/api/platform/v1/inbounds/{inboundId}/loading/picking/start:
|
||||
post:
|
||||
summary: Start the inbound-level PDA picking review task
|
||||
summary: Refresh the compatibility projection from canonical container PDA tasks
|
||||
deprecated: true
|
||||
description: expectedCount and pickedCount must exactly match the server-derived container-task aggregate.
|
||||
parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -30,7 +32,9 @@ paths:
|
||||
'200': {description: Business envelope containing IN_PROGRESS task}
|
||||
/api/platform/v1/inbounds/{inboundId}/loading/picking/complete:
|
||||
post:
|
||||
summary: Complete a started PDA picking task when planned and picked counts match
|
||||
summary: Mark the compatibility projection complete after all container PDA tasks are reviewed
|
||||
deprecated: true
|
||||
description: This endpoint cannot complete canonical picking; counts must match the server aggregate and every related container task must already be completed.
|
||||
parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody:
|
||||
required: true
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Phase 3 Preload and Container Loading Bridge API
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/api/platform/v1/inbounds/{inboundId}/preload/loading-plans:
|
||||
get:
|
||||
summary: Query all container loading plans linked to one inbound preload
|
||||
parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}]
|
||||
responses:
|
||||
'200': {description: Business envelope containing zero or more stable links}
|
||||
/api/platform/v1/inbounds/{inboundId}/preload/loading-plans/{loadingPlanId}:
|
||||
post:
|
||||
summary: Atomically link an inbound preload and a container loading plan
|
||||
description: Both optimistic-lock versions must match; either conflict rolls back the whole link operation.
|
||||
parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/LoadingPlanId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json: {schema: {$ref: '#/components/schemas/BindRequest'}}
|
||||
responses:
|
||||
'200': {description: Business envelope containing the persisted link or a business conflict}
|
||||
/api/platform/v1/first-leg/smart-loading/plans/{loadingPlanId}/preloads:
|
||||
get:
|
||||
summary: Query all inbound preloads linked to one container loading plan
|
||||
parameters: [{$ref: '#/components/parameters/LoadingPlanId'}]
|
||||
responses:
|
||||
'200': {description: Business envelope containing links across authorized customers}
|
||||
components:
|
||||
parameters:
|
||||
InboundId: {name: inboundId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
LoadingPlanId: {name: loadingPlanId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
TenantId: {name: tenantId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
|
||||
schemas:
|
||||
BindRequest:
|
||||
type: object
|
||||
required: [expectedPreloadVersion, expectedLoadingPlanVersion]
|
||||
properties:
|
||||
expectedPreloadVersion: {type: integer, minimum: 0}
|
||||
expectedLoadingPlanVersion: {type: integer, minimum: 0}
|
||||
Link:
|
||||
type: object
|
||||
required: [id, tenantId, inboundId, preloadPlanId, preloadStatus, preloadVersion, loadingPlanId, containerBookingId, loadingPlanStatus, loadingPlanVersion, allocatedPackageCount, linkedAt]
|
||||
properties:
|
||||
id: {type: integer, format: int64}
|
||||
tenantId: {type: integer, format: int64}
|
||||
inboundId: {type: integer, format: int64}
|
||||
preloadPlanId: {type: integer, format: int64}
|
||||
preloadStatus: {type: string, enum: [PENDING, CONFIRMING, CONFIRMED]}
|
||||
preloadVersion: {type: integer, minimum: 0}
|
||||
loadingPlanId: {type: integer, format: int64}
|
||||
containerBookingId: {type: integer, format: int64}
|
||||
containerNo: {type: string, nullable: true}
|
||||
loadingPlanStatus: {type: string, enum: [EDITING, LOADING, COMPLETED]}
|
||||
loadingPlanVersion: {type: integer, minimum: 0}
|
||||
allocatedPackageCount: {type: integer, minimum: 1}
|
||||
linkedAt: {type: string, format: date-time}
|
||||
@@ -0,0 +1,87 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Phase 3 Container PDA Picking API
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/api/platform/v1/first-leg/pda-loading/tasks:
|
||||
get:
|
||||
summary: Find the active container picking task in one platform warehouse
|
||||
parameters:
|
||||
- {name: containerNo, in: query, required: true, schema: {type: string}}
|
||||
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
responses:
|
||||
'200': {description: Task with assignment, review and item-level versions}
|
||||
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/claim:
|
||||
post:
|
||||
summary: Atomically claim an unassigned task for the authenticated operator
|
||||
parameters: [{$ref: '#/components/parameters/TaskId'}]
|
||||
requestBody:
|
||||
required: true
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/ClaimRequest'}}}
|
||||
responses:
|
||||
'200': {description: Claimed task or A0409 when another operator owns it}
|
||||
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/assign:
|
||||
post:
|
||||
summary: Assign or reassign a task with optimistic locking
|
||||
parameters: [{$ref: '#/components/parameters/TaskId'}]
|
||||
requestBody:
|
||||
required: true
|
||||
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}/pick:
|
||||
post:
|
||||
summary: Increment a picked item owned by the authenticated assignee
|
||||
parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/ItemId'}]
|
||||
responses: {'200': {description: Updated task}}
|
||||
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/items/{itemId}/partial:
|
||||
post:
|
||||
summary: Record the final partial quantity for one assigned item
|
||||
parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/ItemId'}]
|
||||
responses: {'200': {description: Task awaiting review when every item is resolved}}
|
||||
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/items/{itemId}/skip:
|
||||
post:
|
||||
summary: Resolve an untouched assigned item as skipped
|
||||
parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/ItemId'}]
|
||||
responses: {'200': {description: Task awaiting review when every item is resolved}}
|
||||
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/review:
|
||||
post:
|
||||
summary: Review all resolved items, settle partial and skipped quantities, then complete the plan
|
||||
parameters: [{$ref: '#/components/parameters/TaskId'}]
|
||||
requestBody:
|
||||
required: true
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/ReviewRequest'}}}
|
||||
responses:
|
||||
'200': {description: Completed task or A0409 when items or task version changed}
|
||||
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/operations:
|
||||
get:
|
||||
summary: Read immutable task-level claim, assignment and review audit records
|
||||
parameters: [{$ref: '#/components/parameters/TaskId'}]
|
||||
responses:
|
||||
'200': {description: Ordered operation history}
|
||||
components:
|
||||
parameters:
|
||||
TaskId: {name: taskId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
ItemId: {name: itemId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
schemas:
|
||||
ClaimRequest:
|
||||
type: object
|
||||
required: [expectedAssignmentVersion, operationId]
|
||||
properties:
|
||||
expectedAssignmentVersion: {type: integer, minimum: 0}
|
||||
operationId: {type: string, maxLength: 128}
|
||||
AssignRequest:
|
||||
type: object
|
||||
required: [assigneeId, assigneeName, expectedAssignmentVersion, operationId]
|
||||
properties:
|
||||
assigneeId: {type: integer, format: int64, minimum: 1}
|
||||
assigneeName: {type: string, maxLength: 64}
|
||||
expectedAssignmentVersion: {type: integer, minimum: 0}
|
||||
operationId: {type: string, maxLength: 128}
|
||||
ReviewRequest:
|
||||
type: object
|
||||
required: [expectedTaskVersion, operationId]
|
||||
properties:
|
||||
expectedTaskVersion: {type: integer, minimum: 0}
|
||||
operationId: {type: string, maxLength: 128}
|
||||
remark: {type: string, maxLength: 500}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PdaConvergenceOpenApiContractTest {
|
||||
@Test
|
||||
void publishesClaimAssignWorkAndReviewRoutes() throws Exception {
|
||||
String yaml = new ClassPathResource("openapi/phase3-pda-convergence-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(yaml)
|
||||
.contains("/api/platform/v1/first-leg/pda-loading/tasks:")
|
||||
.contains("/tasks/{taskId}/claim:")
|
||||
.contains("/tasks/{taskId}/assign:")
|
||||
.contains("/tasks/{taskId}/items/{itemId}/pick:")
|
||||
.contains("/tasks/{taskId}/items/{itemId}/partial:")
|
||||
.contains("/tasks/{taskId}/items/{itemId}/skip:")
|
||||
.contains("/tasks/{taskId}/review:")
|
||||
.contains("/tasks/{taskId}/operations:")
|
||||
.contains("expectedAssignmentVersion")
|
||||
.contains("expectedTaskVersion");
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PlatformLoadingPdaControllerContractTest {
|
||||
@Test
|
||||
void exposesAssignmentWorkAndReviewWithDedicatedAuthorities() throws Exception {
|
||||
authority("task", "platform:firstleg:pda:view", AuthenticatedUser.class, String.class, long.class);
|
||||
authority("claim", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, ClaimRequest.class);
|
||||
authority("assign", "platform:firstleg:pda:assign", AuthenticatedUser.class, long.class, AssignRequest.class);
|
||||
authority("pick", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class, PickRequest.class);
|
||||
authority("partial", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class, PartialPickRequest.class);
|
||||
authority("skip", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class, SkipRequest.class);
|
||||
authority("review", "platform:firstleg:pda:review", AuthenticatedUser.class, long.class, ReviewRequest.class);
|
||||
authority("operations", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class);
|
||||
}
|
||||
|
||||
private void authority(String name, String expected, Class<?>... parameterTypes) throws Exception {
|
||||
Method method = PlatformLoadingPdaController.class.getDeclaredMethod(name, parameterTypes);
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('" + expected + "')");
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.baiyue.firstleg.loading.bridge;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class LoadingModelBridgeContractTest {
|
||||
@Test
|
||||
void moduleSqlUsesExplicitManyToManyLinksAndRepeatableBackfill() throws Exception {
|
||||
String sql = new ClassPathResource("db/phase3-loading-model-bridge-schema.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(sql)
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link")
|
||||
.contains("UNIQUE KEY uk_preload_loading_pair (tenant_id, inbound_id, loading_plan_id)")
|
||||
.contains("FOREIGN KEY (preload_plan_id) REFERENCES fl_preload_plan (id)")
|
||||
.contains("FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id)")
|
||||
.contains("INSERT IGNORE INTO fl_preload_loading_plan_link")
|
||||
.doesNotContain("ALTER TABLE fl_preload_plan ADD COLUMN loading_plan_id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void openApiDocumentsAtomicBindAndBothDirections() throws Exception {
|
||||
String yaml = new ClassPathResource("openapi/phase3-loading-model-bridge-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(yaml)
|
||||
.contains("/api/platform/v1/inbounds/{inboundId}/preload/loading-plans:")
|
||||
.contains("/api/platform/v1/inbounds/{inboundId}/preload/loading-plans/{loadingPlanId}:")
|
||||
.contains("/api/platform/v1/first-leg/smart-loading/plans/{loadingPlanId}/preloads:")
|
||||
.contains("required: [expectedPreloadVersion, expectedLoadingPlanVersion]")
|
||||
.contains("either conflict rolls back the whole link operation");
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package com.baiyue.firstleg.loading.bridge.service;
|
||||
|
||||
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.BindRequest;
|
||||
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
|
||||
import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.LoadingPlanSnapshot;
|
||||
import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.PreloadSnapshot;
|
||||
import com.baiyue.firstleg.loading.bridge.repository.LoadingModelBridgeRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LoadingModelBridgeServiceTest {
|
||||
@Mock LoadingModelBridgeRepository repository;
|
||||
@InjectMocks LoadingModelBridgeService service;
|
||||
|
||||
@Test
|
||||
void bindsManyToManyModelsWithBothOptimisticLocks() {
|
||||
when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "PENDING", 2));
|
||||
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3));
|
||||
when(repository.allocatedPackages(10, 20, 40)).thenReturn(8);
|
||||
when(repository.touchPreload(30, 10, 20, 2, 7, "admin")).thenReturn(1);
|
||||
when(repository.touchLoadingPlan(40, 3, 7, "admin")).thenReturn(1);
|
||||
Link linked = link("PENDING", 3, "EDITING", 4, 8);
|
||||
when(repository.byInbound(10, 20)).thenReturn(List.of(linked));
|
||||
|
||||
Link result = service.bind(10, 20, 40, 7, "admin", "bridge-1", new BindRequest(2, 3));
|
||||
|
||||
assertThat(result.loadingPlanId()).isEqualTo(40);
|
||||
verify(repository).insert(any());
|
||||
verify(repository).touchPreload(30, 10, 20, 2, 7, "admin");
|
||||
verify(repository).touchLoadingPlan(40, 3, 7, "admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondVersionConflictDoesNotProceedAsSuccess() {
|
||||
when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "PENDING", 2));
|
||||
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3));
|
||||
when(repository.allocatedPackages(10, 20, 40)).thenReturn(8);
|
||||
when(repository.touchPreload(30, 10, 20, 2, 7, "admin")).thenReturn(1);
|
||||
when(repository.touchLoadingPlan(40, 3, 7, "admin")).thenReturn(0);
|
||||
|
||||
assertThatThrownBy(() -> service.bind(10, 20, 40, 7, "admin", "bridge-2", new BindRequest(2, 3)))
|
||||
.hasMessageContaining("刷新后重试");
|
||||
verify(repository, never()).byInbound(10, 20);
|
||||
}
|
||||
|
||||
@Test
|
||||
void terminalGateRequiresEveryLinkedContainerPlanCompleted() {
|
||||
when(repository.byInbound(10, 20)).thenReturn(List.of(
|
||||
link("CONFIRMED", 4, "COMPLETED", 7, 5),
|
||||
new Link(2L, 10L, 20L, 30L, "CONFIRMED", 4, 41L, 51L, "C2",
|
||||
"LOADING", 6, 3, LocalDateTime.now())));
|
||||
|
||||
assertThatThrownBy(() -> service.requireCompleted(10, 20))
|
||||
.hasMessageContaining("尚未全部完成装柜");
|
||||
}
|
||||
|
||||
private static Link link(String preloadStatus, int preloadVersion, String loadingStatus,
|
||||
int loadingVersion, int packages) {
|
||||
return new Link(1L, 10L, 20L, 30L, preloadStatus, preloadVersion, 40L, 50L, "C1",
|
||||
loadingStatus, loadingVersion, packages, LocalDateTime.now());
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -21,6 +21,8 @@ class LoadingDeliveryOpenApiContractTest {
|
||||
.contains("required: [expectedVersion, evidenceType, fileId]")
|
||||
.contains("enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]")
|
||||
.contains("Required when evidenceType is SEAL")
|
||||
.contains("Complete a started PDA picking task");
|
||||
.contains("compatibility projection")
|
||||
.contains("must exactly match the server-derived container-task aggregate")
|
||||
.contains("cannot complete canonical picking");
|
||||
}
|
||||
}
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.baiyue.firstleg.loading.repository;
|
||||
|
||||
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
|
||||
import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PdaConvergenceSqlContractTest {
|
||||
@Test
|
||||
void hasIndependentAssignmentReviewOperationAuditAndPermissionGrants() throws Exception {
|
||||
String sql = new ClassPathResource("db/phase3-pda-convergence-schema.sql")
|
||||
.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_review")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_operation")
|
||||
.contains("UNIQUE KEY uk_loading_pick_operation_id")
|
||||
.contains("trg_loading_pick_review_before_update")
|
||||
.contains("trg_loading_pick_operation_before_delete")
|
||||
.contains("5102190, 0, 'PLATFORM'")
|
||||
.contains("5102193, 0, 'PLATFORM'")
|
||||
.contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')")
|
||||
.contains("platform:firstleg:pda:view")
|
||||
.contains("platform:firstleg:pda:assign")
|
||||
.contains("platform:firstleg:pda:work")
|
||||
.contains("platform:firstleg:pda:review");
|
||||
}
|
||||
|
||||
@Test
|
||||
void derivesInboundProjectionFromCanonicalContainerItems() throws Exception {
|
||||
String aggregate = String.join(" ", LoadingPickItemMapper.class
|
||||
.getDeclaredMethod("selectInboundProgress", long.class, long.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
String upsert = InboundLoadingPickTaskMapper.class.getDeclaredMethod("upsertProjection",
|
||||
long.class, long.class, long.class, String.class, int.class, int.class, long.class, String.class)
|
||||
.getAnnotation(Insert.class).value()[0];
|
||||
assertThat(aggregate)
|
||||
.contains("fl_loading_plan_item")
|
||||
.contains("fl_loading_pick_item")
|
||||
.contains("fl_loading_pick_task")
|
||||
.contains("plan_item.account_id=#{tenantId}")
|
||||
.contains("pick_task.task_status='COMPLETED'");
|
||||
assertThat(upsert)
|
||||
.contains("INSERT INTO fl_inbound_loading_pick_task")
|
||||
.contains("ON DUPLICATE KEY UPDATE")
|
||||
.contains("柜次PDA自动汇总");
|
||||
}
|
||||
}
|
||||
+22
-10
@@ -8,8 +8,10 @@ import com.baiyue.firstleg.loading.dto.LoadingRequests.PickingRequest;
|
||||
import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest;
|
||||
import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse;
|
||||
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
|
||||
import com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity;
|
||||
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot;
|
||||
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
|
||||
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
|
||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
import com.baiyue.firstleg.loading.repository.LoadingRepository;
|
||||
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -30,6 +32,8 @@ import static org.mockito.Mockito.*;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LoadingDeliveryServiceTest {
|
||||
@Mock LoadingRepository repository;
|
||||
@Mock LoadingModelBridgeService loadingModelBridge;
|
||||
@Mock LoadingPickRepository loadingPicks;
|
||||
@Mock InboundStatusHistoryMapper histories;
|
||||
@Mock LoadingEvidenceFileClient files;
|
||||
@InjectMocks LoadingDeliveryService service;
|
||||
@@ -48,9 +52,13 @@ class LoadingDeliveryServiceTest {
|
||||
void completesLoadingOnlyAfterPickingAndFiveEvidenceFiles() {
|
||||
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
|
||||
InboundLoadingPickTaskEntity pick = new InboundLoadingPickTaskEntity();
|
||||
pick.setStatus("COMPLETED"); pick.setExpectedCount(5); pick.setPickedCount(5);
|
||||
when(repository.findPickTask(10, 20)).thenReturn(pick);
|
||||
LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class);
|
||||
when(progress.getExpectedCount()).thenReturn(5);
|
||||
when(progress.getPickedCount()).thenReturn(5);
|
||||
when(progress.getTaskCount()).thenReturn(1);
|
||||
when(progress.getCompletedTaskCount()).thenReturn(1);
|
||||
when(progress.getUnresolvedCount()).thenReturn(0);
|
||||
when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress);
|
||||
when(repository.listEvidence(10, 20)).thenReturn(List.of(
|
||||
evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null),
|
||||
evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null),
|
||||
@@ -64,6 +72,7 @@ class LoadingDeliveryServiceTest {
|
||||
when(repository.completeLoading(10, 20, 7, 3)).thenReturn(1);
|
||||
LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "k1", new CompleteLoadingRequest(3, null));
|
||||
assertThat(result.mainStatus()).isEqualTo("LOADED");
|
||||
verify(loadingModelBridge).requireCompleted(10, 20);
|
||||
verify(histories).insert(any(InboundStatusHistoryEntity.class));
|
||||
}
|
||||
|
||||
@@ -71,9 +80,13 @@ class LoadingDeliveryServiceTest {
|
||||
void rejectsCompletionWhenEvidenceIsIncomplete() {
|
||||
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
|
||||
InboundLoadingPickTaskEntity pick = new InboundLoadingPickTaskEntity();
|
||||
pick.setStatus("COMPLETED"); pick.setExpectedCount(5); pick.setPickedCount(5);
|
||||
when(repository.findPickTask(10, 20)).thenReturn(pick);
|
||||
LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class);
|
||||
when(progress.getExpectedCount()).thenReturn(5);
|
||||
when(progress.getPickedCount()).thenReturn(5);
|
||||
when(progress.getTaskCount()).thenReturn(1);
|
||||
when(progress.getCompletedTaskCount()).thenReturn(1);
|
||||
when(progress.getUnresolvedCount()).thenReturn(0);
|
||||
when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress);
|
||||
when(repository.listEvidence(10, 20)).thenReturn(List.of(evidence("SEAL", 5L, "LOCK-001")));
|
||||
|
||||
assertThatThrownBy(() -> service.completeLoading(10, 20, 7, "admin", "k-missing",
|
||||
@@ -111,13 +124,12 @@ class LoadingDeliveryServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCompletingPickingBeforeTaskStarts() {
|
||||
void rejectsCompletingPickingWithoutCanonicalContainerTask() {
|
||||
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
|
||||
when(repository.findPickTask(10, 20)).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> service.completePicking(10, 20, 7, "admin", "k-picking",
|
||||
new PickingRequest(0, 1, 1, null))).hasMessageContaining("尚未开始");
|
||||
new PickingRequest(0, 1, 1, null))).hasMessageContaining("真实柜次");
|
||||
verify(repository, never()).createPickTask(any());
|
||||
}
|
||||
|
||||
|
||||
+8
-1
@@ -23,10 +23,15 @@ class Phase3FinalUnifiedSqlContractTest {
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_customs_receipt")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_port_schedule")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_port_schedule_history")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_assignment")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_review")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_operation")
|
||||
.contains("uk_loading_evidence_seal (tenant_id,seal_no)")
|
||||
.contains("CALL assert_phase3_final_schema()")
|
||||
.contains("trg_port_schedule_history_before_update")
|
||||
.contains("trg_port_schedule_history_before_delete");
|
||||
.contains("trg_port_schedule_history_before_delete")
|
||||
.contains("trg_loading_pick_operation_before_delete");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -36,10 +41,12 @@ class Phase3FinalUnifiedSqlContractTest {
|
||||
for (int id = 5102160; id <= 5102164; id++) assertThat(sql).contains(Integer.toString(id));
|
||||
for (int id = 5102170; id <= 5102175; id++) assertThat(sql).contains(Integer.toString(id));
|
||||
for (int id = 5102180; id <= 5102183; id++) assertThat(sql).contains(Integer.toString(id));
|
||||
for (int id = 5102190; id <= 5102193; id++) assertThat(sql).contains(Integer.toString(id));
|
||||
assertThat(sql)
|
||||
.contains("platform:firstleg:outbound-dispatch:%")
|
||||
.contains("platform:firstleg:customs:%")
|
||||
.contains("platform:firstleg:port-schedule:%")
|
||||
.contains("platform:firstleg:pda:%")
|
||||
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')")
|
||||
.contains("角色授权未完整写入");
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ package com.baiyue.firstleg.service;
|
||||
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.ReviewRequest;
|
||||
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPickOperationEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
|
||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
@@ -34,9 +37,12 @@ class LoadingPickServiceTest {
|
||||
LoadingPickItemEntity item = item();
|
||||
when(repository.history("pick-1")).thenReturn(null);
|
||||
when(repository.task(20L)).thenReturn(task);
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task);
|
||||
when(repository.assignment(20L)).thenReturn(assignment());
|
||||
when(repository.item(20L, 30L, 101L)).thenReturn(item);
|
||||
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
|
||||
when(repository.allPlanItemsPicked(10L)).thenReturn(false);
|
||||
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, "pick-1"), 9L, "operator");
|
||||
@@ -50,14 +56,16 @@ class LoadingPickServiceTest {
|
||||
LoadingPickItemEntity item = item();
|
||||
when(repository.history("pick-2")).thenReturn(null);
|
||||
when(repository.task(20L)).thenReturn(task);
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task);
|
||||
when(repository.assignment(20L)).thenReturn(assignment());
|
||||
when(repository.item(20L, 30L, 101L)).thenReturn(item);
|
||||
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
|
||||
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
|
||||
when(repository.items(20L, 101L)).thenReturn(List.of(item));
|
||||
when(repository.touchTask(20L, 0)).thenReturn(1);
|
||||
|
||||
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-2"), 9L, "operator");
|
||||
|
||||
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
|
||||
verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
|
||||
verify(repository).insert(any(LoadingPickHistoryEntity.class));
|
||||
}
|
||||
|
||||
@@ -67,14 +75,16 @@ class LoadingPickServiceTest {
|
||||
LoadingPickItemEntity item = item();
|
||||
when(repository.history("partial-1")).thenReturn(null);
|
||||
when(repository.task(20L)).thenReturn(task);
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task);
|
||||
when(repository.assignment(20L)).thenReturn(assignment());
|
||||
when(repository.item(20L, 30L, 101L)).thenReturn(item);
|
||||
when(repository.partial(30L, 1, 0, 101L)).thenReturn(1);
|
||||
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
|
||||
when(repository.items(20L, 101L)).thenReturn(List.of(item));
|
||||
when(repository.touchTask(20L, 0)).thenReturn(1);
|
||||
|
||||
service.partial(20L, 30L, new PartialPickRequest(101L, 1, List.of(), 0, "partial-1"), 9L, "operator");
|
||||
|
||||
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
|
||||
verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -83,14 +93,37 @@ class LoadingPickServiceTest {
|
||||
LoadingPickItemEntity item = item();
|
||||
when(repository.history("skip-1")).thenReturn(null);
|
||||
when(repository.task(20L)).thenReturn(task);
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task);
|
||||
when(repository.assignment(20L)).thenReturn(assignment());
|
||||
when(repository.item(20L, 30L, 101L)).thenReturn(item);
|
||||
when(repository.skip(30L, 0, 101L)).thenReturn(1);
|
||||
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
|
||||
when(repository.items(20L, 101L)).thenReturn(List.of(item));
|
||||
when(repository.touchTask(20L, 0)).thenReturn(1);
|
||||
|
||||
service.skip(20L, 30L, new SkipRequest(101L, 0, "skip-1", "fixture unavailable"), 9L, "operator");
|
||||
|
||||
verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
|
||||
}
|
||||
|
||||
@Test
|
||||
void reviewSettlesPartialItemsBeforeCompletingPlan() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
LoadingPickItemEntity item = item();
|
||||
item.setPlannedPackageCount(5);
|
||||
item.setPickedPackageCount(3);
|
||||
item.setItemStatus("PARTIAL");
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task);
|
||||
when(repository.task(20L)).thenReturn(task);
|
||||
when(repository.items(20L)).thenReturn(List.of(item));
|
||||
when(repository.settlePartial(40L, 30L, 3, 5, 9L, "operator")).thenReturn(1);
|
||||
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
|
||||
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
|
||||
|
||||
service.review(20L, new ReviewRequest(0, "review-1", "checked"), 9L, "operator");
|
||||
|
||||
verify(repository).insert(any(LoadingPickOperationEntity.class));
|
||||
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
|
||||
verify(repository).syncInboundProjections(20L, 9L, "operator");
|
||||
}
|
||||
|
||||
private static LoadingPickTaskEntity task() {
|
||||
@@ -116,4 +149,13 @@ class LoadingPickServiceTest {
|
||||
item.setVersion(0);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static LoadingPickAssignmentEntity assignment() {
|
||||
LoadingPickAssignmentEntity assignment = new LoadingPickAssignmentEntity();
|
||||
assignment.setPickTaskId(20L);
|
||||
assignment.setAssigneeId(9L);
|
||||
assignment.setAssigneeName("operator");
|
||||
assignment.setVersion(0);
|
||||
return assignment;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.firstleg.split.service;
|
||||
|
||||
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
|
||||
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
|
||||
import com.baiyue.firstleg.split.dto.SplitRequests.PreloadItem;
|
||||
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
|
||||
@@ -30,6 +31,7 @@ import static org.mockito.Mockito.verify;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SplitPreloadServiceTest {
|
||||
@Mock SplitRepository repository;
|
||||
@Mock LoadingModelBridgeService loadingModelBridge;
|
||||
@Mock InboundStatusHistoryMapper histories;
|
||||
@InjectMocks SplitPreloadService service;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
@@ -61,11 +63,13 @@ class SplitPreloadServiceTest {
|
||||
plan.setTenantId(10L); plan.setInboundId(20L); plan.setStatus("CONFIRMING"); plan.setVersion(1);
|
||||
plan.setItemsJson("[]");
|
||||
when(repository.findPlan(10, 20)).thenReturn(plan);
|
||||
org.mockito.Mockito.doNothing().when(loadingModelBridge).requireStarted(10, 20);
|
||||
when(repository.changePlanStatus(10, 20, "CONFIRMING", "CONFIRMED", 7, "admin", 1)).thenReturn(1);
|
||||
when(repository.beginLoading(10, 20, 7, 4)).thenReturn(1);
|
||||
SplitActionResponse result = service.confirmPreload(10, 20, 7, "admin", "k3",
|
||||
new ConfirmPreloadRequest(1));
|
||||
assertThat(result.mainStatus()).isEqualTo("LOADING");
|
||||
verify(loadingModelBridge).requireStarted(10, 20);
|
||||
verify(repository).beginLoading(10, 20, 7, 4);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user