优化
This commit is contained in:
+13
@@ -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;
|
||||||
|
|||||||
+9
-9
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-11
@@ -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,
|
||||||
|
|||||||
+43
-19
@@ -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);
|
||||||
|
|||||||
+3
-3
@@ -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<>'' THEN 1 ELSE 0 END) validSeal
|
AND seal_no<>'' 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]
|
||||||
|
|||||||
+2
@@ -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")
|
||||||
|
|||||||
+13
@@ -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
-3
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-1
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user