This commit is contained in:
2026-08-10 11:28:57 +08:00
parent e33fb923e4
commit 047f267d4f
17 changed files with 290 additions and 67 deletions
@@ -6,6 +6,7 @@ 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.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;
@@ -47,6 +48,18 @@ public class PlatformLoadingDeliveryController {
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')")
@@ -3,18 +3,18 @@ package com.baiyue.firstleg.loading.domain;
import java.util.Set;
/**
* 装柜证据类型。照片语义尚待产品确认,当前只固定四个步骤和一个铅封证据槽位。
* 装柜证据类型。接口保留稳定英文编码,业务含义已确认:空仓、半仓、满仓、锁仓、带锁码照。
*/
public final class LoadingEvidenceType {
/** 装柜过程第 1 步照片。 */
/** 空仓照片。 */
public static final String PHOTO_STEP_1 = "PHOTO_STEP_1";
/** 装柜过程第 2 步照片。 */
/** 半仓照片。 */
public static final String PHOTO_STEP_2 = "PHOTO_STEP_2";
/** 装柜过程第 3 步照片。 */
/** 满仓照片。 */
public static final String PHOTO_STEP_3 = "PHOTO_STEP_3";
/** 装柜过程第 4 步照片。 */
/** 锁仓照片。 */
public static final String PHOTO_STEP_4 = "PHOTO_STEP_4";
/** 柜门铅封或封条证据。 */
/** 带锁码照片;请求必须同时提供 sealNo。 */
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);
@@ -17,6 +17,10 @@ public final class LoadingRequests {
@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) {}
@@ -8,7 +8,9 @@ public final class 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) {}
Integer version, String status, String voidReason,
Long voidedBy, String voidedByName, LocalDateTime voidedAt,
LocalDateTime createdAt, LocalDateTime updatedAt) {}
public record EvidenceListResponse(Long inboundId, List<EvidenceResponse> evidences) {}
@@ -21,6 +21,12 @@ public class LoadingEvidenceEntity {
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 String voidReason;
private Long voidedBy;
private String voidedByName;
private LocalDateTime voidedAt;
private Integer version;
private Long createdBy;
private String createdByName;
@@ -8,13 +8,13 @@ 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);
@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);
}
@@ -33,13 +33,6 @@ public class LoadingRepository {
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)
@@ -51,15 +44,15 @@ public class LoadingRepository {
return evidences.selectOne(new LambdaQueryWrapper<LoadingEvidenceEntity>()
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
.eq(LoadingEvidenceEntity::getEvidenceType, "SEAL")
.eq(LoadingEvidenceEntity::getStatus, "ACTIVE")
.eq(LoadingEvidenceEntity::getSealNo, sealNo));
}
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 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,
@@ -39,6 +39,7 @@ 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";
private static final String COMPLETE_DELIVERY = "COMPLETE_DELIVERY";
@@ -74,23 +75,17 @@ public class LoadingDeliveryService {
String fileName = file.fileName();
String sealNo = LoadingEvidenceType.SEAL.equals(request.evidenceType()) ? request.sealNo().trim() : null;
try {
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(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,
sealNo, request.remark(), operatorId, operatorName, request.expectedVersion()) != 1) {
throw concurrent();
}
// 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, LoadingEvidenceType.SEAL.equals(request.evidenceType())
? "锁码已用于其他装柜任务" : "数据已被其他人修改,请刷新后重试");
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);
@@ -141,6 +136,23 @@ public class LoadingDeliveryService {
return evidenceResponse(tenantId, inboundId);
}
@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;
requireLoadingStage(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);
}
@Transactional
public PickingResponse startPicking(long tenantId, long inboundId, long operatorId, String operatorName,
String key, PickingRequest request) {
@@ -242,7 +254,8 @@ public class LoadingDeliveryService {
private void requireCompletionEvidence(long tenantId, long inboundId) {
Map<String, LoadingEvidenceEntity> values = repository.listEvidence(tenantId, inboundId).stream()
.collect(Collectors.toMap(LoadingEvidenceEntity::getEvidenceType, Function.identity(), (left, right) -> right));
.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);
@@ -259,7 +272,9 @@ public class LoadingDeliveryService {
try {
var response = files.validate(internalToken, tenantId, inboundId, fileId);
if (response == null || !response.successful() || response.data() == null
|| !("LOADING_" + evidenceType).equals(response.data().businessType())) {
|| !("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();
@@ -286,10 +301,19 @@ public class LoadingDeliveryService {
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();
e.getFileName(), 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 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) {
validateKey(key);
@@ -55,9 +55,9 @@ public interface OutboundDispatchReadMapper {
@Param("customerId") long customerId);
@Select("""
SELECT COUNT(DISTINCT CASE WHEN file_id IS NOT NULL AND evidence_type IN
SELECT COUNT(DISTINCT CASE WHEN status='ACTIVE' AND file_id IS NOT NULL AND evidence_type IN
('PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL') THEN evidence_type END) evidenceCount,
MAX(CASE WHEN evidence_type='SEAL' AND file_id IS NOT NULL AND seal_no IS NOT NULL
MAX(CASE WHEN status='ACTIVE' AND evidence_type='SEAL' AND file_id IS NOT NULL AND seal_no IS NOT NULL
AND seal_no&lt;&gt;'' THEN 1 ELSE 0 END) validSeal
FROM fl_loading_evidence WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId}
""")
@@ -88,7 +88,7 @@ public interface OutboundDispatchReadMapper {
FROM fl_loading_pick_history h JOIN fl_loading_pick_task task ON task.id=h.pick_task_id
WHERE task.loading_plan_id=#{loadingPlanId}
UNION ALL
SELECT 'LOADING_EVIDENCE','UPSERT_EVIDENCE',NULL,NULL,
SELECT 'LOADING_EVIDENCE',CASE WHEN e.status='VOID' THEN 'VOID_EVIDENCE' ELSE 'UPSERT_EVIDENCE' END,NULL,NULL,
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)
@@ -104,15 +104,17 @@ public class UnifiedInboundQueryService {
List<LoadingEvidenceEntity> evidence, LocalDateTime deliveryAt,
InboundTransportSnapshotEntity transport) {
String mainStatus = mainStatus(inbound.getProcessStatus());
String sealNo = evidence.stream().filter(item -> "SEAL".equals(item.getEvidenceType()))
List<LoadingEvidenceEntity> 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);
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(
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(), evidence.size(), sealNo,
pick == null ? evidence.stream().map(LoadingEvidenceEntity::getUpdatedAt)
pick == null ? null : pick.getPickedCount(), activeEvidence.size(), sealNo,
pick == null ? activeEvidence.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(