Merge remote-tracking branch 'origin/test' into dev
# Conflicts: # lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java
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=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg |
|
||||||
| 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 |
|
| 入库扣货凭证 | 通用内部业务文件入口;固定 `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 保存 |
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ enum BusinessFileCategory {
|
|||||||
CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_UNLOAD_PHOTO(true, 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_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_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
LOADING_LIST_CUSTOMS_RESULT(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),
|
ORDER_BATCH_IMPORT_SOURCE(true, FormatPolicy.XLSX, Limits.ORDER_IMPORT, BusinessIdPolicy.ORDER_BATCH_NO),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ USE baiyue_file;
|
|||||||
CREATE TABLE IF NOT EXISTS file_object (
|
CREATE TABLE IF NOT EXISTS file_object (
|
||||||
id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)',
|
id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)',
|
||||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '所有者范围键:0为平台,正整数为客户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',
|
business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID;装柜证据固定为物理containerBookingId',
|
||||||
original_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
|
original_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
|
||||||
object_key VARCHAR(512) 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("明确客户");
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -18,6 +18,7 @@ import com.baiyue.firstleg.mapper.InboundFeeMapper;
|
|||||||
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
|
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
|
||||||
import com.baiyue.firstleg.mapper.InboundPricingIdempotencyMapper;
|
import com.baiyue.firstleg.mapper.InboundPricingIdempotencyMapper;
|
||||||
import com.baiyue.firstleg.mapper.InboundPricingMapper;
|
import com.baiyue.firstleg.mapper.InboundPricingMapper;
|
||||||
|
import com.baiyue.firstleg.mapper.ContainerTierPricingMapper;
|
||||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
import org.apache.ibatis.session.Configuration;
|
import org.apache.ibatis.session.Configuration;
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
|||||||
+159
-129
@@ -28,7 +28,6 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -253,46 +252,80 @@ public class InventoryCommandService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public ReturnedInventoryRestoreResponse restoreReturnedInventory(String customerId, String operatorId,
|
public ReturnedInventoryRestoreResponse restoreReturnedInventory(String customerId, String operatorId,
|
||||||
ReturnedInventoryRestoreRequest request) {
|
ReturnedInventoryRestoreRequest request) {
|
||||||
String exceptionNo = required(request.exceptionNo(), "Return exception number is required");
|
if (request == null) throw badRequest("Returned inventory restore request is required");
|
||||||
String outboundNo = required(request.outboundNo(), "Return outbound order number is required");
|
String exceptionNo = required(request.exceptionNo(), "Exception number is required");
|
||||||
String warehouseId = required(request.overseasWarehouseId(), "Return warehouse is required");
|
String outboundNo = required(request.outboundNo(), "Outbound number is required");
|
||||||
String idempotencyKey = required(request.idempotencyKey(), "Idempotency key is required");
|
String warehouseId = required(request.overseasWarehouseId(), "Overseas warehouse ID is required");
|
||||||
String requestHash = hash(request);
|
long warehouseResourceId;
|
||||||
InventoryOperation existing = operations.findIdempotency(customerId, idempotencyKey);
|
try {
|
||||||
if (existing != null) return replayReturned(existing, requestHash);
|
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()) {
|
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()) {
|
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()) {
|
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) {
|
|| 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()) {
|
for (ReturnedInventoryRestoreRequest.Receipt receipt : request.receipts()) {
|
||||||
if (receipt.outboundPalletItemId() == null || receipt.inventoryPositionId() == null
|
ReturnedInventoryRestoreRequest.SourceItem source = receipt == null ? null
|
||||||
|| receipt.receivedPackages() <= 0 || receipt.targetPalletId() == null
|
: sources.get(receipt.outboundPalletItemId());
|
||||||
|| receipt.targetLocationId() == null || trim(receipt.targetPalletNo()) == null
|
if (source == null || !Objects.equals(source.inventoryPositionId(), receipt.inventoryPositionId())) {
|
||||||
|| trim(receipt.targetLocationCode()) == null) {
|
throw badRequest("Return receipt must match an immutable outbound inventory source");
|
||||||
throw badRequest("Return inventory receipt is incomplete");
|
|
||||||
}
|
}
|
||||||
String key = returnSourceKey(receipt.outboundPalletItemId(), receipt.inventoryPositionId());
|
if (receipt.receivedPackages() <= 0 || receipt.targetPalletId() == null || receipt.targetPalletId() <= 0
|
||||||
if (!sources.containsKey(key)) throw conflict("Return inventory receipt does not match an approved source");
|
|| receipt.targetLocationId() == null || receipt.targetLocationId() <= 0) {
|
||||||
receiptTotals.merge(key, receipt.receivedPackages(), Math::addExact);
|
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();
|
InventoryOperation operation = new InventoryOperation();
|
||||||
operation.setId(IdWorker.getId());
|
operation.setId(IdWorker.getId());
|
||||||
operation.setCustomerId(customerId);
|
operation.setCustomerId(customerId);
|
||||||
operation.setIdempotencyKey(idempotencyKey);
|
operation.setIdempotencyKey(normalizedKey);
|
||||||
operation.setOperationType("RETURN_STOCK_IN");
|
operation.setOperationType("RETURN_STOCK_IN");
|
||||||
operation.setRequestHash(requestHash);
|
operation.setRequestHash(requestHash);
|
||||||
operation.setStatus("PROCESSING");
|
operation.setStatus("PROCESSING");
|
||||||
@@ -301,64 +334,86 @@ public class InventoryCommandService {
|
|||||||
try {
|
try {
|
||||||
operations.insert(operation);
|
operations.insert(operation);
|
||||||
} catch (DuplicateKeyException exception) {
|
} catch (DuplicateKeyException exception) {
|
||||||
InventoryOperation concurrent = operations.findIdempotency(customerId, idempotencyKey);
|
InventoryOperation concurrent = operations.findIdempotency(customerId, normalizedKey);
|
||||||
if (concurrent != null) return replayReturned(concurrent, requestHash);
|
if (concurrent != null) return replayReturned(concurrent, requestHash);
|
||||||
throw exception;
|
throw exception;
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Locked> lockedSources = new LinkedHashMap<>();
|
Map<Long, Locked> lockedSources = new LinkedHashMap<>();
|
||||||
for (Map.Entry<String, ReturnedInventoryRestoreRequest.SourceItem> entry : sources.entrySet()) {
|
Map<Long, Long> balanceBefore = new LinkedHashMap<>();
|
||||||
ReturnedInventoryRestoreRequest.SourceItem source = entry.getValue();
|
for (ReturnedInventoryRestoreRequest.SourceItem source : sources.values()) {
|
||||||
Locked locked = locked(customerId, source.inventoryPositionId());
|
Locked locked = locked(customerId, source.inventoryPositionId());
|
||||||
if (!Objects.equals(locked.balance().getInboundOrderId(), request.inboundOrderId())
|
InventoryBalance balance = locked.balance();
|
||||||
|| !warehouseId.equals(trim(locked.balance().getOverseasWarehouseId()))) {
|
if (!Objects.equals(balance.getInboundOrderId(), request.inboundOrderId())) {
|
||||||
throw conflict("Return inventory source does not belong to the exception inbound order and warehouse");
|
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,
|
long shipped = commands.shippedPackages(customerId, source.inventoryPositionId(), outboundNo,
|
||||||
sku(source.skuCode()));
|
source.skuCode());
|
||||||
long restored = commands.restoredPackages(customerId, sourceIdentity, sku(source.skuCode()));
|
long restored = commands.restoredPackages(customerId, returnSourceIdentity(source), source.skuCode());
|
||||||
long allowed = Math.min(source.maxReturnPackages(), shipped) - restored;
|
long requested = requestedBySource.get(source.outboundPalletItemId());
|
||||||
long requested = receiptTotals.getOrDefault(entry.getKey(), 0L);
|
if (shipped <= 0) throw conflict("Immutable outbound shipment source was not found");
|
||||||
if (requested > allowed) throw conflict("Return inventory receipt exceeds the remaining shipped packages");
|
if (restored + requested > shipped) {
|
||||||
lockedSources.put(entry.getKey(), locked);
|
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;
|
long restoredPackages = 0;
|
||||||
int sequence = 0;
|
List<Long> ledgerIds = new java.util.ArrayList<>();
|
||||||
String batchNo = ("RETURN-" + exceptionNo).substring(0, Math.min(128, 7 + exceptionNo.length()));
|
|
||||||
for (ReturnedInventoryRestoreRequest.Receipt receipt : request.receipts()) {
|
for (ReturnedInventoryRestoreRequest.Receipt receipt : request.receipts()) {
|
||||||
String key = returnSourceKey(receipt.outboundPalletItemId(), receipt.inventoryPositionId());
|
ReturnedInventoryRestoreRequest.SourceItem source = sources.get(receipt.outboundPalletItemId());
|
||||||
ReturnedInventoryRestoreRequest.SourceItem source = sources.get(key);
|
Locked sourceLocked = lockedSources.get(receipt.outboundPalletItemId());
|
||||||
Locked locked = lockedSources.get(key);
|
InventoryBalance balance = sourceLocked.balance();
|
||||||
InventoryBalance balance = locked.balance();
|
WarehouseResourceGateway.WarehousePalletReference pallet = warehouseGateway.resolvePallet(
|
||||||
InventoryPosition target = returnPosition(customerId, balance, receipt, batchNo);
|
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());
|
long before = value(target.getAvailablePackages());
|
||||||
beforePackages = Math.addExact(beforePackages, before);
|
target.setAvailablePackages(before + receipt.receivedPackages());
|
||||||
target.setAvailablePackages(Math.addExact(before, receipt.receivedPackages()));
|
|
||||||
target.setStatus("OCCUPIED");
|
target.setStatus("OCCUPIED");
|
||||||
target.setVersion(value(target.getVersion()) + 1);
|
target.setVersion(value(target.getVersion()) + 1);
|
||||||
target.setUpdatedAt(LocalDateTime.now());
|
target.setUpdatedAt(LocalDateTime.now());
|
||||||
positions.updateById(target);
|
positions.updateById(target);
|
||||||
|
|
||||||
balance.setAvailablePackages(Math.addExact(value(balance.getAvailablePackages()), receipt.receivedPackages()));
|
balance.setAvailablePackages(value(balance.getAvailablePackages()) + receipt.receivedPackages());
|
||||||
touch(balance);
|
touch(balance);
|
||||||
balances.updateById(balance);
|
balances.updateById(balance);
|
||||||
restoredPackages = Math.addExact(restoredPackages, receipt.receivedPackages());
|
InventoryLedger ledger = ledger(customerId, operatorId, operation.getId(), balance, target,
|
||||||
InventoryLedger ledger = returnLedger(customerId, operatorId, operation.getId(), balance, target,
|
source.skuCode(), "RETURN_STOCK_IN", "SHIPPED", "AVAILABLE", before,
|
||||||
source, exceptionNo, outboundNo, returnSourceIdentity(exceptionNo, source), idempotencyKey,
|
receipt.receivedPackages(), before + receipt.receivedPackages(), exceptionNo,
|
||||||
sequence++, before, receipt.receivedPackages(), request.evidenceFileIds());
|
balance.getInboundOrderNo(), outboundNo, returnSourceIdentity(source),
|
||||||
ledgers.insert(ledger);
|
normalizedKey + ":" + receipt.outboundPalletItemId() + ":" + target.getId(),
|
||||||
|
"Exception return received with evidence " + request.evidenceFileIds().size());
|
||||||
ledgerIds.add(ledger.getId());
|
ledgerIds.add(ledger.getId());
|
||||||
|
restoredPackages += receipt.receivedPackages();
|
||||||
}
|
}
|
||||||
LocalDateTime completedAt = LocalDateTime.now();
|
long beforePackages = balanceBefore.values().stream().mapToLong(Long::longValue).sum();
|
||||||
ReturnedInventoryRestoreResponse response = new ReturnedInventoryRestoreResponse(operation.getId(), ledgerIds,
|
long afterPackages = balanceBefore.keySet().stream()
|
||||||
beforePackages, restoredPackages, Math.addExact(beforePackages, restoredPackages), false, completedAt);
|
.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.setStatus("COMPLETED");
|
||||||
operation.setResultJson(write(response));
|
operation.setResultJson(write(response));
|
||||||
operation.setUpdatedAt(completedAt);
|
operation.setUpdatedAt(response.completedAt());
|
||||||
operations.updateById(operation);
|
operations.updateById(operation);
|
||||||
return response;
|
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) {
|
private Locked locked(String customerId, Long positionId) {
|
||||||
InventoryPosition position = commands.lockPosition(customerId, positionId);
|
InventoryPosition position = commands.lockPosition(customerId, positionId);
|
||||||
if (position == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory position not found");
|
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) {
|
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");
|
throw conflict("Idempotency key was already used with a different inventory request");
|
||||||
}
|
}
|
||||||
if (!"COMPLETED".equals(operation.getStatus()) || trim(operation.getResultJson()) == null) {
|
if (!"COMPLETED".equals(operation.getStatus()) || trim(operation.getResultJson()) == null) {
|
||||||
@@ -647,43 +704,15 @@ public class InventoryCommandService {
|
|||||||
try {
|
try {
|
||||||
return objectMapper.readValue(operation.getResultJson(), ReturnedInventoryRestoreResponse.class).asReplay();
|
return objectMapper.readValue(operation.getResultJson(), ReturnedInventoryRestoreResponse.class).asReplay();
|
||||||
} catch (JsonProcessingException exception) {
|
} 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,
|
private static String returnSourceIdentity(ReturnedInventoryRestoreRequest.SourceItem source) {
|
||||||
InventoryBalance balance, InventoryPosition position,
|
return "RETURN_SOURCE:" + source.outboundPalletItemId() + ":" + source.inventoryPositionId();
|
||||||
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 returnSourceKey(Long outboundPalletItemId, Long inventoryPositionId) {
|
private InventoryLedger ledger(String customerId, String operatorId, Long operationId, InventoryBalance balance,
|
||||||
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,
|
|
||||||
InventoryPosition position, String skuCode, String businessType, String from, String to,
|
InventoryPosition position, String skuCode, String businessType, String from, String to,
|
||||||
long beforePackages, long deltaPackages, long afterPackages,
|
long beforePackages, long deltaPackages, long afterPackages,
|
||||||
String businessOrderNo, String inboundOrderNo, String outboundOrderNo,
|
String businessOrderNo, String inboundOrderNo, String outboundOrderNo,
|
||||||
@@ -724,6 +753,7 @@ public class InventoryCommandService {
|
|||||||
ledger.setOccurredAt(LocalDateTime.now());
|
ledger.setOccurredAt(LocalDateTime.now());
|
||||||
ledger.setCreatedAt(ledger.getOccurredAt());
|
ledger.setCreatedAt(ledger.getOccurredAt());
|
||||||
ledgers.insert(ledger);
|
ledgers.insert(ledger);
|
||||||
|
return ledger;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void mergeSkuSnapshot(InventoryBalance balance, String skuCode) {
|
private static void mergeSkuSnapshot(InventoryBalance balance, String skuCode) {
|
||||||
|
|||||||
+103
-1
@@ -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.InventoryCommandRequests;
|
||||||
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
|
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.mapper.InventoryCommandMapper;
|
||||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||||
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
|
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.BeforeEach;
|
||||||
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.apache.ibatis.annotations.Select;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
@@ -57,7 +60,7 @@ class InventoryCommandServiceTest {
|
|||||||
ReflectionTestUtils.setField(service, "operations", operations);
|
ReflectionTestUtils.setField(service, "operations", operations);
|
||||||
ReflectionTestUtils.setField(service, "commands", commands);
|
ReflectionTestUtils.setField(service, "commands", commands);
|
||||||
ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway);
|
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(operations.findIdempotency(any(), any())).thenReturn(null);
|
||||||
lenient().when(commands.lockInboundTransitBalances(any(), any(), any())).thenReturn(List.of());
|
lenient().when(commands.lockInboundTransitBalances(any(), any(), any())).thenReturn(List.of());
|
||||||
}
|
}
|
||||||
@@ -329,6 +332,97 @@ class InventoryCommandServiceTest {
|
|||||||
verify(operations).findIdempotency("200", "platform-hold-1");
|
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) {
|
private InventoryCommandRequests.StockIn stockIn(String inbound, String key, long packages, long quantity) {
|
||||||
return new InventoryCommandRequests.StockIn("100", "Customer 100", "WH-1", "WH1", "Warehouse 1",
|
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,
|
"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);
|
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) {
|
private InventoryBalance balance(Long id, long available) {
|
||||||
InventoryBalance value = new InventoryBalance();
|
InventoryBalance value = new InventoryBalance();
|
||||||
value.setId(id); value.setCustomerId("100"); value.setCustomerName("Customer 100");
|
value.setId(id); value.setCustomerId("100"); value.setCustomerName("Customer 100");
|
||||||
|
|||||||
Reference in New Issue
Block a user