Merge branch 'dev' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev
# Conflicts: # config/nacos/cn/event-bridge-service.yml # config/nacos/overseas/lastleg-service.yml # deploy/sql/头程数据库统一增量-MySQL8.sql # event-bridge-service/src/main/resources/application.yml # iam/src/main/resources/db/lastleg-permissions.sql # lastleg-service/AGENTS.md # lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java # lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java # lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java # lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java # lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java # lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java # lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java # lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java # lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java # lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java # lastleg-service/src/main/resources/application.yml # lastleg-service/src/main/resources/db/lastleg.sql # scripts/db/incremental.sql # warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java # warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java
This commit is contained in:
+4
-4
@@ -13,15 +13,15 @@ import java.util.Map;
|
||||
* 尾程预留服务状态接口,用于验证尾程服务边界及基础可用性。
|
||||
*/
|
||||
@Tag(
|
||||
name = "Last-leg service status / 尾程服务状态",
|
||||
description = "Last-leg reserved service availability API. / 尾程预留服务的可用性接口。"
|
||||
name = "尾程服务状态",
|
||||
description = "尾程服务可用性接口"
|
||||
)
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg")
|
||||
public class LastlegHealthController {
|
||||
@Operation(
|
||||
summary = "Get service status / 查询服务状态",
|
||||
description = "Returns the logical status of the reserved lastleg-service boundary. / 返回预留 lastleg-service 边界的逻辑运行状态。"
|
||||
summary = "查询服务状态",
|
||||
description = "返回尾程服务边界的逻辑运行状态"
|
||||
)
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "200",
|
||||
|
||||
@@ -5,7 +5,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Schema(description = "Tail-leg order row at inbound-code granularity")
|
||||
@Schema(description = "按入仓单编码粒度展示的尾程订单列表行")
|
||||
public record LastlegOrderSummary(
|
||||
Long id,
|
||||
String orderNo,
|
||||
|
||||
+12
@@ -16,6 +16,9 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
@@ -114,6 +117,15 @@ public class InboundPdaController {
|
||||
return ApiResponse.success(service.uploadUnloadPhotos(scope(user, customerId), planNo, request));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ApiResponse<PdaResponses.UnloadPhotoFile> uploadUnloadPhotoFile(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo,
|
||||
@RequestParam(required = false) String customerId, @RequestPart("file") MultipartFile file) {
|
||||
requireOperate(user);
|
||||
return ApiResponse.success(service.uploadUnloadPhotoFile(
|
||||
scope(user, customerId), planNo, user.userId(), file));
|
||||
}
|
||||
|
||||
private String scope(AuthenticatedUser user, String requestedCustomer) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
|
||||
|
||||
@@ -12,6 +12,8 @@ public final class PdaResponses {
|
||||
List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
|
||||
LocalDateTime estimatedEmptyReturnAt) {}
|
||||
|
||||
public record UnloadPhotoFile(long fileId, String fileName, String contentType, long sizeBytes) {}
|
||||
|
||||
public record Operation(String result, long allocationId, int requiredPackageCount,
|
||||
int countedPackageCount, int pickedPackageCount,
|
||||
BigDecimal actualVolumeCbm, Integer remainingPackageCount,
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.gateway;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.MultipartBodyBuilder;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Component
|
||||
public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway {
|
||||
private static final String BUSINESS_TYPE = "OVERSEAS_UNLOAD_PHOTO";
|
||||
@Resource private RestClient.Builder restClientBuilder;
|
||||
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
private RestClient client;
|
||||
|
||||
@PostConstruct
|
||||
void initializeClient() { client = restClientBuilder.baseUrl(baseUrl).build(); }
|
||||
|
||||
@Override
|
||||
public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) {
|
||||
long tenantId = tenantId(customerId);
|
||||
MultipartBodyBuilder body = new MultipartBodyBuilder();
|
||||
try {
|
||||
body.part("file", new ByteArrayResource(file.getBytes()) {
|
||||
@Override public String getFilename() { return file.getOriginalFilename(); }
|
||||
}).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM
|
||||
: MediaType.parseMediaType(file.getContentType()));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalArgumentException("Cannot read unload photo", exception);
|
||||
}
|
||||
MetadataEnvelope response = client.post()
|
||||
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}",
|
||||
tenantId, BUSINESS_TYPE, taskId)
|
||||
.header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", String.valueOf(operatorId))
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
|
||||
.onStatus(HttpStatusCode::isError, (request, result) -> {
|
||||
throw new IllegalArgumentException("Unload photo upload failed");
|
||||
}).body(MetadataEnvelope.class);
|
||||
Metadata data = response == null ? null : response.data();
|
||||
if (data == null || data.fileId() == null) throw new IllegalArgumentException("File service returned no photo metadata");
|
||||
return new UploadedFile(data.fileId(), data.fileName(), data.contentType(),
|
||||
data.sizeBytes() == null ? 0 : data.sizeBytes());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate(String customerId, long taskId, long fileId) {
|
||||
long tenantId = tenantId(customerId);
|
||||
MetadataEnvelope response = client.get()
|
||||
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}",
|
||||
tenantId, BUSINESS_TYPE, taskId, fileId)
|
||||
.header("X-Internal-Service-Token", internalToken)
|
||||
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
|
||||
throw new IllegalArgumentException("Unload photo does not exist or its ownership is invalid");
|
||||
}).body(MetadataEnvelope.class);
|
||||
Metadata data = response == null ? null : response.data();
|
||||
if (data == null || data.fileId() == null || data.fileId() != fileId
|
||||
|| data.tenantId() == null || data.tenantId() != tenantId
|
||||
|| !BUSINESS_TYPE.equals(data.businessType()) || !String.valueOf(taskId).equals(data.businessId())) {
|
||||
throw new IllegalArgumentException("Unload photo metadata does not match the PDA task");
|
||||
}
|
||||
}
|
||||
|
||||
private long tenantId(String customerId) {
|
||||
try { return Long.parseLong(customerId); }
|
||||
catch (NumberFormatException exception) { throw new IllegalArgumentException("Customer ID must be numeric", exception); }
|
||||
}
|
||||
|
||||
private record MetadataEnvelope(Metadata data) {}
|
||||
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
|
||||
String fileName, String contentType, Long sizeBytes) {}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.gateway;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
public interface UnloadPhotoFileGateway {
|
||||
UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file);
|
||||
void validate(String customerId, long taskId, long fileId);
|
||||
|
||||
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) {}
|
||||
}
|
||||
+41
@@ -12,6 +12,7 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
|
||||
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.MapperFeature;
|
||||
@@ -21,6 +22,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -40,12 +42,14 @@ import java.util.Objects;
|
||||
|
||||
@Service
|
||||
public class InboundPdaWorkflowService {
|
||||
private static final long MAX_UNLOAD_PHOTO_SIZE = 10L * 1024 * 1024;
|
||||
private static final BigDecimal ZERO_VOLUME = new BigDecimal("0.0000");
|
||||
|
||||
@Resource private PdaWorkflowRepository repository;
|
||||
@Resource private WarehouseResourceGateway warehouse;
|
||||
@Resource private InboundStockReceiptService stockReceipts;
|
||||
@Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox;
|
||||
@Resource private UnloadPhotoFileGateway unloadPhotoFiles;
|
||||
@Resource private ObjectMapper sourceObjectMapper;
|
||||
|
||||
private ObjectMapper objectMapper;
|
||||
@@ -421,6 +425,7 @@ public class InboundPdaWorkflowService {
|
||||
InboundPdaTask task = requireActiveTask(customerId, plan);
|
||||
List<Long> fileIds = request.fileIds().stream().distinct().sorted().toList();
|
||||
if (fileIds.size() != request.fileIds().size()) throw conflict("Unload photo file IDs cannot be duplicated");
|
||||
fileIds.forEach(fileId -> validateUnloadPhoto(customerId, task.getId(), fileId));
|
||||
LocalDateTime uploadedAt = LocalDateTime.now();
|
||||
task.setUnloadPhotoFileIds(write(fileIds));
|
||||
task.setUnloadPhotoUploadedAt(uploadedAt);
|
||||
@@ -433,6 +438,21 @@ public class InboundPdaWorkflowService {
|
||||
return result;
|
||||
}
|
||||
|
||||
public PdaResponses.UnloadPhotoFile uploadUnloadPhotoFile(String customerId, String planNo,
|
||||
long operatorId, MultipartFile file) {
|
||||
validateUnloadPhotoFile(file);
|
||||
InboundPlan plan = requirePlan(customerId, planNo);
|
||||
InboundPdaTask task = requireActiveTask(customerId, plan);
|
||||
final UnloadPhotoFileGateway.UploadedFile uploaded;
|
||||
try {
|
||||
uploaded = unloadPhotoFiles.upload(customerId, task.getId(), operatorId, file);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, exception.getMessage(), exception);
|
||||
}
|
||||
return new PdaResponses.UnloadPhotoFile(uploaded.fileId(), uploaded.fileName(),
|
||||
uploaded.contentType(), uploaded.sizeBytes());
|
||||
}
|
||||
|
||||
private void moveSourceAllocation(Long planId, InboundPlanPalletAllocation source, Long targetPalletId,
|
||||
int packageCount, BigDecimal movedVolume) {
|
||||
source.setPickedPackageCount(value(source.getPickedPackageCount()) - packageCount);
|
||||
@@ -623,6 +643,27 @@ public class InboundPdaWorkflowService {
|
||||
}
|
||||
}
|
||||
|
||||
private void validateUnloadPhoto(String customerId, long taskId, long fileId) {
|
||||
try {
|
||||
unloadPhotoFiles.validate(customerId, taskId, fileId);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw conflict("Unload photo does not exist or does not belong to this PDA task");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateUnloadPhotoFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo cannot be empty");
|
||||
}
|
||||
if (file.getSize() > MAX_UNLOAD_PHOTO_SIZE) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo cannot exceed 10 MB");
|
||||
}
|
||||
String contentType = file.getContentType();
|
||||
if (!"image/jpeg".equalsIgnoreCase(contentType) && !"image/png".equalsIgnoreCase(contentType)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo must be JPG or PNG");
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal perPackageVolume(InboundPlanLine line) {
|
||||
int required = value(line.getRequiredPackageCount());
|
||||
if (required <= 0) throw conflict("Inbound line required package count must be positive");
|
||||
|
||||
+38
-1
@@ -19,7 +19,9 @@ import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Objects;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
@Service
|
||||
@@ -48,6 +50,7 @@ public class LoadingCompletedConsumer {
|
||||
if(payload.containerNo()==null||payload.containerNo().isBlank())
|
||||
throw new IllegalArgumentException("事件没有实际集装箱号 containerNo");
|
||||
if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单");
|
||||
if (envelope.eventVersion() == 2) validateCartonSkuMappings(payload);
|
||||
LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId());
|
||||
if(existing!=null) {
|
||||
if(!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) throw new IllegalArgumentException("事件摘要冲突");
|
||||
@@ -88,6 +91,39 @@ public class LoadingCompletedConsumer {
|
||||
return Math.toIntExact(orderMapper.countContainer(envelope.tenantId(),containerNo));
|
||||
}
|
||||
|
||||
private void validateCartonSkuMappings(FirstLegLoadingPlanCompletedV1 payload) {
|
||||
for (FirstLegLoadingPlanCompletedV1.Order order : payload.orders()) {
|
||||
Set<String> orderCartonMarks = new HashSet<>();
|
||||
if ("ONE_CLICK_DROPSHIP".equals(order.orderType())) {
|
||||
for (FirstLegLoadingPlanCompletedV1.PalletSnapshot pallet : order.pallets()) {
|
||||
for (FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot sku : pallet.skuPackages()) {
|
||||
requireSkuCartonMapping(sku.skuCode(), sku.packageCount(), sku.cartonMarks(), orderCartonMarks);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (FirstLegLoadingPlanCompletedV1.Item item : order.items()) {
|
||||
requireSkuCartonMapping(item.skuCode(), item.packageCount(), item.cartonMarks(), orderCartonMarks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void requireSkuCartonMapping(String skuCode, int loadedPackages, List<String> cartonMarks,
|
||||
Set<String> orderCartonMarks) {
|
||||
if (skuCode == null || skuCode.isBlank()) {
|
||||
throw new IllegalArgumentException("V2 尾程交接事件的箱唛映射缺少 SKU");
|
||||
}
|
||||
List<String> marks = cartonMarks == null ? List.of() : cartonMarks;
|
||||
if (marks.size() < loadedPackages) {
|
||||
throw new IllegalArgumentException("V2 尾程交接事件的 SKU 箱唛数量少于实装箱数: " + skuCode);
|
||||
}
|
||||
for (String mark : marks) {
|
||||
if (mark == null || mark.isBlank() || !orderCartonMarks.add(mark)) {
|
||||
throw new IllegalArgumentException("V2 尾程交接事件包含空值或重复箱唛: " + skuCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void createOrder(EventEnvelope event,FirstLegLoadingPlanCompletedV1 payload,
|
||||
FirstLegLoadingPlanCompletedV1.Order source,String orderNo, JsonNode rawSource) {
|
||||
String orderHash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(rawSource));
|
||||
@@ -120,7 +156,8 @@ public class LoadingCompletedConsumer {
|
||||
order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg)
|
||||
.filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add));
|
||||
order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId());
|
||||
order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceContainerBookingId(payload.containerBookingId());
|
||||
order.setSourceLoadingPlanId(payload.loadingPlanId());
|
||||
order.setSourceContainerBookingId(payload.containerBookingId());
|
||||
order.setSourceLoadingPlanVersion(event.aggregateVersion());
|
||||
order.setSourceHandoverVersion(event.handoverVersion()); order.setSourcePayloadHash(orderHash);
|
||||
order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now());
|
||||
|
||||
@@ -39,6 +39,10 @@ baiyue:
|
||||
domestic-warehouse:
|
||||
url: ${DOMESTIC_WAREHOUSE_URL:}
|
||||
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
|
||||
file:
|
||||
url: ${FILE_SERVICE_URL:http://127.0.0.1:9205}
|
||||
event-bridge:
|
||||
url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}}
|
||||
firstleg:
|
||||
event-bridge-url: ${FIRSTLEG_EVENT_BRIDGE_URL:}
|
||||
outbox-relay:
|
||||
|
||||
+56
-1
@@ -10,6 +10,7 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
|
||||
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -20,6 +21,7 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -33,6 +35,7 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -44,6 +47,7 @@ class InboundPdaWorkflowServiceTest {
|
||||
@Mock WarehouseResourceGateway warehouse;
|
||||
@Mock InboundStockReceiptService stockReceipts;
|
||||
@Mock InboundUnloadCompletedOutboxService unloadCompletedOutbox;
|
||||
@Mock UnloadPhotoFileGateway unloadPhotoFiles;
|
||||
private InboundPdaWorkflowService service;
|
||||
|
||||
private InboundPlan plan;
|
||||
@@ -64,6 +68,7 @@ class InboundPdaWorkflowServiceTest {
|
||||
ReflectionTestUtils.setField(service, "warehouse", warehouse);
|
||||
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
|
||||
ReflectionTestUtils.setField(service, "unloadCompletedOutbox", unloadCompletedOutbox);
|
||||
ReflectionTestUtils.setField(service, "unloadPhotoFiles", unloadPhotoFiles);
|
||||
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules());
|
||||
service.initializeObjectMapper();
|
||||
plan = plan();
|
||||
@@ -76,7 +81,7 @@ class InboundPdaWorkflowServiceTest {
|
||||
target = allocation(31L, targetLine.getId(), targetPallet.getId(), 3, 3, 0);
|
||||
source = allocation(32L, sourceLine.getId(), sourcePallet.getId(), 2, 2, 2);
|
||||
otherWarehouse = allocation(33L, sourceLine.getId(), otherWarehousePallet.getId(), 2, 2, 2);
|
||||
when(repository.lockPlan("100", "LIP1")).thenReturn(plan);
|
||||
lenient().when(repository.lockPlan("100", "LIP1")).thenReturn(plan);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -326,6 +331,8 @@ class InboundPdaWorkflowServiceTest {
|
||||
|
||||
assertEquals(List.of(101L, 103L), result.unloadPhotoFileIds());
|
||||
assertEquals(result.unloadPhotoUploadedAt().plusDays(2), result.estimatedEmptyReturnAt());
|
||||
verify(unloadPhotoFiles).validate("100", task.getId(), 101L);
|
||||
verify(unloadPhotoFiles).validate("100", task.getId(), 103L);
|
||||
verify(repository).update(task);
|
||||
ArgumentCaptor<InboundPdaOperation> operation = ArgumentCaptor.forClass(InboundPdaOperation.class);
|
||||
verify(repository).insert(operation.capture());
|
||||
@@ -344,6 +351,54 @@ class InboundPdaWorkflowServiceTest {
|
||||
verify(repository, never()).update(task);
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadUnloadPhotosRejectsAFileOwnedByAnotherBusinessTask() {
|
||||
activeTask();
|
||||
org.mockito.Mockito.doThrow(new IllegalArgumentException("ownership mismatch"))
|
||||
.when(unloadPhotoFiles).validate("100", task.getId(), 101L);
|
||||
|
||||
assertThrows(ResponseStatusException.class, () -> service.uploadUnloadPhotos("100", "LIP1",
|
||||
new PdaRequests.UnloadPhotos(List.of(101L), "unload-photo-wrong-owner", "7", "worker")));
|
||||
|
||||
verify(repository, never()).update(task);
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadUnloadPhotoFileBindsTheFileToTheActivePdaTask() {
|
||||
activeTask();
|
||||
var file = new MockMultipartFile("file", "unload.jpg", "image/jpeg", new byte[] {1, 2, 3});
|
||||
when(unloadPhotoFiles.upload("100", task.getId(), 7L, file))
|
||||
.thenReturn(new UnloadPhotoFileGateway.UploadedFile(101L, "unload.jpg", "image/jpeg", 3L));
|
||||
|
||||
var result = service.uploadUnloadPhotoFile("100", "LIP1", 7L, file);
|
||||
|
||||
assertEquals(101L, result.fileId());
|
||||
assertEquals("unload.jpg", result.fileName());
|
||||
assertEquals("image/jpeg", result.contentType());
|
||||
assertEquals(3L, result.sizeBytes());
|
||||
verify(unloadPhotoFiles).upload("100", task.getId(), 7L, file);
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadUnloadPhotoFileRejectsEmptyFileBeforeCallingFileService() {
|
||||
var file = new MockMultipartFile("file", "unload.jpg", "image/jpeg", new byte[0]);
|
||||
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> service.uploadUnloadPhotoFile("100", "LIP1", 7L, file));
|
||||
|
||||
verify(unloadPhotoFiles, never()).upload(any(), anyLong(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadUnloadPhotoFileRejectsNonImageBeforeCallingFileService() {
|
||||
var file = new MockMultipartFile("file", "unload.pdf", "application/pdf", new byte[] {1});
|
||||
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> service.uploadUnloadPhotoFile("100", "LIP1", 7L, file));
|
||||
|
||||
verify(unloadPhotoFiles, never()).upload(any(), anyLong(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completesWhenMergedAllocationsPreserveExactLineTotals() {
|
||||
activeTask();
|
||||
|
||||
+23
-4
@@ -1,24 +1,43 @@
|
||||
package com.baiyue.lastleg.service;
|
||||
import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*;
|
||||
import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*;
|
||||
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
|
||||
class LoadingCompletedConsumerTest {
|
||||
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
|
||||
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); }
|
||||
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
|
||||
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); }
|
||||
@Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
|
||||
@Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); }
|
||||
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
|
||||
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
|
||||
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet)); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
|
||||
@Test void consumesV2LoadingEventAndKeepsDropshipSnapshot(){
|
||||
EventEnvelope source=event();
|
||||
EventEnvelope source=event();
|
||||
JsonNode node=source.payload().deepCopy();
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0)).put("customerRemark","fragile");
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0))
|
||||
.set("cartonMarks", json.valueToTree(List.of("BOX-1","BOX-2","BOX-3","BOX-4","BOX-5")));
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1).path("items").get(0))
|
||||
.set("cartonMarks", json.valueToTree(List.of("BOX-6","BOX-7","BOX-8")));
|
||||
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),
|
||||
source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
|
||||
2L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
|
||||
assertEquals(2,service.consume(v2));
|
||||
verify(orders,times(2)).insert(any(LastlegOrder.class));
|
||||
ArgumentCaptor<com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton> cartonCaptor=
|
||||
ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class);
|
||||
verify(cartons,times(8)).insert(cartonCaptor.capture());
|
||||
assertEquals("SKU-1",cartonCaptor.getAllValues().get(0).getSkuCode());
|
||||
assertEquals("BOX-1",cartonCaptor.getAllValues().get(0).getCartonMark());
|
||||
}
|
||||
@Test void rejectsV2EventWithoutSkuCartonMapping(){
|
||||
EventEnvelope source=event(); JsonNode node=source.payload().deepCopy();
|
||||
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),
|
||||
source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
|
||||
2L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
|
||||
IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(v2));
|
||||
assertTrue(error.getMessage().contains("箱唛数量少于实装箱数"));
|
||||
verify(orders,never()).insert(any(LastlegOrder.class));
|
||||
}
|
||||
private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1)),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2)))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user