fix(firstleg): scope loading evidence to cabinet tasks

This commit is contained in:
2026-08-31 18:14:36 +08:00
parent 4795ab958c
commit df0fbc1c7f
31 changed files with 695 additions and 1314 deletions
@@ -6,20 +6,35 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.http.ResponseEntity;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
/** 仅用于确认证据文件属于当前客户和入库单,不在 firstleg 复制文件元数据。 */
/** Validate that a loading evidence file is owned by the physical cabinet. */
@FeignClient(name = "file-service", contextId = "firstlegLoadingEvidenceFileClient",
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/loading-evidence")
public interface LoadingEvidenceFileClient {
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
@PostMapping(value = "/{containerBookingId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
ApiResponse<UploadResult> upload(@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader("X-Operator-Id") long operatorId, @PathVariable long containerBookingId,
@RequestParam String evidenceType, @RequestPart("file") MultipartFile file);
@GetMapping("/{containerBookingId}/{fileId}")
ApiResponse<FileMetadata> validate(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId, @PathVariable long inboundId,
@PathVariable long containerBookingId,
@RequestParam String evidenceType,
@PathVariable long fileId);
@GetMapping("/{tenantId}/{inboundId}/{fileId}/content")
@GetMapping("/{containerBookingId}/{fileId}/content")
ResponseEntity<byte[]> download(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId, @PathVariable long inboundId,
@PathVariable long containerBookingId,
@RequestParam String evidenceType,
@PathVariable long fileId);
record FileMetadata(Long fileId, String fileName, String businessType, String contentType) {}
record FileMetadata(Long fileId, String ownerScope, Long tenantId, String businessType,
String businessId, String fileName, String contentType) {}
record UploadResult(Long fileId, String ownerScope, Long tenantId, String businessType,
String businessId, String fileName, String contentType, Long sizeBytes) {}
}
@@ -13,10 +13,17 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.Operation;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot;
import com.baiyue.firstleg.dto.LoadingPickDtos.PendingTask;
import com.baiyue.firstleg.dto.LoadingEvidenceDtos.EvidenceList;
import com.baiyue.firstleg.dto.LoadingEvidenceDtos.VoidRequest;
import java.util.List;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.LoadingPickService;
import com.baiyue.firstleg.loading.service.CabinetLoadingEvidenceService;
import org.springframework.http.ResponseEntity;
import org.springframework.http.MediaType;
import org.springframework.web.multipart.MultipartFile;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
@@ -41,6 +48,8 @@ import org.springframework.web.bind.annotation.RestController;
public class PlatformLoadingPdaController {
@Resource
private LoadingPickService service;
@Resource
private CabinetLoadingEvidenceService evidenceService;
@GetMapping("/tasks")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
@@ -70,6 +79,50 @@ public class PlatformLoadingPdaController {
return ApiResponse.success(service.task(taskId, warehouseId));
}
@PostMapping(value = "/tasks/{taskId}/evidence", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<EvidenceList> upsertEvidence(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@RequestHeader("Idempotency-Key") @NotBlank @Size(max = 128) String idempotencyKey,
@RequestParam @Positive long warehouseId,
@RequestParam @PositiveOrZero int expectedVersion,
@RequestParam @NotBlank @Size(max = 32) String evidenceType,
@RequestParam(required = false) @Size(max = 64) String sealNo,
@RequestParam(required = false) @Size(max = 1000) String remark,
@RequestPart("file") MultipartFile file) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(evidenceService.upload(taskId, warehouseId, idempotencyKey,
expectedVersion, evidenceType, sealNo, remark, file, user.userId(), user.username()));
}
@GetMapping("/tasks/{taskId}/evidence")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<EvidenceList> evidence(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(evidenceService.list(taskId, warehouseId, user.userId()));
}
@GetMapping("/tasks/{taskId}/evidence/{evidenceId}/content")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ResponseEntity<byte[]> evidenceContent(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId, @PathVariable @Positive long evidenceId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return evidenceService.content(taskId, warehouseId, evidenceId, user.userId());
}
@PostMapping("/tasks/{taskId}/evidence/{evidenceId}/void")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<EvidenceList> voidEvidence(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId, @PathVariable @Positive long evidenceId,
@Valid @RequestBody VoidRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(evidenceService.voidEvidence(taskId, evidenceId, request,
user.userId(), user.username()));
}
@GetMapping("/tasks/{taskId}/inventory-snapshot")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<InventorySnapshot> inventorySnapshot(@AuthenticationPrincipal AuthenticatedUser user,
@@ -0,0 +1,35 @@
package com.baiyue.firstleg.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;
import java.time.LocalDateTime;
import java.util.List;
/** PDA task-scoped cabinet evidence contract. */
public final class LoadingEvidenceDtos {
private LoadingEvidenceDtos() {}
public record UpsertRequest(@NotNull @Positive Long warehouseId,
@NotNull @PositiveOrZero Integer expectedVersion,
@NotBlank @Size(max = 32) String evidenceType,
@NotNull @Positive Long fileId,
@Size(max = 64) String sealNo,
@Size(max = 1000) String remark) {}
public record VoidRequest(@NotNull @Positive Long warehouseId,
@NotNull @PositiveOrZero Integer expectedVersion,
@NotBlank @Size(max = 500) String reason) {}
public record Evidence(String evidenceId, String evidenceType, String fileId, String fileName,
String sealNo, String remark, String status, Integer version,
LocalDateTime createdAt, LocalDateTime updatedAt) {}
public record EvidenceList(String taskId, String containerBookingId, String cabinetNo,
String containerNo, List<Evidence> evidences) {
public EvidenceList { evidences = evidences == null ? List.of() : List.copyOf(evidences); }
}
}
@@ -4,10 +4,7 @@ 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.CompleteLoadingRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.VoidEvidenceRequest;
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;
@@ -18,7 +15,6 @@ 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.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/** 平台端装柜证据和国内装柜完成操作。 */
@@ -28,79 +24,6 @@ import org.springframework.web.bind.annotation.*;
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));
}
/** PDA现场拍照专用入口。与后台人工上传使用不同权限,避免拣货员获得后台证据维护入口。 */
@Operation(summary = "PDA追加装柜证据")
@PostMapping("/{inboundId}/loading/evidence/pda")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<LoadingActionResponse> pdaEvidence(
@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 = "PDA查询装柜证据")
@GetMapping("/{inboundId}/loading/evidence/pda")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<EvidenceListResponse> pdaEvidence(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.evidence(tenantId, inboundId));
}
@Operation(summary = "PDA查看装柜证据照片")
@GetMapping("/{inboundId}/loading/evidence/{evidenceId}/content/pda")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ResponseEntity<byte[]> pdaEvidenceContent(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
@PathVariable long evidenceId) {
FirstlegScopeGuard.platform(user, tenantId);
return service.evidenceContent(tenantId, inboundId, evidenceId);
}
@Operation(summary = "PDA作废错误装柜证据")
@PostMapping("/{inboundId}/loading/evidence/{evidenceId}/void/pda")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<LoadingActionResponse> pdaVoidEvidence(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
@PathVariable long evidenceId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody VoidEvidenceRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.voidEvidence(tenantId, inboundId, evidenceId, 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/evidence/{evidenceId}/void")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:edit')")
@BusinessAudit(event = "INBOUND_LOADING_EVIDENCE_VOID", resourceType = "INBOUND", resourceId = "#inboundId", description = "作废装柜证据")
public ApiResponse<LoadingActionResponse> voidEvidence(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
@PathVariable long evidenceId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody VoidEvidenceRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.voidEvidence(tenantId, inboundId, evidenceId, user.userId(), user.username(), key, request));
}
@Operation(summary = "完成装柜")
@PostMapping("/{inboundId}/loading/complete")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:complete')")
@@ -9,18 +9,6 @@ import jakarta.validation.constraints.Size;
public final class LoadingRequests {
private LoadingRequests() {}
public record UpsertEvidenceRequest(
@NotNull @PositiveOrZero Integer expectedVersion,
@NotBlank @Size(max = 32) String evidenceType,
@NotNull @Positive Long fileId,
@Size(max = 255) String fileName,
@Size(max = 64) String sealNo,
@Size(max = 1000) String remark) {}
public record VoidEvidenceRequest(
@NotNull @PositiveOrZero Integer expectedVersion,
@NotBlank @Size(max = 500) String reason) {}
public record CompleteLoadingRequest(
@NotNull @PositiveOrZero Integer expectedVersion,
@Size(max = 1000) String remark) {}
@@ -1,20 +1,9 @@
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 previewUrl, String sealNo, String remark,
Integer version, String status, String voidReason,
Long voidedBy, String voidedByName, LocalDateTime voidedAt,
LocalDateTime createdAt, LocalDateTime updatedAt) {}
public record EvidenceListResponse(Long inboundId, List<EvidenceResponse> evidences) {}
public record LoadingActionResponse(String mainStatus, EvidenceListResponse evidences) {}
public record LoadingActionResponse(String mainStatus) {}
public record PickingResponse(Long id, Long inboundId, String status, Integer version,
Integer expectedCount, Integer pickedCount, String remark) {}
@@ -10,19 +10,21 @@ import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_loading_evidence")
public class LoadingEvidenceEntity {
@TableName("fl_loading_cabinet_evidence")
public class CabinetLoadingEvidenceEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Long containerBookingId;
private Long sourceTaskId;
private String evidenceType;
private Long fileId;
private String fileName;
private String sealNo;
private String remark;
/** ACTIVE records count towards the loading gate; VOID records remain for audit only. */
private String status;
private Integer activeFlag;
private String operationKey;
private String requestHash;
private String voidReason;
private Long voidedBy;
private String voidedByName;
@@ -0,0 +1,52 @@
package com.baiyue.firstleg.loading.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface CabinetLoadingEvidenceMapper extends BaseMapper<CabinetLoadingEvidenceEntity> {
@Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} ORDER BY evidence_type,id")
List<CabinetLoadingEvidenceEntity> byContainer(@Param("containerBookingId") long containerBookingId);
@Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} AND evidence_type=#{type} AND active_flag=1 LIMIT 1 FOR UPDATE")
CabinetLoadingEvidenceEntity activeForUpdate(@Param("containerBookingId") long containerBookingId,
@Param("type") String type);
@Select("SELECT * FROM fl_loading_cabinet_evidence WHERE source_task_id=#{taskId} AND operation_key=#{key} LIMIT 1")
CabinetLoadingEvidenceEntity byOperation(@Param("taskId") long taskId, @Param("key") String key);
@Select("SELECT * FROM fl_loading_cabinet_evidence WHERE evidence_type='SEAL' AND seal_no=#{sealNo} AND active_flag=1 LIMIT 1")
CabinetLoadingEvidenceEntity activeSeal(@Param("sealNo") String sealNo);
@Select("""
SELECT plan.container_booking_id
FROM fl_loading_plan_item item
JOIN fl_loading_plan plan ON plan.id=item.loading_plan_id
WHERE item.account_id=#{tenantId} AND item.inbound_id=#{inboundId} AND item.deleted=0
AND plan.plan_status IN ('EDITING','LOADING','COMPLETED')
ORDER BY CASE plan.plan_status WHEN 'LOADING' THEN 0 WHEN 'EDITING' THEN 1 ELSE 2 END,item.id DESC
LIMIT 1
""")
Long containerBookingId(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Select("""
SELECT evidence.* FROM fl_loading_cabinet_evidence evidence
JOIN fl_loading_plan plan ON plan.container_booking_id=evidence.container_booking_id
JOIN fl_loading_plan_item item ON item.loading_plan_id=plan.id AND item.deleted=0
WHERE item.account_id=#{tenantId} AND item.inbound_id=#{inboundId}
ORDER BY evidence.evidence_type,evidence.id
""")
List<CabinetLoadingEvidenceEntity> byInbound(@Param("tenantId") long tenantId,
@Param("inboundId") long inboundId);
@Update("UPDATE fl_loading_cabinet_evidence SET status='VOID',active_flag=NULL,void_reason=#{reason},voided_by=#{operatorId},voided_by_name=#{operatorName},voided_at=CURRENT_TIMESTAMP(3),updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{id} AND container_booking_id=#{containerBookingId} AND active_flag=1 AND version=#{version}")
int voidActive(@Param("id") long id, @Param("containerBookingId") long containerBookingId,
@Param("version") int version, @Param("reason") String reason,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
}
@@ -1,20 +0,0 @@
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 status='VOID', void_reason=#{reason}, voided_by=#{operatorId}, "
+ "voided_by_name=#{operatorName}, voided_at=CURRENT_TIMESTAMP(3), updated_by=#{operatorId}, "
+ "updated_by_name=#{operatorName}, version=version+1 "
+ "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND id=#{evidenceId} "
+ "AND status='ACTIVE' AND version=#{expectedVersion}")
int voidEvidence(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("evidenceId") long evidenceId, @Param("reason") String reason,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName,
@Param("expectedVersion") int expectedVersion);
}
@@ -2,9 +2,7 @@ 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.InboundLoadingPickTaskMapper;
@@ -17,7 +15,6 @@ import java.util.List;
@Repository
public class LoadingRepository {
@Resource private LoadingInboundMapper inbounds;
@Resource private LoadingEvidenceMapper evidences;
@Resource private LoadingOperationMapper operations;
@Resource private InboundLoadingPickTaskMapper picks;
@@ -46,39 +43,6 @@ public class LoadingRepository {
return inbounds.aggregateRootLoaded(tenantId, rootInboundId, operatorId);
}
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 LoadingEvidenceEntity findEvidenceBySealNo(long tenantId, String sealNo) {
return evidences.selectOne(new LambdaQueryWrapper<LoadingEvidenceEntity>()
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
.eq(LoadingEvidenceEntity::getEvidenceType, "SEAL")
.eq(LoadingEvidenceEntity::getStatus, "ACTIVE")
.eq(LoadingEvidenceEntity::getSealNo, sealNo));
}
public LoadingEvidenceEntity findActiveEvidenceByFile(long tenantId, long inboundId,
String evidenceType, long fileId) {
return evidences.selectOne(new LambdaQueryWrapper<LoadingEvidenceEntity>()
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
.eq(LoadingEvidenceEntity::getInboundId, inboundId)
.eq(LoadingEvidenceEntity::getEvidenceType, evidenceType)
.eq(LoadingEvidenceEntity::getFileId, fileId)
.eq(LoadingEvidenceEntity::getStatus, "ACTIVE")
.last("LIMIT 1"));
}
public void createEvidence(LoadingEvidenceEntity entity) { evidences.insert(entity); }
public int voidEvidence(long tenantId, long inboundId, long evidenceId, String reason,
long operatorId, String operatorName, int expectedVersion) {
return evidences.voidEvidence(tenantId, inboundId, evidenceId, reason, operatorId, operatorName, expectedVersion);
}
public LoadingOperationEntity findOperation(long tenantId, long inboundId, long operatorId,
String operation, String key) {
return operations.selectOne(new LambdaQueryWrapper<LoadingOperationEntity>()
@@ -0,0 +1,191 @@
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.dto.LoadingEvidenceDtos.Evidence;
import com.baiyue.firstleg.dto.LoadingEvidenceDtos.EvidenceList;
import com.baiyue.firstleg.dto.LoadingEvidenceDtos.UpsertRequest;
import com.baiyue.firstleg.dto.LoadingEvidenceDtos.VoidRequest;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.loading.domain.LoadingEvidenceType;
import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity;
import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper;
import com.baiyue.firstleg.repository.LoadingPickRepository;
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.dao.DuplicateKeyException;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/** Cabinet-owned evidence; taskId is only the audited command scope. */
@Service
public class CabinetLoadingEvidenceService {
@Resource private CabinetLoadingEvidenceMapper mapper;
@Resource private LoadingPickRepository loadingPicks;
@Resource private LoadingEvidenceFileClient files;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Transactional
public EvidenceList upsert(long taskId, String key, UpsertRequest request, long operatorId, String operatorName) {
Scope scope = scope(taskId, request.warehouseId(), operatorId, true);
requireKey(key);
String requestHash = hash(request);
CabinetLoadingEvidenceEntity replay = mapper.byOperation(taskId, key);
if (replay != null) {
if (!Objects.equals(requestHash, replay.getRequestHash())) throw conflict("相同幂等键的请求内容不一致");
return view(scope);
}
String type = request.evidenceType().trim();
if (!LoadingEvidenceType.VALUES.contains(type)) throw bad("不支持的装柜证据类型");
String sealNo = LoadingEvidenceType.SEAL.equals(type) ? normalizedSeal(request.sealNo()) : null;
CabinetLoadingEvidenceEntity duplicateSeal = sealNo == null ? null : mapper.activeSeal(sealNo);
if (duplicateSeal != null && !Objects.equals(duplicateSeal.getContainerBookingId(), scope.containerBookingId())) {
throw conflict("锁码已用于其他柜次");
}
var file = validateFile(scope.containerBookingId(), request.fileId(), type);
CabinetLoadingEvidenceEntity current = mapper.activeForUpdate(scope.containerBookingId(), type);
int expected = request.expectedVersion();
if (current == null && expected != 0) throw conflict("装柜证据版本已变化,请刷新后重试");
if (current != null && !Objects.equals(current.getVersion(), expected)) throw conflict("装柜证据版本已变化,请刷新后重试");
if (current != null && Objects.equals(current.getFileId(), request.fileId())
&& Objects.equals(current.getSealNo(), sealNo)) return view(scope);
if (current != null && mapper.voidActive(current.getId(), scope.containerBookingId(), current.getVersion(),
"REPLACED", operatorId, operatorName) != 1) throw conflict("装柜证据版本已变化,请刷新后重试");
CabinetLoadingEvidenceEntity value = new CabinetLoadingEvidenceEntity();
value.setContainerBookingId(scope.containerBookingId()); value.setSourceTaskId(taskId);
value.setEvidenceType(type); value.setFileId(request.fileId()); value.setFileName(file.fileName());
value.setSealNo(sealNo); value.setRemark(request.remark()); value.setStatus("ACTIVE"); value.setActiveFlag(1);
value.setOperationKey(key); value.setRequestHash(requestHash); value.setVersion(0);
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName);
try { mapper.insert(value); } catch (DuplicateKeyException exception) {
CabinetLoadingEvidenceEntity concurrent = mapper.byOperation(taskId, key);
if (concurrent != null && Objects.equals(concurrent.getRequestHash(), requestHash)) return view(scope);
throw conflict("装柜证据发生并发变更,请刷新后重试");
}
return view(scope);
}
public EvidenceList upload(long taskId, long warehouseId, String key, int expectedVersion,
String evidenceType, String sealNo, String remark, MultipartFile multipart,
long operatorId, String operatorName) {
Scope scope = scope(taskId, warehouseId, operatorId, true);
try {
var response = files.upload(internalToken, operatorId, scope.containerBookingId(), evidenceType, multipart);
var uploaded = response == null ? null : response.data();
if (uploaded == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "装柜证据文件上传失败");
return upsert(taskId, key, new UpsertRequest(warehouseId, expectedVersion, evidenceType,
uploaded.fileId(), sealNo, remark), operatorId, operatorName);
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用");
}
}
public EvidenceList list(long taskId, long warehouseId, long operatorId) {
return view(scope(taskId, warehouseId, operatorId, false));
}
public ResponseEntity<byte[]> content(long taskId, long warehouseId, long evidenceId, long operatorId) {
Scope scope = scope(taskId, warehouseId, operatorId, false);
CabinetLoadingEvidenceEntity value = mapper.byContainer(scope.containerBookingId()).stream()
.filter(item -> Objects.equals(item.getId(), evidenceId) && "ACTIVE".equals(item.getStatus()))
.findFirst().orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "装柜证据不存在"));
try { return files.download(internalToken, scope.containerBookingId(), value.getEvidenceType(), value.getFileId()); }
catch (FeignException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); }
}
@Transactional
public EvidenceList voidEvidence(long taskId, long evidenceId, VoidRequest request,
long operatorId, String operatorName) {
Scope scope = scope(taskId, request.warehouseId(), operatorId, true);
CabinetLoadingEvidenceEntity value = mapper.byContainer(scope.containerBookingId()).stream()
.filter(item -> Objects.equals(item.getId(), evidenceId)).findFirst()
.orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "装柜证据不存在"));
if (!"ACTIVE".equals(value.getStatus())) throw conflict("装柜证据已经作废");
if (!Objects.equals(value.getVersion(), request.expectedVersion()) || mapper.voidActive(value.getId(),
scope.containerBookingId(), request.expectedVersion(), request.reason().trim(), operatorId, operatorName) != 1) {
throw conflict("装柜证据版本已变化,请刷新后重试");
}
return view(scope);
}
public void requireComplete(long containerBookingId) {
Set<String> active = mapper.byContainer(containerBookingId).stream()
.filter(value -> "ACTIVE".equals(value.getStatus()))
.map(CabinetLoadingEvidenceEntity::getEvidenceType).collect(Collectors.toSet());
if (!active.containsAll(LoadingEvidenceType.VALUES)) throw conflict("柜次装柜证据未完成,请上传四步照片和带锁码照");
}
public long requireContainerBookingId(long tenantId, long inboundId) {
Long value = mapper.containerBookingId(tenantId, inboundId);
if (value == null) throw conflict("入仓单未关联有效柜次,不能完成装柜");
return value;
}
private EvidenceList view(Scope scope) {
List<Evidence> values = mapper.byContainer(scope.containerBookingId()).stream().map(value -> new Evidence(
id(value.getId()), value.getEvidenceType(), id(value.getFileId()), value.getFileName(), value.getSealNo(),
value.getRemark(), value.getStatus(), value.getVersion(), value.getCreatedAt(), value.getUpdatedAt())).toList();
return new EvidenceList(id(scope.task().getId()), id(scope.containerBookingId()), scope.task().getCabinetNo(),
scope.task().getContainerNo(), values);
}
private Scope scope(long taskId, long warehouseId, long operatorId, boolean mutable) {
LoadingPickTaskEntity task = loadingPicks.task(taskId);
var booking = task == null ? null : loadingPicks.activeBookingByTask(taskId);
if (task == null || task.getContainerBookingId() == null || booking == null
|| !Objects.equals(task.getContainerBookingId(), booking.getId())) {
throw new BizException(ResultCode.NOT_FOUND, "PDA装柜任务不存在");
}
if (!Objects.equals(booking.getWarehouseId(), warehouseId)) throw new BizException(ResultCode.FORBIDDEN, "无权访问其他仓库的PDA任务");
var assignment = loadingPicks.assignment(taskId);
if (assignment == null || !Objects.equals(assignment.getAssigneeId(), operatorId)) {
throw new BizException(ResultCode.FORBIDDEN, "PDA任务未由当前操作人领取");
}
if (mutable && !"LOADING".equals(task.getTaskStatus())) throw conflict("PDA任务不在装柜作业中,证据不可修改");
return new Scope(task, task.getContainerBookingId());
}
private LoadingEvidenceFileClient.FileMetadata validateFile(long containerBookingId, long fileId, String type) {
try {
var response = files.validate(internalToken, containerBookingId, type, fileId);
var value = response == null ? null : response.data();
if (value == null || !"PLATFORM".equals(value.ownerScope()) || !Objects.equals(value.tenantId(), 0L)
|| !Objects.equals(value.businessId(), Long.toString(containerBookingId))
|| !Objects.equals(value.businessType(), "LOADING_" + type)) throw bad("装柜证据文件归属或类型不匹配");
return value;
} catch (FeignException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); }
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(
objectMapper.writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); }
catch (JsonProcessingException | NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
}
private static String normalizedSeal(String value) {
if (!StringUtils.hasText(value)) throw bad("铅封证据必须填写锁码");
return value.trim();
}
private static void requireKey(String key) { if (!StringUtils.hasText(key) || key.length() > 128) throw bad("Idempotency-Key不能为空且长度不能超过128"); }
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
private static String id(Long value) { return value == null ? null : value.toString(); }
private record Scope(LoadingPickTaskEntity task, long containerBookingId) {}
}
@@ -2,13 +2,10 @@ 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.InboundLoadingPickTaskEntity;
import com.baiyue.firstleg.loading.domain.PickingStatus;
@@ -20,11 +17,7 @@ import com.baiyue.firstleg.loading.repository.LoadingRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -33,17 +26,12 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/** 装柜证据、拣货复核和国内装柜完成。 */
@Service
public class LoadingDeliveryService {
private static final String UPSERT_EVIDENCE = "UPSERT_LOADING_EVIDENCE";
private static final String VOID_EVIDENCE = "VOID_LOADING_EVIDENCE";
private static final String COMPLETE_LOADING = "COMPLETE_LOADING";
@Resource private LoadingRepository repository;
@@ -51,79 +39,16 @@ public class LoadingDeliveryService {
@Resource private LoadingModelBridgeService loadingModelBridge;
@Resource private LoadingCompletionGateService completionGate;
@Resource private InboundStatusHistoryMapper histories;
@Resource private LoadingEvidenceFileClient files;
@Resource private CabinetLoadingEvidenceService cabinetEvidenceService;
@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;
requireMutableLoadingStage(tenantId, inboundId);
if (!LoadingEvidenceType.VALUES.contains(request.evidenceType())) {
throw new BizException(ResultCode.BAD_REQUEST, "不支持的装柜证据类型");
}
if (request.fileId() == null) throw new BizException(ResultCode.BAD_REQUEST, "装柜证据必须先上传真实文件");
if (LoadingEvidenceType.SEAL.equals(request.evidenceType()) && !StringUtils.hasText(request.sealNo())) {
throw new BizException(ResultCode.BAD_REQUEST, "铅封证据必须填写锁码");
}
if (LoadingEvidenceType.SEAL.equals(request.evidenceType())) {
LoadingEvidenceEntity duplicate = repository.findEvidenceBySealNo(tenantId, request.sealNo().trim());
if (duplicate != null && !Long.valueOf(inboundId).equals(duplicate.getInboundId())) {
throw new BizException(ResultCode.CONFLICT, "锁码已用于其他装柜任务");
}
}
LoadingEvidenceEntity existingFile = repository.findActiveEvidenceByFile(
tenantId, inboundId, request.evidenceType(), request.fileId());
if (existingFile != null) {
String requestedSealNo = LoadingEvidenceType.SEAL.equals(request.evidenceType())
? request.sealNo().trim() : null;
if (!Objects.equals(existingFile.getSealNo(), requestedSealNo)) {
throw new BizException(ResultCode.CONFLICT, "该带锁码照已绑定其他锁码,请先作废后重新拍摄");
}
LoadingActionResponse result = new LoadingActionResponse(
requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId));
return complete(tenantId, inboundId, operatorId, UPSERT_EVIDENCE, key, result);
}
var file = validateFile(tenantId, inboundId, request.fileId(), request.evidenceType());
String fileName = file.fileName();
String sealNo = LoadingEvidenceType.SEAL.equals(request.evidenceType()) ? request.sealNo().trim() : null;
try {
// Evidence is append-only by type. A new photo never replaces an existing
// one; an incorrect photo is explicitly voided through the void endpoint.
LoadingEvidenceEntity entity = new LoadingEvidenceEntity();
entity.setTenantId(tenantId); entity.setInboundId(inboundId);
entity.setEvidenceType(request.evidenceType()); entity.setFileId(request.fileId());
entity.setFileName(fileName); entity.setSealNo(sealNo); entity.setRemark(request.remark());
entity.setStatus("ACTIVE"); entity.setVersion(0); entity.setCreatedBy(operatorId); entity.setCreatedByName(operatorName);
entity.setUpdatedBy(operatorId); entity.setUpdatedByName(operatorName);
repository.createEvidence(entity);
} catch (DuplicateKeyException ex) {
throw new BizException(ResultCode.CONFLICT, "锁码已用于其他装柜任务");
}
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) {
return completeLoading(tenantId, inboundId, tenantId, inboundId,
operatorId, operatorName, key, request);
return completeLoading(tenantId, inboundId, operatorId, operatorName, key, request, false);
}
private LoadingActionResponse completeLoading(long tenantId, long inboundId,
long evidenceTenantId, long evidenceInboundId,
long operatorId, String operatorName, String key,
CompleteLoadingRequest request) {
return completeLoading(tenantId, inboundId, evidenceTenantId, evidenceInboundId,
operatorId, operatorName, key, request, false);
}
private LoadingActionResponse completeLoading(long tenantId, long inboundId,
long evidenceTenantId, long evidenceInboundId,
long operatorId, String operatorName, String key,
CompleteLoadingRequest request,
boolean acceptedPdaCompletion) {
@@ -134,23 +59,23 @@ public class LoadingDeliveryService {
Snapshot inbound = requireInbound(tenantId, inboundId);
CompletionTarget target = repository.completionTarget(tenantId, inboundId);
if (target != null) {
return completeFulfillmentSlice(tenantId, inboundId, evidenceTenantId, evidenceInboundId,
operatorId, operatorName, key, request, target, acceptedPdaCompletion);
return completeFulfillmentSlice(tenantId, inboundId, operatorId, operatorName, key,
request, target, acceptedPdaCompletion);
}
if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) {
requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse(inbound.processStatus(), evidenceResponse(tenantId, inboundId)));
new LoadingActionResponse(inbound.processStatus()));
}
if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中");
requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion);
completionGate.requireReleasedBooking(tenantId, inboundId);
requirePickingGate(tenantId, inboundId, acceptedPdaCompletion);
requireCompletionEvidence(evidenceTenantId, evidenceInboundId);
requireCabinetEvidence(tenantId, inboundId);
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)));
new LoadingActionResponse("LOADED"));
}
/**
@@ -162,23 +87,14 @@ public class LoadingDeliveryService {
public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId,
long operatorId, String operatorName,
String key, String remark) {
return completeAfterPdaSettlement(tenantId, inboundId, tenantId, inboundId,
operatorId, operatorName, key, remark);
}
@Transactional
public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId,
long evidenceTenantId, long evidenceInboundId,
long operatorId,
String operatorName, String key, String remark) {
CompletionTarget target = repository.completionTarget(tenantId, inboundId);
long completionInboundId = target == null ? inboundId : target.completionInboundId();
Snapshot current = repository.findInboundForUpdate(tenantId, completionInboundId);
if (current == null || current.version() == null) {
throw new BizException(ResultCode.CONFLICT, "PDA结算后的实装履约分片不存在,请刷新后重试");
}
return completeLoading(tenantId, inboundId, evidenceTenantId, evidenceInboundId,
operatorId, operatorName, key, new CompleteLoadingRequest(current.version(), remark), true);
return completeLoading(tenantId, inboundId, operatorId, operatorName, key,
new CompleteLoadingRequest(current.version(), remark), true);
}
/**
@@ -186,29 +102,22 @@ public class LoadingDeliveryService {
* every external/business hard gate must pass before the PDA task is mutated.
*/
public void requirePdaCompletionReady(long tenantId, long inboundId) {
requirePdaCompletionReady(tenantId, inboundId, tenantId, inboundId);
}
public void requirePdaCompletionReady(long tenantId, long inboundId,
long evidenceTenantId, long evidenceInboundId) {
Snapshot inbound = requireInbound(tenantId, inboundId);
if (!"LOADING".equals(inbound.processStatus())) {
throw new BizException(ResultCode.CONFLICT, "PDA确认装柜完成前订单必须处于LOADING");
}
loadingModelBridge.requireStarted(tenantId, inboundId);
completionGate.requireReleasedBooking(tenantId, inboundId);
requireCompletionEvidence(evidenceTenantId, evidenceInboundId);
}
private LoadingActionResponse completeFulfillmentSlice(long tenantId, long inboundId,
long evidenceTenantId, long evidenceInboundId,
long operatorId, String operatorName, String key,
CompleteLoadingRequest request,
CompletionTarget target,
boolean acceptedPdaCompletion) {
if ("LOADED".equals(target.loadedChildStatus())) {
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED", evidenceResponse(tenantId, inboundId)));
new LoadingActionResponse("LOADED"));
}
if (!"LOADING".equals(target.loadedChildStatus())) {
throw new BizException(ResultCode.CONFLICT, "实装履约分片不在装柜中");
@@ -216,52 +125,14 @@ public class LoadingDeliveryService {
requireLoadingPlanGate(tenantId, target.sourceInboundId(), acceptedPdaCompletion);
completionGate.requireReleasedBooking(tenantId, target.sourceInboundId());
requirePickingGate(tenantId, target.sourceInboundId(), acceptedPdaCompletion);
requireCompletionEvidence(evidenceTenantId, evidenceInboundId);
requireCabinetEvidence(tenantId, target.sourceInboundId());
if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId,
request.expectedVersion()) != 1) throw concurrent();
repository.aggregateRootLoaded(tenantId, target.rootInboundId(), operatorId);
addHistory(tenantId, target.completionInboundId(), "LOADING", "LOADED", COMPLETE_LOADING,
request.remark(), operatorId, operatorName);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED", evidenceResponse(tenantId, inboundId)));
}
public EvidenceListResponse evidence(long tenantId, long inboundId) {
requireLoadingStage(tenantId, inboundId);
return evidenceResponse(tenantId, inboundId);
}
public ResponseEntity<byte[]> evidenceContent(long tenantId, long inboundId, long evidenceId) {
requireLoadingStage(tenantId, inboundId);
LoadingEvidenceEntity evidence = repository.listEvidence(tenantId, inboundId).stream()
.filter(item -> Long.valueOf(evidenceId).equals(item.getId()) && isActive(item))
.findFirst().orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "装柜证据不存在"));
try {
ResponseEntity<byte[]> response = files.download(internalToken, tenantId, inboundId, evidence.getFileId());
if (response == null || response.getBody() == null) {
throw new BizException(ResultCode.NOT_FOUND, "装柜证据文件不存在");
}
return response;
} catch (FeignException ex) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用");
}
}
@Transactional
public LoadingActionResponse voidEvidence(long tenantId, long inboundId, long evidenceId, long operatorId,
String operatorName, String key, VoidEvidenceRequest request) {
String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, VOID_EVIDENCE, key, hash);
if (replay != null) return replay;
requireMutableLoadingStage(tenantId, inboundId);
LoadingEvidenceEntity target = repository.listEvidence(tenantId, inboundId).stream()
.filter(item -> Long.valueOf(evidenceId).equals(item.getId())).findFirst().orElse(null);
if (target == null) throw new BizException(ResultCode.NOT_FOUND, "装柜证据不存在");
if (!isActive(target)) throw new BizException(ResultCode.CONFLICT, "装柜证据已经作废");
if (repository.voidEvidence(tenantId, inboundId, evidenceId, request.reason().trim(), operatorId,
operatorName, request.expectedVersion()) != 1) throw concurrent();
LoadingActionResponse result = new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId));
return complete(tenantId, inboundId, operatorId, VOID_EVIDENCE, key, result);
new LoadingActionResponse("LOADED"));
}
@Transactional
@@ -287,7 +158,7 @@ public class LoadingDeliveryService {
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)));
new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus()));
return result;
}
@@ -312,7 +183,7 @@ public class LoadingDeliveryService {
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)));
new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus()));
return result;
}
@@ -378,35 +249,9 @@ public class LoadingDeliveryService {
}
}
private void requireCompletionEvidence(long tenantId, long inboundId) {
Map<String, LoadingEvidenceEntity> values = repository.listEvidence(tenantId, inboundId).stream()
.filter(LoadingDeliveryService::isActive)
.collect(Collectors.toMap(LoadingEvidenceEntity::getEvidenceType, Function.identity(), (left, right) -> left));
boolean filesReady = LoadingEvidenceType.VALUES.stream()
.allMatch(type -> values.get(type) != null && values.get(type).getFileId() != null);
LoadingEvidenceEntity seal = values.get(LoadingEvidenceType.SEAL);
if (!filesReady || seal == null || !StringUtils.hasText(seal.getSealNo())) {
throw new BizException(ResultCode.CONFLICT, "空仓、半仓、满仓、锁仓、带锁码照及锁码必须齐全");
}
LoadingEvidenceType.VALUES.forEach(type -> {
LoadingEvidenceEntity evidence = values.get(type);
validateFile(tenantId, inboundId, evidence.getFileId(), type);
});
}
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())
|| !StringUtils.hasText(response.data().contentType())
|| !response.data().contentType().toLowerCase(java.util.Locale.ROOT).startsWith("image/")) {
throw new BizException(ResultCode.BAD_REQUEST, "证据文件不存在或不属于当前订单");
}
return response.data();
} catch (FeignException ex) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用");
}
private void requireCabinetEvidence(long tenantId, long inboundId) {
cabinetEvidenceService.requireComplete(
cabinetEvidenceService.requireContainerBookingId(tenantId, inboundId));
}
private Snapshot requireInbound(long tenantId, long inboundId) {
@@ -426,44 +271,6 @@ public class LoadingDeliveryService {
return value;
}
/** Evidence is append-only while loading. Once LOADED is reached this API is read-only;
* corrections are handled by the offline/manual process defined for phase three. */
private Snapshot requireMutableLoadingStage(long tenantId, long inboundId) {
Snapshot value = requireInbound(tenantId, inboundId);
CompletionTarget target = repository.completionTarget(tenantId, inboundId);
if (target != null) {
if (!"LOADING".equals(target.loadedChildStatus())) {
throw new BizException(ResultCode.CONFLICT, "装柜完成后不允许在线修改证据");
}
return value;
}
if (!"LOADING".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(), evidencePreviewUrl(tenantId, inboundId, e.getId()), e.getSealNo(), e.getRemark(), e.getVersion(), activeStatus(e), e.getVoidReason(),
e.getVoidedBy(), e.getVoidedByName(), e.getVoidedAt(), e.getCreatedAt(), e.getUpdatedAt())).toList();
return new EvidenceListResponse(inboundId, values);
}
private static String evidencePreviewUrl(long tenantId, long inboundId, long evidenceId) {
return "/api/platform/v1/inbounds/" + inboundId + "/loading/evidence/" + evidenceId
+ "/content/pda?tenantId=" + tenantId;
}
private static boolean isActive(LoadingEvidenceEntity evidence) {
return evidence != null && (evidence.getStatus() == null || "ACTIVE".equals(evidence.getStatus()));
}
private static String activeStatus(LoadingEvidenceEntity evidence) {
return evidence.getStatus() == null ? "ACTIVE" : evidence.getStatus();
}
private LoadingActionResponse claimOrReplay(long tenantId, long inboundId, long operatorId,
String operation, String key, String hash) {
return claimOrReplay(tenantId, inboundId, operatorId, operation, key, hash, false);
@@ -149,7 +149,7 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
@Select("SELECT COUNT(*) FROM fl_loading_plan WHERE container_booking_id=#{id}") int hasLoadingPlan(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id WHERE p.container_booking_id=#{id} AND i.deleted=0") int hasActiveLoadingItems(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_loading_pick_task t JOIN fl_loading_plan p ON p.id=t.loading_plan_id WHERE p.container_booking_id=#{id}") int hasPickTask(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_loading_evidence e JOIN fl_loading_plan_item i ON i.inbound_id=e.inbound_id AND i.account_id=e.tenant_id JOIN fl_loading_plan p ON p.id=i.loading_plan_id WHERE p.container_booking_id=#{id} AND i.deleted=0 AND e.status='ACTIVE'") int hasEvidence(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_loading_cabinet_evidence e WHERE e.container_booking_id=#{id} AND e.status='ACTIVE'") int hasEvidence(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_container_document WHERE container_booking_id=#{id}") int hasDocuments(@Param("id") long id);
@Update("""
UPDATE fl_container_booking SET bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},
@@ -29,7 +29,7 @@ import java.util.List;
@Select("SELECT COUNT(*) FROM fl_loading_pick_item WHERE pick_task_id=#{id} AND item_status IN ('PICKED','PARTIAL','SKIPPED')") int completedItemCount(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_loading_pick_item WHERE pick_task_id=#{id} AND item_status NOT IN ('PICKED','PARTIAL','SKIPPED')") int unresolvedItemCount(@Param("id") long id);
@Select("SELECT DISTINCT h.operation_reason FROM fl_loading_pick_history h WHERE h.pick_task_id=#{id} AND h.operation_reason IS NOT NULL AND h.operation_reason<>'' ORDER BY h.id") List<String> pickExceptionReasons(@Param("id") long id);
@Select("SELECT DISTINCT e.id evidenceId,e.tenant_id tenantId,e.inbound_id inboundId,e.evidence_type evidenceType,e.file_id fileId,e.file_name fileName,e.status,e.created_at uploadedAt FROM fl_loading_evidence e JOIN fl_loading_plan_item i ON i.inbound_id=e.inbound_id AND i.account_id=e.tenant_id WHERE i.loading_plan_id=#{id} AND i.deleted=0 ORDER BY e.created_at,e.id") List<PhotoRow> photos(@Param("id") long id);
@Select("SELECT e.id evidenceId,NULL tenantId,NULL inboundId,e.evidence_type evidenceType,e.file_id fileId,e.file_name fileName,e.status,e.created_at uploadedAt FROM fl_loading_cabinet_evidence e JOIN fl_loading_plan p ON p.container_booking_id=e.container_booking_id WHERE p.id=#{id} ORDER BY e.created_at,e.id") List<PhotoRow> photos(@Param("id") long id);
@Select("SELECT action_type eventType,operation_id detail,operator_id operatorId,operator_name operatorName,created_at occurredAt FROM fl_loading_pick_operation WHERE pick_task_id=#{id} ORDER BY created_at,id") List<TimelineRow> pickTimeline(@Param("id") long id);
record ExceptionRow(Long id,String action,String reason,Long operatorId,String operatorName,java.time.LocalDateTime occurredAt,String requestHash) {}
record PickTaskRow(Long id,Long loadingPlanId,Long containerBookingId,String taskStatus,Integer version) {}
@@ -278,11 +278,10 @@ public interface LoadingListQueryMapper {
WHERE task.loading_plan_id=#{planId}
AND (task.task_status<>'LOADING' OR item.item_status<>'PENDING'
OR item.picked_package_count<>0))
OR EXISTS (SELECT 1 FROM fl_loading_evidence evidence
JOIN fl_loading_plan_item plan_item
ON plan_item.account_id=evidence.tenant_id AND plan_item.inbound_id=evidence.inbound_id
WHERE plan_item.loading_plan_id=#{planId} AND plan_item.deleted=0
AND evidence.status='ACTIVE')
OR EXISTS (SELECT 1 FROM fl_loading_cabinet_evidence evidence
JOIN fl_loading_plan evidence_plan
ON evidence_plan.container_booking_id=evidence.container_booking_id
WHERE evidence_plan.id=#{planId} AND evidence.status='ACTIVE')
THEN 1 ELSE 0 END
""")
int hasPdaWorkStarted(@Param("planId") long planId);
@@ -70,9 +70,7 @@ public interface OutboundDispatchReadMapper {
AND evidence.file_id IS NOT NULL AND evidence.seal_no IS NOT NULL
AND evidence.seal_no<>'' THEN 1 ELSE 0 END) validSeal
FROM fl_loading_plan plan
JOIN fl_loading_plan_item item ON item.loading_plan_id=plan.id AND item.deleted=0
JOIN fl_loading_evidence evidence ON evidence.inbound_id=item.inbound_id
AND evidence.tenant_id=item.account_id
JOIN fl_loading_cabinet_evidence evidence ON evidence.container_booking_id=plan.container_booking_id
WHERE plan.id=#{loadingPlanId} AND plan.container_booking_id=#{containerBookingId}
""")
Evidence evidence(@Param("containerBookingId") long containerBookingId,
@@ -95,7 +93,11 @@ public interface OutboundDispatchReadMapper {
COALESCE(e.updated_by,e.created_by),COALESCE(e.updated_by_name,e.created_by_name),
CONCAT(e.evidence_type,CASE WHEN e.seal_no IS NULL OR e.seal_no='' THEN '' ELSE CONCAT(':',e.seal_no) END),
COALESCE(e.updated_at,e.created_at)
FROM fl_loading_evidence e WHERE e.tenant_id=#{tenantId} AND e.inbound_id=#{inboundId}
FROM fl_loading_cabinet_evidence e
JOIN fl_loading_plan evidence_plan ON evidence_plan.container_booking_id=e.container_booking_id
JOIN fl_loading_plan_item evidence_item ON evidence_item.loading_plan_id=evidence_plan.id
AND evidence_item.deleted=0
WHERE evidence_item.account_id=#{tenantId} AND evidence_item.inbound_id=#{inboundId}
UNION ALL
SELECT 'OCEAN_BOOKING',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.reason,h.created_at
FROM fl_ocean_booking_status_history h WHERE h.ocean_booking_id=#{oceanBookingId}
@@ -1,9 +1,9 @@
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.CabinetLoadingEvidenceEntity;
import com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity;
import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper;
import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper;
import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper;
import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
import com.baiyue.firstleg.split.mapper.PreloadPlanMapper;
@@ -26,7 +26,7 @@ import java.util.stream.Collectors;
public class UnifiedInboundReadRepository {
@Resource private PreloadPlanMapper preloads;
@Resource private InboundLoadingPickTaskMapper picks;
@Resource private LoadingEvidenceMapper evidences;
@Resource private CabinetLoadingEvidenceMapper evidences;
@Resource private InboundStatusHistoryMapper histories;
@Resource private UnifiedInboundTransportMapper transports;
@@ -42,11 +42,8 @@ public class UnifiedInboundReadRepository {
.eq(InboundLoadingPickTaskEntity::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 List<CabinetLoadingEvidenceEntity> findEvidence(long tenantId, long inboundId) {
return evidences.byInbound(tenantId, inboundId);
}
public Map<Long, PreloadPlanEntity> findPreloads(Map<Long, Long> tenantByInbound) {
@@ -65,13 +62,12 @@ public class UnifiedInboundReadRepository {
.collect(Collectors.toMap(InboundLoadingPickTaskEntity::getInboundId, Function.identity(), (left, right) -> left));
}
public Map<Long, List<LoadingEvidenceEntity>> findEvidence(Map<Long, Long> tenantByInbound) {
public Map<Long, List<CabinetLoadingEvidenceEntity>> 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));
Map<Long, List<CabinetLoadingEvidenceEntity>> result = new java.util.HashMap<>();
tenantByInbound.forEach((inboundId, tenantId) -> result.put(inboundId,
evidences.byInbound(tenantId, inboundId)));
return result;
}
public Map<Long, InboundStatusHistoryEntity> findDeliveryHistory(Map<Long, Long> tenantByInbound) {
@@ -134,7 +134,7 @@ public class ContainerOverviewService {
.findFirst().orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "PDA照片不存在或不属于该货柜"));
final org.springframework.http.ResponseEntity<byte[]> response;
try {
response = evidenceFiles.download(internalToken, photo.tenantId(), photo.inboundId(), fileId);
response = evidenceFiles.download(internalToken, containerBookingId, photo.evidenceType(), fileId);
} catch (FeignException.NotFound exception) {
throw new BizException(ResultCode.NOT_FOUND, "PDA照片文件不存在或归属不匹配");
} catch (FeignException exception) {
@@ -96,6 +96,8 @@ public class LoadingPickService {
@Resource
private LoadingDeliveryService loadingDeliveryService;
@Resource
private com.baiyue.firstleg.loading.service.CabinetLoadingEvidenceService cabinetEvidenceService;
@Resource
private ColoadGroupAssignmentPort coloadGroups;
@Resource
private TruckAppointmentService truckAppointmentService;
@@ -520,9 +522,10 @@ public class LoadingPickService {
.toList();
if (owners.isEmpty()) throw new CompletionProcessingException("OWNER_DISCOVERY", conflict("PDA任务未关联有效入库单"));
List<GroupSnapshot> taskGroups = coloadGroups(owners);
LoadingPickItemMapper.InboundOwner evidenceOwner = owners.get(0);
runCompletionStage("CABINET_EVIDENCE", () ->
cabinetEvidenceService.requireComplete(task.getContainerBookingId()));
// Phase 1 is read-only: every owner must pass its business gates against the same cabinet evidence owner
// Phase 1 is read-only: cabinet evidence is checked once, then every owner must pass its business gates
// before any warehouse resource or inbound status can be changed. This prevents a later customer gate from
// leaving earlier customers partially completed.
for (int index = 0; index < owners.size(); index++) {
@@ -531,7 +534,7 @@ public class LoadingPickService {
String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId());
if (!checkpointAtLeast(checkpoint, "SETTLED")) {
runCompletionStage("OWNER_READY", () -> loadingDeliveryService.requirePdaCompletionReady(
owner.tenantId(), owner.inboundId(), evidenceOwner.tenantId(), evidenceOwner.inboundId()));
owner.tenantId(), owner.inboundId()));
if (!checkpointAtLeast(checkpoint, "READY")) {
requireCompletionLease(command.getId(), worker);
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(),
@@ -554,7 +557,7 @@ public class LoadingPickService {
}
if (!checkpointAtLeast(checkpoint, "INBOUND_COMPLETED")) {
runCompletionStage("INBOUND_COMPLETE", () -> loadingDeliveryService.completeAfterPdaSettlement(
owner.tenantId(), owner.inboundId(), evidenceOwner.tenantId(), evidenceOwner.inboundId(),
owner.tenantId(), owner.inboundId(),
command.getOperatorId(), command.getOperatorName(),
"PDA-" + command.getOperationId() + '-' + owner.inboundId(), command.getRemark()));
requireCompletionLease(command.getId(), worker);
@@ -8,7 +8,7 @@ import com.baiyue.firstleg.dto.UnifiedInboundQuery;
import com.baiyue.firstleg.dto.UnifiedInboundSummary;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.dto.CurrentContainerTransport;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity;
import com.baiyue.firstleg.repository.InboundOrderRepository;
import com.baiyue.firstleg.repository.UnifiedInboundReadRepository;
import com.fasterxml.jackson.databind.JsonNode;
@@ -69,7 +69,7 @@ public class UnifiedInboundQueryService {
// Phase-three tables are optional during the phase-two measurement workflow.
com.baiyue.firstleg.split.entity.PreloadPlanEntity preload = null;
com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity pick = null;
List<LoadingEvidenceEntity> evidence = List.of();
List<CabinetLoadingEvidenceEntity> evidence = List.of();
LocalDateTime deliveryAt = null;
if (includePhaseThree) {
preload = operations.findPreload(tenantId, inbound.getId());
@@ -116,21 +116,21 @@ public class UnifiedInboundQueryService {
private UnifiedInboundSummary summary(InboundOrderEntity inbound, InboundOrderDetail detail,
com.baiyue.firstleg.split.entity.PreloadPlanEntity preload,
com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity pick,
List<LoadingEvidenceEntity> evidence, LocalDateTime deliveryAt,
List<CabinetLoadingEvidenceEntity> evidence, LocalDateTime deliveryAt,
CurrentContainerTransport transport, String locationCodes,
com.baiyue.firstleg.entity.InboundPickupSnapshotEntity pickup) {
String mainStatus = mainStatus(inbound.getProcessStatus());
List<LoadingEvidenceEntity> activeEvidence = evidence.stream()
List<CabinetLoadingEvidenceEntity> activeEvidence = evidence.stream()
.filter(item -> item.getStatus() == null || "ACTIVE".equals(item.getStatus())).toList();
String sealNo = activeEvidence.stream().filter(item -> "SEAL".equals(item.getEvidenceType()))
.map(LoadingEvidenceEntity::getSealNo).filter(StringUtils::hasText).findFirst().orElse(null);
.map(CabinetLoadingEvidenceEntity::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 && activeEvidence.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(), activeEvidence.size(), sealNo,
pick == null ? activeEvidence.stream().map(LoadingEvidenceEntity::getUpdatedAt)
pick == null ? activeEvidence.stream().map(CabinetLoadingEvidenceEntity::getUpdatedAt)
.filter(java.util.Objects::nonNull).max(LocalDateTime::compareTo).orElse(null) : pick.getUpdatedAt());
var deliverySummary = "LOADED".equals(mainStatus) || "DELIVERED".equals(mainStatus)
? new UnifiedInboundSummary.DeliverySummary(