Test #1

Merged
admin merged 1726 commits from test into main 2026-09-17 11:52:45 +08:00
9 changed files with 453 additions and 131 deletions
Showing only changes of commit 3e7fa40a99 - Show all commits
+1
View File
@@ -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 保存 |
@@ -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),
+1 -1
View File
@@ -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 '对象存储键',
@@ -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}
@@ -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("归属不匹配");
}
}
@@ -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);
}
}
}
@@ -18,6 +18,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;
@@ -28,7 +28,6 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
@@ -253,46 +252,80 @@ public class InventoryCommandService {
@Transactional
public ReturnedInventoryRestoreResponse restoreReturnedInventory(String customerId, String operatorId,
ReturnedInventoryRestoreRequest request) {
String exceptionNo = required(request.exceptionNo(), "Return exception number is required");
String outboundNo = required(request.outboundNo(), "Return outbound order number is required");
String warehouseId = required(request.overseasWarehouseId(), "Return warehouse is required");
String idempotencyKey = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
InventoryOperation existing = operations.findIdempotency(customerId, idempotencyKey);
if (existing != null) return replayReturned(existing, requestHash);
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("Return inventory sources are required");
throw badRequest("Returned inventory sources are required");
}
if (request.receipts() == null || request.receipts().isEmpty()) {
throw badRequest("Return inventory receipts are required");
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<String, ReturnedInventoryRestoreRequest.SourceItem> sources = new LinkedHashMap<>();
Map<Long, ReturnedInventoryRestoreRequest.SourceItem> sources = new LinkedHashMap<>();
for (ReturnedInventoryRestoreRequest.SourceItem source : request.sources()) {
if (source.outboundPalletItemId() == null || source.inventoryPositionId() == null
if (source == null || source.outboundPalletItemId() == null || source.outboundPalletItemId() <= 0
|| source.inventoryPositionId() == null || source.inventoryPositionId() <= 0
|| source.maxReturnPackages() <= 0) {
throw badRequest("Return inventory source is incomplete");
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");
}
String key = returnSourceKey(source.outboundPalletItemId(), source.inventoryPositionId());
if (sources.putIfAbsent(key, source) != null) throw badRequest("Return inventory source is duplicated");
}
Map<String, Long> receiptTotals = new LinkedHashMap<>();
Map<Long, Long> requestedBySource = new LinkedHashMap<>();
Map<String, Boolean> receiptTargets = new LinkedHashMap<>();
for (ReturnedInventoryRestoreRequest.Receipt receipt : request.receipts()) {
if (receipt.outboundPalletItemId() == null || receipt.inventoryPositionId() == null
|| receipt.receivedPackages() <= 0 || receipt.targetPalletId() == null
|| receipt.targetLocationId() == null || trim(receipt.targetPalletNo()) == null
|| trim(receipt.targetLocationCode()) == null) {
throw badRequest("Return inventory receipt is incomplete");
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");
}
String key = returnSourceKey(receipt.outboundPalletItemId(), receipt.inventoryPositionId());
if (!sources.containsKey(key)) throw conflict("Return inventory receipt does not match an approved source");
receiptTotals.merge(key, receipt.receivedPackages(), Math::addExact);
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(idempotencyKey);
operation.setIdempotencyKey(normalizedKey);
operation.setOperationType("RETURN_STOCK_IN");
operation.setRequestHash(requestHash);
operation.setStatus("PROCESSING");
@@ -301,64 +334,86 @@ public class InventoryCommandService {
try {
operations.insert(operation);
} catch (DuplicateKeyException exception) {
InventoryOperation concurrent = operations.findIdempotency(customerId, idempotencyKey);
InventoryOperation concurrent = operations.findIdempotency(customerId, normalizedKey);
if (concurrent != null) return replayReturned(concurrent, requestHash);
throw exception;
}
Map<String, Locked> lockedSources = new LinkedHashMap<>();
for (Map.Entry<String, ReturnedInventoryRestoreRequest.SourceItem> entry : sources.entrySet()) {
ReturnedInventoryRestoreRequest.SourceItem source = entry.getValue();
Map<Long, Locked> lockedSources = new LinkedHashMap<>();
Map<Long, Long> balanceBefore = new LinkedHashMap<>();
for (ReturnedInventoryRestoreRequest.SourceItem source : sources.values()) {
Locked locked = locked(customerId, source.inventoryPositionId());
if (!Objects.equals(locked.balance().getInboundOrderId(), request.inboundOrderId())
|| !warehouseId.equals(trim(locked.balance().getOverseasWarehouseId()))) {
throw conflict("Return inventory source does not belong to the exception inbound order and warehouse");
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");
}
String sourceIdentity = returnSourceIdentity(exceptionNo, source);
long shipped = commands.shippedPackages(customerId, source.inventoryPositionId(), outboundNo,
sku(source.skuCode()));
long restored = commands.restoredPackages(customerId, sourceIdentity, sku(source.skuCode()));
long allowed = Math.min(source.maxReturnPackages(), shipped) - restored;
long requested = receiptTotals.getOrDefault(entry.getKey(), 0L);
if (requested > allowed) throw conflict("Return inventory receipt exceeds the remaining shipped packages");
lockedSources.put(entry.getKey(), locked);
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()));
}
List<Long> ledgerIds = new ArrayList<>();
long beforePackages = 0;
long restoredPackages = 0;
int sequence = 0;
String batchNo = ("RETURN-" + exceptionNo).substring(0, Math.min(128, 7 + exceptionNo.length()));
List<Long> ledgerIds = new java.util.ArrayList<>();
for (ReturnedInventoryRestoreRequest.Receipt receipt : request.receipts()) {
String key = returnSourceKey(receipt.outboundPalletItemId(), receipt.inventoryPositionId());
ReturnedInventoryRestoreRequest.SourceItem source = sources.get(key);
Locked locked = lockedSources.get(key);
InventoryBalance balance = locked.balance();
InventoryPosition target = returnPosition(customerId, balance, receipt, batchNo);
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());
beforePackages = Math.addExact(beforePackages, before);
target.setAvailablePackages(Math.addExact(before, receipt.receivedPackages()));
target.setAvailablePackages(before + receipt.receivedPackages());
target.setStatus("OCCUPIED");
target.setVersion(value(target.getVersion()) + 1);
target.setUpdatedAt(LocalDateTime.now());
positions.updateById(target);
balance.setAvailablePackages(Math.addExact(value(balance.getAvailablePackages()), receipt.receivedPackages()));
balance.setAvailablePackages(value(balance.getAvailablePackages()) + receipt.receivedPackages());
touch(balance);
balances.updateById(balance);
restoredPackages = Math.addExact(restoredPackages, receipt.receivedPackages());
InventoryLedger ledger = returnLedger(customerId, operatorId, operation.getId(), balance, target,
source, exceptionNo, outboundNo, returnSourceIdentity(exceptionNo, source), idempotencyKey,
sequence++, before, receipt.receivedPackages(), request.evidenceFileIds());
ledgers.insert(ledger);
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();
}
LocalDateTime completedAt = LocalDateTime.now();
ReturnedInventoryRestoreResponse response = new ReturnedInventoryRestoreResponse(operation.getId(), ledgerIds,
beforePackages, restoredPackages, Math.addExact(beforePackages, restoredPackages), false, completedAt);
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(completedAt);
operation.setUpdatedAt(response.completedAt());
operations.updateById(operation);
return response;
}
@@ -541,42 +596,6 @@ public class InventoryCommandService {
}
}
private InventoryPosition returnPosition(String customerId, InventoryBalance balance,
ReturnedInventoryRestoreRequest.Receipt receipt, String batchNo) {
InventoryPosition position = positions.lockPosition(customerId, balance.getId(), receipt.targetPalletId(), batchNo);
if (position != null) {
if (!required(receipt.targetPalletNo(), "Return pallet number is required").equals(position.getPalletNo())
|| !Objects.equals(receipt.targetLocationId(), position.getWarehouseLocationId())
|| !required(receipt.targetLocationCode(), "Return location code is required")
.equals(position.getWarehouseLocationCode())) {
throw conflict("Return target pallet or location changed for the same exception");
}
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(required(receipt.targetPalletNo(), "Return pallet number is required"));
position.setBatchNo(batchNo);
position.setWarehouseLocationId(receipt.targetLocationId());
position.setWarehouseLocationCode(required(receipt.targetLocationCode(), "Return location code is required"));
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());
try {
positions.insert(position);
return position;
} catch (DuplicateKeyException exception) {
InventoryPosition concurrent = positions.lockPosition(customerId, balance.getId(), receipt.targetPalletId(), batchNo);
if (concurrent != null) return concurrent;
throw exception;
}
}
private Locked locked(String customerId, Long positionId) {
InventoryPosition position = commands.lockPosition(customerId, positionId);
if (position == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory position not found");
@@ -637,8 +656,46 @@ public class InventoryCommandService {
}
}
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())) {
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) {
@@ -647,43 +704,15 @@ public class InventoryCommandService {
try {
return objectMapper.readValue(operation.getResultJson(), ReturnedInventoryRestoreResponse.class).asReplay();
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Cannot read return inventory idempotency result", exception);
throw new IllegalStateException("Cannot read returned inventory idempotency result", exception);
}
}
private InventoryLedger returnLedger(String customerId, String operatorId, Long operationId,
InventoryBalance balance, InventoryPosition position,
ReturnedInventoryRestoreRequest.SourceItem source,
String exceptionNo, String outboundNo, String sourceIdentity,
String idempotencyKey, int sequence, long before, long delta,
List<String> evidenceFileIds) {
InventoryLedger ledger = new InventoryLedger();
ledger.setId(IdWorker.getId()); ledger.setCustomerId(customerId); ledger.setCustomerName(balance.getCustomerName());
ledger.setBalanceId(balance.getId()); ledger.setPositionId(position.getId());
ledger.setOverseasWarehouseId(balance.getOverseasWarehouseId());
ledger.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); ledger.setInboundOrderId(balance.getInboundOrderId());
ledger.setPalletId(position.getPalletId()); ledger.setPalletNo(position.getPalletNo()); ledger.setBatchNo(position.getBatchNo());
ledger.setWarehouseLocationCode(position.getWarehouseLocationCode()); ledger.setSkuCode(sku(source.skuCode()));
ledger.setBusinessType("RETURN_STOCK_IN"); ledger.setBusinessOrderId(balance.getInboundOrderId());
ledger.setBusinessOrderNo(exceptionNo); ledger.setInboundCode(balance.getInboundCode());
ledger.setInboundOrderNo(balance.getInboundOrderNo()); ledger.setOutboundOrderNo(outboundNo);
ledger.setSourceEventId(sourceIdentity); ledger.setIdempotencyKey(idempotencyKey + ":RETURN:" + sequence + ":" + operationId);
ledger.setStatusFrom("RETURNED"); ledger.setStatusTo("AVAILABLE"); ledger.setBeforePackages(before);
ledger.setDeltaPackages(delta); ledger.setAfterPackages(Math.addExact(before, delta)); ledger.setOperatorId(trim(operatorId));
ledger.setRemark("Exception return received; evidenceCount=" + (evidenceFileIds == null ? 0 : evidenceFileIds.size()));
ledger.setOccurredAt(LocalDateTime.now()); ledger.setCreatedAt(ledger.getOccurredAt());
return ledger;
private static String returnSourceIdentity(ReturnedInventoryRestoreRequest.SourceItem source) {
return "RETURN_SOURCE:" + source.outboundPalletItemId() + ":" + source.inventoryPositionId();
}
private static String returnSourceKey(Long outboundPalletItemId, Long inventoryPositionId) {
return outboundPalletItemId + ":" + inventoryPositionId;
}
private static String returnSourceIdentity(String exceptionNo, ReturnedInventoryRestoreRequest.SourceItem source) {
return "RETURN:" + exceptionNo + ":" + source.outboundPalletItemId() + ":" + source.inventoryPositionId();
}
private void ledger(String customerId, String operatorId, Long operationId, InventoryBalance balance,
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,
@@ -724,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) {
@@ -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<InventoryPosition> 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<InventoryLedger> 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<InventoryOperation> 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");