From 047f267d4fe99dbf59a69be35ec1bb96b2ff9480 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 10 Aug 2026 11:28:57 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PlatformLoadingDeliveryController.java | 13 ++++ .../loading/domain/LoadingEvidenceType.java | 12 +-- .../firstleg/loading/dto/LoadingRequests.java | 4 + .../loading/dto/LoadingResponses.java | 4 +- .../loading/entity/LoadingEvidenceEntity.java | 6 ++ .../loading/mapper/LoadingEvidenceMapper.java | 18 ++--- .../loading/repository/LoadingRepository.java | 15 +--- .../service/LoadingDeliveryService.java | 62 ++++++++++----- .../mapper/OutboundDispatchReadMapper.java | 6 +- .../service/UnifiedInboundQueryService.java | 10 ++- .../db/phase3-loading-delivery-schema.sql | 78 +++++++++++++++++-- .../openapi/phase3-loading-delivery-v1.yaml | 23 +++++- .../LoadingDeliveryOpenApiContractTest.java | 2 + ...LoadingDeliveryControllerContractTest.java | 13 ++++ .../LoadingEvidenceSqlContractTest.java | 12 ++- .../service/LoadingDeliveryServiceTest.java | 38 ++++++++- .../admin-test-env-modules/phase3-loading.mjs | 41 +++++++++- 17 files changed, 290 insertions(+), 67 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java index 0c882e571..9c578e4ae 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java @@ -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 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')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/LoadingEvidenceType.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/LoadingEvidenceType.java index 74a5f40ef..2d840d452 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/LoadingEvidenceType.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/LoadingEvidenceType.java @@ -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 VALUES = Set.of(PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingRequests.java index 446915dd7..c9d8b1136 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingRequests.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingRequests.java @@ -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) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingResponses.java index 9976283bb..df27a188b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingResponses.java @@ -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 evidences) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/entity/LoadingEvidenceEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/entity/LoadingEvidenceEntity.java index 7096c2f10..b09c4d87c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/entity/LoadingEvidenceEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/entity/LoadingEvidenceEntity.java @@ -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingEvidenceMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingEvidenceMapper.java index b6efa3937..bf5747969 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingEvidenceMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingEvidenceMapper.java @@ -8,13 +8,13 @@ import org.apache.ibatis.annotations.Update; @Mapper public interface LoadingEvidenceMapper extends BaseMapper { - @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); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java index 786f65ffe..23d8569e8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java @@ -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() - .eq(LoadingEvidenceEntity::getTenantId, tenantId) - .eq(LoadingEvidenceEntity::getInboundId, inboundId) - .eq(LoadingEvidenceEntity::getEvidenceType, type)); - } - public List listEvidence(long tenantId, long inboundId) { return evidences.selectList(new LambdaQueryWrapper() .eq(LoadingEvidenceEntity::getTenantId, tenantId) @@ -51,15 +44,15 @@ public class LoadingRepository { return evidences.selectOne(new LambdaQueryWrapper() .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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java index 0f694a866..edea22d1e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java @@ -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 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 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); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java index a6fed5237..656516a57 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java @@ -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<>'' 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) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java index 117769a02..34e9b662b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java @@ -104,15 +104,17 @@ public class UnifiedInboundQueryService { List evidence, LocalDateTime deliveryAt, InboundTransportSnapshotEntity transport) { String mainStatus = mainStatus(inbound.getProcessStatus()); - String sealNo = evidence.stream().filter(item -> "SEAL".equals(item.getEvidenceType())) + List 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( diff --git a/firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql b/firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql index 9e57eabd0..b812b323b 100644 --- a/firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql +++ b/firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql @@ -1,16 +1,21 @@ SET NAMES utf8mb4; USE baiyue_firstleg; -/* 装柜证据、PDA 拣货复核和派送完成。业务服务强制五类真实文件证据齐全后才能完成装柜。 */ +/* 装柜证据、PDA 拣货复核和派送完成。五类证据按有效记录计数,同类型允许多图追加;作废记录保留审计。 */ CREATE TABLE IF NOT EXISTS fl_loading_evidence ( id BIGINT UNSIGNED NOT NULL, tenant_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_name VARCHAR(255) NULL, seal_no VARCHAR(64) 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, created_by BIGINT UNSIGNED NOT 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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY (id), - UNIQUE KEY uk_loading_evidence_type (tenant_id, inbound_id, evidence_type), - UNIQUE KEY uk_loading_evidence_seal (tenant_id, seal_no), + KEY idx_loading_evidence_type (tenant_id, inbound_id, evidence_type, status), + KEY idx_loading_evidence_seal (tenant_id, seal_no, status), 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) -) 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 ( id BIGINT UNSIGNED NOT NULL, diff --git a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml index 23c075fd5..1fd3af338 100644 --- a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml @@ -10,7 +10,7 @@ paths: responses: '200': {description: Business envelope containing the stable evidence array} 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'}] requestBody: required: true @@ -18,6 +18,16 @@ paths: application/json: {schema: {$ref: '#/components/schemas/UpsertEvidenceRequest'}} responses: '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: post: summary: Refresh the compatibility projection from canonical container PDA tasks @@ -50,7 +60,7 @@ paths: '200': {description: Business envelope containing task or null} /api/platform/v1/inbounds/{inboundId}/loading/complete: 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'}] requestBody: required: true @@ -62,6 +72,7 @@ components: parameters: 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}} + 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}} schemas: UpsertEvidenceRequest: @@ -69,11 +80,17 @@ components: required: [expectedVersion, evidenceType, fileId] properties: 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} fileName: {type: string, maxLength: 255, deprecated: true, description: Ignored; server uses File metadata} sealNo: {type: string, maxLength: 64, description: Required when evidenceType is SEAL} 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: type: object required: [expectedVersion, expectedCount, pickedCount] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java index f0800264e..3f89defd0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java @@ -15,11 +15,13 @@ class LoadingDeliveryOpenApiContractTest { assertThat(yaml) .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/complete:") .contains("/api/platform/v1/inbounds/{inboundId}/loading/complete:") .contains("required: [expectedVersion, evidenceType, fileId]") .contains("enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]") + .contains("同类型可多次追加") .contains("Required when evidenceType is SEAL") .contains("compatibility projection") .contains("must exactly match the server-derived container-task aggregate") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryControllerContractTest.java index 37e06eb66..d8cfbd2bd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryControllerContractTest.java @@ -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.PickingRequest; import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest; +import com.baiyue.firstleg.loading.dto.LoadingRequests.VoidEvidenceRequest; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; @@ -27,6 +28,7 @@ class PlatformLoadingDeliveryControllerContractTest { "platform:firstleg:inbound:loading:edit", UpsertEvidenceRequest.class); get("evidence", "/{inboundId}/loading/evidence", "platform:firstleg:inbound:loading:view"); + postVoidEvidence(); post("completeLoading", "/{inboundId}/loading/complete", "platform:firstleg:inbound:loading:complete", CompleteLoadingRequest.class); post("completeDelivery", "/{inboundId}/delivery/complete", @@ -39,6 +41,17 @@ class PlatformLoadingDeliveryControllerContractTest { "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 { Method method = PlatformLoadingDeliveryController.class.getDeclaredMethod(name, AuthenticatedUser.class, long.class, long.class, String.class, requestType); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/LoadingEvidenceSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/LoadingEvidenceSqlContractTest.java index 70255178e..4125a5414 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/LoadingEvidenceSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/LoadingEvidenceSqlContractTest.java @@ -9,14 +9,20 @@ import static org.assertj.core.api.Assertions.assertThat; class LoadingEvidenceSqlContractTest { @Test - void newSchemaRequiresRealFilesAndUniqueSealWithinCustomer() throws Exception { + void schemaSupportsAppendOnlyEvidenceAndVoidAudit() throws Exception { String sql = new ClassPathResource("db/phase3-loading-delivery-schema.sql") .getContentAsString(StandardCharsets.UTF_8); assertThat(sql) .contains("file_id BIGINT UNSIGNED NOT NULL") - .contains("UNIQUE KEY uk_loading_evidence_type (tenant_id, inbound_id, evidence_type)") - .contains("UNIQUE KEY uk_loading_evidence_seal (tenant_id, seal_no)") + .contains("KEY idx_loading_evidence_type (tenant_id, inbound_id, evidence_type, status)") + .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"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java index 9cbdd6682..bec1361b3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java @@ -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.PickingRequest; 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.entity.LoadingEvidenceEntity; import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot; @@ -98,7 +99,6 @@ class LoadingDeliveryServiceTest { void evidenceCanBeEditedAndDoesNotChangeMainStatus() { 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.findEvidence(10, 20, "PHOTO_STEP_1")).thenReturn(null); when(repository.listEvidence(10, 20)).thenReturn(List.of()); when(files.validate("token", 10, 20, 1)).thenReturn(ApiResponse.success( 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()); } + @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 void rejectsDuplicateSealUsedByAnotherInbound() { when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 7f57fa985..c8178cbc8 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -317,6 +317,21 @@ export async function run(context) { const firstPhoto = evidenceItems.find((item) => item.evidenceType === 'PHOTO_STEP_1') 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') context.addStep(MODULE, '不同证据类型的真实 fileId 不可交叉绑定', wrongType, failure(wrongType), `HTTP=${wrongType.http}, code=${codeOf(wrongType)}`, OPTIONS) @@ -328,10 +343,29 @@ export async function run(context) { context.addStep(MODULE, '伪造装柜证据 fileId 归属校验拒绝', forged, failure(forged), `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 evidenceListData = context.apiSuccess(MODULE, '回查五类装柜证据列表', evidenceList, '', OPTIONS) context.assert(MODULE, '五类装柜证据完整且铅封号持久化', evidenceList, - evidenceListData?.evidences?.length === evidenceTypes.length + evidenceListData?.evidences?.length >= evidenceTypes.length && evidenceTypes.every((type) => evidenceListData.evidences.some((item) => item.evidenceType === type)) && evidenceListData.evidences.some((item) => item.evidenceType === 'SEAL' && item.sealNo === `SEAL-${suffix}`), `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) }) const completed = context.apiSuccess(MODULE, '确认本轮入库单装柜完成', complete, '', OPTIONS) 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) 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', } + context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 真机在线限制与设备持久化草稿批量提交', + '当前验收运行器只能发起平台 HTTP 请求,未具备 PDA 真机断网、设备草稿隔离及批量提交端点的可执行环境', + 'device-and-batch-fixture') }