From 047ed674b89a7a8623600794fdb7b3da625395f3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 18:26:26 +0800 Subject: [PATCH 1/3] feat(file): support overseas exception evidence --- file/AGENTS.md | 1 + .../file/service/BusinessFileCategory.java | 1 + file/src/main/resources/db/file.sql | 2 +- .../overseas-exception-evidence-v1.yaml | 109 ++++++++++++++++++ .../InternalBusinessFileServiceTest.java | 41 +++++++ ...OverseasExceptionEvidenceContractTest.java | 37 ++++++ 6 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 file/src/main/resources/openapi/overseas-exception-evidence-v1.yaml create mode 100644 file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java diff --git a/file/AGENTS.md b/file/AGENTS.md index 8b9d22a1a..f0fe0b229 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -16,6 +16,7 @@ | 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` | | 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`service/LoadingEvidenceService.java`、`src/main/resources/openapi/loading-evidence-v1.yaml`;五类装柜证据固定为平台所有、`tenantId=0`、`businessId=containerBookingId`,不得绑定客户或入仓单。其他业务文件进入 `InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` | | 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 | +| 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg | | 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 | | 报关源文件、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 | | 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=订舱customerId`、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 | diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index cd5b1f630..ae0b8cc5a 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -21,6 +21,7 @@ enum BusinessFileCategory { CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_UNLOAD_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_EXCEPTION_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), LOADING_LIST_CUSTOMS_RESULT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), ORDER_BATCH_IMPORT_SOURCE(true, FormatPolicy.XLSX, Limits.ORDER_IMPORT, BusinessIdPolicy.ORDER_BATCH_NO), diff --git a/file/src/main/resources/db/file.sql b/file/src/main/resources/db/file.sql index 7c11d291a..2a4a61cb0 100644 --- a/file/src/main/resources/db/file.sql +++ b/file/src/main/resources/db/file.sql @@ -6,7 +6,7 @@ USE baiyue_file; CREATE TABLE IF NOT EXISTS file_object ( id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '所有者范围键:0为平台,正整数为客户ID', - business_type VARCHAR(32) NULL COMMENT '关联业务类型', + business_type VARCHAR(32) NULL COMMENT '关联业务类型,海外异常证据固定为OVERSEAS_EXCEPTION_EVIDENCE', business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID;装柜证据固定为物理containerBookingId', original_name VARCHAR(255) NOT NULL COMMENT '原始文件名', object_key VARCHAR(512) NOT NULL COMMENT '对象存储键', diff --git a/file/src/main/resources/openapi/overseas-exception-evidence-v1.yaml b/file/src/main/resources/openapi/overseas-exception-evidence-v1.yaml new file mode 100644 index 000000000..858cef8d5 --- /dev/null +++ b/file/src/main/resources/openapi/overseas-exception-evidence-v1.yaml @@ -0,0 +1,109 @@ +openapi: 3.0.3 +info: + title: 海外异常订单证据文件内部契约 + version: 1.0.0 + description: >- + File 只保存文件对象并校验复合归属;异常订单状态、完成门禁和证据引用由 Lastleg 持有。 + 所有接口仅接受内部服务令牌,业务失败按统一响应体 code 判断。 +paths: + /internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}: + post: + summary: 上传海外异常订单证据 + description: >- + tenantId 固定为异常订单所属客户ID,exceptionOrderId 固定为异常订单内部ID。 + 单文件最大10MiB,仅支持TXT、PDF、JPG、PNG;File不校验异常状态。 + security: [] + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ExceptionOrderId' + - {name: X-Operator-Id, in: header, 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: 返回文件元数据 + content: + application/json: + schema: {$ref: '#/components/schemas/MetadataEnvelope'} + '403': {description: 内部服务令牌无效} + /internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}/{fileId}: + get: + summary: 校验并读取海外异常订单证据元数据 + description: 必须同时匹配客户ID、异常订单ID、文件类型与文件ID,否则返回不存在或归属不匹配。 + security: [] + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ExceptionOrderId' + - $ref: '#/components/parameters/FileId' + responses: + '200': + description: 返回匹配复合归属的文件元数据 + content: + application/json: + schema: {$ref: '#/components/schemas/MetadataEnvelope'} + '403': {description: 内部服务令牌无效} + /internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}/{fileId}/content: + get: + summary: 下载海外异常订单证据内容 + description: 下载前执行与元数据接口相同的复合归属校验,响应禁止缓存。 + security: [] + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ExceptionOrderId' + - $ref: '#/components/parameters/FileId' + responses: + '200': {description: 文件二进制;携带Content-Disposition并设置Cache-Control no-store} + '403': {description: 内部服务令牌无效} +components: + parameters: + InternalToken: + name: X-Internal-Service-Token + in: header + required: true + schema: {type: string} + TenantId: + name: tenantId + in: path + required: true + schema: {type: string, pattern: '^[1-9][0-9]*$'} + ExceptionOrderId: + name: exceptionOrderId + in: path + required: true + schema: {type: string, pattern: '^[1-9][0-9]*$'} + FileId: + name: fileId + in: path + required: true + schema: {type: string, pattern: '^[1-9][0-9]*$'} + schemas: + MetadataEnvelope: + type: object + required: [code, data] + properties: + code: {type: string, example: '200'} + message: {type: string, nullable: true} + data: {$ref: '#/components/schemas/Metadata'} + Metadata: + type: object + required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes] + properties: + fileId: {type: string, pattern: '^[1-9][0-9]*$'} + ownerScope: {type: string, enum: [CUSTOMER]} + tenantId: {type: string, pattern: '^[1-9][0-9]*$'} + businessType: {type: string, enum: [OVERSEAS_EXCEPTION_EVIDENCE]} + businessId: {type: string, pattern: '^[1-9][0-9]*$', description: 异常订单内部ID} + fileName: {type: string} + sha256: {type: string, pattern: '^[0-9a-f]{64}$'} + contentType: {type: string} + sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760} diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index 4aeac5255..0c1f0ee6a 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -315,4 +315,45 @@ class InternalBusinessFileServiceTest { .isInstanceOf(BizException.class).hasMessageContaining("明确客户"); } + @Test void overseasExceptionEvidenceUsesExactCustomerAndExceptionOwnership() { + MockMultipartFile file = new MockMultipartFile( + "file", "异常处理证据.pdf", "application/pdf", "%PDF-1.4".getBytes()); + when(storage.store(10001L, "701", file, BusinessFileCategory.OVERSEAS_EXCEPTION_EVIDENCE)) + .thenReturn(new BusinessFileStorage.Stored("business-files/10001/701/evidence.pdf", + "application/pdf", file.getSize(), "b".repeat(64), "异常处理证据.pdf")); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { + ((FileObjectEntity) invocation.getArgument(0)).setId(801L); + return 1; + }); + + var result = service.upload(10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 7L, file); + + assertThat(result).extracting(InternalBusinessFileService.Metadata::ownerScope, + InternalBusinessFileService.Metadata::tenantId, + InternalBusinessFileService.Metadata::businessType, + InternalBusinessFileService.Metadata::businessId) + .containsExactly("CUSTOMER", 10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "701"); + verify(storage).store(10001L, "701", file, BusinessFileCategory.OVERSEAS_EXCEPTION_EVIDENCE); + assertThatThrownBy(() -> service.upload(0L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 7L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("明确客户"); + } + + @Test void overseasExceptionEvidenceMetadataFailsClosedForEveryOwnershipMismatch() { + FileObjectEntity value = new FileObjectEntity(); + value.setId(801L); value.setTenantId(10001L); + value.setBusinessType("OVERSEAS_EXCEPTION_EVIDENCE"); value.setBusinessId("701"); + value.setOriginalName("evidence.pdf"); value.setObjectKey("business-files/10001/701/evidence.pdf"); + value.setContentType("application/pdf"); value.setSha256("b".repeat(64)); value.setSizeBytes(8L); + when(files.selectOne(any())).thenReturn(value, null, null, null); + + assertThat(service.metadata(10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 801L).fileId()) + .isEqualTo(801L); + assertThatThrownBy(() -> service.metadata(10002L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 801L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.metadata(10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "702", 801L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.metadata(10001L, "INBOUND_HOLD_EVIDENCE", "701", 801L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + } + } diff --git a/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java b/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java new file mode 100644 index 000000000..f2f826bf9 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java @@ -0,0 +1,37 @@ +package com.baiyue.file.service; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class OverseasExceptionEvidenceContractTest { + @Test + void categoryAndOpenApiFreezeCustomerExceptionOwnership() throws Exception { + BusinessFileCategory category = BusinessFileCategory.require("OVERSEAS_EXCEPTION_EVIDENCE"); + assertThat(category.maxSizeBytes()).isEqualTo(10L * 1024 * 1024); + assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.STANDARD); + + try (var stream = getClass().getResourceAsStream( + "/openapi/overseas-exception-evidence-v1.yaml")) { + assertThat(stream).isNotNull(); + String yaml = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + assertThat(yaml).contains( + "/internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}:", + "ownerScope: {type: string, enum: [CUSTOMER]}", + "businessType: {type: string, enum: [OVERSEAS_EXCEPTION_EVIDENCE]}", + "maximum: 10485760"); + } + } + + @Test + void initializationSqlHasEnoughWidthAndDocumentsTheCategory() throws Exception { + try (var stream = getClass().getResourceAsStream("/db/file.sql")) { + assertThat(stream).isNotNull(); + String sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + assertThat(sql).contains("business_type VARCHAR(32)", "OVERSEAS_EXCEPTION_EVIDENCE"); + assertThat("OVERSEAS_EXCEPTION_EVIDENCE").hasSizeLessThanOrEqualTo(32); + } + } +} From db98da568974f258ae6ee07e7c801b6195abf7c2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 18:30:04 +0800 Subject: [PATCH 2/3] fix(lastleg): restore returned inventory from shipment source --- .../service/InventoryCommandService.java | 232 +++++++++++++++++- .../command/InventoryCommandServiceTest.java | 104 +++++++- 2 files changed, 334 insertions(+), 2 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index f36082193..9b7511135 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -3,6 +3,8 @@ package com.baiyue.lastleg.inventory.command.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreRequest; +import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreResponse; import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; import com.baiyue.lastleg.inventory.entity.InventoryBalance; import com.baiyue.lastleg.inventory.entity.InventoryLedger; @@ -27,7 +29,9 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.LocalDateTime; import java.util.HexFormat; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; @Service @@ -245,6 +249,175 @@ public class InventoryCommandService { }); } + @Transactional + public ReturnedInventoryRestoreResponse restoreReturnedInventory(String customerId, String operatorId, + ReturnedInventoryRestoreRequest request) { + if (request == null) throw badRequest("Returned inventory restore request is required"); + String exceptionNo = required(request.exceptionNo(), "Exception number is required"); + String outboundNo = required(request.outboundNo(), "Outbound number is required"); + String warehouseId = required(request.overseasWarehouseId(), "Overseas warehouse ID is required"); + long warehouseResourceId; + try { + warehouseResourceId = Long.parseLong(warehouseId); + } catch (NumberFormatException exception) { + throw badRequest("Overseas warehouse ID must be numeric"); + } + if (request.inboundOrderId() == null || request.inboundOrderId() <= 0) { + throw badRequest("Inbound order ID is required"); + } + if (request.sources() == null || request.sources().isEmpty()) { + throw badRequest("Returned inventory sources are required"); + } + if (request.receipts() == null || request.receipts().isEmpty()) { + throw badRequest("Returned inventory receipts are required"); + } + if (request.evidenceFileIds() == null || request.evidenceFileIds().isEmpty() + || request.evidenceFileIds().stream().anyMatch(value -> trim(value) == null)) { + throw badRequest("Return evidence file IDs are required"); + } + + Map sources = new LinkedHashMap<>(); + for (ReturnedInventoryRestoreRequest.SourceItem source : request.sources()) { + if (source == null || source.outboundPalletItemId() == null || source.outboundPalletItemId() <= 0 + || source.inventoryPositionId() == null || source.inventoryPositionId() <= 0 + || source.maxReturnPackages() <= 0) { + throw badRequest("A valid returned inventory source is required"); + } + String sourceSku = required(source.skuCode(), "Returned inventory source SKU is required"); + ReturnedInventoryRestoreRequest.SourceItem normalized = new ReturnedInventoryRestoreRequest.SourceItem( + source.outboundPalletItemId(), source.inventoryPositionId(), sourceSku, source.maxReturnPackages()); + if (sources.putIfAbsent(source.outboundPalletItemId(), normalized) != null) { + throw badRequest("Returned inventory source must be unique"); + } + } + Map requestedBySource = new LinkedHashMap<>(); + Map receiptTargets = new LinkedHashMap<>(); + for (ReturnedInventoryRestoreRequest.Receipt receipt : request.receipts()) { + ReturnedInventoryRestoreRequest.SourceItem source = receipt == null ? null + : sources.get(receipt.outboundPalletItemId()); + if (source == null || !Objects.equals(source.inventoryPositionId(), receipt.inventoryPositionId())) { + throw badRequest("Return receipt must match an immutable outbound inventory source"); + } + if (receipt.receivedPackages() <= 0 || receipt.targetPalletId() == null || receipt.targetPalletId() <= 0 + || receipt.targetLocationId() == null || receipt.targetLocationId() <= 0) { + throw badRequest("A valid returned inventory receipt is required"); + } + required(receipt.targetPalletNo(), "Target pallet number is required"); + required(receipt.targetLocationCode(), "Target location code is required"); + String receiptTarget = receipt.outboundPalletItemId() + ":" + receipt.targetPalletId(); + if (receiptTargets.putIfAbsent(receiptTarget, Boolean.TRUE) != null) { + throw badRequest("Return receipt source and target pallet must be unique"); + } + requestedBySource.merge(source.outboundPalletItemId(), receipt.receivedPackages(), Long::sum); + } + for (ReturnedInventoryRestoreRequest.SourceItem source : sources.values()) { + long requested = requestedBySource.getOrDefault(source.outboundPalletItemId(), 0L); + if (requested <= 0 || requested > source.maxReturnPackages()) { + throw conflict("Returned packages exceed the approved source limit"); + } + } + + String normalizedKey = required(request.idempotencyKey(), "Idempotency key is required"); + String requestHash = hash(request); + InventoryOperation existing = operations.findIdempotency(customerId, normalizedKey); + if (existing != null) return replayReturned(existing, requestHash); + + InventoryOperation operation = new InventoryOperation(); + operation.setId(IdWorker.getId()); + operation.setCustomerId(customerId); + operation.setIdempotencyKey(normalizedKey); + operation.setOperationType("RETURN_STOCK_IN"); + operation.setRequestHash(requestHash); + operation.setStatus("PROCESSING"); + operation.setCreatedAt(LocalDateTime.now()); + operation.setUpdatedAt(operation.getCreatedAt()); + try { + operations.insert(operation); + } catch (DuplicateKeyException exception) { + InventoryOperation concurrent = operations.findIdempotency(customerId, normalizedKey); + if (concurrent != null) return replayReturned(concurrent, requestHash); + throw exception; + } + + Map lockedSources = new LinkedHashMap<>(); + Map balanceBefore = new LinkedHashMap<>(); + for (ReturnedInventoryRestoreRequest.SourceItem source : sources.values()) { + Locked locked = locked(customerId, source.inventoryPositionId()); + InventoryBalance balance = locked.balance(); + if (!Objects.equals(balance.getInboundOrderId(), request.inboundOrderId())) { + throw conflict("Returned inventory source inbound order does not match"); + } + if (!warehouseId.equals(trim(balance.getOverseasWarehouseId()))) { + throw conflict("Returned inventory source warehouse does not match"); + } + long shipped = commands.shippedPackages(customerId, source.inventoryPositionId(), outboundNo, + source.skuCode()); + long restored = commands.restoredPackages(customerId, returnSourceIdentity(source), source.skuCode()); + long requested = requestedBySource.get(source.outboundPalletItemId()); + if (shipped <= 0) throw conflict("Immutable outbound shipment source was not found"); + if (restored + requested > shipped) { + throw conflict("Returned packages exceed the remaining shipped packages"); + } + lockedSources.put(source.outboundPalletItemId(), locked); + balanceBefore.putIfAbsent(balance.getId(), value(balance.getAvailablePackages())); + } + + long restoredPackages = 0; + List ledgerIds = new java.util.ArrayList<>(); + for (ReturnedInventoryRestoreRequest.Receipt receipt : request.receipts()) { + ReturnedInventoryRestoreRequest.SourceItem source = sources.get(receipt.outboundPalletItemId()); + Locked sourceLocked = lockedSources.get(receipt.outboundPalletItemId()); + InventoryBalance balance = sourceLocked.balance(); + WarehouseResourceGateway.WarehousePalletReference pallet = warehouseGateway.resolvePallet( + receipt.targetPalletId()); + if (pallet == null || pallet.palletId() != receipt.targetPalletId() + || pallet.warehouseId() != warehouseResourceId + || pallet.locationId() != receipt.targetLocationId() + || !required(receipt.targetPalletNo(), "Target pallet number is required").equals(pallet.palletNo())) { + throw conflict("Target pallet does not match the returned inventory warehouse and location"); + } + WarehouseResourceGateway.WarehouseLocationReference location = warehouseGateway.resolveLocation( + warehouseResourceId, receipt.targetLocationId()); + if (location == null || location.locationId() != receipt.targetLocationId() + || !required(receipt.targetLocationCode(), "Target location code is required") + .equals(location.locationCode())) { + throw conflict("Target location does not match the returned inventory warehouse"); + } + + InventoryPosition target = returnedPosition(customerId, balance, exceptionNo, receipt, location); + long before = value(target.getAvailablePackages()); + target.setAvailablePackages(before + receipt.receivedPackages()); + target.setStatus("OCCUPIED"); + target.setVersion(value(target.getVersion()) + 1); + target.setUpdatedAt(LocalDateTime.now()); + positions.updateById(target); + + balance.setAvailablePackages(value(balance.getAvailablePackages()) + receipt.receivedPackages()); + touch(balance); + balances.updateById(balance); + InventoryLedger ledger = ledger(customerId, operatorId, operation.getId(), balance, target, + source.skuCode(), "RETURN_STOCK_IN", "SHIPPED", "AVAILABLE", before, + receipt.receivedPackages(), before + receipt.receivedPackages(), exceptionNo, + balance.getInboundOrderNo(), outboundNo, returnSourceIdentity(source), + normalizedKey + ":" + receipt.outboundPalletItemId() + ":" + target.getId(), + "Exception return received with evidence " + request.evidenceFileIds().size()); + ledgerIds.add(ledger.getId()); + restoredPackages += receipt.receivedPackages(); + } + long beforePackages = balanceBefore.values().stream().mapToLong(Long::longValue).sum(); + long afterPackages = balanceBefore.keySet().stream() + .mapToLong(balanceId -> value(lockedSources.values().stream() + .map(Locked::balance).filter(balance -> balance.getId().equals(balanceId)).findFirst().orElseThrow() + .getAvailablePackages())).sum(); + ReturnedInventoryRestoreResponse response = new ReturnedInventoryRestoreResponse(operation.getId(), + List.copyOf(ledgerIds), beforePackages, restoredPackages, afterPackages, false, LocalDateTime.now()); + operation.setStatus("COMPLETED"); + operation.setResultJson(write(response)); + operation.setUpdatedAt(response.completedAt()); + operations.updateById(operation); + return response; + } + @Transactional public InventoryCommandResponse adjust(String customerId, String operatorId, InventoryCommandRequests.Adjustment request) { @@ -483,7 +656,63 @@ public class InventoryCommandService { } } - private void ledger(String customerId, String operatorId, Long operationId, InventoryBalance balance, + private InventoryPosition returnedPosition(String customerId, InventoryBalance balance, String exceptionNo, + ReturnedInventoryRestoreRequest.Receipt receipt, + WarehouseResourceGateway.WarehouseLocationReference location) { + String batchNo = "RETURN:" + exceptionNo; + InventoryPosition position = positions.lockPosition(customerId, balance.getId(), receipt.targetPalletId(), batchNo); + if (position != null) { + if (!Objects.equals(position.getWarehouseLocationId(), location.locationId()) + || !Objects.equals(position.getWarehouseLocationCode(), location.locationCode()) + || !Objects.equals(position.getPalletNo(), receipt.targetPalletNo())) { + throw conflict("Returned inventory target position snapshot does not match"); + } + return position; + } + position = new InventoryPosition(); + position.setId(IdWorker.getId()); + position.setCustomerId(customerId); + position.setBalanceId(balance.getId()); + position.setOverseasWarehouseId(balance.getOverseasWarehouseId()); + position.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); + position.setPalletId(receipt.targetPalletId()); + position.setPalletNo(receipt.targetPalletNo().trim()); + position.setBatchNo(batchNo); + position.setWarehouseLocationId(location.locationId()); + position.setWarehouseLocationCode(location.locationCode()); + position.setAvailablePackages(0L); + position.setHoldPackages(0L); + position.setAllocatedPackages(0L); + position.setPickedPackages(0L); + position.setReceivingPackages(0L); + position.setStatus("OCCUPIED"); + position.setVersion(0L); + position.setCreatedAt(LocalDateTime.now()); + position.setUpdatedAt(position.getCreatedAt()); + positions.insert(position); + return position; + } + + private ReturnedInventoryRestoreResponse replayReturned(InventoryOperation operation, String requestHash) { + if (!"RETURN_STOCK_IN".equals(operation.getOperationType()) + || !requestHash.equals(operation.getRequestHash())) { + throw conflict("Idempotency key was already used with a different inventory request"); + } + if (!"COMPLETED".equals(operation.getStatus()) || trim(operation.getResultJson()) == null) { + throw conflict("Inventory operation is still processing"); + } + try { + return objectMapper.readValue(operation.getResultJson(), ReturnedInventoryRestoreResponse.class).asReplay(); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot read returned inventory idempotency result", exception); + } + } + + private static String returnSourceIdentity(ReturnedInventoryRestoreRequest.SourceItem source) { + return "RETURN_SOURCE:" + source.outboundPalletItemId() + ":" + source.inventoryPositionId(); + } + + private InventoryLedger ledger(String customerId, String operatorId, Long operationId, InventoryBalance balance, InventoryPosition position, String skuCode, String businessType, String from, String to, long beforePackages, long deltaPackages, long afterPackages, String businessOrderNo, String inboundOrderNo, String outboundOrderNo, @@ -524,6 +753,7 @@ public class InventoryCommandService { ledger.setOccurredAt(LocalDateTime.now()); ledger.setCreatedAt(ledger.getOccurredAt()); ledgers.insert(ledger); + return ledger; } private static void mergeSkuSnapshot(InventoryBalance balance, String skuCode) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java index 6da77c0ff..ba5f4db32 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java @@ -2,6 +2,8 @@ package com.baiyue.lastleg.inventory.command; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreRequest; +import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreResponse; import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.baiyue.lastleg.inventory.entity.InventoryBalance; @@ -17,6 +19,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.apache.ibatis.annotations.Select; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -57,7 +60,7 @@ class InventoryCommandServiceTest { ReflectionTestUtils.setField(service, "operations", operations); ReflectionTestUtils.setField(service, "commands", commands); ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway); - ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); lenient().when(operations.findIdempotency(any(), any())).thenReturn(null); lenient().when(commands.lockInboundTransitBalances(any(), any(), any())).thenReturn(List.of()); } @@ -329,6 +332,97 @@ class InventoryCommandServiceTest { verify(operations).findIdempotency("200", "platform-hold-1"); } + @Test + void restoresReturnedInventoryFromImmutableShipmentSourceIntoVerifiedTarget() throws Exception { + assertTrue(InventoryCommandService.class.getMethod("restoreReturnedInventory", String.class, String.class, + ReturnedInventoryRestoreRequest.class).getAnnotation(Transactional.class) != null); + InventoryBalance balance = balance(200L, 2); + balance.setOverseasWarehouseId("900"); + InventoryPosition source = position(201L, 200L, 0); + when(commands.lockPosition("100", 201L)).thenReturn(source); + when(commands.lockBalance("100", 200L)).thenReturn(balance); + when(commands.shippedPackages("100", 201L, "OUT-1", "SKU-1")).thenReturn(8L); + when(commands.restoredPackages("100", "RETURN_SOURCE:301:201", "SKU-1")).thenReturn(1L); + when(warehouseGateway.resolvePallet(400L)).thenReturn(new WarehouseResourceGateway.WarehousePalletReference( + 400L, "PLT-RETURN", 900L, 500L, "IDLE", null, 0)); + when(warehouseGateway.resolveLocation(900L, 500L)).thenReturn( + new WarehouseResourceGateway.WarehouseLocationReference(500L, "LOC-RETURN", "STORAGE", null, "ACTIVE")); + + ReturnedInventoryRestoreResponse result = service.restoreReturnedInventory("100", "7", returnRequest(3)); + + assertEquals(2, result.beforePackages()); + assertEquals(3, result.restoredPackages()); + assertEquals(5, result.afterPackages()); + assertFalse(result.replayed()); + ArgumentCaptor target = ArgumentCaptor.forClass(InventoryPosition.class); + verify(positions).insert(target.capture()); + assertEquals("RETURN:EX-1", target.getValue().getBatchNo()); + assertEquals("LOC-RETURN", target.getValue().getWarehouseLocationCode()); + assertEquals(3L, target.getValue().getAvailablePackages()); + ArgumentCaptor ledger = ArgumentCaptor.forClass(InventoryLedger.class); + verify(ledgers).insert(ledger.capture()); + assertEquals("RETURN_STOCK_IN", ledger.getValue().getBusinessType()); + assertEquals("RETURN_SOURCE:301:201", ledger.getValue().getSourceEventId()); + assertEquals("OUT-1", ledger.getValue().getOutboundOrderNo()); + } + + @Test + void returnedInventoryReplayDoesNotMutateInventoryTwice() { + InventoryBalance balance = balance(200L, 0); + balance.setOverseasWarehouseId("900"); + InventoryPosition source = position(201L, 200L, 0); + when(commands.lockPosition("100", 201L)).thenReturn(source); + when(commands.lockBalance("100", 200L)).thenReturn(balance); + when(commands.shippedPackages("100", 201L, "OUT-1", "SKU-1")).thenReturn(8L); + when(commands.restoredPackages("100", "RETURN_SOURCE:301:201", "SKU-1")).thenReturn(0L); + when(warehouseGateway.resolvePallet(400L)).thenReturn(new WarehouseResourceGateway.WarehousePalletReference( + 400L, "PLT-RETURN", 900L, 500L, "IDLE", null, 0)); + when(warehouseGateway.resolveLocation(900L, 500L)).thenReturn( + new WarehouseResourceGateway.WarehouseLocationReference(500L, "LOC-RETURN", "STORAGE", null, "ACTIVE")); + ReturnedInventoryRestoreRequest request = returnRequest(3); + service.restoreReturnedInventory("100", "7", request); + ArgumentCaptor operation = ArgumentCaptor.forClass(InventoryOperation.class); + verify(operations).insert(operation.capture()); + when(operations.findIdempotency("100", "return-1")).thenReturn(operation.getValue()); + + ReturnedInventoryRestoreResponse replay = service.restoreReturnedInventory("100", "7", request); + + assertTrue(replay.replayed()); + verify(positions, times(1)).insert(any(InventoryPosition.class)); + verify(ledgers, times(1)).insert(any(InventoryLedger.class)); + } + + @Test + void rejectsReturnBeyondRemainingImmutableShipmentWithoutInventoryMutation() { + InventoryBalance balance = balance(200L, 0); + balance.setOverseasWarehouseId("900"); + when(commands.lockPosition("100", 201L)).thenReturn(position(201L, 200L, 0)); + when(commands.lockBalance("100", 200L)).thenReturn(balance); + when(commands.shippedPackages("100", 201L, "OUT-1", "SKU-1")).thenReturn(4L); + when(commands.restoredPackages("100", "RETURN_SOURCE:301:201", "SKU-1")).thenReturn(2L); + + assertThrows(ResponseStatusException.class, + () -> service.restoreReturnedInventory("100", "7", returnRequest(3))); + + verify(positions, never()).insert(any(InventoryPosition.class)); + verify(positions, never()).updateById(any(InventoryPosition.class)); + verify(balances, never()).updateById(any(InventoryBalance.class)); + verify(ledgers, never()).insert(any(InventoryLedger.class)); + } + + @Test + void returnRestoreSqlIsBoundToImmutableShipLedgerAndSourceIdentity() throws Exception { + String shippedSql = String.join(" ", InventoryCommandMapper.class.getMethod("shippedPackages", + String.class, Long.class, String.class, String.class).getAnnotation(Select.class).value()); + assertTrue(shippedSql.contains("position_id=#{positionId}")); + assertTrue(shippedSql.contains("outbound_order_no=#{outboundNo}")); + assertTrue(shippedSql.contains("business_type='SHIP'")); + String restoredSql = String.join(" ", InventoryCommandMapper.class.getMethod("restoredPackages", + String.class, String.class, String.class).getAnnotation(Select.class).value()); + assertTrue(restoredSql.contains("source_event_id=#{sourceIdentity}")); + assertTrue(restoredSql.contains("business_type='RETURN_STOCK_IN'")); + } + private InventoryCommandRequests.StockIn stockIn(String inbound, String key, long packages, long quantity) { return new InventoryCommandRequests.StockIn("100", "Customer 100", "WH-1", "WH1", "Warehouse 1", "DEST-1", "ONT8", "ONT8", 101L, inbound, "IN-CODE-1", "SKU-1", "Product 1", null, @@ -342,6 +436,14 @@ class InventoryCommandServiceTest { packages, "ORD-1", "EVT-1", key, null); } + private ReturnedInventoryRestoreRequest returnRequest(long packages) { + return new ReturnedInventoryRestoreRequest("EX-1", "OUT-1", "900", 101L, + List.of(new ReturnedInventoryRestoreRequest.SourceItem(301L, 201L, "SKU-1", 5)), + List.of(new ReturnedInventoryRestoreRequest.Receipt(301L, 201L, packages, + 400L, "PLT-RETURN", 500L, "LOC-RETURN")), + "return-1", List.of("FILE-1")); + } + private InventoryBalance balance(Long id, long available) { InventoryBalance value = new InventoryBalance(); value.setId(id); value.setCustomerId("100"); value.setCustomerName("Customer 100"); From f6b0bca2bf6352356665d11583affa8ec6fd591d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 18:32:59 +0800 Subject: [PATCH 3/3] test(firstleg): mock container tier mapper --- .../firstleg/repository/FbaPricingRepositoryRuleListTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java index 48004d23f..b90f80014 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java @@ -17,6 +17,7 @@ import com.baiyue.firstleg.mapper.InboundFeeMapper; import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.mapper.InboundPricingIdempotencyMapper; import com.baiyue.firstleg.mapper.InboundPricingMapper; +import com.baiyue.firstleg.mapper.ContainerTierPricingMapper; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.apache.ibatis.session.Configuration; import org.junit.jupiter.api.BeforeAll; @@ -46,6 +47,7 @@ class FbaPricingRepositoryRuleListTest { @MockBean private InboundFeeItemMapper feeItems; @MockBean private InboundPricingIdempotencyMapper idempotency; @MockBean private InboundOutboxMapper outbox; + @MockBean private ContainerTierPricingMapper containerTierPricing; @Resource private FbaPricingRepository repository; @BeforeAll static void initializeEntityMetadata() {