fix: close inbound hold bypass paths
This commit is contained in:
@@ -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/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` |
|
| 装柜与业务文件证据 | `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 精确校验 |
|
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `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/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 保存 |
|
| 订舱文件 | 通用内部业务文件入口,契约 `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`,不得复制为拖车附件 |
|
| 拖车预约附件 | 通用内部业务文件入口;固定 `tenantId=0`、`businessType=TRUCK_APPOINTMENT_ATTACHMENT`、`businessId=appointmentId`,只允许派车单、司机/车辆资料和其他平台附件;提单文件归订舱 `OCEAN_BOOKING_DOCUMENT`,不得复制为拖车附件 |
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ enum BusinessFileCategory {
|
|||||||
CUSTOMS_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
CUSTOMS_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
CUSTOMS_RECEIPT(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),
|
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_RECEIPT(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_PROCUREMENT_VOUCHER(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),
|
CONTAINER_CLEARANCE_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
|||||||
@@ -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("明确客户");
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
|
| 内部模块化基线与依赖分析 | `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/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` |
|
||||||
| 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.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`,只接受内部服务令牌 |
|
| 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`,计算结果以服务端返回值为准 |
|
| 平台测量/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` |
|
| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `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` |
|
||||||
|
|||||||
@@ -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.InboundCartonLabelPrintService;
|
||||||
import com.baiyue.firstleg.service.InboundOrderQueryService;
|
import com.baiyue.firstleg.service.InboundOrderQueryService;
|
||||||
import com.baiyue.firstleg.service.InboundHoldService;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
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.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestHeader;
|
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.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.http.ResponseEntity;
|
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;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -43,6 +51,7 @@ import java.util.List;
|
|||||||
public class PlatformInboundOrderController {
|
public class PlatformInboundOrderController {
|
||||||
@Resource private InboundOrderQueryService service;
|
@Resource private InboundOrderQueryService service;
|
||||||
@Resource private InboundHoldService holdService;
|
@Resource private InboundHoldService holdService;
|
||||||
|
@Resource private InboundHoldEvidenceService holdEvidenceService;
|
||||||
@Resource private InboundCartonLabelPrintService cartonLabelPrintService;
|
@Resource private InboundCartonLabelPrintService cartonLabelPrintService;
|
||||||
|
|
||||||
@Operation(summary = "分页查询入库单",
|
@Operation(summary = "分页查询入库单",
|
||||||
@@ -91,6 +100,7 @@ public class PlatformInboundOrderController {
|
|||||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||||
@Valid @org.springframework.web.bind.annotation.RequestBody CommandRequest request) {
|
@Valid @org.springframework.web.bind.annotation.RequestBody CommandRequest request) {
|
||||||
requirePlatformTenant(user, tenantId);
|
requirePlatformTenant(user, tenantId);
|
||||||
|
holdEvidenceService.validateReference(tenantId, inboundId, request.evidenceFileId());
|
||||||
return ApiResponse.success(holdService.hold(tenantId, inboundId, user.userId(), user.username(),
|
return ApiResponse.success(holdService.hold(tenantId, inboundId, user.userId(), user.username(),
|
||||||
idempotencyKey, request));
|
idempotencyKey, request));
|
||||||
}
|
}
|
||||||
@@ -104,10 +114,35 @@ public class PlatformInboundOrderController {
|
|||||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||||
@Valid @org.springframework.web.bind.annotation.RequestBody CommandRequest request) {
|
@Valid @org.springframework.web.bind.annotation.RequestBody CommandRequest request) {
|
||||||
requirePlatformTenant(user, tenantId);
|
requirePlatformTenant(user, tenantId);
|
||||||
|
holdEvidenceService.validateReference(tenantId, inboundId, request.evidenceFileId());
|
||||||
return ApiResponse.success(holdService.release(tenantId, inboundId, user.userId(), user.username(),
|
return ApiResponse.success(holdService.release(tenantId, inboundId, user.userId(), user.username(),
|
||||||
idempotencyKey, request));
|
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",
|
@Operation(summary = "生成入仓箱唛 PDF",
|
||||||
description = "首次调用按订单货物明细冻结整单箱号;重复打印及指定箱下标补打复用原箱号映射。")
|
description = "首次调用按订单货物明细冻结整单箱号;重复打印及指定箱下标补打复用原箱号映射。")
|
||||||
@PostMapping("/{inboundId}/carton-label-pdf")
|
@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.entity.PreloadLoadingPlanLinkEntity;
|
||||||
import com.baiyue.firstleg.loading.bridge.repository.LoadingModelBridgeRepository;
|
import com.baiyue.firstleg.loading.bridge.repository.LoadingModelBridgeRepository;
|
||||||
import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.AllocationScope;
|
import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.AllocationScope;
|
||||||
|
import com.baiyue.firstleg.service.InboundHoldGuard;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -24,12 +25,14 @@ import java.util.Set;
|
|||||||
@Service
|
@Service
|
||||||
public class LoadingModelBridgeService {
|
public class LoadingModelBridgeService {
|
||||||
@Resource private LoadingModelBridgeRepository repository;
|
@Resource private LoadingModelBridgeRepository repository;
|
||||||
|
@Resource private InboundHoldGuard inboundHoldGuard;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@BusinessAudit(event = "PRELOAD_LOADING_PLAN_BIND", resourceType = "LOADING_PLAN",
|
@BusinessAudit(event = "PRELOAD_LOADING_PLAN_BIND", resourceType = "LOADING_PLAN",
|
||||||
resourceId = "#loadingPlanId", description = "关联入库预装与柜次装柜计划")
|
resourceId = "#loadingPlanId", description = "关联入库预装与柜次装柜计划")
|
||||||
public Link bind(long tenantId, long inboundId, long loadingPlanId, long operatorId,
|
public Link bind(long tenantId, long inboundId, long loadingPlanId, long operatorId,
|
||||||
String operatorName, String idempotencyKey, BindRequest request) {
|
String operatorName, String idempotencyKey, BindRequest request) {
|
||||||
|
inboundHoldGuard.requireInboundNotHeld(tenantId, inboundId);
|
||||||
String key = validateKey(idempotencyKey);
|
String key = validateKey(idempotencyKey);
|
||||||
String requestHash = hash(tenantId + "|" + inboundId + "|" + loadingPlanId + "|"
|
String requestHash = hash(tenantId + "|" + inboundId + "|" + loadingPlanId + "|"
|
||||||
+ request.expectedPreloadVersion() + "|" + request.expectedLoadingPlanVersion());
|
+ request.expectedPreloadVersion() + "|" + request.expectedLoadingPlanVersion());
|
||||||
@@ -77,6 +80,7 @@ public class LoadingModelBridgeService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public List<Link> bindAllocatedBeforeLoading(long loadingPlanId, long operatorId,
|
public List<Link> bindAllocatedBeforeLoading(long loadingPlanId, long operatorId,
|
||||||
String operatorName, String orchestrationKey) {
|
String operatorName, String orchestrationKey) {
|
||||||
|
inboundHoldGuard.requirePlanNotHeld(loadingPlanId);
|
||||||
var loadingPlan = repository.loadingPlan(loadingPlanId);
|
var loadingPlan = repository.loadingPlan(loadingPlanId);
|
||||||
if (loadingPlan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在");
|
if (loadingPlan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在");
|
||||||
List<AllocationScope> scopes = repository.allocatedScopes(loadingPlanId);
|
List<AllocationScope> scopes = repository.allocatedScopes(loadingPlanId);
|
||||||
@@ -117,6 +121,7 @@ public class LoadingModelBridgeService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void bindIncrementalAllocation(long loadingPlanId, long tenantId, long inboundId,
|
public void bindIncrementalAllocation(long loadingPlanId, long tenantId, long inboundId,
|
||||||
long operatorId, String operatorName, String operationId) {
|
long operatorId, String operatorName, String operationId) {
|
||||||
|
inboundHoldGuard.requireInboundNotHeld(tenantId, inboundId);
|
||||||
if (repository.byPair(tenantId, inboundId, loadingPlanId) != null) return;
|
if (repository.byPair(tenantId, inboundId, loadingPlanId) != null) return;
|
||||||
var plan = repository.loadingPlan(loadingPlanId);
|
var plan = repository.loadingPlan(loadingPlanId);
|
||||||
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在");
|
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在");
|
||||||
|
|||||||
+3
@@ -118,6 +118,9 @@ public class OutboundDispatchService {
|
|||||||
if (replay != null) return replay;
|
if (replay != null) return replay;
|
||||||
|
|
||||||
List<OutboundDispatchReadMapper.Binding> bindings = lockedBindings(containerBookingId);
|
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();
|
List<OutboundDispatchEntity> states = bindings.stream().map(this::existingStateLocked).toList();
|
||||||
StateView visible = visibleState(states);
|
StateView visible = visibleState(states);
|
||||||
if (!OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(visible.status())) {
|
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",
|
@BusinessAudit(event = "PDA_COMPLETE_LOADING", resourceType = "LOADING_PLAN",
|
||||||
resourceId = "#planId", description = "PDA确认装柜完成后生成装柜清单")
|
resourceId = "#planId", description = "PDA确认装柜完成后生成装柜清单")
|
||||||
public void finishFromPda(long planId, long pickTaskId, long operatorId, String operatorName) {
|
public void finishFromPda(long planId, long pickTaskId, long operatorId, String operatorName) {
|
||||||
|
inboundHoldGuard.requirePlanNotHeld(planId);
|
||||||
String operationId = "PDA-COMPLETE-" + planId + '-' + pickTaskId;
|
String operationId = "PDA-COMPLETE-" + planId + '-' + pickTaskId;
|
||||||
if (repository.history(operationId) != null) {
|
if (repository.history(operationId) != null) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ public class LoadingPickService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void sync(long planId, LoadingPlanItemEntity planItem) {
|
public void sync(long planId, LoadingPlanItemEntity planItem) {
|
||||||
|
inboundHoldGuard.requirePlanNotHeld(planId);
|
||||||
LoadingPickTaskEntity task = repository.taskByPlan(planId);
|
LoadingPickTaskEntity task = repository.taskByPlan(planId);
|
||||||
if (task == null) return;
|
if (task == null) return;
|
||||||
task = repository.taskForUpdate(task.getId());
|
task = repository.taskForUpdate(task.getId());
|
||||||
@@ -551,6 +552,7 @@ public class LoadingPickService {
|
|||||||
/** Called only by the completion worker, after its durable lease has committed. */
|
/** Called only by the completion worker, after its durable lease has committed. */
|
||||||
public void processCompletion(LoadingCompletionCommandEntity command, String worker) {
|
public void processCompletion(LoadingCompletionCommandEntity command, String worker) {
|
||||||
long taskId = command.getPickTaskId();
|
long taskId = command.getPickTaskId();
|
||||||
|
inboundHoldGuard.requireTaskNotHeld(taskId);
|
||||||
LoadingPickTaskEntity task = requiredTask(taskId);
|
LoadingPickTaskEntity task = requiredTask(taskId);
|
||||||
List<LoadingPickItemMapper.InboundOwner> owners = repository.inboundOwners(taskId).stream()
|
List<LoadingPickItemMapper.InboundOwner> owners = repository.inboundOwners(taskId).stream()
|
||||||
.filter(owner -> owner.tenantId() != null && owner.inboundId() != null)
|
.filter(owner -> owner.tenantId() != null && owner.inboundId() != null)
|
||||||
|
|||||||
@@ -48,12 +48,15 @@ public class PdaPickSettlementService {
|
|||||||
private WarehousePalletLocationClient warehouse;
|
private WarehousePalletLocationClient warehouse;
|
||||||
@Resource
|
@Resource
|
||||||
private OrderFulfillmentSplitClient orderSplits;
|
private OrderFulfillmentSplitClient orderSplits;
|
||||||
|
@Resource
|
||||||
|
private InboundHoldGuard inboundHoldGuard;
|
||||||
@Value("${baiyue.security.internal-service-token}")
|
@Value("${baiyue.security.internal-service-token}")
|
||||||
private String internalToken;
|
private String internalToken;
|
||||||
|
|
||||||
/** 每个柜次生成一张 LOADED 分片;存在余量时另生成可继续分柜的 REMAINDER 分片。 */
|
/** 每个柜次生成一张 LOADED 分片;存在余量时另生成可继续分柜的 REMAINDER 分片。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public void settle(long taskId, long operatorId, String operatorName) {
|
public void settle(long taskId, long operatorId, String operatorName) {
|
||||||
|
inboundHoldGuard.requireTaskNotHeld(taskId);
|
||||||
if (!picks.allTaskItemsResolved(taskId)) {
|
if (!picks.allTaskItemsResolved(taskId)) {
|
||||||
return;
|
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. */
|
/** Worker boundary: one inbound owner per transaction, so a cabinet never holds 15 owner locks at once. */
|
||||||
@Transactional
|
@Transactional
|
||||||
public void settleOwner(long taskId, long inboundId, long operatorId, String operatorName) {
|
public void settleOwner(long taskId, long inboundId, long operatorId, String operatorName) {
|
||||||
|
inboundHoldGuard.requireTaskNotHeld(taskId);
|
||||||
if (!picks.allTaskItemsResolved(taskId)) {
|
if (!picks.allTaskItemsResolved(taskId)) {
|
||||||
throw conflict("PDA任务仍有未处理明细");
|
throw conflict("PDA任务仍有未处理明细");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,34 @@ paths:
|
|||||||
schema: {$ref: '#/components/schemas/InboundHoldCommand'}
|
schema: {$ref: '#/components/schemas/InboundHoldCommand'}
|
||||||
responses:
|
responses:
|
||||||
'200': {description: 成功或完全同参幂等重放时 data 为 InboundHoldDetail;版本、状态、异参重放或实物作业冲突时响应体 code=A0409}
|
'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:
|
/api/merchant/v1/first-leg/inbounds/{inboundId}/carton-label-pdf:
|
||||||
post:
|
post:
|
||||||
summary: 客户端生成本客户入库单箱唛 PDF
|
summary: 客户端生成本客户入库单箱唛 PDF
|
||||||
@@ -1262,7 +1290,15 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
expectedVersion: {type: integer, minimum: 0, description: 当前入库聚合 version;成功后递增 1}
|
expectedVersion: {type: integer, minimum: 0, description: 当前入库聚合 version;成功后递增 1}
|
||||||
reason: {type: string, minLength: 1, maxLength: 1000}
|
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:
|
InboundHoldHistoryItem:
|
||||||
type: object
|
type: object
|
||||||
required: [id, action, reason, operatedAt]
|
required: [id, action, reason, operatedAt]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.springframework.core.io.ClassPathResource;
|
import org.springframework.core.io.ClassPathResource;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.web.bind.annotation.RequestHeader;
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.yaml.snakeyaml.Yaml;
|
import org.yaml.snakeyaml.Yaml;
|
||||||
|
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
@@ -22,6 +23,10 @@ class InboundHoldContractTest {
|
|||||||
"holdDetail", AuthenticatedUser.class, long.class, long.class);
|
"holdDetail", AuthenticatedUser.class, long.class, long.class);
|
||||||
Method hold = commandMethod("hold");
|
Method hold = commandMethod("hold");
|
||||||
Method release = commandMethod("releaseHold");
|
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())
|
assertThat(detail.getAnnotation(PreAuthorize.class).value())
|
||||||
.isEqualTo("hasAuthority('platform:firstleg:inbound:list')");
|
.isEqualTo("hasAuthority('platform:firstleg:inbound:list')");
|
||||||
@@ -29,6 +34,10 @@ class InboundHoldContractTest {
|
|||||||
.isEqualTo("hasAuthority('platform:firstleg:inbound:hold')");
|
.isEqualTo("hasAuthority('platform:firstleg:inbound:hold')");
|
||||||
assertThat(release.getAnnotation(PreAuthorize.class).value())
|
assertThat(release.getAnnotation(PreAuthorize.class).value())
|
||||||
.isEqualTo("hasAuthority('platform:firstleg:inbound:hold')");
|
.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())
|
assertThat(hold.getParameters()[3].getAnnotation(RequestHeader.class).value())
|
||||||
.isEqualTo("Idempotency-Key");
|
.isEqualTo("Idempotency-Key");
|
||||||
assertThat(hold.getParameters()[3].getAnnotation(RequestHeader.class).required()).isTrue();
|
assertThat(hold.getParameters()[3].getAnnotation(RequestHeader.class).required()).isTrue();
|
||||||
@@ -43,7 +52,9 @@ class InboundHoldContractTest {
|
|||||||
Map<String, Object> paths = stringMap(document.get("paths"));
|
Map<String, Object> paths = stringMap(document.get("paths"));
|
||||||
assertThat(paths.keySet()).contains(
|
assertThat(paths.keySet()).contains(
|
||||||
"/api/platform/inbounds/{inboundId}/hold",
|
"/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> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
|
||||||
Map<String, Object> command = stringMap(schemas.get("InboundHoldCommand"));
|
Map<String, Object> command = stringMap(schemas.get("InboundHoldCommand"));
|
||||||
@@ -62,6 +73,10 @@ class InboundHoldContractTest {
|
|||||||
assertThat(stringMap(stringMap(detail.get("properties")).get("holdFlag")))
|
assertThat(stringMap(stringMap(detail.get("properties")).get("holdFlag")))
|
||||||
.containsEntry("type", "boolean").doesNotContainKey("nullable");
|
.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"));
|
Map<String, Object> history = stringMap(schemas.get("InboundHoldHistoryItem"));
|
||||||
assertThat(stringMap(history.get("properties")).keySet()).containsExactlyInAnyOrder(
|
assertThat(stringMap(history.get("properties")).keySet()).containsExactlyInAnyOrder(
|
||||||
"id", "action", "reason", "evidenceFileId", "operatorName", "operatedAt");
|
"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.mapper.LoadingModelBridgeMapper.AllocationScope;
|
||||||
import com.baiyue.firstleg.loading.bridge.entity.PreloadLoadingPlanLinkEntity;
|
import com.baiyue.firstleg.loading.bridge.entity.PreloadLoadingPlanLinkEntity;
|
||||||
import com.baiyue.firstleg.loading.bridge.repository.LoadingModelBridgeRepository;
|
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.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
@@ -24,8 +27,27 @@ import static org.mockito.Mockito.*;
|
|||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class LoadingModelBridgeServiceTest {
|
class LoadingModelBridgeServiceTest {
|
||||||
@Mock LoadingModelBridgeRepository repository;
|
@Mock LoadingModelBridgeRepository repository;
|
||||||
|
@Mock InboundHoldGuard inboundHoldGuard;
|
||||||
@InjectMocks LoadingModelBridgeService service;
|
@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
|
@Test
|
||||||
void bindsManyToManyModelsWithBothOptimisticLocks() {
|
void bindsManyToManyModelsWithBothOptimisticLocks() {
|
||||||
when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "PENDING", 2));
|
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());
|
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
|
@Test
|
||||||
void sameIdempotencyKeyAndPayloadReplaysContainerResultWithoutMutation() {
|
void sameIdempotencyKeyAndPayloadReplaysContainerResultWithoutMutation() {
|
||||||
OutboundDispatchService service = serviceWithoutCarrier();
|
OutboundDispatchService service = serviceWithoutCarrier();
|
||||||
@@ -265,8 +282,12 @@ class OutboundDispatchServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static OutboundDispatchReadMapper.Binding binding(long inboundId, String inboundCode) {
|
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",
|
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) {
|
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.eq;
|
||||||
import static org.mockito.ArgumentMatchers.isNull;
|
import static org.mockito.ArgumentMatchers.isNull;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.verifyNoInteractions;
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
@@ -67,6 +68,17 @@ class LoadingListServiceTest {
|
|||||||
@Resource
|
@Resource
|
||||||
private LoadingListService service;
|
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
|
@TestConfiguration
|
||||||
static class Config {
|
static class Config {
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@@ -106,6 +106,30 @@ class LoadingPickServiceTest {
|
|||||||
@Resource
|
@Resource
|
||||||
private LoadingPickService service;
|
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
|
@BeforeEach
|
||||||
void activeOperationalContainer() {
|
void activeOperationalContainer() {
|
||||||
doAnswer(invocation -> {
|
doAnswer(invocation -> {
|
||||||
|
|||||||
@@ -28,9 +28,11 @@ import static org.mockito.ArgumentMatchers.anyLong;
|
|||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
import static org.mockito.Mockito.inOrder;
|
import static org.mockito.Mockito.inOrder;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@SpringJUnitConfig(PdaPickSettlementService.class)
|
@SpringJUnitConfig(PdaPickSettlementService.class)
|
||||||
@@ -41,8 +43,23 @@ class PdaPickSettlementServiceTest {
|
|||||||
@MockBean private InboundPalletAllocationRepository allocations;
|
@MockBean private InboundPalletAllocationRepository allocations;
|
||||||
@MockBean private WarehousePalletLocationClient warehouse;
|
@MockBean private WarehousePalletLocationClient warehouse;
|
||||||
@MockBean private OrderFulfillmentSplitClient orderSplits;
|
@MockBean private OrderFulfillmentSplitClient orderSplits;
|
||||||
|
@MockBean private InboundHoldGuard inboundHoldGuard;
|
||||||
@Resource private PdaPickSettlementService service;
|
@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
|
@Test
|
||||||
void createsChildForAllUnloadedPackagesAndTransfersRemainingAllocations() {
|
void createsChildForAllUnloadedPackagesAndTransfersRemainingAllocations() {
|
||||||
LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 10, 8, "PARTIAL");
|
LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 10, 8, "PARTIAL");
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.springframework.core.io.ClassPathResource;
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
@@ -60,8 +62,29 @@ class InboundHoldPermissionSqlContractTest {
|
|||||||
") <> 1 THEN");
|
") <> 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 {
|
private static String sql() throws Exception {
|
||||||
return new ClassPathResource("db/inbound-hold-permission.sql")
|
return new ClassPathResource("db/inbound-hold-permission.sql")
|
||||||
.getContentAsString(StandardCharsets.UTF_8);
|
.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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+149
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -162,7 +162,9 @@ public class UnifiedOrderQueryService {
|
|||||||
query.page(), query.size());
|
query.page(), query.size());
|
||||||
PageResult<FirstlegUnifiedOrderClient.Summary> source = require(
|
PageResult<FirstlegUnifiedOrderClient.Summary> source = require(
|
||||||
call(() -> firstleg.page(internalToken, request)), "统一订单履约查询响应无效");
|
call(() -> firstleg.page(internalToken, request)), "统一订单履约查询响应无效");
|
||||||
List<Long> orderIds = source.records().stream().map(item -> item.inbound().orderId()).toList();
|
List<FirstlegUnifiedOrderClient.Summary> summaries = validateSummaries(
|
||||||
|
source.records(), "统一订单履约查询响应无效");
|
||||||
|
List<Long> orderIds = summaries.stream().map(item -> item.inbound().orderId()).toList();
|
||||||
Map<Long, OrderEntity> orderById = orders.findByIds(tenantId, orderIds).stream()
|
Map<Long, OrderEntity> orderById = orders.findByIds(tenantId, orderIds).stream()
|
||||||
.collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left));
|
.collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left));
|
||||||
Map<Long, String> latestNotes = latestNotes(tenantId, orderIds);
|
Map<Long, String> latestNotes = latestNotes(tenantId, orderIds);
|
||||||
@@ -170,7 +172,7 @@ public class UnifiedOrderQueryService {
|
|||||||
Map<Long, Integer> childCounts = childCounts(List.copyOf(orderById.values()));
|
Map<Long, Integer> childCounts = childCounts(List.copyOf(orderById.values()));
|
||||||
Map<Long, UnifiedOrderResponse.CoLoadGroupSummary> coLoadByOrder = coLoadGroups(
|
Map<Long, UnifiedOrderResponse.CoLoadGroupSummary> coLoadByOrder = coLoadGroups(
|
||||||
orderIds, includeCoLoadGroups);
|
orderIds, includeCoLoadGroups);
|
||||||
List<UnifiedOrderResponse.Row> records = source.records().stream()
|
List<UnifiedOrderResponse.Row> records = summaries.stream()
|
||||||
.map(summary -> row(summary, orderById.get(summary.inbound().orderId()),
|
.map(summary -> row(summary, orderById.get(summary.inbound().orderId()),
|
||||||
latestNotes.get(summary.inbound().orderId()), childCounts,
|
latestNotes.get(summary.inbound().orderId()), childCounts,
|
||||||
cargoSummaries.get(summary.inbound().orderId()),
|
cargoSummaries.get(summary.inbound().orderId()),
|
||||||
@@ -185,13 +187,33 @@ public class UnifiedOrderQueryService {
|
|||||||
call(() -> firstleg.summaries(internalToken,
|
call(() -> firstleg.summaries(internalToken,
|
||||||
new FirstlegUnifiedOrderClient.BatchRequest(tenantId, orderIds))),
|
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));
|
Function.identity(), (left, right) -> left));
|
||||||
}
|
}
|
||||||
|
|
||||||
private FirstlegUnifiedOrderClient.Detail firstlegDetail(long tenantId, long orderId) {
|
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))), "统一订单履约详情响应无效");
|
new FirstlegUnifiedOrderClient.DetailRequest(tenantId, orderId))), "统一订单履约详情响应无效");
|
||||||
|
if (detail != null) validateSummary(detail.summary(), "统一订单履约详情响应无效");
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<FirstlegUnifiedOrderClient.Summary> validateSummaries(
|
||||||
|
List<FirstlegUnifiedOrderClient.Summary> 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<Long, String> latestNotes(Long tenantId, List<Long> orderIds) {
|
private Map<Long, String> latestNotes(Long tenantId, List<Long> orderIds) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.baiyue.order.service;
|
|||||||
|
|
||||||
import com.baiyue.common.core.api.ApiResponse;
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
import com.baiyue.common.core.api.PageResult;
|
import com.baiyue.common.core.api.PageResult;
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import com.baiyue.order.client.FirstlegUnifiedOrderClient;
|
import com.baiyue.order.client.FirstlegUnifiedOrderClient;
|
||||||
import com.baiyue.order.client.FirstlegColoadGroupClient;
|
import com.baiyue.order.client.FirstlegColoadGroupClient;
|
||||||
@@ -605,6 +606,125 @@ class UnifiedOrderQueryServiceTest {
|
|||||||
assertThat(captor.getValue().orderId()).isEqualTo(77L);
|
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<FirstlegUnifiedOrderClient.Query> 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
|
@Test
|
||||||
void fulfillmentTabsForwardStatusSearchAndPaginationToFirstleg() {
|
void fulfillmentTabsForwardStatusSearchAndPaginationToFirstleg() {
|
||||||
List<String> statuses = List.of(
|
List<String> statuses = List.of(
|
||||||
@@ -679,6 +799,13 @@ class UnifiedOrderQueryServiceTest {
|
|||||||
null, null, 1, 20);
|
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) {
|
private OrderEntity order(OrderStatus status) {
|
||||||
OrderEntity order = new OrderEntity();
|
OrderEntity order = new OrderEntity();
|
||||||
order.setId(77L); order.setTenantId(10001L); order.setOrderNo("FL-77");
|
order.setId(77L); order.setTenantId(10001L); order.setOrderNo("FL-77");
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ test('入库扣货模块覆盖隔离、幂等、冲突、候选门禁和统一
|
|||||||
assert.match(source, /解除后入库单恢复智能装柜候选/)
|
assert.match(source, /解除后入库单恢复智能装柜候选/)
|
||||||
assert.match(source, /统一订单列表同步展示扣货字段/)
|
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\?\.holdFlag/)
|
||||||
assert.match(source, /summary\?\.currentHoldReason/)
|
assert.match(source, /summary\?\.currentHoldReason/)
|
||||||
assert.doesNotMatch(source, /\.holdCargo\b|\.holdReason\b|\bholdCargo\s*:|\bholdReason\s*:/)
|
assert.doesNotMatch(source, /\.holdCargo\b|\.holdReason\b|\bholdCargo\s*:|\bholdReason\s*:/)
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ function write(context, path, body, key) {
|
|||||||
return context.request('POST', path, body, context.token, { 'Idempotency-Key': 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) {
|
export async function run(context) {
|
||||||
const fixture = context.createdResources.orders || {}
|
const fixture = context.createdResources.orders || {}
|
||||||
const tenantId = id(fixture.tenantId)
|
const tenantId = id(fixture.tenantId)
|
||||||
@@ -95,21 +102,87 @@ export async function run(context) {
|
|||||||
`/api/platform/inbounds/${encodeURIComponent(inboundId)}/hold?tenantId=${encodeURIComponent(foreignTenantId)}`)
|
`/api/platform/inbounds/${encodeURIComponent(inboundId)}/hold?tenantId=${encodeURIComponent(foreignTenantId)}`)
|
||||||
expectCode(context, '错误客户不能读取扣货快照', foreignResponse, 'A0404')
|
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 reason = `${context.marker} 财务复核扣货`.slice(0, 1000)
|
||||||
const holdKey = `${context.marker}-inbound-hold`.slice(0, 128)
|
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 holdResponse = await write(context, holdPath, holdBody, holdKey)
|
||||||
const held = context.apiSuccess(MODULE, '手工扣货入库单', holdResponse, evidence(holdResponse), OPTIONS)
|
const held = context.apiSuccess(MODULE, '手工扣货入库单', holdResponse, evidence(holdResponse), OPTIONS)
|
||||||
context.assert(MODULE, '扣货写入当前快照和不可变历史', holdResponse,
|
context.assert(MODULE, '扣货写入当前快照和不可变历史', holdResponse,
|
||||||
held?.holdFlag === true && held?.currentHoldReason === reason && held?.canHold === false
|
held?.holdFlag === true && held?.currentHoldReason === reason && held?.canHold === false
|
||||||
&& held?.canRelease === true && Number(held?.version) === initialVersion + 1
|
&& 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'}`,
|
`holdFlag=${held?.holdFlag}, version=${held?.version}, history=${held?.history?.length ?? 'missing'}`,
|
||||||
OPTIONS)
|
OPTIONS)
|
||||||
const heldVersion = Number(held?.version)
|
const heldVersion = Number(held?.version)
|
||||||
const heldHistorySize = held?.history?.length
|
const heldHistorySize = held?.history?.length
|
||||||
const heldHistoryFacts = historyFacts(held)
|
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 replayResponse = await write(context, holdPath, holdBody, holdKey)
|
||||||
const replayed = context.apiSuccess(MODULE, '扣货请求同键同载荷重放', replayResponse,
|
const replayed = context.apiSuccess(MODULE, '扣货请求同键同载荷重放', replayResponse,
|
||||||
evidence(replayResponse), OPTIONS)
|
evidence(replayResponse), OPTIONS)
|
||||||
@@ -180,6 +253,18 @@ export async function run(context) {
|
|||||||
&& historyFactsFrom(historyRows(released).slice(1)) === heldHistoryFacts,
|
&& historyFactsFrom(historyRows(released).slice(1)) === heldHistoryFacts,
|
||||||
`before=${heldHistorySize}, after=${releasedHistorySize}`, OPTIONS)
|
`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 releaseReplayResponse = await write(context, releasePath, releaseBody, releaseKey)
|
||||||
const releaseReplay = context.apiSuccess(MODULE, '解除请求同键同载荷重放', releaseReplayResponse,
|
const releaseReplay = context.apiSuccess(MODULE, '解除请求同键同载荷重放', releaseReplayResponse,
|
||||||
evidence(releaseReplayResponse), OPTIONS)
|
evidence(releaseReplayResponse), OPTIONS)
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([
|
|||||||
'firstleg-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '装柜/船节点 Outbox 按 eventId 投递'),
|
'firstleg-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '装柜/船节点 Outbox 按 eventId 投递'),
|
||||||
feign('firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java',
|
feign('firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java',
|
||||||
'firstleg-service', 'file-service', ['REQUEST'], 'READ', 'SAFE_READ', '校验付款凭证元数据'),
|
'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',
|
feign('firstleg/src/main/java/com/baiyue/firstleg/client/FinanceFeeSourceEventClient.java',
|
||||||
'firstleg-service', 'finance-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '费用 Outbox 按事件幂等键投递'),
|
'firstleg-service', 'finance-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '费用 Outbox 按事件幂等键投递'),
|
||||||
feign('firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java',
|
feign('firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java',
|
||||||
|
|||||||
@@ -98,11 +98,11 @@ async function discoverFeignEdges() {
|
|||||||
return clients.sort((left, right) => left.path.localeCompare(right.path))
|
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')
|
const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN')
|
||||||
.map(({ path, source, target }) => ({ path, source, target }))
|
.map(({ path, source, target }) => ({ path, source, target }))
|
||||||
.sort((left, right) => left.path.localeCompare(right.path))
|
.sort((left, right) => left.path.localeCompare(right.path))
|
||||||
assert.equal(reviewed.length, 54)
|
assert.equal(reviewed.length, 55)
|
||||||
assert.deepEqual(await discoverFeignEdges(), reviewed)
|
assert.deepEqual(await discoverFeignEdges(), reviewed)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user