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.CompleteDeliveryRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest; import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest; 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.LoadingRequests.PickingRequest;
import com.baiyue.firstleg.loading.dto.LoadingResponses.EvidenceListResponse; import com.baiyue.firstleg.loading.dto.LoadingResponses.EvidenceListResponse;
import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse; import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse;
@@ -47,6 +48,18 @@ public class PlatformLoadingDeliveryController {
return ApiResponse.success(service.evidence(tenantId, inboundId)); 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 = "完成装柜") @Operation(summary = "完成装柜")
@PostMapping("/{inboundId}/loading/complete") @PostMapping("/{inboundId}/loading/complete")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:complete')") @PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:complete')")
@@ -3,18 +3,18 @@ package com.baiyue.firstleg.loading.domain;
import java.util.Set; import java.util.Set;
/** /**
* 装柜证据类型。照片语义尚待产品确认,当前只固定四个步骤和一个铅封证据槽位。 * 装柜证据类型。接口保留稳定英文编码,业务含义已确认:空仓、半仓、满仓、锁仓、带锁码照。
*/ */
public final class LoadingEvidenceType { public final class LoadingEvidenceType {
/** 装柜过程第 1 步照片。 */ /** 空仓照片。 */
public static final String PHOTO_STEP_1 = "PHOTO_STEP_1"; public static final String PHOTO_STEP_1 = "PHOTO_STEP_1";
/** 装柜过程第 2 步照片。 */ /** 半仓照片。 */
public static final String PHOTO_STEP_2 = "PHOTO_STEP_2"; public static final String PHOTO_STEP_2 = "PHOTO_STEP_2";
/** 装柜过程第 3 步照片。 */ /** 满仓照片。 */
public static final String PHOTO_STEP_3 = "PHOTO_STEP_3"; public static final String PHOTO_STEP_3 = "PHOTO_STEP_3";
/** 装柜过程第 4 步照片。 */ /** 锁仓照片。 */
public static final String PHOTO_STEP_4 = "PHOTO_STEP_4"; public static final String PHOTO_STEP_4 = "PHOTO_STEP_4";
/** 柜门铅封或封条证据。 */ /** 带锁码照片;请求必须同时提供 sealNo。 */
public static final String SEAL = "SEAL"; 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); 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 = 64) String sealNo,
@Size(max = 1000) String remark) {} @Size(max = 1000) String remark) {}
public record VoidEvidenceRequest(
@NotNull @PositiveOrZero Integer expectedVersion,
@NotBlank @Size(max = 500) String reason) {}
public record CompleteLoadingRequest( public record CompleteLoadingRequest(
@NotNull @PositiveOrZero Integer expectedVersion, @NotNull @PositiveOrZero Integer expectedVersion,
@Size(max = 1000) String remark) {} @Size(max = 1000) String remark) {}
@@ -8,7 +8,9 @@ public final class LoadingResponses {
public record EvidenceResponse(Long id, Long inboundId, Long tenantId, String evidenceType, public record EvidenceResponse(Long id, Long inboundId, Long tenantId, String evidenceType,
Long fileId, String fileName, String sealNo, String remark, 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) {} public record EvidenceListResponse(Long inboundId, List<EvidenceResponse> evidences) {}
@@ -21,6 +21,12 @@ public class LoadingEvidenceEntity {
private String fileName; private String fileName;
private String sealNo; private String sealNo;
private String remark; 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 Integer version;
private Long createdBy; private Long createdBy;
private String createdByName; private String createdByName;
@@ -8,13 +8,13 @@ import org.apache.ibatis.annotations.Update;
@Mapper @Mapper
public interface LoadingEvidenceMapper extends BaseMapper<LoadingEvidenceEntity> { public interface LoadingEvidenceMapper extends BaseMapper<LoadingEvidenceEntity> {
@Update("UPDATE fl_loading_evidence SET file_id=#{fileId}, file_name=#{fileName}, seal_no=#{sealNo}, " @Update("UPDATE fl_loading_evidence SET status='VOID', void_reason=#{reason}, voided_by=#{operatorId}, "
+ "remark=#{remark}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, version=version+1 " + "voided_by_name=#{operatorName}, voided_at=CURRENT_TIMESTAMP(3), updated_by=#{operatorId}, "
+ "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND evidence_type=#{evidenceType} " + "updated_by_name=#{operatorName}, version=version+1 "
+ "AND version=#{expectedVersion}") + "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND id=#{evidenceId} "
int updateEvidence(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, + "AND status='ACTIVE' AND version=#{expectedVersion}")
@Param("evidenceType") String evidenceType, @Param("fileId") Long fileId, int voidEvidence(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("fileName") String fileName, @Param("sealNo") String sealNo, @Param("evidenceId") long evidenceId, @Param("reason") String reason,
@Param("remark") String remark, @Param("operatorId") long operatorId, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName,
@Param("operatorName") String operatorName, @Param("expectedVersion") int expectedVersion); @Param("expectedVersion") int expectedVersion);
} }
@@ -33,13 +33,6 @@ public class LoadingRepository {
return inbounds.completeDelivery(tenantId, inboundId, operatorId, 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) { public List<LoadingEvidenceEntity> listEvidence(long tenantId, long inboundId) {
return evidences.selectList(new LambdaQueryWrapper<LoadingEvidenceEntity>() return evidences.selectList(new LambdaQueryWrapper<LoadingEvidenceEntity>()
.eq(LoadingEvidenceEntity::getTenantId, tenantId) .eq(LoadingEvidenceEntity::getTenantId, tenantId)
@@ -51,15 +44,15 @@ public class LoadingRepository {
return evidences.selectOne(new LambdaQueryWrapper<LoadingEvidenceEntity>() return evidences.selectOne(new LambdaQueryWrapper<LoadingEvidenceEntity>()
.eq(LoadingEvidenceEntity::getTenantId, tenantId) .eq(LoadingEvidenceEntity::getTenantId, tenantId)
.eq(LoadingEvidenceEntity::getEvidenceType, "SEAL") .eq(LoadingEvidenceEntity::getEvidenceType, "SEAL")
.eq(LoadingEvidenceEntity::getStatus, "ACTIVE")
.eq(LoadingEvidenceEntity::getSealNo, sealNo)); .eq(LoadingEvidenceEntity::getSealNo, sealNo));
} }
public void createEvidence(LoadingEvidenceEntity entity) { evidences.insert(entity); } public void createEvidence(LoadingEvidenceEntity entity) { evidences.insert(entity); }
public int updateEvidence(long tenantId, long inboundId, String type, Long fileId, String fileName, public int voidEvidence(long tenantId, long inboundId, long evidenceId, String reason,
String sealNo, String remark, long operatorId, String operatorName, int version) { long operatorId, String operatorName, int expectedVersion) {
return evidences.updateEvidence(tenantId, inboundId, type, fileId, fileName, sealNo, remark, return evidences.voidEvidence(tenantId, inboundId, evidenceId, reason, operatorId, operatorName, expectedVersion);
operatorId, operatorName, version);
} }
public LoadingOperationEntity findOperation(long tenantId, long inboundId, long operatorId, public LoadingOperationEntity findOperation(long tenantId, long inboundId, long operatorId,
@@ -39,6 +39,7 @@ import java.util.stream.Collectors;
@Service @Service
public class LoadingDeliveryService { public class LoadingDeliveryService {
private static final String UPSERT_EVIDENCE = "UPSERT_LOADING_EVIDENCE"; 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_LOADING = "COMPLETE_LOADING";
private static final String COMPLETE_DELIVERY = "COMPLETE_DELIVERY"; private static final String COMPLETE_DELIVERY = "COMPLETE_DELIVERY";
@@ -74,23 +75,17 @@ public class LoadingDeliveryService {
String fileName = file.fileName(); String fileName = file.fileName();
String sealNo = LoadingEvidenceType.SEAL.equals(request.evidenceType()) ? request.sealNo().trim() : null; String sealNo = LoadingEvidenceType.SEAL.equals(request.evidenceType()) ? request.sealNo().trim() : null;
try { try {
LoadingEvidenceEntity existing = repository.findEvidence(tenantId, inboundId, request.evidenceType()); // Evidence is append-only by type. A new photo never replaces an existing
if (existing == null) { // one; an incorrect photo is explicitly voided through the void endpoint.
if (request.expectedVersion() != 0) throw concurrent(); LoadingEvidenceEntity entity = new LoadingEvidenceEntity();
LoadingEvidenceEntity entity = new LoadingEvidenceEntity(); entity.setTenantId(tenantId); entity.setInboundId(inboundId);
entity.setTenantId(tenantId); entity.setInboundId(inboundId); entity.setEvidenceType(request.evidenceType()); entity.setFileId(request.fileId());
entity.setEvidenceType(request.evidenceType()); entity.setFileId(request.fileId()); entity.setFileName(fileName); entity.setSealNo(sealNo); entity.setRemark(request.remark());
entity.setFileName(fileName); entity.setSealNo(sealNo); entity.setRemark(request.remark()); entity.setStatus("ACTIVE"); entity.setVersion(0); entity.setCreatedBy(operatorId); entity.setCreatedByName(operatorName);
entity.setVersion(0); entity.setCreatedBy(operatorId); entity.setCreatedByName(operatorName); entity.setUpdatedBy(operatorId); entity.setUpdatedByName(operatorName);
entity.setUpdatedBy(operatorId); entity.setUpdatedByName(operatorName); repository.createEvidence(entity);
repository.createEvidence(entity);
} else if (repository.updateEvidence(tenantId, inboundId, request.evidenceType(), request.fileId(), fileName,
sealNo, request.remark(), operatorId, operatorName, request.expectedVersion()) != 1) {
throw concurrent();
}
} catch (DuplicateKeyException ex) { } 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)); LoadingActionResponse result = new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId));
return complete(tenantId, inboundId, operatorId, UPSERT_EVIDENCE, key, result); return complete(tenantId, inboundId, operatorId, UPSERT_EVIDENCE, key, result);
@@ -141,6 +136,23 @@ public class LoadingDeliveryService {
return evidenceResponse(tenantId, inboundId); 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 @Transactional
public PickingResponse startPicking(long tenantId, long inboundId, long operatorId, String operatorName, public PickingResponse startPicking(long tenantId, long inboundId, long operatorId, String operatorName,
String key, PickingRequest request) { String key, PickingRequest request) {
@@ -242,7 +254,8 @@ public class LoadingDeliveryService {
private void requireCompletionEvidence(long tenantId, long inboundId) { private void requireCompletionEvidence(long tenantId, long inboundId) {
Map<String, LoadingEvidenceEntity> values = repository.listEvidence(tenantId, inboundId).stream() 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() boolean filesReady = LoadingEvidenceType.VALUES.stream()
.allMatch(type -> values.get(type) != null && values.get(type).getFileId() != null); .allMatch(type -> values.get(type) != null && values.get(type).getFileId() != null);
LoadingEvidenceEntity seal = values.get(LoadingEvidenceType.SEAL); LoadingEvidenceEntity seal = values.get(LoadingEvidenceType.SEAL);
@@ -259,7 +272,9 @@ public class LoadingDeliveryService {
try { try {
var response = files.validate(internalToken, tenantId, inboundId, fileId); var response = files.validate(internalToken, tenantId, inboundId, fileId);
if (response == null || !response.successful() || response.data() == null 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, "证据文件不存在或不属于当前订单"); throw new BizException(ResultCode.BAD_REQUEST, "证据文件不存在或不属于当前订单");
} }
return response.data(); return response.data();
@@ -286,10 +301,19 @@ public class LoadingDeliveryService {
private EvidenceListResponse evidenceResponse(long tenantId, long inboundId) { private EvidenceListResponse evidenceResponse(long tenantId, long inboundId) {
List<EvidenceResponse> values = repository.listEvidence(tenantId, inboundId).stream().map(e -> List<EvidenceResponse> values = repository.listEvidence(tenantId, inboundId).stream().map(e ->
new EvidenceResponse(e.getId(), e.getInboundId(), e.getTenantId(), e.getEvidenceType(), e.getFileId(), 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); 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, private LoadingActionResponse claimOrReplay(long tenantId, long inboundId, long operatorId,
String operation, String key, String hash) { String operation, String key, String hash) {
validateKey(key); validateKey(key);
@@ -55,9 +55,9 @@ public interface OutboundDispatchReadMapper {
@Param("customerId") long customerId); @Param("customerId") long customerId);
@Select(""" @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, ('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 AND seal_no&lt;&gt;'' THEN 1 ELSE 0 END) validSeal
FROM fl_loading_evidence WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} 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 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} WHERE task.loading_plan_id=#{loadingPlanId}
UNION ALL 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), 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), 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) COALESCE(e.updated_at,e.created_at)
@@ -104,15 +104,17 @@ public class UnifiedInboundQueryService {
List<LoadingEvidenceEntity> evidence, LocalDateTime deliveryAt, List<LoadingEvidenceEntity> evidence, LocalDateTime deliveryAt,
InboundTransportSnapshotEntity transport) { InboundTransportSnapshotEntity transport) {
String mainStatus = mainStatus(inbound.getProcessStatus()); 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); .map(LoadingEvidenceEntity::getSealNo).filter(StringUtils::hasText).findFirst().orElse(null);
var preloadSummary = preload == null ? null : new UnifiedInboundSummary.PreloadSummary( var preloadSummary = preload == null ? null : new UnifiedInboundSummary.PreloadSummary(
preload.getStatus(), preload.getVersion(), itemCount(preload.getItemsJson()), preload.getUpdatedAt()); 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.getStatus(), pick == null ? null : pick.getVersion(),
pick == null ? null : pick.getVersion(), pick == null ? null : pick.getExpectedCount(), pick == null ? null : pick.getVersion(), pick == null ? null : pick.getExpectedCount(),
pick == null ? null : pick.getPickedCount(), evidence.size(), sealNo, pick == null ? null : pick.getPickedCount(), activeEvidence.size(), sealNo,
pick == null ? evidence.stream().map(LoadingEvidenceEntity::getUpdatedAt) pick == null ? activeEvidence.stream().map(LoadingEvidenceEntity::getUpdatedAt)
.filter(java.util.Objects::nonNull).max(LocalDateTime::compareTo).orElse(null) : pick.getUpdatedAt()); .filter(java.util.Objects::nonNull).max(LocalDateTime::compareTo).orElse(null) : pick.getUpdatedAt());
var deliverySummary = "LOADED".equals(mainStatus) || "DELIVERED".equals(mainStatus) var deliverySummary = "LOADED".equals(mainStatus) || "DELIVERED".equals(mainStatus)
? new UnifiedInboundSummary.DeliverySummary( ? new UnifiedInboundSummary.DeliverySummary(
@@ -1,16 +1,21 @@
SET NAMES utf8mb4; SET NAMES utf8mb4;
USE baiyue_firstleg; USE baiyue_firstleg;
/* 装柜证据、PDA 拣货复核和派送完成。业务服务强制五类真实文件证据齐全后才能完成装柜。 */ /* 装柜证据、PDA 拣货复核和派送完成。五类证据按有效记录计数,同类型允许多图追加;作废记录保留审计。 */
CREATE TABLE IF NOT EXISTS fl_loading_evidence ( CREATE TABLE IF NOT EXISTS fl_loading_evidence (
id BIGINT UNSIGNED NOT NULL, id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL, inbound_id BIGINT UNSIGNED NOT NULL,
evidence_type VARCHAR(32) NOT NULL COMMENT 'PHOTO_STEP_1..4/SEAL', evidence_type VARCHAR(32) NOT NULL COMMENT 'PHOTO_STEP_1空仓/PHOTO_STEP_2半仓/PHOTO_STEP_3满仓/PHOTO_STEP_4锁仓/SEAL带锁码照',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File 服务逻辑文件ID', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File 服务逻辑文件ID',
file_name VARCHAR(255) NULL, file_name VARCHAR(255) NULL,
seal_no VARCHAR(64) NULL, seal_no VARCHAR(64) NULL,
remark VARCHAR(1000) NULL, remark VARCHAR(1000) NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE/VOID,作废不硬删除',
void_reason VARCHAR(500) NULL,
voided_by BIGINT UNSIGNED NULL,
voided_by_name VARCHAR(64) NULL,
voided_at DATETIME(3) NULL,
version INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL, created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL, created_by_name VARCHAR(64) NULL,
@@ -19,11 +24,74 @@ CREATE TABLE IF NOT EXISTS fl_loading_evidence (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_loading_evidence_type (tenant_id, inbound_id, evidence_type), KEY idx_loading_evidence_type (tenant_id, inbound_id, evidence_type, status),
UNIQUE KEY uk_loading_evidence_seal (tenant_id, seal_no), KEY idx_loading_evidence_seal (tenant_id, seal_no, status),
KEY idx_loading_evidence_time (tenant_id, inbound_id, updated_at), KEY idx_loading_evidence_time (tenant_id, inbound_id, updated_at),
CONSTRAINT fk_loading_evidence_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) CONSTRAINT fk_loading_evidence_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='订单装柜四步照片和铅封证据'; ) ENGINE=InnoDB COMMENT='订单装柜空仓/半仓/满仓/锁仓/带锁码照证据';
/* Repeatable migration for installations that already created the single-slot table. */
DROP PROCEDURE IF EXISTS phase3_loading_add_column;
DELIMITER $$
CREATE PROCEDURE phase3_loading_add_column(IN p_column VARCHAR(64), IN p_definition VARCHAR(512))
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='fl_loading_evidence' AND column_name=p_column
) THEN
SET @phase3_loading_ddl = CONCAT('ALTER TABLE fl_loading_evidence ADD COLUMN `', p_column, '` ', p_definition);
PREPARE phase3_loading_stmt FROM @phase3_loading_ddl;
EXECUTE phase3_loading_stmt;
DEALLOCATE PREPARE phase3_loading_stmt;
END IF;
END$$
DELIMITER ;
CALL phase3_loading_add_column('status', 'VARCHAR(16) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''ACTIVE/VOID,作废不硬删除''');
CALL phase3_loading_add_column('void_reason', 'VARCHAR(500) NULL');
CALL phase3_loading_add_column('voided_by', 'BIGINT UNSIGNED NULL');
CALL phase3_loading_add_column('voided_by_name', 'VARCHAR(64) NULL');
CALL phase3_loading_add_column('voided_at', 'DATETIME(3) NULL');
DROP PROCEDURE IF EXISTS phase3_loading_add_column;
SET @phase3_loading_has_legacy_type_index = (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='fl_loading_evidence' AND index_name='uk_loading_evidence_type'
);
SET @phase3_loading_drop_legacy_type_index = IF(@phase3_loading_has_legacy_type_index > 0,
'ALTER TABLE fl_loading_evidence DROP INDEX uk_loading_evidence_type', 'SELECT 1');
PREPARE phase3_loading_drop_stmt FROM @phase3_loading_drop_legacy_type_index;
EXECUTE phase3_loading_drop_stmt;
DEALLOCATE PREPARE phase3_loading_drop_stmt;
SET @phase3_loading_has_type_index = (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='fl_loading_evidence' AND index_name='idx_loading_evidence_type'
);
SET @phase3_loading_add_type_index = IF(@phase3_loading_has_type_index = 0,
'ALTER TABLE fl_loading_evidence ADD KEY idx_loading_evidence_type (tenant_id, inbound_id, evidence_type, status)', 'SELECT 1');
PREPARE phase3_loading_type_stmt FROM @phase3_loading_add_type_index;
EXECUTE phase3_loading_type_stmt;
DEALLOCATE PREPARE phase3_loading_type_stmt;
SET @phase3_loading_has_legacy_seal_index = (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='fl_loading_evidence' AND index_name='uk_loading_evidence_seal'
);
SET @phase3_loading_drop_legacy_seal_index = IF(@phase3_loading_has_legacy_seal_index > 0,
'ALTER TABLE fl_loading_evidence DROP INDEX uk_loading_evidence_seal', 'SELECT 1');
PREPARE phase3_loading_drop_seal_stmt FROM @phase3_loading_drop_legacy_seal_index;
EXECUTE phase3_loading_drop_seal_stmt;
DEALLOCATE PREPARE phase3_loading_drop_seal_stmt;
SET @phase3_loading_has_seal_index = (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='fl_loading_evidence' AND index_name='idx_loading_evidence_seal'
);
SET @phase3_loading_add_seal_index = IF(@phase3_loading_has_seal_index = 0,
'ALTER TABLE fl_loading_evidence ADD KEY idx_loading_evidence_seal (tenant_id, seal_no, status)', 'SELECT 1');
PREPARE phase3_loading_seal_stmt FROM @phase3_loading_add_seal_index;
EXECUTE phase3_loading_seal_stmt;
DEALLOCATE PREPARE phase3_loading_seal_stmt;
CREATE TABLE IF NOT EXISTS fl_loading_operation ( CREATE TABLE IF NOT EXISTS fl_loading_operation (
id BIGINT UNSIGNED NOT NULL, id BIGINT UNSIGNED NOT NULL,
@@ -10,7 +10,7 @@ paths:
responses: responses:
'200': {description: Business envelope containing the stable evidence array} '200': {description: Business envelope containing the stable evidence array}
post: post:
summary: Bind one real File service object to a loading evidence slot summary: Append one real File service object to a loading evidence type
parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}] parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: requestBody:
required: true required: true
@@ -18,6 +18,16 @@ paths:
application/json: {schema: {$ref: '#/components/schemas/UpsertEvidenceRequest'}} application/json: {schema: {$ref: '#/components/schemas/UpsertEvidenceRequest'}}
responses: responses:
'200': {description: Business envelope; ownership, type and version failures use business error codes} '200': {description: Business envelope; ownership, type and version failures use business error codes}
/api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/void:
post:
summary: Void an incorrect loading evidence photo without hard deletion
parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/EvidenceId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
content:
application/json: {schema: {$ref: '#/components/schemas/VoidEvidenceRequest'}}
responses:
'200': {description: Business envelope containing the voided record and audit fields}
/api/platform/v1/inbounds/{inboundId}/loading/picking/start: /api/platform/v1/inbounds/{inboundId}/loading/picking/start:
post: post:
summary: Refresh the compatibility projection from canonical container PDA tasks summary: Refresh the compatibility projection from canonical container PDA tasks
@@ -50,7 +60,7 @@ paths:
'200': {description: Business envelope containing task or null} '200': {description: Business envelope containing task or null}
/api/platform/v1/inbounds/{inboundId}/loading/complete: /api/platform/v1/inbounds/{inboundId}/loading/complete:
post: post:
summary: Complete loading only after picking and PHOTO_STEP_1..4 plus SEAL evidence are complete summary: Complete loading only after picking and five evidence types each have an active photo
parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}] parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: requestBody:
required: true required: true
@@ -62,6 +72,7 @@ components:
parameters: parameters:
InboundId: {name: inboundId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} InboundId: {name: inboundId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
TenantId: {name: tenantId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} TenantId: {name: tenantId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
EvidenceId: {name: evidenceId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
schemas: schemas:
UpsertEvidenceRequest: UpsertEvidenceRequest:
@@ -69,11 +80,17 @@ components:
required: [expectedVersion, evidenceType, fileId] required: [expectedVersion, evidenceType, fileId]
properties: properties:
expectedVersion: {type: integer, minimum: 0} expectedVersion: {type: integer, minimum: 0}
evidenceType: {type: string, enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]} evidenceType: {type: string, enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL], description: PHOTO_STEP_1=空仓,PHOTO_STEP_2=半仓,PHOTO_STEP_3=满仓,PHOTO_STEP_4=锁仓,SEAL=带锁码照;同类型可多次追加}
fileId: {type: integer, format: int64, minimum: 1} fileId: {type: integer, format: int64, minimum: 1}
fileName: {type: string, maxLength: 255, deprecated: true, description: Ignored; server uses File metadata} fileName: {type: string, maxLength: 255, deprecated: true, description: Ignored; server uses File metadata}
sealNo: {type: string, maxLength: 64, description: Required when evidenceType is SEAL} sealNo: {type: string, maxLength: 64, description: Required when evidenceType is SEAL}
remark: {type: string, maxLength: 1000} remark: {type: string, maxLength: 1000}
VoidEvidenceRequest:
type: object
required: [expectedVersion, reason]
properties:
expectedVersion: {type: integer, minimum: 0}
reason: {type: string, minLength: 1, maxLength: 500, description: 作废原因,记录操作人和时间}
PickingRequest: PickingRequest:
type: object type: object
required: [expectedVersion, expectedCount, pickedCount] required: [expectedVersion, expectedCount, pickedCount]
@@ -15,11 +15,13 @@ class LoadingDeliveryOpenApiContractTest {
assertThat(yaml) assertThat(yaml)
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence:") .contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/void:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/start:") .contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/start:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/complete:") .contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/complete:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/complete:") .contains("/api/platform/v1/inbounds/{inboundId}/loading/complete:")
.contains("required: [expectedVersion, evidenceType, fileId]") .contains("required: [expectedVersion, evidenceType, fileId]")
.contains("enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]") .contains("enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]")
.contains("同类型可多次追加")
.contains("Required when evidenceType is SEAL") .contains("Required when evidenceType is SEAL")
.contains("compatibility projection") .contains("compatibility projection")
.contains("must exactly match the server-derived container-task aggregate") .contains("must exactly match the server-derived container-task aggregate")
@@ -5,6 +5,7 @@ import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteDeliveryRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest; import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.PickingRequest; import com.baiyue.firstleg.loading.dto.LoadingRequests.PickingRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest; import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.VoidEvidenceRequest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -27,6 +28,7 @@ class PlatformLoadingDeliveryControllerContractTest {
"platform:firstleg:inbound:loading:edit", UpsertEvidenceRequest.class); "platform:firstleg:inbound:loading:edit", UpsertEvidenceRequest.class);
get("evidence", "/{inboundId}/loading/evidence", get("evidence", "/{inboundId}/loading/evidence",
"platform:firstleg:inbound:loading:view"); "platform:firstleg:inbound:loading:view");
postVoidEvidence();
post("completeLoading", "/{inboundId}/loading/complete", post("completeLoading", "/{inboundId}/loading/complete",
"platform:firstleg:inbound:loading:complete", CompleteLoadingRequest.class); "platform:firstleg:inbound:loading:complete", CompleteLoadingRequest.class);
post("completeDelivery", "/{inboundId}/delivery/complete", post("completeDelivery", "/{inboundId}/delivery/complete",
@@ -39,6 +41,17 @@ class PlatformLoadingDeliveryControllerContractTest {
"platform:firstleg:inbound:picking:view"); "platform:firstleg:inbound:picking:view");
} }
private static void postVoidEvidence() throws Exception {
Method method = PlatformLoadingDeliveryController.class.getDeclaredMethod("voidEvidence",
AuthenticatedUser.class, long.class, long.class, long.class, String.class, VoidEvidenceRequest.class);
assertThat(method.getAnnotation(PostMapping.class).value())
.containsExactly("/{inboundId}/loading/evidence/{evidenceId}/void");
assertAuthority(method, "platform:firstleg:inbound:loading:edit");
assertThat(method.getParameters()[1].getAnnotation(RequestParam.class)).isNotNull();
assertThat(method.getParameters()[4].getAnnotation(RequestHeader.class).value())
.isEqualTo("Idempotency-Key");
}
private static void post(String name, String path, String authority, Class<?> requestType) throws Exception { private static void post(String name, String path, String authority, Class<?> requestType) throws Exception {
Method method = PlatformLoadingDeliveryController.class.getDeclaredMethod(name, Method method = PlatformLoadingDeliveryController.class.getDeclaredMethod(name,
AuthenticatedUser.class, long.class, long.class, String.class, requestType); AuthenticatedUser.class, long.class, long.class, String.class, requestType);
@@ -9,14 +9,20 @@ import static org.assertj.core.api.Assertions.assertThat;
class LoadingEvidenceSqlContractTest { class LoadingEvidenceSqlContractTest {
@Test @Test
void newSchemaRequiresRealFilesAndUniqueSealWithinCustomer() throws Exception { void schemaSupportsAppendOnlyEvidenceAndVoidAudit() throws Exception {
String sql = new ClassPathResource("db/phase3-loading-delivery-schema.sql") String sql = new ClassPathResource("db/phase3-loading-delivery-schema.sql")
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
assertThat(sql) assertThat(sql)
.contains("file_id BIGINT UNSIGNED NOT NULL") .contains("file_id BIGINT UNSIGNED NOT NULL")
.contains("UNIQUE KEY uk_loading_evidence_type (tenant_id, inbound_id, evidence_type)") .contains("KEY idx_loading_evidence_type (tenant_id, inbound_id, evidence_type, status)")
.contains("UNIQUE KEY uk_loading_evidence_seal (tenant_id, seal_no)") .contains("status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE'")
.contains("void_reason VARCHAR(500)")
.contains("voided_by BIGINT UNSIGNED")
.contains("voided_at DATETIME(3)")
.contains("DROP INDEX uk_loading_evidence_type")
.contains("DROP INDEX uk_loading_evidence_seal")
.contains("idx_loading_evidence_seal (tenant_id, seal_no, status)")
.contains("fl_inbound_loading_pick_task"); .contains("fl_inbound_loading_pick_task");
} }
} }
@@ -6,6 +6,7 @@ import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest; import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.PickingRequest; import com.baiyue.firstleg.loading.dto.LoadingRequests.PickingRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest; import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.VoidEvidenceRequest;
import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse; import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot; import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot;
@@ -98,7 +99,6 @@ class LoadingDeliveryServiceTest {
void evidenceCanBeEditedAndDoesNotChangeMainStatus() { void evidenceCanBeEditedAndDoesNotChangeMainStatus() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
when(repository.findEvidence(10, 20, "PHOTO_STEP_1")).thenReturn(null);
when(repository.listEvidence(10, 20)).thenReturn(List.of()); when(repository.listEvidence(10, 20)).thenReturn(List.of());
when(files.validate("token", 10, 20, 1)).thenReturn(ApiResponse.success( when(files.validate("token", 10, 20, 1)).thenReturn(ApiResponse.success(
new LoadingEvidenceFileClient.FileMetadata(1L, "photo.jpg", "LOADING_PHOTO_STEP_1", "image/jpeg"))); new LoadingEvidenceFileClient.FileMetadata(1L, "photo.jpg", "LOADING_PHOTO_STEP_1", "image/jpeg")));
@@ -109,6 +109,42 @@ class LoadingDeliveryServiceTest {
verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt()); verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt());
} }
@Test
void appendsMultiplePhotosOfTheSameType() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
when(repository.listEvidence(10, 20)).thenReturn(List.of());
when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> {
long id = invocation.getArgument(3);
return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata(id, "photo-" + id + ".jpg",
"LOADING_PHOTO_STEP_1", "image/jpeg"));
});
service.upsertEvidence(10, 20, 7, "admin", "append-1",
new UpsertEvidenceRequest(0, "PHOTO_STEP_1", 11L, null, null, null));
service.upsertEvidence(10, 20, 7, "admin", "append-2",
new UpsertEvidenceRequest(0, "PHOTO_STEP_1", 12L, null, null, null));
verify(repository, times(2)).createEvidence(argThat(value ->
"PHOTO_STEP_1".equals(value.getEvidenceType()) && "ACTIVE".equals(value.getStatus())));
}
@Test
void voidsPhotoWithReasonAndKeepsTheRecord() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
LoadingEvidenceEntity target = evidence("PHOTO_STEP_1", 11L, null);
target.setId(99L); target.setStatus("ACTIVE"); target.setVersion(2);
when(repository.listEvidence(10, 20)).thenReturn(List.of(target));
when(repository.voidEvidence(10, 20, 99L, "模糊", 7L, "admin", 2)).thenReturn(1);
LoadingActionResponse result = service.voidEvidence(10, 20, 99L, 7L, "admin", "void-1",
new VoidEvidenceRequest(2, "模糊"));
assertThat(result.mainStatus()).isEqualTo("LOADING");
verify(repository).voidEvidence(10, 20, 99L, "模糊", 7L, "admin", 2);
}
@Test @Test
void rejectsDuplicateSealUsedByAnotherInbound() { void rejectsDuplicateSealUsedByAnotherInbound() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
@@ -317,6 +317,21 @@ export async function run(context) {
const firstPhoto = evidenceItems.find((item) => item.evidenceType === 'PHOTO_STEP_1') const firstPhoto = evidenceItems.find((item) => item.evidenceType === 'PHOTO_STEP_1')
const sealEvidence = evidenceItems.find((item) => item.evidenceType === 'SEAL') const sealEvidence = evidenceItems.find((item) => item.evidenceType === 'SEAL')
const appendedUpload = await uploadEvidence(context, tenantId, inboundId, 'PHOTO_STEP_1')
const appendedFile = context.apiSuccess(MODULE, '追加同类型第二张装柜证据文件', appendedUpload, '', OPTIONS)
const appendedFileId = id(appendedFile?.fileId)
const appended = appendedFileId
? await bindEvidence(context, base, tenantId, 'PHOTO_STEP_1', appendedFileId, suffix, 'PHOTO_STEP_1_APPEND')
: appendedUpload
const appendedData = context.apiSuccess(MODULE, '追加同类型第二张装柜证据', appended, '', OPTIONS)
const appendedItems = appendedData?.evidences?.evidences?.filter?.((item) => item.evidenceType === 'PHOTO_STEP_1') || []
context.assert(MODULE, '同类型装柜证据支持多图追加且不替换历史', appended,
Boolean(appendedFileId) && appendedItems.length >= 2
&& appendedItems.some((item) => id(item.fileId) === id(firstPhoto?.fileId))
&& appendedItems.some((item) => id(item.fileId) === appendedFileId),
`photoStep1Count=${appendedItems.length}, original=${id(firstPhoto?.fileId) || 'missing'}, appended=${appendedFileId || 'missing'}`, OPTIONS)
const wrongType = await bindEvidence(context, base, tenantId, 'PHOTO_STEP_2', id(firstPhoto?.fileId), suffix, 'wrong-type') const wrongType = await bindEvidence(context, base, tenantId, 'PHOTO_STEP_2', id(firstPhoto?.fileId), suffix, 'wrong-type')
context.addStep(MODULE, '不同证据类型的真实 fileId 不可交叉绑定', wrongType, failure(wrongType), context.addStep(MODULE, '不同证据类型的真实 fileId 不可交叉绑定', wrongType, failure(wrongType),
`HTTP=${wrongType.http}, code=${codeOf(wrongType)}`, OPTIONS) `HTTP=${wrongType.http}, code=${codeOf(wrongType)}`, OPTIONS)
@@ -328,10 +343,29 @@ export async function run(context) {
context.addStep(MODULE, '伪造装柜证据 fileId 归属校验拒绝', forged, failure(forged), context.addStep(MODULE, '伪造装柜证据 fileId 归属校验拒绝', forged, failure(forged),
`HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS) `HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS)
const missingSealText = await request(context, 'POST', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, {
expectedVersion: Number(sealEvidence?.version), evidenceType: 'SEAL', fileId: id(sealEvidence?.fileId),
fileName: `${context.marker}-missing-seal-text.png`, sealNo: ' ', remark: context.marker,
}, { 'Idempotency-Key': `${context.marker}-p3-missing-seal-text`.slice(0, 128) })
context.addStep(MODULE, '带锁码照片缺少锁码文本时拒绝绑定', missingSealText, failure(missingSealText),
`HTTP=${missingSealText.http}, code=${codeOf(missingSealText)}`, OPTIONS)
const voidTarget = appendedItems.find((item) => id(item.fileId) === appendedFileId)
const voidResponse = voidTarget
? await request(context, 'POST', `${base}/loading/evidence/${encodeURIComponent(id(voidTarget.id))}/void?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: Number(voidTarget.version), reason: `${context.marker}-wrong-photo` },
{ 'Idempotency-Key': `${context.marker}-p3-evidence-void`.slice(0, 128) })
: appended
const voidData = context.apiSuccess(MODULE, '作废错误装柜证据并保留审计', voidResponse, '', OPTIONS)
context.assert(MODULE, '错图作废后原记录保留且不再计入有效证据', voidResponse,
Boolean(voidTarget) && voidData?.evidences?.evidences?.some?.((item) => id(item.id) === id(voidTarget.id)
&& (item.status === 'VOID' || item.valid === false) && item.voidReason === `${context.marker}-wrong-photo`),
`evidenceId=${id(voidTarget?.id) || 'missing'}, status=${voidTarget?.status || 'missing'}`, OPTIONS)
const evidenceList = await context.request('GET', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`) const evidenceList = await context.request('GET', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`)
const evidenceListData = context.apiSuccess(MODULE, '回查五类装柜证据列表', evidenceList, '', OPTIONS) const evidenceListData = context.apiSuccess(MODULE, '回查五类装柜证据列表', evidenceList, '', OPTIONS)
context.assert(MODULE, '五类装柜证据完整且铅封号持久化', evidenceList, context.assert(MODULE, '五类装柜证据完整且铅封号持久化', evidenceList,
evidenceListData?.evidences?.length === evidenceTypes.length evidenceListData?.evidences?.length >= evidenceTypes.length
&& evidenceTypes.every((type) => evidenceListData.evidences.some((item) => item.evidenceType === type)) && evidenceTypes.every((type) => evidenceListData.evidences.some((item) => item.evidenceType === type))
&& evidenceListData.evidences.some((item) => item.evidenceType === 'SEAL' && item.sealNo === `SEAL-${suffix}`), && evidenceListData.evidences.some((item) => item.evidenceType === 'SEAL' && item.sealNo === `SEAL-${suffix}`),
`evidences=${evidenceListData?.evidences?.length ?? 'n/a'}`, OPTIONS) `evidences=${evidenceListData?.evidences?.length ?? 'n/a'}`, OPTIONS)
@@ -340,7 +374,7 @@ export async function run(context) {
{ expectedVersion: loadingVersion, remark: context.marker }, { 'Idempotency-Key': `${context.marker}-p3-complete`.slice(0, 128) }) { expectedVersion: loadingVersion, remark: context.marker }, { 'Idempotency-Key': `${context.marker}-p3-complete`.slice(0, 128) })
const completed = context.apiSuccess(MODULE, '确认本轮入库单装柜完成', complete, '', OPTIONS) const completed = context.apiSuccess(MODULE, '确认本轮入库单装柜完成', complete, '', OPTIONS)
context.assert(MODULE, '装柜完成推进主状态 LOADED 且保留五类证据', complete, context.assert(MODULE, '装柜完成推进主状态 LOADED 且保留五类证据', complete,
completed?.mainStatus === 'LOADED' && completed?.evidences?.evidences?.length === evidenceTypes.length, completed?.mainStatus === 'LOADED' && completed?.evidences?.evidences?.length >= evidenceTypes.length,
`mainStatus=${completed?.mainStatus || 'missing'}, evidences=${completed?.evidences?.evidences?.length ?? 'n/a'}`, OPTIONS) `mainStatus=${completed?.mainStatus || 'missing'}, evidences=${completed?.evidences?.evidences?.length ?? 'n/a'}`, OPTIONS)
const finalResponse = await context.request('GET', detailPath) const finalResponse = await context.request('GET', detailPath)
@@ -355,4 +389,7 @@ export async function run(context) {
pickingTaskId: id(pickingCompleted?.id), marker: context.marker, finalStatus: 'LOADED', pickingTaskId: id(pickingCompleted?.id), marker: context.marker, finalStatus: 'LOADED',
} }
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 真机在线限制与设备持久化草稿批量提交',
'当前验收运行器只能发起平台 HTTP 请求,未具备 PDA 真机断网、设备草稿隔离及批量提交端点的可执行环境',
'device-and-batch-fixture')
} }