fix: close inbound hold bypass paths

This commit is contained in:
2026-09-07 12:10:11 +08:00
parent 6b479d1a68
commit 41582fe17b
28 changed files with 899 additions and 11 deletions
@@ -0,0 +1,35 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
/** Stores and validates hold evidence against the exact customer inbound. */
@FeignClient(name = "file-service", contextId = "inboundHoldFileClient",
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/business-files")
public interface InboundHoldFileClient {
@PostMapping(value = "/{tenantId}/INBOUND_HOLD_EVIDENCE/{inboundId}", consumes = "multipart/form-data")
ApiResponse<Metadata> upload(@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId, @PathVariable long inboundId,
@RequestPart("file") MultipartFile file);
@GetMapping("/{tenantId}/INBOUND_HOLD_EVIDENCE/{inboundId}/{fileId}")
ApiResponse<Metadata> metadata(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId, @PathVariable long inboundId,
@PathVariable long fileId);
@GetMapping("/{tenantId}/INBOUND_HOLD_EVIDENCE/{inboundId}/{fileId}/content")
ResponseEntity<byte[]> download(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId, @PathVariable long inboundId,
@PathVariable long fileId);
record Metadata(Long fileId, String ownerScope, Long tenantId, String businessType, String businessId,
String fileName, String sha256, String contentType, Long sizeBytes) { }
}
@@ -16,6 +16,7 @@ import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.InboundCartonLabelPrintService;
import com.baiyue.firstleg.service.InboundOrderQueryService;
import com.baiyue.firstleg.service.InboundHoldService;
import com.baiyue.firstleg.service.InboundHoldEvidenceService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -29,9 +30,16 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.ResponseEntity;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import java.util.List;
@@ -43,6 +51,7 @@ import java.util.List;
public class PlatformInboundOrderController {
@Resource private InboundOrderQueryService service;
@Resource private InboundHoldService holdService;
@Resource private InboundHoldEvidenceService holdEvidenceService;
@Resource private InboundCartonLabelPrintService cartonLabelPrintService;
@Operation(summary = "分页查询入库单",
@@ -91,6 +100,7 @@ public class PlatformInboundOrderController {
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @org.springframework.web.bind.annotation.RequestBody CommandRequest request) {
requirePlatformTenant(user, tenantId);
holdEvidenceService.validateReference(tenantId, inboundId, request.evidenceFileId());
return ApiResponse.success(holdService.hold(tenantId, inboundId, user.userId(), user.username(),
idempotencyKey, request));
}
@@ -104,10 +114,35 @@ public class PlatformInboundOrderController {
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @org.springframework.web.bind.annotation.RequestBody CommandRequest request) {
requirePlatformTenant(user, tenantId);
holdEvidenceService.validateReference(tenantId, inboundId, request.evidenceFileId());
return ApiResponse.success(holdService.release(tenantId, inboundId, user.userId(), user.username(),
idempotencyKey, request));
}
@Operation(summary = "上传扣货或解除凭证")
@PostMapping(value = "/{inboundId}/hold/evidence", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:firstleg:inbound:hold')")
public ApiResponse<InboundHoldEvidenceService.EvidenceFile> uploadHoldEvidence(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable @Min(1) long inboundId, @RequestPart("file") MultipartFile file) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(holdEvidenceService.upload(tenantId, inboundId, user.userId(), file));
}
@Operation(summary = "下载已关联扣货凭证")
@GetMapping("/{inboundId}/hold/evidence/{fileId}/download")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:list')")
public ResponseEntity<byte[]> downloadHoldEvidence(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable @Min(1) long inboundId, @PathVariable @Min(1) long fileId) {
requirePlatformTenant(user, tenantId);
var value = holdEvidenceService.download(tenantId, inboundId, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment()
.filename(value.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(value.contentType()).contentLength(value.content().length).body(value.content());
}
@Operation(summary = "生成入仓箱唛 PDF",
description = "首次调用按订单货物明细冻结整单箱号;重复打印及指定箱下标补打复用原箱号映射。")
@PostMapping("/{inboundId}/carton-label-pdf")
@@ -8,6 +8,7 @@ import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.loading.bridge.entity.PreloadLoadingPlanLinkEntity;
import com.baiyue.firstleg.loading.bridge.repository.LoadingModelBridgeRepository;
import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.AllocationScope;
import com.baiyue.firstleg.service.InboundHoldGuard;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
@@ -24,12 +25,14 @@ import java.util.Set;
@Service
public class LoadingModelBridgeService {
@Resource private LoadingModelBridgeRepository repository;
@Resource private InboundHoldGuard inboundHoldGuard;
@Transactional
@BusinessAudit(event = "PRELOAD_LOADING_PLAN_BIND", resourceType = "LOADING_PLAN",
resourceId = "#loadingPlanId", description = "关联入库预装与柜次装柜计划")
public Link bind(long tenantId, long inboundId, long loadingPlanId, long operatorId,
String operatorName, String idempotencyKey, BindRequest request) {
inboundHoldGuard.requireInboundNotHeld(tenantId, inboundId);
String key = validateKey(idempotencyKey);
String requestHash = hash(tenantId + "|" + inboundId + "|" + loadingPlanId + "|"
+ request.expectedPreloadVersion() + "|" + request.expectedLoadingPlanVersion());
@@ -77,6 +80,7 @@ public class LoadingModelBridgeService {
@Transactional
public List<Link> bindAllocatedBeforeLoading(long loadingPlanId, long operatorId,
String operatorName, String orchestrationKey) {
inboundHoldGuard.requirePlanNotHeld(loadingPlanId);
var loadingPlan = repository.loadingPlan(loadingPlanId);
if (loadingPlan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在");
List<AllocationScope> scopes = repository.allocatedScopes(loadingPlanId);
@@ -117,6 +121,7 @@ public class LoadingModelBridgeService {
@Transactional
public void bindIncrementalAllocation(long loadingPlanId, long tenantId, long inboundId,
long operatorId, String operatorName, String operationId) {
inboundHoldGuard.requireInboundNotHeld(tenantId, inboundId);
if (repository.byPair(tenantId, inboundId, loadingPlanId) != null) return;
var plan = repository.loadingPlan(loadingPlanId);
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在");
@@ -118,6 +118,9 @@ public class OutboundDispatchService {
if (replay != null) return replay;
List<OutboundDispatchReadMapper.Binding> bindings = lockedBindings(containerBookingId);
if (bindings.stream().anyMatch(binding -> Boolean.TRUE.equals(binding.holdFlag()))) {
throw conflict("柜内存在已扣货入库单,禁止撤销国内出仓");
}
List<OutboundDispatchEntity> states = bindings.stream().map(this::existingStateLocked).toList();
StateView visible = visibleState(states);
if (!OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(visible.status())) {
@@ -0,0 +1,109 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.InboundHoldFileClient;
import com.baiyue.firstleg.entity.InboundHoldRecordEntity;
import com.baiyue.firstleg.mapper.InboundHoldRecordMapper;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.util.Objects;
/** File ownership boundary for optional hold/release evidence. */
@Service
public class InboundHoldEvidenceService {
private static final String BUSINESS_TYPE = "INBOUND_HOLD_EVIDENCE";
private final InboundHoldService holds;
private final InboundHoldRecordMapper history;
private final InboundHoldFileClient files;
private final String internalToken;
public InboundHoldEvidenceService(InboundHoldService holds, InboundHoldRecordMapper history,
InboundHoldFileClient files,
@Value("${baiyue.security.internal-service-token:}") String internalToken) {
this.holds = holds;
this.history = history;
this.files = files;
this.internalToken = internalToken;
}
public EvidenceFile upload(long tenantId, long inboundId, long operatorId, MultipartFile file) {
var detail = holds.detail(tenantId, inboundId);
if (!detail.canHold() && !detail.canRelease()) {
throw new BizException(ResultCode.CONFLICT, "当前入库单不可执行扣货或解除,不能上传扣货凭证");
}
try {
var response = files.upload(internalToken, operatorId, tenantId, inboundId, file);
return evidence(requireMetadata(response, tenantId, inboundId, null));
} catch (FeignException exception) {
throw unavailable();
}
}
public void validateReference(long tenantId, long inboundId, Long fileId) {
if (fileId == null) return;
try {
requireMetadata(files.metadata(internalToken, tenantId, inboundId, fileId), tenantId, inboundId, fileId);
} catch (FeignException exception) {
throw unavailable();
}
}
public Download download(long tenantId, long inboundId, long fileId) {
holds.detail(tenantId, inboundId);
Long references = history.selectCount(new LambdaQueryWrapper<InboundHoldRecordEntity>()
.eq(InboundHoldRecordEntity::getTenantId, tenantId)
.eq(InboundHoldRecordEntity::getInboundId, inboundId)
.eq(InboundHoldRecordEntity::getEvidenceFileId, fileId));
if (references == null || references == 0) {
throw new BizException(ResultCode.NOT_FOUND, "扣货凭证不存在或未关联当前入库单");
}
try {
var metadata = requireMetadata(files.metadata(internalToken, tenantId, inboundId, fileId),
tenantId, inboundId, fileId);
var response = files.download(internalToken, tenantId, inboundId, fileId);
byte[] content = response == null ? null : response.getBody();
if (content == null) throw new BizException(ResultCode.NOT_FOUND, "扣货凭证不存在");
MediaType contentType;
try {
contentType = MediaType.parseMediaType(metadata.contentType());
} catch (RuntimeException invalid) {
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
return new Download(metadata.fileName(), contentType, content);
} catch (FeignException exception) {
throw unavailable();
}
}
private static InboundHoldFileClient.Metadata requireMetadata(
com.baiyue.common.core.api.ApiResponse<InboundHoldFileClient.Metadata> response,
long tenantId, long inboundId, Long expectedFileId) {
var metadata = response == null ? null : response.data();
if (response == null || !response.successful() || metadata == null
|| (expectedFileId != null && !Objects.equals(expectedFileId, metadata.fileId()))
|| !Objects.equals(tenantId, metadata.tenantId())
|| !BUSINESS_TYPE.equals(metadata.businessType())
|| !Long.toString(inboundId).equals(metadata.businessId())) {
throw new BizException(ResultCode.BAD_REQUEST, "扣货凭证不存在或归属不匹配");
}
return metadata;
}
private static EvidenceFile evidence(InboundHoldFileClient.Metadata value) {
return new EvidenceFile(value.fileId(), value.fileName(), value.contentType(), value.sizeBytes());
}
private static BizException unavailable() {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用,无法处理扣货凭证");
}
public record EvidenceFile(Long fileId, String fileName, String contentType, Long sizeBytes) { }
public record Download(String fileName, MediaType contentType, byte[] content) { }
}
@@ -536,6 +536,7 @@ public class LoadingListService {
@BusinessAudit(event = "PDA_COMPLETE_LOADING", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "PDA确认装柜完成后生成装柜清单")
public void finishFromPda(long planId, long pickTaskId, long operatorId, String operatorName) {
inboundHoldGuard.requirePlanNotHeld(planId);
String operationId = "PDA-COMPLETE-" + planId + '-' + pickTaskId;
if (repository.history(operationId) != null) {
return;
@@ -179,6 +179,7 @@ public class LoadingPickService {
@Transactional
public void sync(long planId, LoadingPlanItemEntity planItem) {
inboundHoldGuard.requirePlanNotHeld(planId);
LoadingPickTaskEntity task = repository.taskByPlan(planId);
if (task == null) return;
task = repository.taskForUpdate(task.getId());
@@ -551,6 +552,7 @@ public class LoadingPickService {
/** Called only by the completion worker, after its durable lease has committed. */
public void processCompletion(LoadingCompletionCommandEntity command, String worker) {
long taskId = command.getPickTaskId();
inboundHoldGuard.requireTaskNotHeld(taskId);
LoadingPickTaskEntity task = requiredTask(taskId);
List<LoadingPickItemMapper.InboundOwner> owners = repository.inboundOwners(taskId).stream()
.filter(owner -> owner.tenantId() != null && owner.inboundId() != null)
@@ -48,12 +48,15 @@ public class PdaPickSettlementService {
private WarehousePalletLocationClient warehouse;
@Resource
private OrderFulfillmentSplitClient orderSplits;
@Resource
private InboundHoldGuard inboundHoldGuard;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
/** 每个柜次生成一张 LOADED 分片;存在余量时另生成可继续分柜的 REMAINDER 分片。 */
@Transactional
public void settle(long taskId, long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
if (!picks.allTaskItemsResolved(taskId)) {
return;
}
@@ -88,6 +91,7 @@ public class PdaPickSettlementService {
/** Worker boundary: one inbound owner per transaction, so a cabinet never holds 15 owner locks at once. */
@Transactional
public void settleOwner(long taskId, long inboundId, long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
if (!picks.allTaskItemsResolved(taskId)) {
throw conflict("PDA任务仍有未处理明细");
}