This commit is contained in:
2026-08-15 12:33:23 +08:00
parent 3a9558bd1c
commit 0ddffeddc8
14 changed files with 290 additions and 29 deletions
@@ -90,6 +90,12 @@ public class LoadingModelBridgeService {
throw conflict("PDA开始前的预装关联不完整,禁止在LOADING后补关联");
}
for (AllocationScope scope : scopes) {
// Internal push replay is pair-idempotent: an existing association has
// already advanced both versions, so calling public bind again would
// derive a different request hash for the same AUTO key.
if (repository.byPair(scope.tenantId(), scope.inboundId(), loadingPlanId) != null) {
continue;
}
var preload = repository.preload(scope.tenantId(), scope.inboundId());
var currentPlan = repository.loadingPlan(loadingPlanId);
if (preload == null || currentPlan == null) {
@@ -18,6 +18,7 @@ import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/** 平台端装柜证据和国内装柜完成操作。 */
@@ -58,6 +59,16 @@ public class PlatformLoadingDeliveryController {
return ApiResponse.success(service.evidence(tenantId, inboundId));
}
@Operation(summary = "PDA查看装柜证据照片")
@GetMapping("/{inboundId}/loading/evidence/{evidenceId}/content/pda")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ResponseEntity<byte[]> pdaEvidenceContent(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
@PathVariable long evidenceId) {
FirstlegScopeGuard.platform(user, tenantId);
return service.evidenceContent(tenantId, inboundId, evidenceId);
}
@Operation(summary = "PDA作废错误装柜证据")
@PostMapping("/{inboundId}/loading/evidence/{evidenceId}/void/pda")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
@@ -7,7 +7,7 @@ public final class LoadingResponses {
private LoadingResponses() {}
public record EvidenceResponse(Long id, Long inboundId, Long tenantId, String evidenceType,
Long fileId, String fileName, String sealNo, String remark,
Long fileId, String fileName, String previewUrl, String sealNo, String remark,
Integer version, String status, String voidReason,
Long voidedBy, String voidedByName, LocalDateTime voidedAt,
LocalDateTime createdAt, LocalDateTime updatedAt) {}
@@ -24,6 +24,7 @@ import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -149,6 +150,22 @@ public class LoadingDeliveryService {
return evidenceResponse(tenantId, inboundId);
}
public ResponseEntity<byte[]> evidenceContent(long tenantId, long inboundId, long evidenceId) {
requireLoadingStage(tenantId, inboundId);
LoadingEvidenceEntity evidence = repository.listEvidence(tenantId, inboundId).stream()
.filter(item -> Long.valueOf(evidenceId).equals(item.getId()) && isActive(item))
.findFirst().orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "装柜证据不存在"));
try {
ResponseEntity<byte[]> response = files.download(internalToken, tenantId, inboundId, evidence.getFileId());
if (response == null || response.getBody() == null) {
throw new BizException(ResultCode.NOT_FOUND, "装柜证据文件不存在");
}
return response;
} catch (FeignException ex) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用");
}
}
@Transactional
public LoadingActionResponse voidEvidence(long tenantId, long inboundId, long evidenceId, long operatorId,
String operatorName, String key, VoidEvidenceRequest request) {
@@ -333,11 +350,16 @@ 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(), activeStatus(e), e.getVoidReason(),
e.getFileName(), evidencePreviewUrl(tenantId, inboundId, e.getId()), 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 String evidencePreviewUrl(long tenantId, long inboundId, long evidenceId) {
return "/api/platform/v1/inbounds/" + inboundId + "/loading/evidence/" + evidenceId
+ "/content/pda?tenantId=" + tenantId;
}
private static boolean isActive(LoadingEvidenceEntity evidence) {
return evidence != null && (evidence.getStatus() == null || "ACTIVE".equals(evidence.getStatus()));
}
@@ -233,20 +233,18 @@ public class SmartLoadingService {
public Plan pushPreload(long planId, String idempotencyKey, PushRequest request, long operatorId, String operatorName) {
String key = trim(idempotencyKey);
if (!StringUtils.hasText(key) || key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "必须提供有效 Idempotency-Key");
String requestHash = sha256("PUSH_PRELOAD|" + planId + '|' + request.expectedVersion());
LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
if (!StringUtils.hasText(booking.getContainerNo())) {
throw validation("录入真实柜号后才能推送预装清单并开启PDA作业");
}
if (key.equals(plan.getPushIdempotencyKey())) {
if ("EDITING".equals(plan.getPlanStatus())) {
ensurePendingPreloads(repository.items(planId), operatorId, operatorName);
}
loadingModelBridge.bindAllocatedBeforeLoading(plan.getId(), operatorId, operatorName, key);
loadingPickService.beginIfReady(plan.getContainerBookingId(), operatorId, operatorName);
requirePushReplay(planId, key, requestHash);
return view(plan, booking);
}
if (StringUtils.hasText(plan.getPushIdempotencyKey())) throw conflict("预装清单已推送,不能使用新的幂等键重复推送");
if (repository.operation(key) != null) throw conflict("Idempotency-Key已用于不同推送请求");
if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配");
List<LoadingPlanItemEntity> items = repository.items(planId);
ensurePendingPreloads(items, operatorId, operatorName);
@@ -254,13 +252,26 @@ public class SmartLoadingService {
try {
if (repository.push(planId, key, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("装柜计划已被并发修改");
} catch (DuplicateKeyException e) { throw conflict("幂等键已用于其他推送"); }
history(planId, null, "PUSH_PRELOAD", "EDITING", "EDITING", key, operatorId, operatorName);
history(planId, null, "PUSH_PRELOAD", "EDITING", "EDITING", key,
json(values("version", request.expectedVersion())),
json(values("requestHash", requestHash)), operatorId, operatorName);
LoadingPlanEntity pushed = repository.plan(planId);
loadingModelBridge.bindAllocatedBeforeLoading(planId, operatorId, operatorName, key);
loadingPickService.beginIfReady(pushed.getContainerBookingId(), operatorId, operatorName);
return view(pushed, requireBooking(pushed.getContainerBookingId()));
}
private void requirePushReplay(long planId, String key, String requestHash) {
LoadingPlanHistoryEntity history = repository.operation(key);
if (history == null
|| !Objects.equals(history.getLoadingPlanId(), planId)
|| !Objects.equals(history.getActionType(), "PUSH_PRELOAD")
|| history.getAfterSnapshot() == null
|| !sameRequestHash(history.getAfterSnapshot(), requestHash)) {
throw conflict("Idempotency-Key已用于不同推送请求");
}
}
private void ensurePendingPreloads(List<LoadingPlanItemEntity> items, long operatorId, String operatorName) {
if (items.isEmpty()) throw validation("装柜明细不能为空");
if (items.stream().anyMatch(item -> item.getAccountId() == null || item.getInboundId() == null)) {