Merge branch 'test' into test-02huogui
# Conflicts: # deploy/sql/头程数据库统一增量-MySQL8.sql
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.firstleg.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
/** 仅用于确认证据文件属于当前客户和入库单,不在 firstleg 复制文件元数据。 */
|
||||
@FeignClient(name = "file-service", contextId = "firstlegLoadingEvidenceFileClient",
|
||||
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/loading-evidence")
|
||||
public interface LoadingEvidenceFileClient {
|
||||
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
|
||||
ApiResponse<FileMetadata> validate(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@PathVariable long tenantId, @PathVariable long inboundId,
|
||||
@PathVariable long fileId);
|
||||
|
||||
record FileMetadata(Long fileId, String fileName, String businessType, String contentType) {}
|
||||
}
|
||||
+33
@@ -9,6 +9,12 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
|
||||
import com.baiyue.firstleg.dto.InboundOrderReference;
|
||||
import com.baiyue.firstleg.service.InboundOrderCreationService;
|
||||
import com.baiyue.firstleg.service.UnifiedInboundQueryService;
|
||||
import com.baiyue.firstleg.dto.UnifiedInboundDetail;
|
||||
import com.baiyue.firstleg.dto.UnifiedInboundQuery;
|
||||
import com.baiyue.firstleg.dto.UnifiedInboundRequest;
|
||||
import com.baiyue.firstleg.dto.UnifiedInboundSummary;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
@@ -28,6 +34,8 @@ import java.security.MessageDigest;
|
||||
public class InternalInboundOrderController {
|
||||
@Resource
|
||||
private InboundOrderCreationService service;
|
||||
@Resource
|
||||
private UnifiedInboundQueryService unifiedQueryService;
|
||||
@Value("${baiyue.security.internal-service-token}")
|
||||
private String internalToken;
|
||||
private byte[] expectedToken;
|
||||
@@ -60,6 +68,31 @@ public class InternalInboundOrderController {
|
||||
request.tenantId(), request.orderIds().stream().distinct().toList()));
|
||||
}
|
||||
|
||||
@PostMapping("/unified-orders/query")
|
||||
public ApiResponse<PageResult<UnifiedInboundSummary>> unifiedPage(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody UnifiedInboundQuery request) {
|
||||
requireToken(token);
|
||||
return ApiResponse.success(unifiedQueryService.page(request));
|
||||
}
|
||||
|
||||
@PostMapping("/unified-orders/summaries")
|
||||
public ApiResponse<List<UnifiedInboundSummary>> unifiedSummaries(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody UnifiedInboundRequest.BatchRequest request) {
|
||||
requireToken(token);
|
||||
return ApiResponse.success(unifiedQueryService.summaries(
|
||||
request.tenantId(), request.orderIds().stream().distinct().toList()));
|
||||
}
|
||||
|
||||
@PostMapping("/unified-orders/detail")
|
||||
public ApiResponse<UnifiedInboundDetail> unifiedDetail(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody UnifiedInboundRequest.DetailRequest request) {
|
||||
requireToken(token);
|
||||
return ApiResponse.success(unifiedQueryService.detail(request.tenantId(), request.orderId()));
|
||||
}
|
||||
|
||||
private void requireToken(String token) {
|
||||
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
|
||||
|
||||
+4
-6
@@ -10,7 +10,6 @@ import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
|
||||
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
|
||||
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
|
||||
import com.baiyue.firstleg.service.MeasurementReviewService;
|
||||
import com.baiyue.firstleg.service.AutomaticInboundCompletionService;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -36,12 +35,11 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@RequestMapping("/api/platform/v1/inbounds")
|
||||
public class PlatformInboundMeasurementReviewController {
|
||||
@Resource private MeasurementReviewService service;
|
||||
@Resource private AutomaticInboundCompletionService automaticCompletion;
|
||||
|
||||
@BusinessAudit(event = "INBOUND_MEASURE_APPROVE", resourceType = "INBOUND",
|
||||
resourceId = "#inboundId", description = "测量审核通过")
|
||||
@Operation(summary = "测量审核通过",
|
||||
description = "客服审核通过后自动计费、发布费用给客户并完成入库;客户无需确认费用。")
|
||||
description = "客服确认测量数据后直接进入待分柜;费用和其他资料可在后续继续维护。")
|
||||
@PostMapping("/{inboundId}/measurements/approve")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:measurement-review')")
|
||||
public ApiResponse<FeeSnapshotResponse> approve(
|
||||
@@ -53,9 +51,9 @@ public class PlatformInboundMeasurementReviewController {
|
||||
@Valid @RequestBody MeasurementReviewRequest request) {
|
||||
validateScope(user, tenantId);
|
||||
service.approve(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
|
||||
return ApiResponse.success(automaticCompletion.completeApproved(
|
||||
tenantId, inboundId, user.userId(), user.username(), request.measurementId(),
|
||||
request.expectedVersion() + 1));
|
||||
// 统一订单主线要求测量审核后直接进入 WAIT_SPLIT,不把计费、库位或其他资料
|
||||
// 作为本版本的强制门禁。保留原 FeeSnapshotResponse 泛型以兼容既有客户端。
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "INBOUND_MEASURE_REJECT", resourceType = "INBOUND",
|
||||
|
||||
@@ -6,7 +6,8 @@ import java.util.Set;
|
||||
public final class InboundProcessStatus {
|
||||
public static final Set<String> VALUES = Set.of(
|
||||
"WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
|
||||
"WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", "VOIDED"
|
||||
"WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED",
|
||||
"WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED", "VOIDED"
|
||||
);
|
||||
|
||||
private InboundProcessStatus() {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** Detail payload for the product-level order detail. */
|
||||
public record UnifiedInboundDetail(
|
||||
UnifiedInboundSummary summary,
|
||||
List<TimelineEvent> timeline
|
||||
) {
|
||||
public record TimelineEvent(String source, String eventType, String fromStatus, String toStatus,
|
||||
String action, String remark, Long operatorId, String operatorName,
|
||||
LocalDateTime occurredAt) {}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** Order service internal query for the product-level order line. */
|
||||
public record UnifiedInboundQuery(
|
||||
Long tenantId,
|
||||
@Size(max = 64) String keyword,
|
||||
@Size(max = 64) String orderNo,
|
||||
@Size(max = 32) String mainStatus,
|
||||
Long customerId,
|
||||
Long receivingWarehouseId,
|
||||
Long destinationWarehouseId,
|
||||
@Size(max = 64) String inboundCode,
|
||||
@Size(max = 32) String internalOrderStatus,
|
||||
LocalDate expectedArrivalDate,
|
||||
@Size(max = 32) String feeReviewStatus,
|
||||
@Size(max = 128) String channelName,
|
||||
@Size(max = 64) String containerNo,
|
||||
@Size(max = 64) String cabinetNo,
|
||||
LocalDate sailingDate,
|
||||
Boolean loaded,
|
||||
Boolean fullContainer,
|
||||
@Size(max = 32) String measurementReviewStatus,
|
||||
List<Long> candidateOrderIds,
|
||||
LocalDateTime createdFrom,
|
||||
LocalDateTime createdTo,
|
||||
@Min(1) long page,
|
||||
@Min(1) @Max(200) long size
|
||||
) {
|
||||
public UnifiedInboundQuery {
|
||||
if (page == 0) page = 1;
|
||||
if (size == 0) size = 20;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Internal request contracts used by Order service. */
|
||||
public final class UnifiedInboundRequest {
|
||||
private UnifiedInboundRequest() {}
|
||||
|
||||
public record BatchRequest(Long tenantId,
|
||||
@NotEmpty @Size(max = 200) List<@Positive Long> orderIds) {}
|
||||
|
||||
public record DetailRequest(@Positive long tenantId, @Positive long orderId) {}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** Product-level status and operational summaries returned to Order service. */
|
||||
public record UnifiedInboundSummary(
|
||||
InboundOrderSummary inbound,
|
||||
String mainStatus,
|
||||
String nextAction,
|
||||
PreloadSummary preload,
|
||||
LoadingSummary loading,
|
||||
DeliverySummary delivery
|
||||
) {
|
||||
public record PreloadSummary(String status, Integer version, Integer itemCount, LocalDateTime updatedAt) {}
|
||||
/**
|
||||
* loadingVersion is currently a compatibility alias of pickingVersion because no separate
|
||||
* loading aggregate row exists. C04 loading complete must use inbound.version instead.
|
||||
*/
|
||||
public record LoadingSummary(String status, Integer loadingVersion, Integer pickingVersion,
|
||||
Integer expectedCount, Integer pickedCount, Integer evidenceCount,
|
||||
String sealNo, LocalDateTime updatedAt) {}
|
||||
public record DeliverySummary(String status, LocalDateTime completedAt, String trackingNo) {}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package com.baiyue.firstleg.loading.controller;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteDeliveryRequest;
|
||||
import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest;
|
||||
import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest;
|
||||
import com.baiyue.firstleg.loading.dto.LoadingRequests.PickingRequest;
|
||||
import com.baiyue.firstleg.loading.dto.LoadingResponses.EvidenceListResponse;
|
||||
import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse;
|
||||
import com.baiyue.firstleg.loading.dto.LoadingResponses.PickingResponse;
|
||||
import com.baiyue.firstleg.loading.service.LoadingDeliveryService;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/** 平台端装柜证据、装柜完成和派送完成操作。 */
|
||||
@Tag(name = "订单装柜与派送")
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/inbounds")
|
||||
public class PlatformLoadingDeliveryController {
|
||||
@Resource private LoadingDeliveryService service;
|
||||
|
||||
@Operation(summary = "新增或修改装柜证据")
|
||||
@PostMapping("/{inboundId}/loading/evidence")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:edit')")
|
||||
@BusinessAudit(event = "INBOUND_LOADING_EVIDENCE_UPSERT", resourceType = "INBOUND", resourceId = "#inboundId", description = "维护装柜证据")
|
||||
public ApiResponse<LoadingActionResponse> evidence(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
|
||||
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpsertEvidenceRequest request) {
|
||||
FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.upsertEvidence(tenantId, inboundId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "查询装柜证据")
|
||||
@GetMapping("/{inboundId}/loading/evidence")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:view')")
|
||||
public ApiResponse<EvidenceListResponse> evidence(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId) {
|
||||
FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.evidence(tenantId, inboundId));
|
||||
}
|
||||
|
||||
@Operation(summary = "完成装柜")
|
||||
@PostMapping("/{inboundId}/loading/complete")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:complete')")
|
||||
@BusinessAudit(event = "INBOUND_COMPLETE_LOADING", resourceType = "INBOUND", resourceId = "#inboundId", description = "确认装柜完成")
|
||||
public ApiResponse<LoadingActionResponse> completeLoading(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
|
||||
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CompleteLoadingRequest request) {
|
||||
FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.completeLoading(tenantId, inboundId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "确认派送完成")
|
||||
@PostMapping("/{inboundId}/delivery/complete")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:delivery:complete')")
|
||||
@BusinessAudit(event = "INBOUND_COMPLETE_DELIVERY", resourceType = "INBOUND", resourceId = "#inboundId", description = "确认派送完成")
|
||||
public ApiResponse<LoadingActionResponse> completeDelivery(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
|
||||
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CompleteDeliveryRequest request) {
|
||||
FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.completeDelivery(tenantId, inboundId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "PDA开始拣货")
|
||||
@PostMapping("/{inboundId}/loading/picking/start")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:picking:edit')")
|
||||
@BusinessAudit(event = "INBOUND_START_PICKING", resourceType = "INBOUND", resourceId = "#inboundId", description = "开始PDA拣货")
|
||||
public ApiResponse<PickingResponse> startPicking(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
|
||||
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody PickingRequest request) {
|
||||
FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.startPicking(tenantId, inboundId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "PDA完成拣货")
|
||||
@PostMapping("/{inboundId}/loading/picking/complete")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:picking:edit')")
|
||||
@BusinessAudit(event = "INBOUND_COMPLETE_PICKING", resourceType = "INBOUND", resourceId = "#inboundId", description = "完成PDA拣货")
|
||||
public ApiResponse<PickingResponse> completePicking(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
|
||||
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody PickingRequest request) {
|
||||
FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.completePicking(tenantId, inboundId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "查询PDA拣货任务")
|
||||
@GetMapping("/{inboundId}/loading/picking")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:picking:view')")
|
||||
public ApiResponse<PickingResponse> picking(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId) {
|
||||
FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.picking(tenantId, inboundId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.baiyue.firstleg.loading.domain;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 装柜证据类型。照片语义尚待产品确认,当前只固定四个步骤和一个铅封证据槽位。
|
||||
*/
|
||||
public final class LoadingEvidenceType {
|
||||
public static final String PHOTO_STEP_1 = "PHOTO_STEP_1";
|
||||
public static final String PHOTO_STEP_2 = "PHOTO_STEP_2";
|
||||
public static final String PHOTO_STEP_3 = "PHOTO_STEP_3";
|
||||
public static final String PHOTO_STEP_4 = "PHOTO_STEP_4";
|
||||
public static final String SEAL = "SEAL";
|
||||
public static final Set<String> VALUES = Set.of(PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL);
|
||||
|
||||
private LoadingEvidenceType() {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.baiyue.firstleg.loading.domain;
|
||||
|
||||
public final class PickingStatus {
|
||||
public static final String PENDING = "PENDING";
|
||||
public static final String IN_PROGRESS = "IN_PROGRESS";
|
||||
public static final String COMPLETED = "COMPLETED";
|
||||
private PickingStatus() {}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.firstleg.loading.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public final class LoadingRequests {
|
||||
private LoadingRequests() {}
|
||||
|
||||
public record UpsertEvidenceRequest(
|
||||
@NotNull @PositiveOrZero Integer expectedVersion,
|
||||
@NotBlank @Size(max = 32) String evidenceType,
|
||||
@Positive Long fileId,
|
||||
@Size(max = 255) String fileName,
|
||||
@Size(max = 64) String sealNo,
|
||||
@Size(max = 1000) String remark) {}
|
||||
|
||||
public record CompleteLoadingRequest(
|
||||
@NotNull @PositiveOrZero Integer expectedVersion,
|
||||
@Size(max = 1000) String remark) {}
|
||||
|
||||
public record CompleteDeliveryRequest(
|
||||
@NotNull @PositiveOrZero Integer expectedVersion,
|
||||
@Size(max = 1000) String remark) {}
|
||||
|
||||
public record PickingRequest(@NotNull @PositiveOrZero Integer expectedVersion,
|
||||
@PositiveOrZero Integer expectedCount,
|
||||
@PositiveOrZero Integer pickedCount,
|
||||
@Size(max = 1000) String remark) {}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.firstleg.loading.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public final class LoadingResponses {
|
||||
private LoadingResponses() {}
|
||||
|
||||
public record EvidenceResponse(Long id, Long inboundId, Long tenantId, String evidenceType,
|
||||
Long fileId, String fileName, String sealNo, String remark,
|
||||
Integer version, LocalDateTime createdAt, LocalDateTime updatedAt) {}
|
||||
|
||||
public record EvidenceListResponse(Long inboundId, List<EvidenceResponse> evidences) {}
|
||||
|
||||
public record LoadingActionResponse(String mainStatus, EvidenceListResponse evidences) {}
|
||||
|
||||
public record PickingResponse(Long id, Long inboundId, String status, Integer version,
|
||||
Integer expectedCount, Integer pickedCount, String remark) {}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.firstleg.loading.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_evidence")
|
||||
public class LoadingEvidenceEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long inboundId;
|
||||
private String evidenceType;
|
||||
private Long fileId;
|
||||
private String fileName;
|
||||
private String sealNo;
|
||||
private String remark;
|
||||
private Integer version;
|
||||
private Long createdBy;
|
||||
private String createdByName;
|
||||
private Long updatedBy;
|
||||
private String updatedByName;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.firstleg.loading.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_operation")
|
||||
public class LoadingOperationEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long inboundId;
|
||||
private Long operatorId;
|
||||
private String operation;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String status;
|
||||
private String resultJson;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime completedAt;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.firstleg.loading.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_task")
|
||||
public class LoadingPickTaskEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private Long tenantId;
|
||||
private Long inboundId;
|
||||
private String status;
|
||||
private Integer version;
|
||||
private Integer expectedCount;
|
||||
private Integer pickedCount;
|
||||
private String remark;
|
||||
private Long updatedBy;
|
||||
private String updatedByName;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.firstleg.loading.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
public interface LoadingEvidenceMapper extends BaseMapper<LoadingEvidenceEntity> {
|
||||
@Update("UPDATE fl_loading_evidence SET file_id=#{fileId}, file_name=#{fileName}, seal_no=#{sealNo}, "
|
||||
+ "remark=#{remark}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, version=version+1 "
|
||||
+ "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND evidence_type=#{evidenceType} "
|
||||
+ "AND version=#{expectedVersion}")
|
||||
int updateEvidence(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("evidenceType") String evidenceType, @Param("fileId") Long fileId,
|
||||
@Param("fileName") String fileName, @Param("sealNo") String sealNo,
|
||||
@Param("remark") String remark, @Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName, @Param("expectedVersion") int expectedVersion);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.firstleg.loading.mapper;
|
||||
|
||||
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 LoadingInboundMapper {
|
||||
@Select("SELECT id, tenant_id AS tenantId, process_status AS processStatus, version "
|
||||
+ "FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0")
|
||||
Snapshot find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
|
||||
|
||||
@Update("UPDATE fl_inbound_order SET process_status='LOADED', updated_by=#{operatorId}, version=version+1 "
|
||||
+ "WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 AND process_status='LOADING' AND version=#{expectedVersion}")
|
||||
int completeLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
|
||||
|
||||
@Update("UPDATE fl_inbound_order SET process_status='DELIVERED', updated_by=#{operatorId}, version=version+1 "
|
||||
+ "WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 AND process_status='LOADED' AND version=#{expectedVersion}")
|
||||
int completeDelivery(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
|
||||
|
||||
record Snapshot(Long id, Long tenantId, String processStatus, Integer version) {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.firstleg.loading.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.loading.entity.LoadingOperationEntity;
|
||||
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 LoadingOperationMapper extends BaseMapper<LoadingOperationEntity> {
|
||||
@Insert("INSERT IGNORE INTO fl_loading_operation "
|
||||
+ "(id,tenant_id,inbound_id,operator_id,operation,idempotency_key,request_hash,status,created_at) "
|
||||
+ "VALUES(#{id},#{tenantId},#{inboundId},#{operatorId},#{operation},#{key},#{requestHash},'PROCESSING',CURRENT_TIMESTAMP(3))")
|
||||
int claim(@Param("id") long id, @Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("operatorId") long operatorId, @Param("operation") String operation,
|
||||
@Param("key") String key, @Param("requestHash") String requestHash);
|
||||
|
||||
@Update("UPDATE fl_loading_operation SET status='SUCCESS', result_json=#{resultJson}, completed_at=CURRENT_TIMESTAMP(3) "
|
||||
+ "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND operator_id=#{operatorId} "
|
||||
+ "AND operation=#{operation} AND idempotency_key=#{key} AND status='PROCESSING'")
|
||||
int complete(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("operatorId") long operatorId, @Param("operation") String operation,
|
||||
@Param("key") String key, @Param("resultJson") String resultJson);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.firstleg.loading.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> {
|
||||
@Update("UPDATE fl_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} "
|
||||
+ "AND status=#{fromStatus} AND version=#{expectedVersion}")
|
||||
int changeStatus(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus,
|
||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName,
|
||||
@Param("remark") String remark, @Param("expectedCount") Integer expectedCount,
|
||||
@Param("pickedCount") Integer pickedCount,
|
||||
@Param("expectedVersion") int expectedVersion);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.baiyue.firstleg.loading.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
|
||||
import com.baiyue.firstleg.loading.entity.LoadingOperationEntity;
|
||||
import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper;
|
||||
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
|
||||
import com.baiyue.firstleg.loading.mapper.LoadingOperationMapper;
|
||||
import com.baiyue.firstleg.loading.mapper.LoadingPickTaskMapper;
|
||||
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public class LoadingRepository {
|
||||
@Resource private LoadingInboundMapper inbounds;
|
||||
@Resource private LoadingEvidenceMapper evidences;
|
||||
@Resource private LoadingOperationMapper operations;
|
||||
@Resource private LoadingPickTaskMapper picks;
|
||||
|
||||
public LoadingInboundMapper.Snapshot findInbound(long tenantId, long inboundId) {
|
||||
return inbounds.find(tenantId, inboundId);
|
||||
}
|
||||
|
||||
public int completeLoading(long tenantId, long inboundId, long operatorId, int expectedVersion) {
|
||||
return inbounds.completeLoading(tenantId, inboundId, operatorId, expectedVersion);
|
||||
}
|
||||
|
||||
public int completeDelivery(long tenantId, long inboundId, long operatorId, int expectedVersion) {
|
||||
return inbounds.completeDelivery(tenantId, inboundId, operatorId, expectedVersion);
|
||||
}
|
||||
|
||||
public LoadingEvidenceEntity findEvidence(long tenantId, long inboundId, String type) {
|
||||
return evidences.selectOne(new LambdaQueryWrapper<LoadingEvidenceEntity>()
|
||||
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
|
||||
.eq(LoadingEvidenceEntity::getInboundId, inboundId)
|
||||
.eq(LoadingEvidenceEntity::getEvidenceType, type));
|
||||
}
|
||||
|
||||
public List<LoadingEvidenceEntity> listEvidence(long tenantId, long inboundId) {
|
||||
return evidences.selectList(new LambdaQueryWrapper<LoadingEvidenceEntity>()
|
||||
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
|
||||
.eq(LoadingEvidenceEntity::getInboundId, inboundId)
|
||||
.orderByAsc(LoadingEvidenceEntity::getEvidenceType));
|
||||
}
|
||||
|
||||
public void createEvidence(LoadingEvidenceEntity entity) { evidences.insert(entity); }
|
||||
|
||||
public int updateEvidence(long tenantId, long inboundId, String type, Long fileId, String fileName,
|
||||
String sealNo, String remark, long operatorId, String operatorName, int version) {
|
||||
return evidences.updateEvidence(tenantId, inboundId, type, fileId, fileName, sealNo, remark,
|
||||
operatorId, operatorName, version);
|
||||
}
|
||||
|
||||
public LoadingOperationEntity findOperation(long tenantId, long inboundId, long operatorId,
|
||||
String operation, String key) {
|
||||
return operations.selectOne(new LambdaQueryWrapper<LoadingOperationEntity>()
|
||||
.eq(LoadingOperationEntity::getTenantId, tenantId)
|
||||
.eq(LoadingOperationEntity::getInboundId, inboundId)
|
||||
.eq(LoadingOperationEntity::getOperatorId, operatorId)
|
||||
.eq(LoadingOperationEntity::getOperation, operation)
|
||||
.eq(LoadingOperationEntity::getIdempotencyKey, key));
|
||||
}
|
||||
|
||||
public boolean claimOperation(long tenantId, long inboundId, long operatorId, String operation,
|
||||
String key, String requestHash) {
|
||||
return operations.claim(IdWorker.getId(), tenantId, inboundId, operatorId, operation, key, requestHash) == 1;
|
||||
}
|
||||
|
||||
public void completeOperation(long tenantId, long inboundId, long operatorId, String operation,
|
||||
String key, String resultJson) {
|
||||
operations.complete(tenantId, inboundId, operatorId, operation, key, resultJson);
|
||||
}
|
||||
|
||||
public LoadingPickTaskEntity findPickTask(long tenantId, long inboundId) {
|
||||
return picks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
|
||||
.eq(LoadingPickTaskEntity::getTenantId, tenantId).eq(LoadingPickTaskEntity::getInboundId, inboundId));
|
||||
}
|
||||
public void createPickTask(LoadingPickTaskEntity task) { picks.insert(task); }
|
||||
public int changePickStatus(long tenantId, long inboundId, String from, String to, long operatorId,
|
||||
String operatorName, String remark, Integer expectedCount,
|
||||
Integer pickedCount, int version) {
|
||||
return picks.changeStatus(tenantId, inboundId, from, to, operatorId, operatorName, remark,
|
||||
expectedCount, pickedCount, version);
|
||||
}
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
package com.baiyue.firstleg.loading.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.LoadingEvidenceFileClient;
|
||||
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
||||
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
import com.baiyue.firstleg.loading.domain.LoadingEvidenceType;
|
||||
import com.baiyue.firstleg.loading.dto.LoadingRequests.*;
|
||||
import com.baiyue.firstleg.loading.dto.LoadingResponses.*;
|
||||
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
|
||||
import com.baiyue.firstleg.loading.entity.LoadingOperationEntity;
|
||||
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity;
|
||||
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.repository.LoadingRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
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;
|
||||
|
||||
/** 装柜证据、装柜完成和派送完成;不检查证据完整度,不阻断业务流转。 */
|
||||
@Service
|
||||
public class LoadingDeliveryService {
|
||||
private static final String UPSERT_EVIDENCE = "UPSERT_LOADING_EVIDENCE";
|
||||
private static final String COMPLETE_LOADING = "COMPLETE_LOADING";
|
||||
private static final String COMPLETE_DELIVERY = "COMPLETE_DELIVERY";
|
||||
|
||||
@Resource private LoadingRepository repository;
|
||||
@Resource private InboundStatusHistoryMapper histories;
|
||||
@Resource private LoadingEvidenceFileClient files;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
|
||||
@Transactional
|
||||
public LoadingActionResponse upsertEvidence(long tenantId, long inboundId, long operatorId,
|
||||
String operatorName, String key, UpsertEvidenceRequest request) {
|
||||
String hash = hash(request);
|
||||
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, UPSERT_EVIDENCE, key, hash);
|
||||
if (replay != null) return replay;
|
||||
requireLoadingStage(tenantId, inboundId);
|
||||
if (!LoadingEvidenceType.VALUES.contains(request.evidenceType())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "不支持的装柜证据类型");
|
||||
}
|
||||
var file = request.fileId() == null ? null : validateFile(tenantId, inboundId, request.fileId(), request.evidenceType());
|
||||
String fileName = file == null ? request.fileName() : file.fileName();
|
||||
LoadingEvidenceEntity existing = repository.findEvidence(tenantId, inboundId, request.evidenceType());
|
||||
if (existing == null) {
|
||||
if (request.expectedVersion() != 0) throw concurrent();
|
||||
LoadingEvidenceEntity entity = new LoadingEvidenceEntity();
|
||||
entity.setTenantId(tenantId); entity.setInboundId(inboundId);
|
||||
entity.setEvidenceType(request.evidenceType()); entity.setFileId(request.fileId());
|
||||
entity.setFileName(fileName); entity.setSealNo(request.sealNo()); entity.setRemark(request.remark());
|
||||
entity.setVersion(0); entity.setCreatedBy(operatorId); entity.setCreatedByName(operatorName);
|
||||
entity.setUpdatedBy(operatorId); entity.setUpdatedByName(operatorName);
|
||||
repository.createEvidence(entity);
|
||||
} else if (repository.updateEvidence(tenantId, inboundId, request.evidenceType(), request.fileId(), fileName,
|
||||
request.sealNo(), request.remark(), operatorId, operatorName, request.expectedVersion()) != 1) {
|
||||
throw concurrent();
|
||||
}
|
||||
LoadingActionResponse result = new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId));
|
||||
return complete(tenantId, inboundId, operatorId, UPSERT_EVIDENCE, key, result);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public LoadingActionResponse completeLoading(long tenantId, long inboundId, long operatorId,
|
||||
String operatorName, String key, CompleteLoadingRequest request) {
|
||||
String hash = hash(request);
|
||||
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, hash);
|
||||
if (replay != null) return replay;
|
||||
Snapshot inbound = requireInbound(tenantId, inboundId);
|
||||
if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) {
|
||||
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, "当前订单不在装柜中");
|
||||
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
|
||||
addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName);
|
||||
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
|
||||
new LoadingActionResponse("LOADED", evidenceResponse(tenantId, inboundId)));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public LoadingActionResponse completeDelivery(long tenantId, long inboundId, long operatorId,
|
||||
String operatorName, String key, CompleteDeliveryRequest request) {
|
||||
String hash = hash(request);
|
||||
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_DELIVERY, key, hash);
|
||||
if (replay != null) return replay;
|
||||
Snapshot inbound = requireInbound(tenantId, inboundId);
|
||||
if ("DELIVERED".equals(inbound.processStatus())) {
|
||||
return complete(tenantId, inboundId, operatorId, COMPLETE_DELIVERY, key,
|
||||
new LoadingActionResponse("DELIVERED", evidenceResponse(tenantId, inboundId)));
|
||||
}
|
||||
if (!"LOADED".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜完成");
|
||||
if (repository.completeDelivery(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
|
||||
addHistory(tenantId, inboundId, "LOADED", "DELIVERED", COMPLETE_DELIVERY, request.remark(), operatorId, operatorName);
|
||||
return complete(tenantId, inboundId, operatorId, COMPLETE_DELIVERY, key,
|
||||
new LoadingActionResponse("DELIVERED", evidenceResponse(tenantId, inboundId)));
|
||||
}
|
||||
|
||||
public EvidenceListResponse evidence(long tenantId, long inboundId) {
|
||||
requireLoadingStage(tenantId, inboundId);
|
||||
return evidenceResponse(tenantId, inboundId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PickingResponse startPicking(long tenantId, long inboundId, long operatorId, String operatorName,
|
||||
String key, PickingRequest request) {
|
||||
String hash = hash(request);
|
||||
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "START_PICKING", key, hash);
|
||||
if (replay != null) return pickResponse(tenantId, inboundId);
|
||||
requireLoadingStage(tenantId, inboundId);
|
||||
LoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
|
||||
if (task == null) {
|
||||
if (request.expectedVersion() != 0) throw concurrent();
|
||||
task = new LoadingPickTaskEntity(); task.setTenantId(tenantId); task.setInboundId(inboundId);
|
||||
task.setStatus(PickingStatus.IN_PROGRESS); task.setVersion(0); task.setExpectedCount(request.expectedCount());
|
||||
task.setPickedCount(request.pickedCount());
|
||||
task.setRemark(request.remark()); task.setUpdatedBy(operatorId); task.setUpdatedByName(operatorName);
|
||||
repository.createPickTask(task);
|
||||
} else if (repository.changePickStatus(tenantId, inboundId, task.getStatus(), PickingStatus.IN_PROGRESS,
|
||||
operatorId, operatorName, request.remark(), request.expectedCount(), request.pickedCount(), request.expectedVersion()) != 1) throw concurrent();
|
||||
PickingResponse result = pickResponse(tenantId, inboundId);
|
||||
complete(tenantId, inboundId, operatorId, "START_PICKING", key,
|
||||
new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId)));
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PickingResponse completePicking(long tenantId, long inboundId, long operatorId, String operatorName,
|
||||
String key, PickingRequest request) {
|
||||
String hash = hash(request);
|
||||
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key, hash);
|
||||
if (replay != null) return pickResponse(tenantId, inboundId);
|
||||
requireLoadingStage(tenantId, inboundId);
|
||||
LoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
|
||||
if (task == null) {
|
||||
task = new LoadingPickTaskEntity(); task.setTenantId(tenantId); task.setInboundId(inboundId);
|
||||
task.setStatus(PickingStatus.COMPLETED); task.setVersion(0); task.setExpectedCount(request.expectedCount());
|
||||
task.setPickedCount(request.pickedCount());
|
||||
task.setRemark(request.remark()); task.setUpdatedBy(operatorId); task.setUpdatedByName(operatorName);
|
||||
repository.createPickTask(task);
|
||||
} else if (repository.changePickStatus(tenantId, inboundId,
|
||||
task.getStatus(), PickingStatus.COMPLETED, operatorId, operatorName, request.remark(), request.expectedCount(), request.pickedCount(), request.expectedVersion()) != 1) throw concurrent();
|
||||
PickingResponse result = pickResponse(tenantId, inboundId);
|
||||
complete(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key,
|
||||
new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId)));
|
||||
return result;
|
||||
}
|
||||
|
||||
public PickingResponse picking(long tenantId, long inboundId) {
|
||||
requireInbound(tenantId, inboundId);
|
||||
return pickResponse(tenantId, inboundId);
|
||||
}
|
||||
|
||||
private PickingResponse pickResponse(long tenantId, long inboundId) {
|
||||
LoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
|
||||
if (task == null) return null;
|
||||
return new PickingResponse(task.getId(), task.getInboundId(), task.getStatus(), task.getVersion(),
|
||||
task.getExpectedCount(), task.getPickedCount(), task.getRemark());
|
||||
}
|
||||
|
||||
private LoadingEvidenceFileClient.FileMetadata validateFile(long tenantId, long inboundId, long fileId, String evidenceType) {
|
||||
try {
|
||||
var response = files.validate(internalToken, tenantId, inboundId, fileId);
|
||||
if (response == null || !response.successful() || response.data() == null
|
||||
|| !("LOADING_" + evidenceType).equals(response.data().businessType())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "证据文件不存在或不属于当前订单");
|
||||
}
|
||||
return response.data();
|
||||
} catch (FeignException ex) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用");
|
||||
}
|
||||
}
|
||||
|
||||
private Snapshot requireInbound(long tenantId, long inboundId) {
|
||||
Snapshot value = repository.findInbound(tenantId, inboundId);
|
||||
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在");
|
||||
return value;
|
||||
}
|
||||
|
||||
private Snapshot requireLoadingStage(long tenantId, long inboundId) {
|
||||
Snapshot value = requireInbound(tenantId, inboundId);
|
||||
if (!("LOADING".equals(value.processStatus()) || "LOADED".equals(value.processStatus())
|
||||
|| "DELIVERED".equals(value.processStatus()))) {
|
||||
throw new BizException(ResultCode.CONFLICT, "订单尚未进入装柜阶段");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private EvidenceListResponse evidenceResponse(long tenantId, long inboundId) {
|
||||
List<EvidenceResponse> values = repository.listEvidence(tenantId, inboundId).stream().map(e ->
|
||||
new EvidenceResponse(e.getId(), e.getInboundId(), e.getTenantId(), e.getEvidenceType(), e.getFileId(),
|
||||
e.getFileName(), e.getSealNo(), e.getRemark(), e.getVersion(), e.getCreatedAt(), e.getUpdatedAt())).toList();
|
||||
return new EvidenceListResponse(inboundId, values);
|
||||
}
|
||||
|
||||
private LoadingActionResponse claimOrReplay(long tenantId, long inboundId, long operatorId,
|
||||
String operation, String key, String hash) {
|
||||
validateKey(key);
|
||||
if (repository.claimOperation(tenantId, inboundId, operatorId, operation, key, hash)) return null;
|
||||
LoadingOperationEntity existing = repository.findOperation(tenantId, inboundId, operatorId, operation, key);
|
||||
if (existing == null || !hash.equals(existing.getRequestHash())) throw new BizException(ResultCode.CONFLICT, "幂等键已用于其他请求");
|
||||
if (!"SUCCESS".equals(existing.getStatus()) || !StringUtils.hasText(existing.getResultJson())) throw new BizException(ResultCode.CONFLICT, "相同请求正在处理中,请稍后重试");
|
||||
try { return objectMapper.readValue(existing.getResultJson(), LoadingActionResponse.class); }
|
||||
catch (JsonProcessingException e) { throw new IllegalStateException("无法读取装柜幂等结果", e); }
|
||||
}
|
||||
|
||||
private LoadingActionResponse complete(long tenantId, long inboundId, long operatorId, String operation,
|
||||
String key, LoadingActionResponse result) {
|
||||
try { repository.completeOperation(tenantId, inboundId, operatorId, operation, key, objectMapper.writeValueAsString(result)); }
|
||||
catch (JsonProcessingException e) { throw new IllegalStateException("无法保存装柜幂等结果", e); }
|
||||
return result;
|
||||
}
|
||||
|
||||
private void addHistory(long tenantId, long inboundId, String from, String to, String action, String reason,
|
||||
long operatorId, String operatorName) {
|
||||
InboundStatusHistoryEntity history = new InboundStatusHistoryEntity();
|
||||
history.setTenantId(tenantId); history.setInboundId(inboundId); history.setFromStatus(from); history.setToStatus(to);
|
||||
history.setAction(action); history.setReason(reason); history.setOperatorId(operatorId); history.setOperatorName(operatorName);
|
||||
histories.insert(history);
|
||||
}
|
||||
|
||||
private String hash(Object request) {
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest(objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(digest);
|
||||
} catch (Exception e) { throw new IllegalStateException("无法计算幂等请求摘要", e); }
|
||||
}
|
||||
|
||||
private static void validateKey(String key) {
|
||||
if (!StringUtils.hasText(key) || key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空且不能超过128个字符");
|
||||
}
|
||||
private static BizException concurrent() { return new BizException(ResultCode.CONFLICT, "数据已被其他人修改,请刷新后重试"); }
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import org.apache.ibatis.annotations.Update;
|
||||
public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
|
||||
@Update("""
|
||||
UPDATE fl_inbound_order
|
||||
SET process_status = 'WAIT_PRICING', measure_review_status = 'APPROVED',
|
||||
SET process_status = 'WAIT_SPLIT', measure_review_status = 'APPROVED',
|
||||
inbounded_at = COALESCE(inbounded_at, CURRENT_TIMESTAMP(3)),
|
||||
updated_by = #{operatorId}, version = version + 1
|
||||
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
|
||||
AND process_status = 'MEASURE_PENDING' AND measure_review_status = 'PENDING'
|
||||
|
||||
@@ -27,6 +27,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import com.baiyue.firstleg.dto.UnifiedInboundQuery;
|
||||
|
||||
/** 入库单只读仓储;平台分页可跨企业查询,详情继续强制租户隔离。 */
|
||||
@Repository
|
||||
@@ -103,6 +104,88 @@ public class InboundOrderRepository {
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
/** Internal unified-query accessors. They always retain the tenant predicate when one is supplied. */
|
||||
public Page<InboundOrderEntity> pageUnifiedEntities(UnifiedInboundQuery query, List<String> processStatuses) {
|
||||
LambdaQueryWrapper<InboundOrderEntity> wrapper = new LambdaQueryWrapper<InboundOrderEntity>()
|
||||
.eq(query.tenantId() != null, InboundOrderEntity::getTenantId, query.tenantId())
|
||||
.in(processStatuses != null && !processStatuses.isEmpty(), InboundOrderEntity::getProcessStatus, processStatuses)
|
||||
.like(StringUtils.hasText(query.orderNo()), InboundOrderEntity::getOrderNo, query.orderNo())
|
||||
.like(StringUtils.hasText(query.inboundCode()), InboundOrderEntity::getInboundCode, query.inboundCode())
|
||||
.eq(StringUtils.hasText(query.internalOrderStatus()), InboundOrderEntity::getOrderStatusSnapshot,
|
||||
query.internalOrderStatus())
|
||||
.and(StringUtils.hasText(query.keyword()), nested -> nested
|
||||
.like(InboundOrderEntity::getOrderNo, query.keyword())
|
||||
.or().like(InboundOrderEntity::getInboundCode, query.keyword())
|
||||
.or().like(InboundOrderEntity::getCustomerName, query.keyword()))
|
||||
.eq(query.customerId() != null, InboundOrderEntity::getCustomerId, query.customerId())
|
||||
.eq(query.receivingWarehouseId() != null, InboundOrderEntity::getWarehouseId, query.receivingWarehouseId())
|
||||
.eq(query.destinationWarehouseId() != null, InboundOrderEntity::getDeliveryWarehouseId, query.destinationWarehouseId())
|
||||
.ge(query.expectedArrivalDate() != null, InboundOrderEntity::getExpectedArrivalAt,
|
||||
query.expectedArrivalDate() == null ? null : query.expectedArrivalDate().atStartOfDay())
|
||||
.lt(query.expectedArrivalDate() != null, InboundOrderEntity::getExpectedArrivalAt,
|
||||
query.expectedArrivalDate() == null ? null : query.expectedArrivalDate().plusDays(1).atStartOfDay())
|
||||
.eq(StringUtils.hasText(query.channelName()), InboundOrderEntity::getChannelName, query.channelName())
|
||||
.eq(query.fullContainer() != null, InboundOrderEntity::getContainerMode,
|
||||
query.fullContainer() == null ? null : query.fullContainer() ? "FULL_CONTAINER" : "LCL")
|
||||
.and("UNRECORDED".equals(query.measurementReviewStatus()), nested -> nested
|
||||
.isNull(InboundOrderEntity::getMeasureReviewStatus)
|
||||
.or().eq(InboundOrderEntity::getMeasureReviewStatus, ""))
|
||||
.eq(StringUtils.hasText(query.measurementReviewStatus())
|
||||
&& !"UNRECORDED".equals(query.measurementReviewStatus()),
|
||||
InboundOrderEntity::getMeasureReviewStatus, query.measurementReviewStatus())
|
||||
.in(query.candidateOrderIds() != null && !query.candidateOrderIds().isEmpty(),
|
||||
InboundOrderEntity::getOrderId,
|
||||
query.candidateOrderIds() == null ? List.of() : query.candidateOrderIds())
|
||||
.apply("UNRECORDED".equals(query.feeReviewStatus()),
|
||||
"NOT EXISTS (SELECT 1 FROM fl_inbound_fee f WHERE f.tenant_id = fl_inbound_order.tenant_id "
|
||||
+ "AND f.inbound_id = fl_inbound_order.id)")
|
||||
.apply(StringUtils.hasText(query.feeReviewStatus()) && !"UNRECORDED".equals(query.feeReviewStatus()),
|
||||
"EXISTS (SELECT 1 FROM fl_inbound_fee f WHERE f.tenant_id = fl_inbound_order.tenant_id "
|
||||
+ "AND f.inbound_id = fl_inbound_order.id AND f.review_status = {0} "
|
||||
+ "AND NOT EXISTS (SELECT 1 FROM fl_inbound_fee newer "
|
||||
+ "WHERE newer.tenant_id = f.tenant_id AND newer.inbound_id = f.inbound_id "
|
||||
+ "AND (newer.fee_version > f.fee_version "
|
||||
+ "OR (newer.fee_version = f.fee_version AND newer.id > f.id))))",
|
||||
query.feeReviewStatus())
|
||||
.apply(StringUtils.hasText(query.containerNo()),
|
||||
"EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t "
|
||||
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id "
|
||||
+ "AND t.container_no LIKE CONCAT('%', {0}, '%'))", query.containerNo())
|
||||
.apply(StringUtils.hasText(query.cabinetNo()),
|
||||
"EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t "
|
||||
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id "
|
||||
+ "AND t.cabinet_no LIKE CONCAT('%', {0}, '%'))", query.cabinetNo())
|
||||
.apply(query.sailingDate() != null,
|
||||
"EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t "
|
||||
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id "
|
||||
+ "AND t.sailing_at >= {0} AND t.sailing_at < {1})",
|
||||
query.sailingDate() == null ? null : query.sailingDate().atStartOfDay(),
|
||||
query.sailingDate() == null ? null : query.sailingDate().plusDays(1).atStartOfDay())
|
||||
.apply(Boolean.TRUE.equals(query.loaded()),
|
||||
"EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t "
|
||||
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id "
|
||||
+ "AND COALESCE(TRIM(t.loading_status), '') <> '' "
|
||||
+ "AND t.loading_status NOT IN ('UNLOADED', 'PENDING'))")
|
||||
.apply(Boolean.FALSE.equals(query.loaded()),
|
||||
"NOT EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t "
|
||||
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id "
|
||||
+ "AND COALESCE(TRIM(t.loading_status), '') <> '' "
|
||||
+ "AND t.loading_status NOT IN ('UNLOADED', 'PENDING'))")
|
||||
.ge(query.createdFrom() != null, InboundOrderEntity::getCreatedAt, query.createdFrom())
|
||||
.le(query.createdTo() != null, InboundOrderEntity::getCreatedAt, query.createdTo())
|
||||
.orderByDesc(InboundOrderEntity::getCreatedAt).orderByDesc(InboundOrderEntity::getId);
|
||||
return orders.selectPage(new Page<>(query.page(), query.size()), wrapper);
|
||||
}
|
||||
|
||||
public List<InboundOrderEntity> findByOrderIds(Long tenantId, List<Long> orderIds) {
|
||||
if (orderIds == null || orderIds.isEmpty()) return List.of();
|
||||
return orders.selectList(new LambdaQueryWrapper<InboundOrderEntity>()
|
||||
.eq(tenantId != null, InboundOrderEntity::getTenantId, tenantId)
|
||||
.in(InboundOrderEntity::getOrderId, orderIds)
|
||||
.orderByDesc(InboundOrderEntity::getCreatedAt));
|
||||
}
|
||||
|
||||
|
||||
public InboundOrderDetail detail(long tenantId, InboundOrderEntity order) {
|
||||
long inboundId = order.getId();
|
||||
InboundTransportSnapshotEntity transport = transports.selectOne(
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
|
||||
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity;
|
||||
import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper;
|
||||
import com.baiyue.firstleg.loading.mapper.LoadingPickTaskMapper;
|
||||
import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
|
||||
import com.baiyue.firstleg.split.mapper.PreloadPlanMapper;
|
||||
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
||||
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
|
||||
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundTransportSnapshotMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Read-only access to phase-three operational summaries for unified order queries. */
|
||||
@Repository
|
||||
public class UnifiedInboundReadRepository {
|
||||
@Resource private PreloadPlanMapper preloads;
|
||||
@Resource private LoadingPickTaskMapper picks;
|
||||
@Resource private LoadingEvidenceMapper evidences;
|
||||
@Resource private InboundStatusHistoryMapper histories;
|
||||
@Resource private InboundTransportSnapshotMapper transports;
|
||||
|
||||
public PreloadPlanEntity findPreload(long tenantId, long inboundId) {
|
||||
return preloads.selectOne(new LambdaQueryWrapper<PreloadPlanEntity>()
|
||||
.eq(PreloadPlanEntity::getTenantId, tenantId)
|
||||
.eq(PreloadPlanEntity::getInboundId, inboundId));
|
||||
}
|
||||
|
||||
public LoadingPickTaskEntity findPick(long tenantId, long inboundId) {
|
||||
return picks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
|
||||
.eq(LoadingPickTaskEntity::getTenantId, tenantId)
|
||||
.eq(LoadingPickTaskEntity::getInboundId, inboundId));
|
||||
}
|
||||
|
||||
public List<LoadingEvidenceEntity> findEvidence(long tenantId, long inboundId) {
|
||||
return evidences.selectList(new LambdaQueryWrapper<LoadingEvidenceEntity>()
|
||||
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
|
||||
.eq(LoadingEvidenceEntity::getInboundId, inboundId)
|
||||
.orderByAsc(LoadingEvidenceEntity::getEvidenceType));
|
||||
}
|
||||
|
||||
public Map<Long, PreloadPlanEntity> findPreloads(Map<Long, Long> tenantByInbound) {
|
||||
if (tenantByInbound.isEmpty()) return Map.of();
|
||||
return preloads.selectList(new LambdaQueryWrapper<PreloadPlanEntity>()
|
||||
.in(PreloadPlanEntity::getInboundId, tenantByInbound.keySet()))
|
||||
.stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId()))
|
||||
.collect(Collectors.toMap(PreloadPlanEntity::getInboundId, Function.identity(), (left, right) -> left));
|
||||
}
|
||||
|
||||
public Map<Long, LoadingPickTaskEntity> findPicks(Map<Long, Long> tenantByInbound) {
|
||||
if (tenantByInbound.isEmpty()) return Map.of();
|
||||
return picks.selectList(new LambdaQueryWrapper<LoadingPickTaskEntity>()
|
||||
.in(LoadingPickTaskEntity::getInboundId, tenantByInbound.keySet()))
|
||||
.stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId()))
|
||||
.collect(Collectors.toMap(LoadingPickTaskEntity::getInboundId, Function.identity(), (left, right) -> left));
|
||||
}
|
||||
|
||||
public Map<Long, List<LoadingEvidenceEntity>> findEvidence(Map<Long, Long> tenantByInbound) {
|
||||
if (tenantByInbound.isEmpty()) return Map.of();
|
||||
return evidences.selectList(new LambdaQueryWrapper<LoadingEvidenceEntity>()
|
||||
.in(LoadingEvidenceEntity::getInboundId, tenantByInbound.keySet())
|
||||
.orderByAsc(LoadingEvidenceEntity::getEvidenceType))
|
||||
.stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId()))
|
||||
.collect(Collectors.groupingBy(LoadingEvidenceEntity::getInboundId));
|
||||
}
|
||||
|
||||
public Map<Long, InboundStatusHistoryEntity> findDeliveryHistory(Map<Long, Long> tenantByInbound) {
|
||||
if (tenantByInbound.isEmpty()) return Map.of();
|
||||
return histories.selectList(new LambdaQueryWrapper<InboundStatusHistoryEntity>()
|
||||
.in(InboundStatusHistoryEntity::getInboundId, tenantByInbound.keySet())
|
||||
.and(nested -> nested.eq(InboundStatusHistoryEntity::getAction, "COMPLETE_DELIVERY")
|
||||
.or().eq(InboundStatusHistoryEntity::getToStatus, "DELIVERED"))
|
||||
.orderByDesc(InboundStatusHistoryEntity::getOccurredAt))
|
||||
.stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId()))
|
||||
.collect(Collectors.toMap(InboundStatusHistoryEntity::getInboundId,
|
||||
Function.identity(), (latest, ignored) -> latest));
|
||||
}
|
||||
|
||||
public InboundTransportSnapshotEntity findTransport(long tenantId, long inboundId) {
|
||||
return transports.selectOne(new LambdaQueryWrapper<InboundTransportSnapshotEntity>()
|
||||
.eq(InboundTransportSnapshotEntity::getTenantId, tenantId)
|
||||
.eq(InboundTransportSnapshotEntity::getInboundId, inboundId));
|
||||
}
|
||||
|
||||
public Map<Long, InboundTransportSnapshotEntity> findTransports(Map<Long, Long> tenantByInbound) {
|
||||
if (tenantByInbound.isEmpty()) return Map.of();
|
||||
return transports.selectList(new LambdaQueryWrapper<InboundTransportSnapshotEntity>()
|
||||
.in(InboundTransportSnapshotEntity::getInboundId, tenantByInbound.keySet()))
|
||||
.stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId()))
|
||||
.collect(Collectors.toMap(InboundTransportSnapshotEntity::getInboundId,
|
||||
Function.identity(), (left, right) -> left));
|
||||
}
|
||||
|
||||
private static boolean owns(Map<Long, Long> tenantByInbound, Long inboundId, Long tenantId) {
|
||||
return inboundId != null && tenantId != null && tenantId.equals(tenantByInbound.get(inboundId));
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,6 @@ public class InboundMeasurementService {
|
||||
private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL";
|
||||
/** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */
|
||||
private static final String RECOMMENDED_PALLET_TYPE = "STANDARD";
|
||||
private static final Set<String> EDITABLE_INBOUND_STATUSES =
|
||||
Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
|
||||
private static final BigDecimal LB_TO_KG = new BigDecimal("0.45359237");
|
||||
|
||||
@Resource
|
||||
@@ -58,7 +56,6 @@ public class InboundMeasurementService {
|
||||
long tenantId, long inboundId, long operatorId,
|
||||
InboundMeasurementRequests.SaveDraftRequest request) {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
requireEditable(inbound);
|
||||
MeasurementBatchCommand command = new MeasurementBatchCommand(
|
||||
tenantId, inboundId, request.measurementId(), request.version(),
|
||||
MeasurementBatchService.SOURCE_MANUAL, "PLATFORM", null, null, null,
|
||||
@@ -78,35 +75,42 @@ public class InboundMeasurementService {
|
||||
long tenantId, long inboundId, long operatorId,
|
||||
InboundMeasurementRequests.SubmitRequest request) {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
requireEditable(inbound);
|
||||
InboundMeasurementEntity measurement = requireMeasurement(tenantId, inboundId, request.measurementId());
|
||||
requireVersion(request.version(), measurement.getVersion());
|
||||
if (!Set.of(MeasurementBatchService.STATUS_DRAFT, MeasurementBatchService.STATUS_RECEIVED)
|
||||
boolean alreadyAdvanced = !Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED")
|
||||
.contains(inbound.getProcessStatus());
|
||||
if (!alreadyAdvanced && !Set.of(MeasurementBatchService.STATUS_DRAFT, MeasurementBatchService.STATUS_RECEIVED)
|
||||
.contains(measurement.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "当前测量批次不能重复提交审核");
|
||||
}
|
||||
List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId());
|
||||
if (items.isEmpty()) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细不能为空");
|
||||
}
|
||||
if (items.stream().noneMatch(item -> item.getSourceImageFileId() != null)) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "至少需要一张测量图片");
|
||||
}
|
||||
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
|
||||
BigDecimal confirmedWeight = confirmedWeightForSubmission(weights);
|
||||
|
||||
measurement.setStatus(STATUS_SUBMITTED);
|
||||
if (!repository.updateMeasurement(measurement)) {
|
||||
throw concurrentModification();
|
||||
if (!alreadyAdvanced) {
|
||||
measurement.setStatus(STATUS_SUBMITTED);
|
||||
if (!repository.updateMeasurement(measurement)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
}
|
||||
|
||||
inbound.setProcessStatus("MEASURE_PENDING");
|
||||
inbound.setMeasureReviewStatus("PENDING");
|
||||
inbound.setTotalPackageCount(measurement.getTotalPackageCount());
|
||||
inbound.setActualGrossWeightKg(confirmedWeight);
|
||||
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
|
||||
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
|
||||
inbound.setMeasureCompletedAt(LocalDateTime.now());
|
||||
if (!alreadyAdvanced) {
|
||||
inbound.setProcessStatus("MEASURE_PENDING");
|
||||
inbound.setMeasureReviewStatus("PENDING");
|
||||
inbound.setMeasureCompletedAt(LocalDateTime.now());
|
||||
}
|
||||
if (measurement.getTotalPackageCount() != null) {
|
||||
inbound.setTotalPackageCount(measurement.getTotalPackageCount());
|
||||
}
|
||||
if (confirmedWeight != null) {
|
||||
inbound.setActualGrossWeightKg(confirmedWeight);
|
||||
}
|
||||
if (measurement.getTotalVolumeCbm() != null) {
|
||||
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
|
||||
}
|
||||
if (measurement.getVolumeWeightKg() != null) {
|
||||
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
|
||||
}
|
||||
inbound.setUpdatedBy(operatorId);
|
||||
if (!repository.updateInbound(inbound)) {
|
||||
throw concurrentModification();
|
||||
@@ -121,7 +125,6 @@ public class InboundMeasurementService {
|
||||
long tenantId, long inboundId, long operatorId,
|
||||
InboundMeasurementRequests.CreateWeightResultRequest request) {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
requireEditable(inbound);
|
||||
if (repository.findWeightByFile(tenantId, inboundId, request.fileId()) != null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "同一称重图片不能重复计重");
|
||||
}
|
||||
@@ -150,7 +153,6 @@ public class InboundMeasurementService {
|
||||
long tenantId, long inboundId, long resultId, long operatorId, String operatorName,
|
||||
InboundMeasurementRequests.CorrectWeightResultRequest request) {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
requireEditable(inbound);
|
||||
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
|
||||
requireVersion(request.version(), weight.getVersion());
|
||||
String adjustedUnit = normalizeUnit(request.adjustedWeightUnit());
|
||||
@@ -181,7 +183,6 @@ public class InboundMeasurementService {
|
||||
long tenantId, long inboundId, long resultId, long operatorId, String operatorName,
|
||||
InboundMeasurementRequests.ConfirmWeightResultRequest request) {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
requireEditable(inbound);
|
||||
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
|
||||
requireVersion(request.version(), weight.getVersion());
|
||||
if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) {
|
||||
@@ -225,18 +226,12 @@ public class InboundMeasurementService {
|
||||
}
|
||||
|
||||
private BigDecimal confirmedWeightForSubmission(List<InboundWeightResultEntity> weights) {
|
||||
if (weights.isEmpty()) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "至少需要一张已确认的称重图片");
|
||||
}
|
||||
if (weights.stream().anyMatch(weight -> !WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "所有有效称重图片必须先完成确认");
|
||||
}
|
||||
BigDecimal total = weights.stream().map(InboundWeightResultEntity::getFinalWeightKg)
|
||||
BigDecimal total = weights.stream()
|
||||
.filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))
|
||||
.map(InboundWeightResultEntity::getFinalWeightKg)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(3, RoundingMode.HALF_UP);
|
||||
if (!isPositive(total)) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "确认后的实际总重量必须大于 0");
|
||||
}
|
||||
return total;
|
||||
return isPositive(total) ? total : null;
|
||||
}
|
||||
|
||||
private void insertRevision(InboundWeightResultEntity weight, int revisionNo, String action,
|
||||
@@ -328,12 +323,6 @@ public class InboundMeasurementService {
|
||||
return weight;
|
||||
}
|
||||
|
||||
private void requireEditable(InboundOrderEntity inbound) {
|
||||
if (!EDITABLE_INBOUND_STATUSES.contains(inbound.getProcessStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "当前入库状态不允许维护测量数据");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireVersion(Integer expected, Integer actual) {
|
||||
if (expected == null || !expected.equals(actual)) {
|
||||
throw concurrentModification();
|
||||
|
||||
@@ -38,7 +38,8 @@ public class MeasurementBatchService {
|
||||
|
||||
@Transactional
|
||||
public InboundMeasurementEntity saveBatch(MeasurementBatchCommand command) {
|
||||
if (repository.findInbound(command.tenantId(), command.inboundId()) == null) {
|
||||
var inbound = repository.findInbound(command.tenantId(), command.inboundId());
|
||||
if (inbound == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
|
||||
}
|
||||
validateSource(command);
|
||||
@@ -50,7 +51,13 @@ public class MeasurementBatchService {
|
||||
applyTotals(measurement, calculatedItems);
|
||||
repository.insertMeasurement(measurement);
|
||||
} else {
|
||||
measurement = requireEditableManualBatch(command);
|
||||
measurement = requireEditableBatch(command);
|
||||
// 修正待测量或待审核资料时重新形成可提交的内部草稿;已入库订单只更新资料,
|
||||
// 不回退其主状态,也不把已经审核的批次重新标成待审核。
|
||||
if (Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED")
|
||||
.contains(inbound.getProcessStatus())) {
|
||||
measurement.setStatus(STATUS_DRAFT);
|
||||
}
|
||||
applyHeader(measurement, command);
|
||||
applyTotals(measurement, calculatedItems);
|
||||
if (!repository.updateMeasurement(measurement)) {
|
||||
@@ -90,15 +97,12 @@ public class MeasurementBatchService {
|
||||
return measurement;
|
||||
}
|
||||
|
||||
private InboundMeasurementEntity requireEditableManualBatch(MeasurementBatchCommand command) {
|
||||
private InboundMeasurementEntity requireEditableBatch(MeasurementBatchCommand command) {
|
||||
InboundMeasurementEntity measurement = repository.findMeasurement(
|
||||
command.tenantId(), command.inboundId(), command.measurementId());
|
||||
if (measurement == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "测量批次不存在");
|
||||
}
|
||||
if (!SOURCE_MANUAL.equals(measurement.getSourceType()) || !STATUS_DRAFT.equals(measurement.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "仅人工草稿允许直接修改");
|
||||
}
|
||||
if (!Integer.valueOf(command.expectedVersion()).equals(measurement.getVersion())) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ import org.springframework.util.StringUtils;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 测量审核应用服务。审核是 MEASURE_PENDING 到计费阶段的唯一正常推进关口。
|
||||
* 测量审核应用服务。客服确认测量数据后将入库单推进到产品主状态 WAIT_SPLIT;
|
||||
* 计费、库位和其他资料不作为本版本的强制门禁。
|
||||
*/
|
||||
@Service
|
||||
public class MeasurementReviewService {
|
||||
@@ -79,7 +80,7 @@ public class MeasurementReviewService {
|
||||
repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result,
|
||||
request.reason(), false, null, false, operatorId, operatorName));
|
||||
repository.insertHistory(history(tenantId, inboundId, "MEASURE_PENDING",
|
||||
approved ? "WAIT_PRICING" : "MEASURE_REJECTED",
|
||||
approved ? "WAIT_SPLIT" : "MEASURE_REJECTED",
|
||||
approved ? "MEASURE_APPROVE" : "MEASURE_REJECT", request.reason(), operatorId, operatorName));
|
||||
idempotency.complete(context, inboundId);
|
||||
}
|
||||
|
||||
+2
-3
@@ -11,15 +11,14 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
public class MeasurementSubmissionService {
|
||||
@Resource
|
||||
private InboundMeasurementService measurements;
|
||||
@Resource
|
||||
private MeasurementLocationOccupationService locations;
|
||||
|
||||
@Transactional
|
||||
public InboundMeasurementResponses.MeasurementSubmitted submit(
|
||||
long tenantId, long inboundId, long operatorId,
|
||||
InboundMeasurementRequests.SubmitRequest request) {
|
||||
var result = measurements.submit(tenantId, inboundId, operatorId, request);
|
||||
locations.occupy(tenantId, inboundId, operatorId, result.measurementId(), result.version());
|
||||
// 当前版本不把托盘/库位占用作为测量提交的强制门禁;入库主状态由客服确认测量时推进,
|
||||
// 仓储资源可在后续作业阶段继续维护。
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.firstleg.dto.InboundOrderDetail;
|
||||
import com.baiyue.firstleg.dto.UnifiedInboundDetail;
|
||||
import com.baiyue.firstleg.dto.UnifiedInboundQuery;
|
||||
import com.baiyue.firstleg.dto.UnifiedInboundSummary;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
|
||||
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
|
||||
import com.baiyue.firstleg.repository.InboundOrderRepository;
|
||||
import com.baiyue.firstleg.repository.UnifiedInboundReadRepository;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Read-only adapter from internal inbound states to the seven product main statuses. */
|
||||
@Service
|
||||
public class UnifiedInboundQueryService {
|
||||
private static final Set<String> MAIN_STATUSES = Set.of(
|
||||
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
|
||||
private static final Set<String> REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED");
|
||||
|
||||
@Resource private InboundOrderRepository inbounds;
|
||||
@Resource private UnifiedInboundReadRepository operations;
|
||||
@Resource private ObjectMapper json;
|
||||
|
||||
public PageResult<UnifiedInboundSummary> page(UnifiedInboundQuery query) {
|
||||
validate(query);
|
||||
if ("PENDING_REVIEW".equals(query.mainStatus())
|
||||
|| query.candidateOrderIds() != null && query.candidateOrderIds().isEmpty()) {
|
||||
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
|
||||
}
|
||||
Page<InboundOrderEntity> source = inbounds.pageUnifiedEntities(query, internalStatuses(query.mainStatus()));
|
||||
List<UnifiedInboundSummary> records = summaries(source.getRecords());
|
||||
return new PageResult<>(records, source.getCurrent(), source.getSize(), source.getTotal(), source.getPages());
|
||||
}
|
||||
|
||||
public List<UnifiedInboundSummary> summaries(Long tenantId, List<Long> orderIds) {
|
||||
Map<Long, UnifiedInboundSummary> byOrder = new LinkedHashMap<>();
|
||||
for (UnifiedInboundSummary item : summaries(inbounds.findByOrderIds(tenantId, orderIds)))
|
||||
byOrder.putIfAbsent(item.inbound().orderId(), item);
|
||||
return orderIds.stream().distinct().map(byOrder::get).filter(java.util.Objects::nonNull).toList();
|
||||
}
|
||||
|
||||
public UnifiedInboundDetail detail(long tenantId, long orderId) {
|
||||
InboundOrderEntity inbound = inbounds.findByOrderId(tenantId, orderId);
|
||||
if (inbound == null) return null;
|
||||
InboundOrderDetail detail = inbounds.detail(tenantId, inbound);
|
||||
List<UnifiedInboundDetail.TimelineEvent> timeline = detail.statusHistory().stream()
|
||||
.map(item -> new UnifiedInboundDetail.TimelineEvent(
|
||||
"FIRSTLEG", "STATUS", item.fromStatus(), item.toStatus(), item.action(), item.reason(),
|
||||
item.operatorId(), item.operatorName(), item.occurredAt()))
|
||||
.toList();
|
||||
return new UnifiedInboundDetail(summary(inbound, detail,
|
||||
operations.findPreload(tenantId, inbound.getId()), operations.findPick(tenantId, inbound.getId()),
|
||||
operations.findEvidence(tenantId, inbound.getId()), deliveryAt(detail.statusHistory()),
|
||||
operations.findTransport(tenantId, inbound.getId())), timeline);
|
||||
}
|
||||
|
||||
private List<UnifiedInboundSummary> summaries(List<InboundOrderEntity> records) {
|
||||
Map<Long, Long> owners = records.stream().collect(Collectors.toMap(
|
||||
InboundOrderEntity::getId, InboundOrderEntity::getTenantId, (left, right) -> left));
|
||||
var preloads = operations.findPreloads(owners);
|
||||
var picks = operations.findPicks(owners);
|
||||
var evidences = operations.findEvidence(owners);
|
||||
var deliveries = operations.findDeliveryHistory(owners);
|
||||
var transports = operations.findTransports(owners);
|
||||
return records.stream().map(inbound -> summary(inbound, null, preloads.get(inbound.getId()),
|
||||
picks.get(inbound.getId()), evidences.getOrDefault(inbound.getId(), List.of()),
|
||||
deliveries.get(inbound.getId()) == null ? null : deliveries.get(inbound.getId()).getOccurredAt(),
|
||||
transports.get(inbound.getId()))).toList();
|
||||
}
|
||||
|
||||
private UnifiedInboundSummary summary(InboundOrderEntity inbound, InboundOrderDetail detail,
|
||||
com.baiyue.firstleg.split.entity.PreloadPlanEntity preload,
|
||||
com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity pick,
|
||||
List<LoadingEvidenceEntity> evidence, LocalDateTime deliveryAt,
|
||||
InboundTransportSnapshotEntity transport) {
|
||||
String mainStatus = mainStatus(inbound.getProcessStatus());
|
||||
String sealNo = evidence.stream().filter(item -> "SEAL".equals(item.getEvidenceType()))
|
||||
.map(LoadingEvidenceEntity::getSealNo).filter(StringUtils::hasText).findFirst().orElse(null);
|
||||
var preloadSummary = preload == null ? null : new UnifiedInboundSummary.PreloadSummary(
|
||||
preload.getStatus(), preload.getVersion(), itemCount(preload.getItemsJson()), preload.getUpdatedAt());
|
||||
var loadingSummary = pick == null && evidence.isEmpty() ? null : new UnifiedInboundSummary.LoadingSummary(
|
||||
pick == null ? null : pick.getStatus(), pick == null ? null : pick.getVersion(),
|
||||
pick == null ? null : pick.getVersion(), pick == null ? null : pick.getExpectedCount(),
|
||||
pick == null ? null : pick.getPickedCount(), evidence.size(), sealNo,
|
||||
pick == null ? evidence.stream().map(LoadingEvidenceEntity::getUpdatedAt)
|
||||
.filter(java.util.Objects::nonNull).max(LocalDateTime::compareTo).orElse(null) : pick.getUpdatedAt());
|
||||
var deliverySummary = "LOADED".equals(mainStatus) || "DELIVERED".equals(mainStatus)
|
||||
? new UnifiedInboundSummary.DeliverySummary(
|
||||
"DELIVERED".equals(mainStatus) ? "COMPLETED" : "PENDING", deliveryAt,
|
||||
inbound.getExpressTrackingNo()) : null;
|
||||
var inboundSummary = detail == null
|
||||
? com.baiyue.firstleg.dto.InboundOrderSummary.from(inbound, transport, null) : detail.summary();
|
||||
return new UnifiedInboundSummary(inboundSummary, mainStatus,
|
||||
nextAction(mainStatus, inbound.getProcessStatus(), preload == null ? null : preload.getStatus()),
|
||||
preloadSummary, loadingSummary, deliverySummary);
|
||||
}
|
||||
|
||||
private LocalDateTime deliveryAt(List<InboundOrderDetail.StatusHistorySummary> history) {
|
||||
return history.stream()
|
||||
.filter(item -> "COMPLETE_DELIVERY".equals(item.action()) || "DELIVERED".equals(item.toStatus()))
|
||||
.map(InboundOrderDetail.StatusHistorySummary::occurredAt)
|
||||
.filter(java.util.Objects::nonNull).max(LocalDateTime::compareTo).orElse(null);
|
||||
}
|
||||
|
||||
static String mainStatus(String status) {
|
||||
if (status == null) return null;
|
||||
return switch (status) {
|
||||
case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED" -> "WAIT_MEASURE";
|
||||
case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT" -> "WAIT_SPLIT";
|
||||
case "INBOUNDED" -> "INBOUNDED";
|
||||
case "LOADING", "LOADED", "DELIVERED" -> status;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
static String nextAction(String mainStatus, String processStatus, String preloadStatus) {
|
||||
if (mainStatus == null) return null;
|
||||
return switch (mainStatus) {
|
||||
case "PENDING_REVIEW" -> "APPROVE_ORDER";
|
||||
case "WAIT_MEASURE" -> "MEASURE_PENDING".equals(processStatus) ? "CONFIRM_MEASUREMENT" : null;
|
||||
case "WAIT_SPLIT" -> !"WAIT_SPLIT".equals(processStatus) ? null
|
||||
: "CONFIRMED".equals(preloadStatus) ? null
|
||||
: preloadStatus == null ? "SMART_SPLIT" : "CONFIRM_PRELOAD";
|
||||
case "LOADING" -> "COMPLETE_LOADING";
|
||||
case "LOADED" -> "COMPLETE_DELIVERY";
|
||||
case "DELIVERED" -> null;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private List<String> internalStatuses(String mainStatus) {
|
||||
if (!StringUtils.hasText(mainStatus)) return List.of();
|
||||
return switch (mainStatus) {
|
||||
case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED");
|
||||
case "WAIT_SPLIT" -> List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT");
|
||||
case "INBOUNDED", "LOADING", "LOADED", "DELIVERED" -> List.of(mainStatus);
|
||||
default -> List.of("__NO_MATCH__");
|
||||
};
|
||||
}
|
||||
|
||||
private int itemCount(String itemsJson) {
|
||||
if (!StringUtils.hasText(itemsJson)) return 0;
|
||||
try {
|
||||
JsonNode node = json.readTree(itemsJson);
|
||||
return node.isArray() ? node.size() : 0;
|
||||
} catch (Exception ignored) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void validate(UnifiedInboundQuery query) {
|
||||
if (StringUtils.hasText(query.mainStatus()) && !MAIN_STATUSES.contains(query.mainStatus())) {
|
||||
throw new com.baiyue.common.core.exception.BizException(
|
||||
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "统一订单主状态不合法");
|
||||
}
|
||||
if (query.createdFrom() != null && query.createdTo() != null
|
||||
&& query.createdFrom().isAfter(query.createdTo())) {
|
||||
throw new com.baiyue.common.core.exception.BizException(
|
||||
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "创建时间起点不能晚于终点");
|
||||
}
|
||||
if (StringUtils.hasText(query.feeReviewStatus()) && !REVIEW_STATUSES.contains(query.feeReviewStatus())) {
|
||||
throw new com.baiyue.common.core.exception.BizException(
|
||||
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "费用审核状态不合法");
|
||||
}
|
||||
if (StringUtils.hasText(query.measurementReviewStatus())
|
||||
&& !REVIEW_STATUSES.contains(query.measurementReviewStatus())) {
|
||||
throw new com.baiyue.common.core.exception.BizException(
|
||||
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "测量审核状态不合法");
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package com.baiyue.firstleg.split.controller;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
|
||||
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
|
||||
import com.baiyue.firstleg.split.dto.SplitResponses.PreloadPlanResponse;
|
||||
import com.baiyue.firstleg.split.dto.SplitResponses.SplitActionResponse;
|
||||
import com.baiyue.firstleg.split.service.SplitPreloadService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** 平台端分柜、预装清单和装柜中主状态推进接口。 */
|
||||
@Tag(name = "订单分柜与预装")
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/inbounds")
|
||||
public class PlatformSplitPreloadController {
|
||||
@Resource private SplitPreloadService service;
|
||||
|
||||
@Operation(summary = "智能分柜并生成或覆盖预装清单")
|
||||
@PostMapping("/{inboundId}/split/smart")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:split')")
|
||||
@BusinessAudit(event = "INBOUND_SMART_SPLIT", resourceType = "INBOUND", resourceId = "#inboundId",
|
||||
description = "智能分柜")
|
||||
public ApiResponse<SplitActionResponse> smart(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
|
||||
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody SmartSplitRequest request) {
|
||||
FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.smartSplit(tenantId, inboundId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "提交预装清单确认")
|
||||
@PostMapping("/{inboundId}/preload/confirming")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:preload')")
|
||||
@BusinessAudit(event = "INBOUND_PRELOAD_CONFIRMING", resourceType = "INBOUND", resourceId = "#inboundId",
|
||||
description = "提交预装清单确认")
|
||||
public ApiResponse<SplitActionResponse> confirming(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
|
||||
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ConfirmPreloadRequest request) {
|
||||
FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.startConfirming(tenantId, inboundId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "确认预装清单并进入装柜中")
|
||||
@PostMapping("/{inboundId}/preload/confirm")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:preload')")
|
||||
@BusinessAudit(event = "INBOUND_CONFIRM_PRELOAD", resourceType = "INBOUND", resourceId = "#inboundId",
|
||||
description = "确认预装清单")
|
||||
public ApiResponse<SplitActionResponse> confirm(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
|
||||
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ConfirmPreloadRequest request) {
|
||||
FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.confirmPreload(tenantId, inboundId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "查询预装清单")
|
||||
@GetMapping("/{inboundId}/preload")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:preload-view')")
|
||||
public ApiResponse<PreloadPlanResponse> detail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
|
||||
@PathVariable long inboundId) {
|
||||
FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.detail(tenantId, inboundId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.baiyue.firstleg.split.domain;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/** 预装清单子状态,固定为三态。 */
|
||||
public final class PreloadStatus {
|
||||
public static final String PENDING = "PENDING";
|
||||
public static final String CONFIRMING = "CONFIRMING";
|
||||
public static final String CONFIRMED = "CONFIRMED";
|
||||
public static final Set<String> VALUES = Set.of(PENDING, CONFIRMING, CONFIRMED);
|
||||
private PreloadStatus() {}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.firstleg.split.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/** 分柜与预装清单写入请求。资料字段本版本不做完整性门禁。 */
|
||||
public final class SplitRequests {
|
||||
private SplitRequests() {}
|
||||
|
||||
public record SmartSplitRequest(
|
||||
@NotNull @PositiveOrZero Integer expectedVersion,
|
||||
List<@Valid PreloadItem> items,
|
||||
@Size(max = 1000) String remark) {}
|
||||
|
||||
public record ConfirmPreloadRequest(@NotNull @PositiveOrZero Integer expectedVersion) {}
|
||||
|
||||
public record PreloadItem(
|
||||
@Size(max = 64) String containerCode,
|
||||
@Size(max = 128) String cargoReference,
|
||||
@Positive Integer packageCount,
|
||||
@PositiveOrZero BigDecimal volumeCbm,
|
||||
@Size(max = 500) String remark) {}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.baiyue.firstleg.split.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public final class SplitResponses {
|
||||
private SplitResponses() {}
|
||||
|
||||
public record PreloadPlanResponse(Long id, Long inboundId, Long tenantId, String status,
|
||||
Integer version, List<SplitRequests.PreloadItem> items,
|
||||
String remark, LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt) {}
|
||||
|
||||
public record SplitActionResponse(String mainStatus, PreloadPlanResponse preloadPlan) {}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.firstleg.split.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_plan")
|
||||
public class PreloadPlanEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long inboundId;
|
||||
private String status;
|
||||
private Integer version;
|
||||
private String itemsJson;
|
||||
private String remark;
|
||||
private Long createdBy;
|
||||
private String createdByName;
|
||||
private Long updatedBy;
|
||||
private String updatedByName;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.firstleg.split.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_split_operation")
|
||||
public class SplitOperationEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long inboundId;
|
||||
private Long operatorId;
|
||||
private String operation;
|
||||
private String idempotencyKey;
|
||||
private String status;
|
||||
private String resultJson;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.firstleg.split.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
public interface PreloadPlanMapper extends BaseMapper<PreloadPlanEntity> {
|
||||
@Update("UPDATE fl_preload_plan SET items_json=#{itemsJson}, remark=#{remark}, status='PENDING', "
|
||||
+ "updated_by=#{operatorId}, updated_by_name=#{operatorName}, version=version+1 "
|
||||
+ "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND version=#{expectedVersion}")
|
||||
int replace(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("itemsJson") String itemsJson, @Param("remark") String remark,
|
||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName,
|
||||
@Param("expectedVersion") int expectedVersion);
|
||||
|
||||
@Update("UPDATE fl_preload_plan SET status=#{toStatus}, updated_by=#{operatorId}, "
|
||||
+ "updated_by_name=#{operatorName}, version=version+1 "
|
||||
+ "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND status=#{fromStatus} "
|
||||
+ "AND version=#{expectedVersion}")
|
||||
int changeStatus(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus,
|
||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName,
|
||||
@Param("expectedVersion") int expectedVersion);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.firstleg.split.mapper;
|
||||
|
||||
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 SplitInboundMapper {
|
||||
@Select("SELECT id, tenant_id AS tenantId, process_status AS processStatus, version FROM fl_inbound_order "
|
||||
+ "WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0")
|
||||
InboundSnapshot find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
|
||||
|
||||
@Update("UPDATE fl_inbound_order SET process_status='LOADING', updated_by=#{operatorId}, "
|
||||
+ "version=version+1 WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 "
|
||||
+ "AND process_status='WAIT_SPLIT' AND version=#{expectedVersion}")
|
||||
int beginLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
|
||||
|
||||
record InboundSnapshot(Long id, Long tenantId, String processStatus, Integer version) {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.firstleg.split.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.split.entity.SplitOperationEntity;
|
||||
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 SplitOperationMapper extends BaseMapper<SplitOperationEntity> {
|
||||
@Insert("INSERT IGNORE INTO fl_split_operation "
|
||||
+ "(id,tenant_id,inbound_id,operator_id,operation,idempotency_key,status,created_at) "
|
||||
+ "VALUES(#{id},#{tenantId},#{inboundId},#{operatorId},#{operation},#{key},'PROCESSING',CURRENT_TIMESTAMP(3))")
|
||||
int claim(@Param("id") long id, @Param("tenantId") long tenantId,
|
||||
@Param("inboundId") long inboundId, @Param("operatorId") long operatorId,
|
||||
@Param("operation") String operation, @Param("key") String key);
|
||||
|
||||
@Update("UPDATE fl_split_operation SET status='SUCCESS', result_json=#{resultJson} "
|
||||
+ "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND operator_id=#{operatorId} "
|
||||
+ "AND operation=#{operation} AND idempotency_key=#{key} AND status='PROCESSING'")
|
||||
int complete(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("operatorId") long operatorId, @Param("operation") String operation,
|
||||
@Param("key") String key, @Param("resultJson") String resultJson);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.baiyue.firstleg.split.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
|
||||
import com.baiyue.firstleg.split.entity.SplitOperationEntity;
|
||||
import com.baiyue.firstleg.split.mapper.PreloadPlanMapper;
|
||||
import com.baiyue.firstleg.split.mapper.SplitInboundMapper;
|
||||
import com.baiyue.firstleg.split.mapper.SplitOperationMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
|
||||
@Repository
|
||||
public class SplitRepository {
|
||||
@Resource private SplitInboundMapper inbounds;
|
||||
@Resource private PreloadPlanMapper plans;
|
||||
@Resource private SplitOperationMapper operations;
|
||||
|
||||
public SplitInboundMapper.InboundSnapshot findInbound(long tenantId, long inboundId) {
|
||||
return inbounds.find(tenantId, inboundId);
|
||||
}
|
||||
|
||||
public int beginLoading(long tenantId, long inboundId, long operatorId, int expectedVersion) {
|
||||
return inbounds.beginLoading(tenantId, inboundId, operatorId, expectedVersion);
|
||||
}
|
||||
|
||||
public PreloadPlanEntity findPlan(long tenantId, long inboundId) {
|
||||
return plans.selectOne(new LambdaQueryWrapper<PreloadPlanEntity>()
|
||||
.eq(PreloadPlanEntity::getTenantId, tenantId)
|
||||
.eq(PreloadPlanEntity::getInboundId, inboundId));
|
||||
}
|
||||
|
||||
public void createPlan(PreloadPlanEntity plan) {
|
||||
plans.insert(plan);
|
||||
}
|
||||
|
||||
public int replacePlan(long tenantId, long inboundId, String itemsJson, String remark,
|
||||
long operatorId, String operatorName, int expectedVersion) {
|
||||
return plans.replace(tenantId, inboundId, itemsJson, remark, operatorId, operatorName, expectedVersion);
|
||||
}
|
||||
|
||||
public int changePlanStatus(long tenantId, long inboundId, String fromStatus, String toStatus,
|
||||
long operatorId, String operatorName, int expectedVersion) {
|
||||
return plans.changeStatus(tenantId, inboundId, fromStatus, toStatus,
|
||||
operatorId, operatorName, expectedVersion);
|
||||
}
|
||||
|
||||
public SplitOperationEntity findOperation(long tenantId, long inboundId, long operatorId,
|
||||
String operation, String key) {
|
||||
return operations.selectOne(new LambdaQueryWrapper<SplitOperationEntity>()
|
||||
.eq(SplitOperationEntity::getTenantId, tenantId)
|
||||
.eq(SplitOperationEntity::getInboundId, inboundId)
|
||||
.eq(SplitOperationEntity::getOperatorId, operatorId)
|
||||
.eq(SplitOperationEntity::getOperation, operation)
|
||||
.eq(SplitOperationEntity::getIdempotencyKey, key));
|
||||
}
|
||||
|
||||
public void saveOperation(SplitOperationEntity operation) {
|
||||
operations.insert(operation);
|
||||
}
|
||||
|
||||
public boolean claimOperation(long tenantId, long inboundId, long operatorId,
|
||||
String operation, String key) {
|
||||
return operations.claim(IdWorker.getId(), tenantId, inboundId, operatorId, operation, key) == 1;
|
||||
}
|
||||
|
||||
public void completeOperation(long tenantId, long inboundId, long operatorId,
|
||||
String operation, String key, String resultJson) {
|
||||
operations.complete(tenantId, inboundId, operatorId, operation, key, resultJson);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
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.mapper.InboundStatusHistoryMapper;
|
||||
import com.baiyue.firstleg.split.domain.PreloadStatus;
|
||||
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
|
||||
import com.baiyue.firstleg.split.dto.SplitRequests.PreloadItem;
|
||||
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
|
||||
import com.baiyue.firstleg.split.dto.SplitResponses.PreloadPlanResponse;
|
||||
import com.baiyue.firstleg.split.dto.SplitResponses.SplitActionResponse;
|
||||
import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
|
||||
import com.baiyue.firstleg.split.entity.SplitOperationEntity;
|
||||
import com.baiyue.firstleg.split.mapper.SplitInboundMapper.InboundSnapshot;
|
||||
import com.baiyue.firstleg.split.repository.SplitRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** 分柜、预装清单及其主状态推进;不校验资料完整度和客户确认。 */
|
||||
@Service
|
||||
public class SplitPreloadService {
|
||||
private static final String SMART_SPLIT = "SMART_SPLIT";
|
||||
private static final String START_CONFIRMING = "START_PRELOAD_CONFIRMING";
|
||||
private static final String CONFIRM_PRELOAD = "CONFIRM_PRELOAD";
|
||||
|
||||
@Resource private SplitRepository repository;
|
||||
@Resource private InboundStatusHistoryMapper histories;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
@Transactional
|
||||
public SplitActionResponse smartSplit(long tenantId, long inboundId, long operatorId,
|
||||
String operatorName, String key, SmartSplitRequest request) {
|
||||
SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, SMART_SPLIT, key);
|
||||
if (replay != null) return replay;
|
||||
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
|
||||
if (!"WAIT_SPLIT".equals(inbound.processStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以生成预装清单");
|
||||
}
|
||||
PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId);
|
||||
String itemsJson = write(request.items() == null ? List.of() : request.items());
|
||||
if (plan == null) {
|
||||
if (request.expectedVersion() != 0) throw concurrent();
|
||||
plan = new PreloadPlanEntity();
|
||||
plan.setTenantId(tenantId);
|
||||
plan.setInboundId(inboundId);
|
||||
plan.setStatus(PreloadStatus.PENDING);
|
||||
plan.setVersion(0);
|
||||
plan.setItemsJson(itemsJson);
|
||||
plan.setRemark(request.remark());
|
||||
plan.setCreatedBy(operatorId);
|
||||
plan.setCreatedByName(operatorName);
|
||||
plan.setUpdatedBy(operatorId);
|
||||
plan.setUpdatedByName(operatorName);
|
||||
repository.createPlan(plan);
|
||||
} else if (repository.replacePlan(tenantId, inboundId, itemsJson, request.remark(),
|
||||
operatorId, operatorName, request.expectedVersion()) != 1) {
|
||||
throw concurrent();
|
||||
}
|
||||
PreloadPlanResponse result = response(repository.findPlan(tenantId, inboundId));
|
||||
return complete(tenantId, inboundId, operatorId, SMART_SPLIT, key,
|
||||
new SplitActionResponse("WAIT_SPLIT", result));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SplitActionResponse startConfirming(long tenantId, long inboundId, long operatorId,
|
||||
String operatorName, String key,
|
||||
ConfirmPreloadRequest request) {
|
||||
SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, START_CONFIRMING, key);
|
||||
if (replay != null) return replay;
|
||||
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
|
||||
requireStatus(inbound, "WAIT_SPLIT", "仅待分柜订单可以提交预装清单确认");
|
||||
PreloadPlanEntity plan = requirePlan(tenantId, inboundId);
|
||||
if (PreloadStatus.CONFIRMING.equals(plan.getStatus())) {
|
||||
return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key,
|
||||
new SplitActionResponse("WAIT_SPLIT", response(plan)));
|
||||
}
|
||||
if (!PreloadStatus.PENDING.equals(plan.getStatus())
|
||||
|| repository.changePlanStatus(tenantId, inboundId, PreloadStatus.PENDING,
|
||||
PreloadStatus.CONFIRMING, operatorId, operatorName, request.expectedVersion()) != 1) {
|
||||
throw concurrent();
|
||||
}
|
||||
return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key,
|
||||
new SplitActionResponse("WAIT_SPLIT", response(repository.findPlan(tenantId, inboundId))));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SplitActionResponse confirmPreload(long tenantId, long inboundId, long operatorId,
|
||||
String operatorName, String key,
|
||||
ConfirmPreloadRequest request) {
|
||||
SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key);
|
||||
if (replay != null) return replay;
|
||||
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
|
||||
if ("LOADING".equals(inbound.processStatus())) {
|
||||
return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key,
|
||||
new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId))));
|
||||
}
|
||||
requireStatus(inbound, "WAIT_SPLIT", "仅待分柜订单可以确认预装清单");
|
||||
PreloadPlanEntity plan = requirePlan(tenantId, inboundId);
|
||||
if (PreloadStatus.CONFIRMED.equals(plan.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "预装清单已确认但订单状态尚未推进,请刷新重试");
|
||||
}
|
||||
if (repository.changePlanStatus(tenantId, inboundId, plan.getStatus(), PreloadStatus.CONFIRMED,
|
||||
operatorId, operatorName, request.expectedVersion()) != 1) {
|
||||
throw concurrent();
|
||||
}
|
||||
if (repository.beginLoading(tenantId, inboundId, operatorId, inbound.version()) != 1) {
|
||||
throw concurrent();
|
||||
}
|
||||
addHistory(tenantId, inboundId, "WAIT_SPLIT", "LOADING", CONFIRM_PRELOAD,
|
||||
operatorId, operatorName);
|
||||
return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key,
|
||||
new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId))));
|
||||
}
|
||||
|
||||
public PreloadPlanResponse detail(long tenantId, long inboundId) {
|
||||
requireInbound(tenantId, inboundId);
|
||||
return response(requirePlan(tenantId, inboundId));
|
||||
}
|
||||
|
||||
private SplitActionResponse claimOrReplay(long tenantId, long inboundId, long operatorId,
|
||||
String operation, String key) {
|
||||
validateKey(key);
|
||||
if (repository.claimOperation(tenantId, inboundId, operatorId, operation, key)) return null;
|
||||
SplitOperationEntity existing = repository.findOperation(tenantId, inboundId, operatorId, operation, key);
|
||||
if (existing == null || !"SUCCESS".equals(existing.getStatus())
|
||||
|| !StringUtils.hasText(existing.getResultJson())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "相同幂等请求正在处理中,请稍后重试");
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(existing.getResultJson(), SplitActionResponse.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException("无法读取分柜幂等结果", e);
|
||||
}
|
||||
}
|
||||
|
||||
private SplitActionResponse complete(long tenantId, long inboundId, long operatorId,
|
||||
String operation, String key, SplitActionResponse result) {
|
||||
repository.completeOperation(tenantId, inboundId, operatorId, operation, key, write(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
private InboundSnapshot requireInbound(long tenantId, long inboundId) {
|
||||
InboundSnapshot inbound = repository.findInbound(tenantId, inboundId);
|
||||
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在");
|
||||
return inbound;
|
||||
}
|
||||
|
||||
private PreloadPlanEntity requirePlan(long tenantId, long inboundId) {
|
||||
PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId);
|
||||
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "预装清单不存在");
|
||||
return plan;
|
||||
}
|
||||
|
||||
private static void requireStatus(InboundSnapshot inbound, String expected, String message) {
|
||||
if (!expected.equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, message);
|
||||
}
|
||||
|
||||
private void addHistory(long tenantId, long inboundId, String from, String to, String action,
|
||||
long operatorId, String operatorName) {
|
||||
InboundStatusHistoryEntity history = new InboundStatusHistoryEntity();
|
||||
history.setTenantId(tenantId);
|
||||
history.setInboundId(inboundId);
|
||||
history.setFromStatus(from);
|
||||
history.setToStatus(to);
|
||||
history.setAction(action);
|
||||
history.setOperatorId(operatorId);
|
||||
history.setOperatorName(operatorName);
|
||||
histories.insert(history);
|
||||
}
|
||||
|
||||
private PreloadPlanResponse response(PreloadPlanEntity plan) {
|
||||
if (plan == null) return null;
|
||||
return new PreloadPlanResponse(plan.getId(), plan.getInboundId(), plan.getTenantId(),
|
||||
plan.getStatus(), plan.getVersion(), readItems(plan.getItemsJson()), plan.getRemark(),
|
||||
plan.getCreatedAt(), plan.getUpdatedAt());
|
||||
}
|
||||
|
||||
private List<PreloadItem> readItems(String json) {
|
||||
if (!StringUtils.hasText(json)) return List.of();
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<List<PreloadItem>>() {});
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException("无法读取预装清单", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String write(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException("无法保存预装清单", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateKey(String key) {
|
||||
if (!StringUtils.hasText(key) || key.length() > 128) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空且不能超过128个字符");
|
||||
}
|
||||
}
|
||||
|
||||
private static BizException concurrent() {
|
||||
return new BizException(ResultCode.CONFLICT, "数据已被其他人修改,请刷新后重试");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user