fix: close inbound hold bypass paths
This commit is contained in:
@@ -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) { }
|
||||
}
|
||||
+35
@@ -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")
|
||||
|
||||
+5
@@ -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, "柜次装柜计划不存在");
|
||||
|
||||
+3
@@ -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任务仍有未处理明细");
|
||||
}
|
||||
|
||||
@@ -44,6 +44,34 @@ paths:
|
||||
schema: {$ref: '#/components/schemas/InboundHoldCommand'}
|
||||
responses:
|
||||
'200': {description: 成功或完全同参幂等重放时 data 为 InboundHoldDetail;版本、状态、异参重放或实物作业冲突时响应体 code=A0409}
|
||||
/api/platform/inbounds/{inboundId}/hold/evidence:
|
||||
post:
|
||||
summary: 上传扣货或解除凭证
|
||||
description: 需要 platform:firstleg:inbound:hold;文件按明确客户和入库单归属保存,单文件最大 10MiB。
|
||||
parameters:
|
||||
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file: {type: string, format: binary}
|
||||
responses:
|
||||
'200': {description: 成功时 data 为 InboundHoldEvidenceFile;入库单已发生实物作业时响应体 code=A0409}
|
||||
/api/platform/inbounds/{inboundId}/hold/evidence/{fileId}/download:
|
||||
get:
|
||||
summary: 下载已关联扣货凭证
|
||||
description: 需要 platform:firstleg:inbound:list;只允许下载已进入当前入库单不可变扣货历史的精确文件。
|
||||
parameters:
|
||||
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
responses:
|
||||
'200': {description: 附件二进制;未关联当前入库单或归属不匹配时响应体 code=A0404}
|
||||
/api/merchant/v1/first-leg/inbounds/{inboundId}/carton-label-pdf:
|
||||
post:
|
||||
summary: 客户端生成本客户入库单箱唛 PDF
|
||||
@@ -1262,7 +1290,15 @@ components:
|
||||
properties:
|
||||
expectedVersion: {type: integer, minimum: 0, description: 当前入库聚合 version;成功后递增 1}
|
||||
reason: {type: string, minLength: 1, maxLength: 1000}
|
||||
evidenceFileId: {type: integer, format: int64, minimum: 1, nullable: true, description: 可选的既有证据文件引用;本接口不上传文件}
|
||||
evidenceFileId: {type: integer, format: int64, minimum: 1, nullable: true, description: 可选的 INBOUND_HOLD_EVIDENCE 文件引用,必须归属当前客户和入库单}
|
||||
InboundHoldEvidenceFile:
|
||||
type: object
|
||||
required: [fileId, fileName, contentType, sizeBytes]
|
||||
properties:
|
||||
fileId: {type: integer, format: int64}
|
||||
fileName: {type: string}
|
||||
contentType: {type: string}
|
||||
sizeBytes: {type: integer, format: int64, minimum: 0}
|
||||
InboundHoldHistoryItem:
|
||||
type: object
|
||||
required: [id, action, reason, operatedAt]
|
||||
|
||||
@@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
@@ -22,6 +23,10 @@ class InboundHoldContractTest {
|
||||
"holdDetail", AuthenticatedUser.class, long.class, long.class);
|
||||
Method hold = commandMethod("hold");
|
||||
Method release = commandMethod("releaseHold");
|
||||
Method upload = PlatformInboundOrderController.class.getDeclaredMethod(
|
||||
"uploadHoldEvidence", AuthenticatedUser.class, long.class, long.class, MultipartFile.class);
|
||||
Method download = PlatformInboundOrderController.class.getDeclaredMethod(
|
||||
"downloadHoldEvidence", AuthenticatedUser.class, long.class, long.class, long.class);
|
||||
|
||||
assertThat(detail.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('platform:firstleg:inbound:list')");
|
||||
@@ -29,6 +34,10 @@ class InboundHoldContractTest {
|
||||
.isEqualTo("hasAuthority('platform:firstleg:inbound:hold')");
|
||||
assertThat(release.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('platform:firstleg:inbound:hold')");
|
||||
assertThat(upload.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('platform:firstleg:inbound:hold')");
|
||||
assertThat(download.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('platform:firstleg:inbound:list')");
|
||||
assertThat(hold.getParameters()[3].getAnnotation(RequestHeader.class).value())
|
||||
.isEqualTo("Idempotency-Key");
|
||||
assertThat(hold.getParameters()[3].getAnnotation(RequestHeader.class).required()).isTrue();
|
||||
@@ -43,7 +52,9 @@ class InboundHoldContractTest {
|
||||
Map<String, Object> paths = stringMap(document.get("paths"));
|
||||
assertThat(paths.keySet()).contains(
|
||||
"/api/platform/inbounds/{inboundId}/hold",
|
||||
"/api/platform/inbounds/{inboundId}/hold/release");
|
||||
"/api/platform/inbounds/{inboundId}/hold/release",
|
||||
"/api/platform/inbounds/{inboundId}/hold/evidence",
|
||||
"/api/platform/inbounds/{inboundId}/hold/evidence/{fileId}/download");
|
||||
|
||||
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
|
||||
Map<String, Object> command = stringMap(schemas.get("InboundHoldCommand"));
|
||||
@@ -62,6 +73,10 @@ class InboundHoldContractTest {
|
||||
assertThat(stringMap(stringMap(detail.get("properties")).get("holdFlag")))
|
||||
.containsEntry("type", "boolean").doesNotContainKey("nullable");
|
||||
|
||||
Map<String, Object> evidence = stringMap(schemas.get("InboundHoldEvidenceFile"));
|
||||
assertThat(stringMap(evidence.get("properties")).keySet())
|
||||
.containsExactlyInAnyOrder("fileId", "fileName", "contentType", "sizeBytes");
|
||||
|
||||
Map<String, Object> history = stringMap(schemas.get("InboundHoldHistoryItem"));
|
||||
assertThat(stringMap(history.get("properties")).keySet()).containsExactlyInAnyOrder(
|
||||
"id", "action", "reason", "evidenceFileId", "operatorName", "operatedAt");
|
||||
|
||||
+22
@@ -7,6 +7,9 @@ import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.Preloa
|
||||
import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.AllocationScope;
|
||||
import com.baiyue.firstleg.loading.bridge.entity.PreloadLoadingPlanLinkEntity;
|
||||
import com.baiyue.firstleg.loading.bridge.repository.LoadingModelBridgeRepository;
|
||||
import com.baiyue.firstleg.service.InboundHoldGuard;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
@@ -24,8 +27,27 @@ import static org.mockito.Mockito.*;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LoadingModelBridgeServiceTest {
|
||||
@Mock LoadingModelBridgeRepository repository;
|
||||
@Mock InboundHoldGuard inboundHoldGuard;
|
||||
@InjectMocks LoadingModelBridgeService service;
|
||||
|
||||
@Test
|
||||
void heldInboundOrPlanBlocksEveryPreloadBindingWriteBoundary() {
|
||||
doThrow(new BizException(ResultCode.CONFLICT, "入库单已扣货"))
|
||||
.when(inboundHoldGuard).requireInboundNotHeld(10L, 20L);
|
||||
doThrow(new BizException(ResultCode.CONFLICT, "入库单已扣货"))
|
||||
.when(inboundHoldGuard).requirePlanNotHeld(40L);
|
||||
|
||||
assertThatThrownBy(() -> service.bind(10L, 20L, 40L, 7L, "admin", "bind-held",
|
||||
new BindRequest(1, 1))).isInstanceOf(BizException.class).hasMessageContaining("扣货");
|
||||
assertThatThrownBy(() -> service.bindAllocatedBeforeLoading(40L, 7L, "admin", "push-held"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("扣货");
|
||||
assertThatThrownBy(() -> service.bindIncrementalAllocation(
|
||||
40L, 10L, 20L, 7L, "admin", "incremental-held"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("扣货");
|
||||
|
||||
verifyNoInteractions(repository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindsManyToManyModelsWithBothOptimisticLocks() {
|
||||
when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "PENDING", 2));
|
||||
|
||||
+22
-1
@@ -194,6 +194,23 @@ class OutboundDispatchServiceTest {
|
||||
verify(repository, never()).reverse(anyLong(), anyInt(), anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heldInboundBlocksContainerReverseBeforeAnyRowChanges() {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
List<OutboundDispatchReadMapper.Binding> held = List.of(
|
||||
binding(11L, "I-11", true), binding(12L, "I-12", false));
|
||||
stubContainer(held);
|
||||
when(repository.lockBindings(22L)).thenReturn(held);
|
||||
when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "WAITING_FOR_LOADING", 1));
|
||||
when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "WAITING_FOR_LOADING", 1));
|
||||
when(repository.claim(eq(9L), eq("REVERSE_DISPATCH"), eq("reverse-held"), anyString())).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.reverse(22L, 9L, "admin", "reverse-held",
|
||||
new ActionRequest(1, "复核"))).isInstanceOf(BizException.class).hasMessageContaining("扣货");
|
||||
|
||||
verify(repository, never()).reverse(anyLong(), anyInt(), anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIdempotencyKeyAndPayloadReplaysContainerResultWithoutMutation() {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
@@ -265,8 +282,12 @@ class OutboundDispatchServiceTest {
|
||||
}
|
||||
|
||||
private static OutboundDispatchReadMapper.Binding binding(long inboundId, String inboundCode) {
|
||||
return binding(inboundId, inboundCode, false);
|
||||
}
|
||||
|
||||
private static OutboundDispatchReadMapper.Binding binding(long inboundId, String inboundCode, boolean held) {
|
||||
return new OutboundDispatchReadMapper.Binding(10L, "O-10", inboundId, inboundCode, 7L, 8L, "客户A",
|
||||
"LOADED", false, 22L, "CAB-22", "C-22", 44L, "COMPLETED");
|
||||
"LOADED", held, 22L, "CAB-22", "C-22", 44L, "COMPLETED");
|
||||
}
|
||||
|
||||
private static OutboundDispatchEntity state(long id, long inboundId, String status, int version) {
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.InboundHoldFileClient;
|
||||
import com.baiyue.firstleg.dto.InboundHoldDtos.Detail;
|
||||
import com.baiyue.firstleg.mapper.InboundHoldRecordMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class InboundHoldEvidenceServiceTest {
|
||||
private final InboundHoldService holds = mock(InboundHoldService.class);
|
||||
private final InboundHoldRecordMapper history = mock(InboundHoldRecordMapper.class);
|
||||
private final InboundHoldFileClient files = mock(InboundHoldFileClient.class);
|
||||
private InboundHoldEvidenceService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new InboundHoldEvidenceService(holds, history, files, "internal-token");
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadsCustomerOwnedEvidenceForActionableInbound() {
|
||||
when(holds.detail(7L, 11L)).thenReturn(detail(false, true, false));
|
||||
var file = new MockMultipartFile("file", "hold.pdf", "application/pdf", new byte[] {1, 2});
|
||||
when(files.upload("internal-token", 9L, 7L, 11L, file)).thenReturn(ApiResponse.success(
|
||||
metadata(88L, 7L, 11L, "hold.pdf", "application/pdf", 2L)));
|
||||
|
||||
var result = service.upload(7L, 11L, 9L, file);
|
||||
|
||||
assertThat(result.fileId()).isEqualTo(88L);
|
||||
assertThat(result.fileName()).isEqualTo("hold.pdf");
|
||||
verify(files).upload("internal-token", 9L, 7L, 11L, file);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUploadWhenPhysicalWorkBlocksBothActions() {
|
||||
when(holds.detail(7L, 11L)).thenReturn(detail(false, false, false));
|
||||
|
||||
assertThatThrownBy(() -> service.upload(7L, 11L, 9L,
|
||||
new MockMultipartFile("file", "x.pdf", "application/pdf", new byte[] {1})))
|
||||
.isInstanceOfSatisfying(BizException.class,
|
||||
error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEvidenceFromAnotherInboundOrCustomer() {
|
||||
when(files.metadata("internal-token", 7L, 11L, 88L)).thenReturn(ApiResponse.success(
|
||||
metadata(88L, 8L, 12L, "other.pdf", "application/pdf", 2L)));
|
||||
|
||||
assertThatThrownBy(() -> service.validateReference(7L, 11L, 88L))
|
||||
.isInstanceOfSatisfying(BizException.class,
|
||||
error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.BAD_REQUEST));
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadsOnlyEvidenceReferencedByImmutableHistory() {
|
||||
when(holds.detail(7L, 11L)).thenReturn(detail(true, false, true));
|
||||
when(history.selectCount(any(Wrapper.class))).thenReturn(1L);
|
||||
when(files.metadata("internal-token", 7L, 11L, 88L)).thenReturn(ApiResponse.success(
|
||||
metadata(88L, 7L, 11L, "hold.pdf", "application/pdf", 2L)));
|
||||
when(files.download("internal-token", 7L, 11L, 88L))
|
||||
.thenReturn(ResponseEntity.ok(new byte[] {1, 2}));
|
||||
|
||||
var result = service.download(7L, 11L, 88L);
|
||||
|
||||
assertThat(result.fileName()).isEqualTo("hold.pdf");
|
||||
assertThat(result.contentType()).isEqualTo(MediaType.APPLICATION_PDF);
|
||||
assertThat(result.content()).containsExactly(1, 2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesUnreferencedEvidenceDownload() {
|
||||
when(holds.detail(7L, 11L)).thenReturn(detail(false, true, false));
|
||||
when(history.selectCount(any(Wrapper.class))).thenReturn(0L);
|
||||
|
||||
assertThatThrownBy(() -> service.download(7L, 11L, 88L))
|
||||
.isInstanceOfSatisfying(BizException.class,
|
||||
error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND));
|
||||
}
|
||||
|
||||
private static Detail detail(boolean held, boolean canHold, boolean canRelease) {
|
||||
return new Detail(11L, 7L, held, held ? "reason" : null, 3, canHold, canRelease,
|
||||
canHold || canRelease ? null : "physical work", List.of());
|
||||
}
|
||||
|
||||
private static InboundHoldFileClient.Metadata metadata(long fileId, long tenantId, long inboundId,
|
||||
String name, String type, long size) {
|
||||
return new InboundHoldFileClient.Metadata(fileId, "CUSTOMER", tenantId, "INBOUND_HOLD_EVIDENCE",
|
||||
Long.toString(inboundId), name, "sha", type, size);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
@@ -67,6 +68,17 @@ class LoadingListServiceTest {
|
||||
@Resource
|
||||
private LoadingListService service;
|
||||
|
||||
@Test
|
||||
void heldPlanBlocksDirectPdaFinalizationBeforeAnyPlanMutation() {
|
||||
doThrow(new BizException(ResultCode.CONFLICT, "入库单已扣货"))
|
||||
.when(inboundHoldGuard).requirePlanNotHeld(10L);
|
||||
|
||||
assertThatThrownBy(() -> service.finishFromPda(10L, 21L, 9L, "operator"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("扣货");
|
||||
|
||||
verifyNoInteractions(repository, pickRepository);
|
||||
}
|
||||
|
||||
@TestConfiguration
|
||||
static class Config {
|
||||
@Bean
|
||||
|
||||
@@ -106,6 +106,30 @@ class LoadingPickServiceTest {
|
||||
@Resource
|
||||
private LoadingPickService service;
|
||||
|
||||
@Test
|
||||
void heldPlanBlocksDirectPdaItemSynchronizationBeforeTaskMutation() {
|
||||
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "入库单已扣货"))
|
||||
.when(inboundHoldGuard).requirePlanNotHeld(10L);
|
||||
|
||||
assertThatThrownBy(() -> service.sync(10L, new LoadingPlanItemEntity()))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("扣货");
|
||||
|
||||
verify(repository, never()).taskByPlan(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heldTaskStopsDurableCompletionWorkerBeforeAnyFulfillmentReadOrWrite() {
|
||||
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "入库单已扣货"))
|
||||
.when(inboundHoldGuard).requireTaskNotHeld(20L);
|
||||
|
||||
assertThatThrownBy(() -> service.processCompletion(
|
||||
completionCommand(999L, "held-worker"), "worker-held"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("扣货");
|
||||
|
||||
verify(repository, never()).task(anyLong());
|
||||
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void activeOperationalContainer() {
|
||||
doAnswer(invocation -> {
|
||||
|
||||
@@ -28,9 +28,11 @@ import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(PdaPickSettlementService.class)
|
||||
@@ -41,8 +43,23 @@ class PdaPickSettlementServiceTest {
|
||||
@MockBean private InboundPalletAllocationRepository allocations;
|
||||
@MockBean private WarehousePalletLocationClient warehouse;
|
||||
@MockBean private OrderFulfillmentSplitClient orderSplits;
|
||||
@MockBean private InboundHoldGuard inboundHoldGuard;
|
||||
@Resource private PdaPickSettlementService service;
|
||||
|
||||
@Test
|
||||
void heldTaskBlocksDirectSettlementAndOwnerSettlementBeforeResourceMutation() {
|
||||
doThrow(new com.baiyue.common.core.exception.BizException(
|
||||
com.baiyue.common.core.api.ResultCode.CONFLICT, "入库单已扣货"))
|
||||
.when(inboundHoldGuard).requireTaskNotHeld(1L);
|
||||
|
||||
assertThatThrownBy(() -> service.settle(1L, 9L, "operator"))
|
||||
.isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("扣货");
|
||||
assertThatThrownBy(() -> service.settleOwner(1L, 31L, 9L, "operator"))
|
||||
.isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("扣货");
|
||||
|
||||
verifyNoInteractions(picks, splits, allocations, warehouse, orderSplits);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsChildForAllUnloadedPackagesAndTransfersRemainingAllocations() {
|
||||
LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 10, 8, "PARTIAL");
|
||||
|
||||
Reference in New Issue
Block a user