From 41582fe17b13410627b80b2814d86df6205e9014 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 7 Sep 2026 12:10:11 +0800 Subject: [PATCH] fix: close inbound hold bypass paths --- file/AGENTS.md | 1 + .../file/service/BusinessFileCategory.java | 1 + .../InternalBusinessFileServiceTest.java | 19 +++ firstleg/AGENTS.md | 1 + .../client/InboundHoldFileClient.java | 35 ++++ .../PlatformInboundOrderController.java | 35 ++++ .../service/LoadingModelBridgeService.java | 5 + .../service/OutboundDispatchService.java | 3 + .../service/InboundHoldEvidenceService.java | 109 +++++++++++++ .../firstleg/service/LoadingListService.java | 1 + .../firstleg/service/LoadingPickService.java | 2 + .../service/PdaPickSettlementService.java | 4 + .../main/resources/openapi/firstleg-v1.yaml | 38 ++++- .../controller/InboundHoldContractTest.java | 17 +- .../LoadingModelBridgeServiceTest.java | 22 +++ .../dispatch/OutboundDispatchServiceTest.java | 23 ++- .../InboundHoldEvidenceServiceTest.java | 106 +++++++++++++ .../service/LoadingListServiceTest.java | 12 ++ .../service/LoadingPickServiceTest.java | 24 +++ .../service/PdaPickSettlementServiceTest.java | 17 ++ .../InboundHoldPermissionSqlContractTest.java | 23 +++ ...InboundHoldPermissionSqlExecutionTest.java | 149 ++++++++++++++++++ .../service/UnifiedOrderQueryService.java | 30 +++- .../service/UnifiedOrderQueryServiceTest.java | 127 +++++++++++++++ .../inbound-hold-contract.test.mjs | 11 ++ .../admin-test-env-modules/inbound-hold.mjs | 89 ++++++++++- scripts/synchronous-dependency-baseline.mjs | 2 + .../synchronous-dependency-contract.test.mjs | 4 +- 28 files changed, 899 insertions(+), 11 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/client/InboundHoldFileClient.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/InboundHoldEvidenceService.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/InboundHoldEvidenceServiceTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/repository/InboundHoldPermissionSqlExecutionTest.java diff --git a/file/AGENTS.md b/file/AGENTS.md index 9ef068465..8b9d22a1a 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -16,6 +16,7 @@ | 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` | | 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`service/LoadingEvidenceService.java`、`src/main/resources/openapi/loading-evidence-v1.yaml`;五类装柜证据固定为平台所有、`tenantId=0`、`businessId=containerBookingId`,不得绑定客户或入仓单。其他业务文件进入 `InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` | | 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 | +| 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 | | 报关源文件、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 | | 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=订舱customerId`、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 | | 拖车预约附件 | 通用内部业务文件入口;固定 `tenantId=0`、`businessType=TRUCK_APPOINTMENT_ATTACHMENT`、`businessId=appointmentId`,只允许派车单、司机/车辆资料和其他平台附件;提单文件归订舱 `OCEAN_BOOKING_DOCUMENT`,不得复制为拖车附件 | diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index 69685d3e3..cd5b1f630 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -12,6 +12,7 @@ enum BusinessFileCategory { CUSTOMS_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CUSTOMS_RECEIPT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FINANCE_VOUCHER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + INBOUND_HOLD_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_PROCUREMENT_RECEIPT(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_PROCUREMENT_VOUCHER(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CONTAINER_CLEARANCE_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index 432e381de..4aeac5255 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -296,4 +296,23 @@ class InternalBusinessFileServiceTest { } } + @Test void inboundHoldEvidenceIsCustomerOwnedAndScopedToInboundId() { + MockMultipartFile file = new MockMultipartFile("file", "hold.pdf", "application/pdf", new byte[]{1, 2}); + when(storage.store(10001L, "61", file, BusinessFileCategory.INBOUND_HOLD_EVIDENCE)) + .thenReturn(new BusinessFileStorage.Stored("business-files/10001/61/hold.pdf", + "application/pdf", 2, "a".repeat(64), "hold.pdf")); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { + ((FileObjectEntity) invocation.getArgument(0)).setId(301L); + return 1; + }); + + var result = service.upload(10001L, "INBOUND_HOLD_EVIDENCE", "61", 7L, file); + + assertThat(result.fileId()).isEqualTo(301L); + assertThat(result.ownerScope()).isEqualTo("CUSTOMER"); + assertThat(result.businessId()).isEqualTo("61"); + assertThatThrownBy(() -> service.upload(0L, "INBOUND_HOLD_EVIDENCE", "61", 7L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("明确客户"); + } + } diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 29dc65e1b..ebb81a42e 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -14,6 +14,7 @@ | 内部模块化基线与依赖分析 | `docs/firstleg-internal-modularization-baseline.md`、`scripts/analyze-firstleg-dependencies.mjs`、`scripts/analyze-firstleg-dependencies.test.mjs`、`src/test/resources/architecture/firstleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | | 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` | | 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` | +| 入库单扣货、履约门禁与凭证 | `service/InboundHoldService.java`、`InboundHoldGuard.java`、`InboundHoldEvidenceService.java`、`client/InboundHoldFileClient.java`;凭证固定按客户、入库单和 `INBOUND_HOLD_EVIDENCE` 精确归属,只有进入不可变扣货历史的文件可下载 | | Order 申报同步与渠道变更履约门禁 | `controller/InternalInboundOrderController.java` 的 `/order-channel-changes/validate`、`/order-declaration-updated`,`service/OrderDeclarationSyncService.java`、`mapper/OrderDeclarationSyncMapper.java`、`dto/OrderDeclarationSync.java`;契约 `src/main/resources/openapi/firstleg-v1.yaml`,只接受内部服务令牌 | | 平台测量/OCR 与箱规计费快照 | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`、`service/CustomerDimensionWeightingService.java`;手工单箱测量保存实际尺寸、可选外箱修正、`chargeRuleId` 与 `coefficientMode`,计算结果以服务端返回值为准 | | 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。`fl_inbound_carton_label.sku_code` 是箱唛的 SKU 冻结快照,生成时经 `client/OrderCargoClient.java` 获取;既有记录按 `inbound_detail_id` 回填。`ONE_CLICK_DROPSHIP` 缺 SKU 时禁止生成箱唛,打印面显示 SKU;`box_code` 与二维码内容仍只携带稳定箱码。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/InboundHoldFileClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/InboundHoldFileClient.java new file mode 100644 index 000000000..a9eaff744 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/InboundHoldFileClient.java @@ -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 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(@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 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) { } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java index edf1ef508..c53158e70 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java @@ -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 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 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") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java index 548df7958..6c9a8ce28 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java @@ -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 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 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, "柜次装柜计划不存在"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java index 87b4896fc..6f950da75 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java @@ -118,6 +118,9 @@ public class OutboundDispatchService { if (replay != null) return replay; List bindings = lockedBindings(containerBookingId); + if (bindings.stream().anyMatch(binding -> Boolean.TRUE.equals(binding.holdFlag()))) { + throw conflict("柜内存在已扣货入库单,禁止撤销国内出仓"); + } List states = bindings.stream().map(this::existingStateLocked).toList(); StateView visible = visibleState(states); if (!OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(visible.status())) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundHoldEvidenceService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundHoldEvidenceService.java new file mode 100644 index 000000000..f9780f5dc --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundHoldEvidenceService.java @@ -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() + .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 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) { } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 6b0335f58..8875925df 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index f7f5b6643..8484f92cd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -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 owners = repository.inboundOwners(taskId).stream() .filter(owner -> owner.tenantId() != null && owner.inboundId() != null) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java index e5076b08e..4e410fb49 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java @@ -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任务仍有未处理明细"); } diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 64a7432ae..1252ac4b4 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundHoldContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundHoldContractTest.java index 0b62a2d3f..db294a48b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundHoldContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundHoldContractTest.java @@ -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 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 schemas = stringMap(stringMap(document.get("components")).get("schemas")); Map 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 evidence = stringMap(schemas.get("InboundHoldEvidenceFile")); + assertThat(stringMap(evidence.get("properties")).keySet()) + .containsExactlyInAnyOrder("fileId", "fileName", "contentType", "sizeBytes"); + Map history = stringMap(schemas.get("InboundHoldHistoryItem")); assertThat(stringMap(history.get("properties")).keySet()).containsExactlyInAnyOrder( "id", "action", "reason", "evidenceFileId", "operatorName", "operatedAt"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java index f4282bc20..6455be97f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java @@ -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)); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java index 4c5f3b462..bd6b8e660 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java @@ -194,6 +194,23 @@ class OutboundDispatchServiceTest { verify(repository, never()).reverse(anyLong(), anyInt(), anyLong(), anyString(), any()); } + @Test + void heldInboundBlocksContainerReverseBeforeAnyRowChanges() { + OutboundDispatchService service = serviceWithoutCarrier(); + List 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) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundHoldEvidenceServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundHoldEvidenceServiceTest.java new file mode 100644 index 000000000..b08cbaf38 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundHoldEvidenceServiceTest.java @@ -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); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 4732996f0..28c6ecf41 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -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 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 05c064f19..dc043ee27 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -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 -> { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java index fbdedcc0a..9d6bbb093 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java @@ -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"); diff --git a/iam/src/test/java/com/baiyue/iam/repository/InboundHoldPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/InboundHoldPermissionSqlContractTest.java index e7b72194b..4df605477 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/InboundHoldPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/InboundHoldPermissionSqlContractTest.java @@ -4,6 +4,8 @@ import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; @@ -60,8 +62,29 @@ class InboundHoldPermissionSqlContractTest { ") <> 1 THEN"); } + @Test + void unifiedIncrementContainsTheSameMigrationBody() throws Exception { + String marker = "DROP PROCEDURE IF EXISTS migrate_inbound_hold_permission;"; + + assertThat(migrationBody(unifiedSql(), marker)).isEqualTo(migrationBody(sql(), marker)); + } + private static String sql() throws Exception { return new ClassPathResource("db/inbound-hold-permission.sql") .getContentAsString(StandardCharsets.UTF_8); } + + private static String unifiedSql() throws Exception { + Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + Path path = Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + return Files.readString(path.normalize(), StandardCharsets.UTF_8); + } + + private static String migrationBody(String sql, String marker) { + int start = sql.indexOf(marker); + int end = sql.indexOf(marker, start + marker.length()); + assertThat(start).isGreaterThanOrEqualTo(0); + assertThat(end).isGreaterThan(start); + return sql.substring(start, end + marker.length()).replaceAll("\\s+", " ").trim(); + } } diff --git a/iam/src/test/java/com/baiyue/iam/repository/InboundHoldPermissionSqlExecutionTest.java b/iam/src/test/java/com/baiyue/iam/repository/InboundHoldPermissionSqlExecutionTest.java new file mode 100644 index 000000000..fff80a052 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/InboundHoldPermissionSqlExecutionTest.java @@ -0,0 +1,149 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundHoldPermissionSqlExecutionTest { + @Test + void replayKeepsOneResourceAndOnePlatformAdminGrant() throws Exception { + try (Connection connection = database()) { + insertRole(connection, 11L, "PLATFORM_ADMIN", "ACTIVE", 0); + + executeMigrationDml(connection); + executeMigrationDml(connection); + + assertThat(count(connection, "SELECT COUNT(*) FROM iam_permission WHERE permission_code=?", + "platform:firstleg:inbound:hold")).isOne(); + assertThat(grantCount(connection, 11L)).isOne(); + assertThat(count(connection, "SELECT COUNT(*) FROM iam_role_permission", null)).isOne(); + } + } + + @Test + void ordinaryAndInactiveRolesDoNotReceiveTheWritePermission() throws Exception { + try (Connection connection = database()) { + insertRole(connection, 11L, "PLATFORM_ADMIN", "ACTIVE", 0); + insertRole(connection, 12L, "ORDER_OPERATOR", "ACTIVE", 0); + insertRole(connection, 13L, "WAREHOUSE_OPERATOR", "ACTIVE", 0); + insertRole(connection, 14L, "WAREHOUSE_TALLY", "ACTIVE", 0); + insertRole(connection, 15L, "PLATFORM_CUSTOM_ADMIN", "ACTIVE", 0); + insertRole(connection, 16L, "PLATFORM_ADMIN", "DISABLED", 0); + insertRole(connection, 17L, "PLATFORM_ADMIN", "ACTIVE", 1); + + executeMigrationDml(connection); + + assertThat(grantCount(connection, 11L)).isOne(); + for (long roleId = 12L; roleId <= 17L; roleId++) { + assertThat(grantCount(connection, roleId)).as("role %s", roleId).isZero(); + } + } + } + + private static Connection database() throws Exception { + Connection connection = DriverManager.getConnection( + "jdbc:h2:mem:inbound_hold_" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE"); + try (Statement statement = connection.createStatement()) { + statement.execute(""" + CREATE TABLE iam_role ( + id BIGINT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + app_code VARCHAR(32) NOT NULL, + role_code VARCHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL, + deleted INT NOT NULL + ) + """); + statement.execute(""" + CREATE TABLE iam_permission ( + id BIGINT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + app_code VARCHAR(32) NOT NULL, + permission_type VARCHAR(16) NOT NULL, + parent_id BIGINT, + permission_code VARCHAR(160) NOT NULL UNIQUE, + permission_name VARCHAR(128) NOT NULL, + route_path VARCHAR(255), + http_method VARCHAR(16), + sort_no INT NOT NULL, + status VARCHAR(16) NOT NULL, + built_in INT NOT NULL, + deleted INT NOT NULL + ) + """); + statement.execute(""" + CREATE TABLE iam_role_permission ( + tenant_id BIGINT NOT NULL, + role_id BIGINT NOT NULL, + permission_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY (tenant_id, role_id, permission_id) + ) + """); + } + return connection; + } + + private static void insertRole(Connection connection, long id, String roleCode, String status, int deleted) + throws Exception { + try (PreparedStatement insert = connection.prepareStatement( + "INSERT INTO iam_role VALUES (?,0,'PLATFORM',?,?,?)")) { + insert.setLong(1, id); + insert.setString(2, roleCode); + insert.setString(3, status); + insert.setInt(4, deleted); + insert.executeUpdate(); + } + } + + private static void executeMigrationDml(Connection connection) throws Exception { + String sql = sql(); + try (Statement statement = connection.createStatement()) { + statement.executeUpdate(statement(sql, "INSERT INTO iam_permission", "INSERT IGNORE INTO iam_role_permission")); + statement.executeUpdate(statement(sql, "INSERT IGNORE INTO iam_role_permission", " IF (SELECT COUNT(*)")); + } + } + + private static String statement(String sql, String startMarker, String endMarker) { + int start = sql.indexOf(startMarker); + int end = sql.indexOf(endMarker, start + startMarker.length()); + assertThat(start).isGreaterThanOrEqualTo(0); + assertThat(end).isGreaterThan(start); + return sql.substring(start, end).trim().replaceFirst(";$", ""); + } + + private static int grantCount(Connection connection, long roleId) throws Exception { + try (PreparedStatement query = connection.prepareStatement( + "SELECT COUNT(*) FROM iam_role_permission WHERE tenant_id=0 AND role_id=?")) { + query.setLong(1, roleId); + try (ResultSet result = query.executeQuery()) { + result.next(); + return result.getInt(1); + } + } + } + + private static int count(Connection connection, String sql, String permissionCode) throws Exception { + try (PreparedStatement query = connection.prepareStatement(sql)) { + if (permissionCode != null) query.setString(1, permissionCode); + try (ResultSet result = query.executeQuery()) { + result.next(); + return result.getInt(1); + } + } + } + + private static String sql() throws Exception { + return new ClassPathResource("db/inbound-hold-permission.sql") + .getContentAsString(StandardCharsets.UTF_8); + } +} diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index 726231c5b..39caa2e28 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -162,7 +162,9 @@ public class UnifiedOrderQueryService { query.page(), query.size()); PageResult source = require( call(() -> firstleg.page(internalToken, request)), "统一订单履约查询响应无效"); - List orderIds = source.records().stream().map(item -> item.inbound().orderId()).toList(); + List summaries = validateSummaries( + source.records(), "统一订单履约查询响应无效"); + List orderIds = summaries.stream().map(item -> item.inbound().orderId()).toList(); Map orderById = orders.findByIds(tenantId, orderIds).stream() .collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left)); Map latestNotes = latestNotes(tenantId, orderIds); @@ -170,7 +172,7 @@ public class UnifiedOrderQueryService { Map childCounts = childCounts(List.copyOf(orderById.values())); Map coLoadByOrder = coLoadGroups( orderIds, includeCoLoadGroups); - List records = source.records().stream() + List records = summaries.stream() .map(summary -> row(summary, orderById.get(summary.inbound().orderId()), latestNotes.get(summary.inbound().orderId()), childCounts, cargoSummaries.get(summary.inbound().orderId()), @@ -185,13 +187,33 @@ public class UnifiedOrderQueryService { call(() -> firstleg.summaries(internalToken, new FirstlegUnifiedOrderClient.BatchRequest(tenantId, orderIds))), "统一订单履约摘要响应无效"); - return data.stream().collect(Collectors.toMap(item -> item.inbound().orderId(), + return validateSummaries(data, "统一订单履约摘要响应无效").stream() + .collect(Collectors.toMap(item -> item.inbound().orderId(), Function.identity(), (left, right) -> left)); } private FirstlegUnifiedOrderClient.Detail firstlegDetail(long tenantId, long orderId) { - return requireNullable(call(() -> firstleg.detail(internalToken, + FirstlegUnifiedOrderClient.Detail detail = requireNullable(call(() -> firstleg.detail(internalToken, new FirstlegUnifiedOrderClient.DetailRequest(tenantId, orderId))), "统一订单履约详情响应无效"); + if (detail != null) validateSummary(detail.summary(), "统一订单履约详情响应无效"); + return detail; + } + + private List validateSummaries( + List summaries, String message) { + return summaries.stream().map(summary -> validateSummary(summary, message)).toList(); + } + + private FirstlegUnifiedOrderClient.Summary validateSummary( + FirstlegUnifiedOrderClient.Summary summary, String message) { + FirstlegUnifiedOrderClient.Inbound inbound = summary == null ? null : summary.inbound(); + if (inbound == null || inbound.id() == null || inbound.tenantId() == null + || inbound.orderId() == null || inbound.holdFlag() == null + || (Boolean.TRUE.equals(inbound.holdFlag()) && !StringUtils.hasText(inbound.currentHoldReason())) + || (Boolean.FALSE.equals(inbound.holdFlag()) && inbound.currentHoldReason() != null)) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, message); + } + return summary; } private Map latestNotes(Long tenantId, List orderIds) { diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index 422706d48..a7b18a898 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -2,6 +2,7 @@ package com.baiyue.order.service; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.order.client.FirstlegUnifiedOrderClient; import com.baiyue.order.client.FirstlegColoadGroupClient; @@ -605,6 +606,125 @@ class UnifiedOrderQueryServiceTest { assertThat(captor.getValue().orderId()).isEqualTo(77L); } + @Test + void releasedInboundProjectsFalseFlagAndNullReasonWithFirstlegPagination() { + UnifiedOrderQuery query = new UnifiedOrderQuery( + null, null, "INBOUNDED", null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, + null, null, 4, 10); + var summary = summary("INBOUNDED", null); + when(summary.inbound().holdFlag()).thenReturn(false); + when(summary.inbound().currentHoldReason()).thenReturn(null); + when(firstleg.page(eq("internal-token"), any())).thenReturn( + ApiResponse.success(new PageResult<>(List.of(summary), 4, 10, 31, 4))); + + var result = service.page(10001L, query); + + assertThat(result.page()).isEqualTo(4); + assertThat(result.size()).isEqualTo(10); + assertThat(result.total()).isEqualTo(31); + assertThat(result.pages()).isEqualTo(4); + assertThat(result.records()).singleElement().satisfies(row -> { + assertThat(row.holdFlag()).isFalse(); + assertThat(row.currentHoldReason()).isNull(); + }); + ArgumentCaptor captor = + ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class); + verify(firstleg).page(eq("internal-token"), captor.capture()); + assertThat(captor.getValue().mainStatus()).isEqualTo("INBOUNDED"); + assertThat(captor.getValue().page()).isEqualTo(4); + assertThat(captor.getValue().size()).isEqualTo(10); + } + + @Test + void preservesLongIdentifiersBeyondJavascriptSafeIntegerRange() { + long orderId = 9_007_199_254_740_993L; + long inboundId = 9_007_199_254_740_994L; + long tenantId = 9_007_199_254_740_995L; + var summary = summary("INBOUNDED", null); + when(summary.inbound().orderId()).thenReturn(orderId); + when(summary.inbound().id()).thenReturn(inboundId); + when(summary.inbound().tenantId()).thenReturn(tenantId); + when(firstleg.page(eq("internal-token"), any())).thenReturn( + ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + + assertThat(service.page(null, query("INBOUNDED")).records()).singleElement().satisfies(row -> { + assertThat(row.orderId()).isEqualTo(orderId); + assertThat(row.inboundId()).isEqualTo(inboundId); + assertThat(row.tenantId()).isEqualTo(tenantId); + assertThat(row.holdFlag()).isTrue(); + }); + } + + @Test + void approvedOrderWithoutFirstlegProjectionKeepsHoldFieldsUnknown() { + OrderEntity order = order(OrderStatus.APPROVED); + when(orders.findOwned(10001L, 77L)).thenReturn(order); + when(orders.findCargo(10001L, 77L)).thenReturn(List.of()); + when(orders.findParties(10001L, 77L)).thenReturn(List.of()); + when(orders.findNotes(10001L, 77L)).thenReturn(List.of()); + when(orders.findHistory(10001L, 77L)).thenReturn(List.of()); + when(firstleg.detail(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); + + assertThat(service.detail(10001L, 77L, false).summary()).satisfies(row -> { + assertThat(row.inboundId()).isNull(); + assertThat(row.holdFlag()).isNull(); + assertThat(row.currentHoldReason()).isNull(); + }); + } + + @Test + void missingHoldFlagFailsClosed() { + var summary = summary("INBOUNDED", null); + when(summary.inbound().holdFlag()).thenReturn(null); + when(firstleg.page(eq("internal-token"), any())).thenReturn( + ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + + assertServiceUnavailable(() -> service.page(null, query("INBOUNDED"))); + } + + @Test + void heldInboundWithoutReasonFailsClosed() { + var summary = summary("INBOUNDED", null); + when(summary.inbound().currentHoldReason()).thenReturn(" "); + when(firstleg.page(eq("internal-token"), any())).thenReturn( + ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + + assertServiceUnavailable(() -> service.page(null, query("INBOUNDED"))); + } + + @Test + void releasedInboundWithStaleReasonFailsClosed() { + var summary = summary("INBOUNDED", null); + when(summary.inbound().holdFlag()).thenReturn(false); + when(summary.inbound().currentHoldReason()).thenReturn("旧扣货原因"); + when(firstleg.page(eq("internal-token"), any())).thenReturn( + ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + + assertServiceUnavailable(() -> service.page(null, query("INBOUNDED"))); + } + + @Test + void malformedDetailWithoutSummaryFailsClosed() { + OrderEntity order = order(OrderStatus.APPROVED); + when(orders.findOwned(10001L, 77L)).thenReturn(order); + when(orders.findCargo(10001L, 77L)).thenReturn(List.of()); + when(orders.findParties(10001L, 77L)).thenReturn(List.of()); + when(orders.findNotes(10001L, 77L)).thenReturn(List.of()); + when(orders.findHistory(10001L, 77L)).thenReturn(List.of()); + when(firstleg.detail(eq("internal-token"), any())).thenReturn( + ApiResponse.success(new FirstlegUnifiedOrderClient.Detail(null, List.of()))); + + assertServiceUnavailable(() -> service.detail(10001L, 77L, false)); + } + + @Test + void firstlegCallExceptionFailsClosed() { + when(firstleg.page(eq("internal-token"), any())).thenThrow(new IllegalStateException("boom")); + + assertServiceUnavailable(() -> service.page(null, query("INBOUNDED"))); + } + @Test void fulfillmentTabsForwardStatusSearchAndPaginationToFirstleg() { List statuses = List.of( @@ -679,6 +799,13 @@ class UnifiedOrderQueryServiceTest { null, null, 1, 20); } + private void assertServiceUnavailable(org.assertj.core.api.ThrowableAssert.ThrowingCallable invocation) { + assertThatThrownBy(invocation) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.SERVICE_UNAVAILABLE); + } + private OrderEntity order(OrderStatus status) { OrderEntity order = new OrderEntity(); order.setId(77L); order.setTenantId(10001L); order.setOrderNo("FL-77"); diff --git a/scripts/admin-test-env-modules/inbound-hold-contract.test.mjs b/scripts/admin-test-env-modules/inbound-hold-contract.test.mjs index 308fdb787..a6a0d51da 100644 --- a/scripts/admin-test-env-modules/inbound-hold-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-hold-contract.test.mjs @@ -21,6 +21,17 @@ test('入库扣货模块覆盖隔离、幂等、冲突、候选门禁和统一 assert.match(source, /解除后入库单恢复智能装柜候选/) assert.match(source, /统一订单列表同步展示扣货字段/) assert.match(source, /统一订单列表同步恢复扣货字段/) + assert.match(source, /扣货缺少幂等键返回A0400/) + assert.match(source, /扣货空白原因返回A0422/) + assert.match(source, /扣货超长原因返回A0422/) + assert.match(source, /扣货负版本返回A0422/) + assert.match(source, /扣货无效凭证ID返回A0422/) + assert.match(source, /错误客户不能执行扣货/) + assert.match(source, /已扣货时使用新键再次扣货返回A0409/) + assert.match(source, /未扣货时使用新键再次解除返回A0409/) + assert.match(source, /上传本轮扣货关联凭证/) + assert.match(source, /尚未进入不可变历史的凭证禁止下载/) + assert.match(source, /进入不可变历史后的扣货凭证可下载且内容一致/) assert.match(source, /summary\?\.holdFlag/) assert.match(source, /summary\?\.currentHoldReason/) assert.doesNotMatch(source, /\.holdCargo\b|\.holdReason\b|\bholdCargo\s*:|\bholdReason\s*:/) diff --git a/scripts/admin-test-env-modules/inbound-hold.mjs b/scripts/admin-test-env-modules/inbound-hold.mjs index e12d9b551..9e179f953 100644 --- a/scripts/admin-test-env-modules/inbound-hold.mjs +++ b/scripts/admin-test-env-modules/inbound-hold.mjs @@ -39,6 +39,13 @@ function write(context, path, body, key) { return context.request('POST', path, body, context.token, { 'Idempotency-Key': key }) } +function sameSnapshot(actual, expected) { + return actual?.holdFlag === expected?.holdFlag + && actual?.currentHoldReason === expected?.currentHoldReason + && Number(actual?.version) === Number(expected?.version) + && historyFacts(actual) === historyFacts(expected) +} + export async function run(context) { const fixture = context.createdResources.orders || {} const tenantId = id(fixture.tenantId) @@ -95,21 +102,87 @@ export async function run(context) { `/api/platform/inbounds/${encodeURIComponent(inboundId)}/hold?tenantId=${encodeURIComponent(foreignTenantId)}`) expectCode(context, '错误客户不能读取扣货快照', foreignResponse, 'A0404') + const invalidCommands = [ + ['扣货缺少幂等键返回A0400', context.request('POST', holdPath, + { expectedVersion: initialVersion, reason: '缺少幂等键' }, context.token), 'A0400'], + ['扣货空白原因返回A0422', write(context, holdPath, + { expectedVersion: initialVersion, reason: ' ' }, `${context.marker}-hold-blank`), 'A0422'], + ['扣货超长原因返回A0422', write(context, holdPath, + { expectedVersion: initialVersion, reason: '测'.repeat(1001) }, `${context.marker}-hold-long-reason`), 'A0422'], + ['扣货负版本返回A0422', write(context, holdPath, + { expectedVersion: -1, reason: '负版本' }, `${context.marker}-hold-negative-version`), 'A0422'], + ['扣货无效凭证ID返回A0422', write(context, holdPath, + { expectedVersion: initialVersion, reason: '无效凭证', evidenceFileId: 0 }, `${context.marker}-hold-bad-evidence`), 'A0422'], + ['错误客户不能执行扣货', write(context, + `/api/platform/inbounds/${encodeURIComponent(inboundId)}/hold?tenantId=${encodeURIComponent(foreignTenantId)}`, + { expectedVersion: initialVersion, reason: '跨客户扣货' }, `${context.marker}-hold-foreign`), 'A0404'], + ] + for (const [label, pending, expectedCode] of invalidCommands) { + expectCode(context, label, await pending, expectedCode) + } + const afterInvalidResponse = await context.request('GET', holdPath) + const afterInvalid = context.apiSuccess(MODULE, '回查非法命令后的初始快照', afterInvalidResponse, + evidence(afterInvalidResponse), OPTIONS) + context.assert(MODULE, '非法命令不改变版本、状态或历史', afterInvalidResponse, + sameSnapshot(afterInvalid, initialHold), + `version=${afterInvalid?.version}, history=${afterInvalid?.history?.length ?? 'missing'}`, OPTIONS) + + const evidenceBytes = new TextEncoder().encode(`%PDF-1.4\n${context.marker}\n%%EOF`) + const evidencePath = `/api/platform/inbounds/${encodeURIComponent(inboundId)}/hold/evidence?tenantId=${encodeURIComponent(tenantId)}` + const evidenceResponse = await context.requestMultipart('POST', evidencePath, { + content: evidenceBytes, type: 'application/pdf', name: `${context.marker}-hold.pdf`, + }) + const evidenceFile = context.apiSuccess(MODULE, '上传本轮扣货关联凭证', evidenceResponse, + evidence(evidenceResponse), OPTIONS) + const evidenceFileId = id(evidenceFile?.fileId) + context.assert(MODULE, '扣货凭证返回安全文件元数据和字符串长整型ID', evidenceResponse, + Boolean(evidenceFileId) && typeof evidenceFile?.fileId === 'string' + && evidenceFile?.fileName === `${context.marker}-hold.pdf` + && evidenceFile?.contentType === 'application/pdf' + && Number(evidenceFile?.sizeBytes) === evidenceBytes.length, + `fileId=${evidenceFileId || 'missing'}, fileName=${evidenceFile?.fileName || 'missing'}, contentType=${evidenceFile?.contentType || 'missing'}`, + OPTIONS) + if (!evidenceFileId) throw new Error('hold evidence upload missing fileId') + + const evidenceDownloadPath = `/api/platform/inbounds/${encodeURIComponent(inboundId)}/hold/evidence/${encodeURIComponent(evidenceFileId)}/download?tenantId=${encodeURIComponent(tenantId)}` + const unreferencedDownload = await context.requestBinary('GET', evidenceDownloadPath) + expectCode(context, '尚未进入不可变历史的凭证禁止下载', unreferencedDownload, 'A0404') + const reason = `${context.marker} 财务复核扣货`.slice(0, 1000) const holdKey = `${context.marker}-inbound-hold`.slice(0, 128) - const holdBody = { expectedVersion: initialVersion, reason } + const holdBody = { expectedVersion: initialVersion, reason, evidenceFileId } const holdResponse = await write(context, holdPath, holdBody, holdKey) const held = context.apiSuccess(MODULE, '手工扣货入库单', holdResponse, evidence(holdResponse), OPTIONS) context.assert(MODULE, '扣货写入当前快照和不可变历史', holdResponse, held?.holdFlag === true && held?.currentHoldReason === reason && held?.canHold === false && held?.canRelease === true && Number(held?.version) === initialVersion + 1 - && held?.history?.[0]?.action === 'HOLD' && held?.history?.[0]?.reason === reason, + && held?.history?.[0]?.action === 'HOLD' && held?.history?.[0]?.reason === reason + && id(held?.history?.[0]?.evidenceFileId) === evidenceFileId, `holdFlag=${held?.holdFlag}, version=${held?.version}, history=${held?.history?.length ?? 'missing'}`, OPTIONS) const heldVersion = Number(held?.version) const heldHistorySize = held?.history?.length const heldHistoryFacts = historyFacts(held) + const evidenceDownload = await context.requestBinary('GET', evidenceDownloadPath) + context.assert(MODULE, '进入不可变历史后的扣货凭证可下载且内容一致', evidenceDownload, + evidenceDownload?.http === 200 && evidenceDownload?.body == null + && evidenceDownload?.contentType?.includes('application/pdf') + && Buffer.from(evidenceDownload?.bytes || []).equals(Buffer.from(evidenceBytes)), + `HTTP=${evidenceDownload?.http ?? 'missing'}, contentType=${evidenceDownload?.contentType || 'missing'}, bytes=${evidenceDownload?.bytes?.length ?? 'missing'}`, + OPTIONS) + + const duplicateStateResponse = await write(context, holdPath, + { expectedVersion: heldVersion, reason: `${context.marker} 重复状态` }, + `${context.marker}-inbound-hold-again`.slice(0, 128)) + expectCode(context, '已扣货时使用新键再次扣货返回A0409', duplicateStateResponse, 'A0409') + const heldAfterDuplicateResponse = await context.request('GET', holdPath) + const heldAfterDuplicate = context.apiSuccess(MODULE, '回查重复扣货后的当前快照', heldAfterDuplicateResponse, + evidence(heldAfterDuplicateResponse), OPTIONS) + context.assert(MODULE, '重复扣货不改变版本、原因或历史', heldAfterDuplicateResponse, + sameSnapshot(heldAfterDuplicate, held), + `version=${heldAfterDuplicate?.version}, history=${heldAfterDuplicate?.history?.length ?? 'missing'}`, OPTIONS) + const replayResponse = await write(context, holdPath, holdBody, holdKey) const replayed = context.apiSuccess(MODULE, '扣货请求同键同载荷重放', replayResponse, evidence(replayResponse), OPTIONS) @@ -180,6 +253,18 @@ export async function run(context) { && historyFactsFrom(historyRows(released).slice(1)) === heldHistoryFacts, `before=${heldHistorySize}, after=${releasedHistorySize}`, OPTIONS) + const duplicateReleaseResponse = await write(context, releasePath, + { expectedVersion: releasedVersion, reason: `${context.marker} 重复解除` }, + `${context.marker}-inbound-release-again`.slice(0, 128)) + expectCode(context, '未扣货时使用新键再次解除返回A0409', duplicateReleaseResponse, 'A0409') + const releasedAfterDuplicateResponse = await context.request('GET', holdPath) + const releasedAfterDuplicate = context.apiSuccess(MODULE, '回查重复解除后的当前快照', + releasedAfterDuplicateResponse, evidence(releasedAfterDuplicateResponse), OPTIONS) + context.assert(MODULE, '重复解除不改变版本、状态或历史', releasedAfterDuplicateResponse, + sameSnapshot(releasedAfterDuplicate, released), + `version=${releasedAfterDuplicate?.version}, history=${releasedAfterDuplicate?.history?.length ?? 'missing'}`, + OPTIONS) + const releaseReplayResponse = await write(context, releasePath, releaseBody, releaseKey) const releaseReplay = context.apiSuccess(MODULE, '解除请求同键同载荷重放', releaseReplayResponse, evidence(releaseReplayResponse), OPTIONS) diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index e21ea482a..33823f179 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -55,6 +55,8 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ 'firstleg-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '装柜/船节点 Outbox 按 eventId 投递'), feign('firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java', 'firstleg-service', 'file-service', ['REQUEST'], 'READ', 'SAFE_READ', '校验付款凭证元数据'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/InboundHoldFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '扣货凭证上传无幂等键,元数据与下载可重复'), feign('firstleg/src/main/java/com/baiyue/firstleg/client/FinanceFeeSourceEventClient.java', 'firstleg-service', 'finance-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '费用 Outbox 按事件幂等键投递'), feign('firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java', diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index 446ca05c0..a7a2687f6 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -98,11 +98,11 @@ async function discoverFeignEdges() { return clients.sort((left, right) => left.path.localeCompare(right.path)) } -test('all 54 Feign clients have reviewed context, access and idempotency metadata', async () => { +test('all 55 Feign clients have reviewed context, access and idempotency metadata', async () => { const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN') .map(({ path, source, target }) => ({ path, source, target })) .sort((left, right) => left.path.localeCompare(right.path)) - assert.equal(reviewed.length, 54) + assert.equal(reviewed.length, 55) assert.deepEqual(await discoverFeignEdges(), reviewed) })