Merge branch 'test' into test-02huogui

# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
This commit is contained in:
2026-08-06 15:54:25 +08:00
123 changed files with 8194 additions and 205 deletions
@@ -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) {}
}
@@ -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, "内部服务凭证无效");
@@ -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) {}
}
@@ -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);
}
}
@@ -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(
@@ -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);
}
@@ -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, "测量审核状态不合法");
}
}
}
@@ -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, "数据已被其他人修改,请刷新后重试");
}
}