This commit is contained in:
2026-08-10 11:28:57 +08:00
parent e33fb923e4
commit 047f267d4f
17 changed files with 290 additions and 67 deletions
@@ -6,6 +6,7 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteDeliveryRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.VoidEvidenceRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.PickingRequest;
import com.baiyue.firstleg.loading.dto.LoadingResponses.EvidenceListResponse;
import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse;
@@ -47,6 +48,18 @@ public class PlatformLoadingDeliveryController {
return ApiResponse.success(service.evidence(tenantId, inboundId));
}
@Operation(summary = "作废装柜证据并保留审计记录")
@PostMapping("/{inboundId}/loading/evidence/{evidenceId}/void")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:edit')")
@BusinessAudit(event = "INBOUND_LOADING_EVIDENCE_VOID", resourceType = "INBOUND", resourceId = "#inboundId", description = "作废装柜证据")
public ApiResponse<LoadingActionResponse> voidEvidence(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
@PathVariable long evidenceId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody VoidEvidenceRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.voidEvidence(tenantId, inboundId, evidenceId, user.userId(), user.username(), key, request));
}
@Operation(summary = "完成装柜")
@PostMapping("/{inboundId}/loading/complete")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:complete')")
@@ -3,18 +3,18 @@ package com.baiyue.firstleg.loading.domain;
import java.util.Set;
/**
* 装柜证据类型。照片语义尚待产品确认,当前只固定四个步骤和一个铅封证据槽位。
* 装柜证据类型。接口保留稳定英文编码,业务含义已确认:空仓、半仓、满仓、锁仓、带锁码照。
*/
public final class LoadingEvidenceType {
/** 装柜过程第 1 步照片。 */
/** 空仓照片。 */
public static final String PHOTO_STEP_1 = "PHOTO_STEP_1";
/** 装柜过程第 2 步照片。 */
/** 半仓照片。 */
public static final String PHOTO_STEP_2 = "PHOTO_STEP_2";
/** 装柜过程第 3 步照片。 */
/** 满仓照片。 */
public static final String PHOTO_STEP_3 = "PHOTO_STEP_3";
/** 装柜过程第 4 步照片。 */
/** 锁仓照片。 */
public static final String PHOTO_STEP_4 = "PHOTO_STEP_4";
/** 柜门铅封或封条证据。 */
/** 带锁码照片;请求必须同时提供 sealNo。 */
public static final String SEAL = "SEAL";
public static final Set<String> VALUES = Set.of(PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL);
@@ -17,6 +17,10 @@ public final class LoadingRequests {
@Size(max = 64) String sealNo,
@Size(max = 1000) String remark) {}
public record VoidEvidenceRequest(
@NotNull @PositiveOrZero Integer expectedVersion,
@NotBlank @Size(max = 500) String reason) {}
public record CompleteLoadingRequest(
@NotNull @PositiveOrZero Integer expectedVersion,
@Size(max = 1000) String remark) {}
@@ -8,7 +8,9 @@ public final class LoadingResponses {
public record EvidenceResponse(Long id, Long inboundId, Long tenantId, String evidenceType,
Long fileId, String fileName, String sealNo, String remark,
Integer version, LocalDateTime createdAt, LocalDateTime updatedAt) {}
Integer version, String status, String voidReason,
Long voidedBy, String voidedByName, LocalDateTime voidedAt,
LocalDateTime createdAt, LocalDateTime updatedAt) {}
public record EvidenceListResponse(Long inboundId, List<EvidenceResponse> evidences) {}
@@ -21,6 +21,12 @@ public class LoadingEvidenceEntity {
private String fileName;
private String sealNo;
private String remark;
/** ACTIVE records count towards the loading gate; VOID records remain for audit only. */
private String status;
private String voidReason;
private Long voidedBy;
private String voidedByName;
private LocalDateTime voidedAt;
private Integer version;
private Long createdBy;
private String createdByName;
@@ -8,13 +8,13 @@ import org.apache.ibatis.annotations.Update;
@Mapper
public interface LoadingEvidenceMapper extends BaseMapper<LoadingEvidenceEntity> {
@Update("UPDATE fl_loading_evidence SET file_id=#{fileId}, file_name=#{fileName}, seal_no=#{sealNo}, "
+ "remark=#{remark}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, version=version+1 "
+ "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND evidence_type=#{evidenceType} "
+ "AND version=#{expectedVersion}")
int updateEvidence(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("evidenceType") String evidenceType, @Param("fileId") Long fileId,
@Param("fileName") String fileName, @Param("sealNo") String sealNo,
@Param("remark") String remark, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName, @Param("expectedVersion") int expectedVersion);
@Update("UPDATE fl_loading_evidence SET status='VOID', void_reason=#{reason}, voided_by=#{operatorId}, "
+ "voided_by_name=#{operatorName}, voided_at=CURRENT_TIMESTAMP(3), updated_by=#{operatorId}, "
+ "updated_by_name=#{operatorName}, version=version+1 "
+ "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND id=#{evidenceId} "
+ "AND status='ACTIVE' AND version=#{expectedVersion}")
int voidEvidence(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("evidenceId") long evidenceId, @Param("reason") String reason,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName,
@Param("expectedVersion") int expectedVersion);
}
@@ -33,13 +33,6 @@ public class LoadingRepository {
return inbounds.completeDelivery(tenantId, inboundId, operatorId, expectedVersion);
}
public LoadingEvidenceEntity findEvidence(long tenantId, long inboundId, String type) {
return evidences.selectOne(new LambdaQueryWrapper<LoadingEvidenceEntity>()
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
.eq(LoadingEvidenceEntity::getInboundId, inboundId)
.eq(LoadingEvidenceEntity::getEvidenceType, type));
}
public List<LoadingEvidenceEntity> listEvidence(long tenantId, long inboundId) {
return evidences.selectList(new LambdaQueryWrapper<LoadingEvidenceEntity>()
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
@@ -51,15 +44,15 @@ public class LoadingRepository {
return evidences.selectOne(new LambdaQueryWrapper<LoadingEvidenceEntity>()
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
.eq(LoadingEvidenceEntity::getEvidenceType, "SEAL")
.eq(LoadingEvidenceEntity::getStatus, "ACTIVE")
.eq(LoadingEvidenceEntity::getSealNo, sealNo));
}
public void createEvidence(LoadingEvidenceEntity entity) { evidences.insert(entity); }
public int updateEvidence(long tenantId, long inboundId, String type, Long fileId, String fileName,
String sealNo, String remark, long operatorId, String operatorName, int version) {
return evidences.updateEvidence(tenantId, inboundId, type, fileId, fileName, sealNo, remark,
operatorId, operatorName, version);
public int voidEvidence(long tenantId, long inboundId, long evidenceId, String reason,
long operatorId, String operatorName, int expectedVersion) {
return evidences.voidEvidence(tenantId, inboundId, evidenceId, reason, operatorId, operatorName, expectedVersion);
}
public LoadingOperationEntity findOperation(long tenantId, long inboundId, long operatorId,
@@ -39,6 +39,7 @@ import java.util.stream.Collectors;
@Service
public class LoadingDeliveryService {
private static final String UPSERT_EVIDENCE = "UPSERT_LOADING_EVIDENCE";
private static final String VOID_EVIDENCE = "VOID_LOADING_EVIDENCE";
private static final String COMPLETE_LOADING = "COMPLETE_LOADING";
private static final String COMPLETE_DELIVERY = "COMPLETE_DELIVERY";
@@ -74,23 +75,17 @@ public class LoadingDeliveryService {
String fileName = file.fileName();
String sealNo = LoadingEvidenceType.SEAL.equals(request.evidenceType()) ? request.sealNo().trim() : null;
try {
LoadingEvidenceEntity existing = repository.findEvidence(tenantId, inboundId, request.evidenceType());
if (existing == null) {
if (request.expectedVersion() != 0) throw concurrent();
LoadingEvidenceEntity entity = new LoadingEvidenceEntity();
entity.setTenantId(tenantId); entity.setInboundId(inboundId);
entity.setEvidenceType(request.evidenceType()); entity.setFileId(request.fileId());
entity.setFileName(fileName); entity.setSealNo(sealNo); entity.setRemark(request.remark());
entity.setVersion(0); entity.setCreatedBy(operatorId); entity.setCreatedByName(operatorName);
entity.setUpdatedBy(operatorId); entity.setUpdatedByName(operatorName);
repository.createEvidence(entity);
} else if (repository.updateEvidence(tenantId, inboundId, request.evidenceType(), request.fileId(), fileName,
sealNo, request.remark(), operatorId, operatorName, request.expectedVersion()) != 1) {
throw concurrent();
}
// Evidence is append-only by type. A new photo never replaces an existing
// one; an incorrect photo is explicitly voided through the void endpoint.
LoadingEvidenceEntity entity = new LoadingEvidenceEntity();
entity.setTenantId(tenantId); entity.setInboundId(inboundId);
entity.setEvidenceType(request.evidenceType()); entity.setFileId(request.fileId());
entity.setFileName(fileName); entity.setSealNo(sealNo); entity.setRemark(request.remark());
entity.setStatus("ACTIVE"); entity.setVersion(0); entity.setCreatedBy(operatorId); entity.setCreatedByName(operatorName);
entity.setUpdatedBy(operatorId); entity.setUpdatedByName(operatorName);
repository.createEvidence(entity);
} catch (DuplicateKeyException ex) {
throw new BizException(ResultCode.CONFLICT, LoadingEvidenceType.SEAL.equals(request.evidenceType())
? "锁码已用于其他装柜任务" : "数据已被其他人修改,请刷新后重试");
throw new BizException(ResultCode.CONFLICT, "锁码已用于其他装柜任务");
}
LoadingActionResponse result = new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId));
return complete(tenantId, inboundId, operatorId, UPSERT_EVIDENCE, key, result);
@@ -141,6 +136,23 @@ public class LoadingDeliveryService {
return evidenceResponse(tenantId, inboundId);
}
@Transactional
public LoadingActionResponse voidEvidence(long tenantId, long inboundId, long evidenceId, long operatorId,
String operatorName, String key, VoidEvidenceRequest request) {
String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, VOID_EVIDENCE, key, hash);
if (replay != null) return replay;
requireLoadingStage(tenantId, inboundId);
LoadingEvidenceEntity target = repository.listEvidence(tenantId, inboundId).stream()
.filter(item -> Long.valueOf(evidenceId).equals(item.getId())).findFirst().orElse(null);
if (target == null) throw new BizException(ResultCode.NOT_FOUND, "装柜证据不存在");
if (!isActive(target)) throw new BizException(ResultCode.CONFLICT, "装柜证据已经作废");
if (repository.voidEvidence(tenantId, inboundId, evidenceId, request.reason().trim(), operatorId,
operatorName, request.expectedVersion()) != 1) throw concurrent();
LoadingActionResponse result = new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId));
return complete(tenantId, inboundId, operatorId, VOID_EVIDENCE, key, result);
}
@Transactional
public PickingResponse startPicking(long tenantId, long inboundId, long operatorId, String operatorName,
String key, PickingRequest request) {
@@ -242,7 +254,8 @@ public class LoadingDeliveryService {
private void requireCompletionEvidence(long tenantId, long inboundId) {
Map<String, LoadingEvidenceEntity> values = repository.listEvidence(tenantId, inboundId).stream()
.collect(Collectors.toMap(LoadingEvidenceEntity::getEvidenceType, Function.identity(), (left, right) -> right));
.filter(LoadingDeliveryService::isActive)
.collect(Collectors.toMap(LoadingEvidenceEntity::getEvidenceType, Function.identity(), (left, right) -> left));
boolean filesReady = LoadingEvidenceType.VALUES.stream()
.allMatch(type -> values.get(type) != null && values.get(type).getFileId() != null);
LoadingEvidenceEntity seal = values.get(LoadingEvidenceType.SEAL);
@@ -259,7 +272,9 @@ public class LoadingDeliveryService {
try {
var response = files.validate(internalToken, tenantId, inboundId, fileId);
if (response == null || !response.successful() || response.data() == null
|| !("LOADING_" + evidenceType).equals(response.data().businessType())) {
|| !("LOADING_" + evidenceType).equals(response.data().businessType())
|| !StringUtils.hasText(response.data().contentType())
|| !response.data().contentType().toLowerCase(java.util.Locale.ROOT).startsWith("image/")) {
throw new BizException(ResultCode.BAD_REQUEST, "证据文件不存在或不属于当前订单");
}
return response.data();
@@ -286,10 +301,19 @@ public class LoadingDeliveryService {
private EvidenceListResponse evidenceResponse(long tenantId, long inboundId) {
List<EvidenceResponse> values = repository.listEvidence(tenantId, inboundId).stream().map(e ->
new EvidenceResponse(e.getId(), e.getInboundId(), e.getTenantId(), e.getEvidenceType(), e.getFileId(),
e.getFileName(), e.getSealNo(), e.getRemark(), e.getVersion(), e.getCreatedAt(), e.getUpdatedAt())).toList();
e.getFileName(), e.getSealNo(), e.getRemark(), e.getVersion(), activeStatus(e), e.getVoidReason(),
e.getVoidedBy(), e.getVoidedByName(), e.getVoidedAt(), e.getCreatedAt(), e.getUpdatedAt())).toList();
return new EvidenceListResponse(inboundId, values);
}
private static boolean isActive(LoadingEvidenceEntity evidence) {
return evidence != null && (evidence.getStatus() == null || "ACTIVE".equals(evidence.getStatus()));
}
private static String activeStatus(LoadingEvidenceEntity evidence) {
return evidence.getStatus() == null ? "ACTIVE" : evidence.getStatus();
}
private LoadingActionResponse claimOrReplay(long tenantId, long inboundId, long operatorId,
String operation, String key, String hash) {
validateKey(key);
@@ -55,9 +55,9 @@ public interface OutboundDispatchReadMapper {
@Param("customerId") long customerId);
@Select("""
SELECT COUNT(DISTINCT CASE WHEN file_id IS NOT NULL AND evidence_type IN
SELECT COUNT(DISTINCT CASE WHEN status='ACTIVE' AND file_id IS NOT NULL AND evidence_type IN
('PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL') THEN evidence_type END) evidenceCount,
MAX(CASE WHEN evidence_type='SEAL' AND file_id IS NOT NULL AND seal_no IS NOT NULL
MAX(CASE WHEN status='ACTIVE' AND evidence_type='SEAL' AND file_id IS NOT NULL AND seal_no IS NOT NULL
AND seal_no&lt;&gt;'' THEN 1 ELSE 0 END) validSeal
FROM fl_loading_evidence WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId}
""")
@@ -88,7 +88,7 @@ public interface OutboundDispatchReadMapper {
FROM fl_loading_pick_history h JOIN fl_loading_pick_task task ON task.id=h.pick_task_id
WHERE task.loading_plan_id=#{loadingPlanId}
UNION ALL
SELECT 'LOADING_EVIDENCE','UPSERT_EVIDENCE',NULL,NULL,
SELECT 'LOADING_EVIDENCE',CASE WHEN e.status='VOID' THEN 'VOID_EVIDENCE' ELSE 'UPSERT_EVIDENCE' END,NULL,NULL,
COALESCE(e.updated_by,e.created_by),COALESCE(e.updated_by_name,e.created_by_name),
CONCAT(e.evidence_type,CASE WHEN e.seal_no IS NULL OR e.seal_no='' THEN '' ELSE CONCAT(':',e.seal_no) END),
COALESCE(e.updated_at,e.created_at)
@@ -104,15 +104,17 @@ public class UnifiedInboundQueryService {
List<LoadingEvidenceEntity> evidence, LocalDateTime deliveryAt,
InboundTransportSnapshotEntity transport) {
String mainStatus = mainStatus(inbound.getProcessStatus());
String sealNo = evidence.stream().filter(item -> "SEAL".equals(item.getEvidenceType()))
List<LoadingEvidenceEntity> activeEvidence = evidence.stream()
.filter(item -> item.getStatus() == null || "ACTIVE".equals(item.getStatus())).toList();
String sealNo = activeEvidence.stream().filter(item -> "SEAL".equals(item.getEvidenceType()))
.map(LoadingEvidenceEntity::getSealNo).filter(StringUtils::hasText).findFirst().orElse(null);
var preloadSummary = preload == null ? null : new UnifiedInboundSummary.PreloadSummary(
preload.getStatus(), preload.getVersion(), itemCount(preload.getItemsJson()), preload.getUpdatedAt());
var loadingSummary = pick == null && evidence.isEmpty() ? null : new UnifiedInboundSummary.LoadingSummary(
var loadingSummary = pick == null && activeEvidence.isEmpty() ? null : new UnifiedInboundSummary.LoadingSummary(
pick == null ? null : pick.getStatus(), pick == null ? null : pick.getVersion(),
pick == null ? null : pick.getVersion(), pick == null ? null : pick.getExpectedCount(),
pick == null ? null : pick.getPickedCount(), evidence.size(), sealNo,
pick == null ? evidence.stream().map(LoadingEvidenceEntity::getUpdatedAt)
pick == null ? null : pick.getPickedCount(), activeEvidence.size(), sealNo,
pick == null ? activeEvidence.stream().map(LoadingEvidenceEntity::getUpdatedAt)
.filter(java.util.Objects::nonNull).max(LocalDateTime::compareTo).orElse(null) : pick.getUpdatedAt());
var deliverySummary = "LOADED".equals(mainStatus) || "DELIVERED".equals(mainStatus)
? new UnifiedInboundSummary.DeliverySummary(
@@ -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,
@@ -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]
@@ -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")
@@ -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);
@@ -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");
}
}
@@ -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);
@@ -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')
}