fix: auto materialize completed container plans
This commit is contained in:
+3
-2
@@ -49,7 +49,7 @@ public class InboundPlanController {
|
||||
|
||||
@PostMapping("/manual")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')")
|
||||
@Operation(summary = "手工填写并创建单票入仓计划", description = "要求订单版本和 Idempotency-Key;仓库主档、入仓码及客户归属均由服务端校验。Required authority: "
|
||||
@Operation(summary = "手工填写并创建单票入仓计划", description = "要求订单版本和 Idempotency-Key;仓库主档、入仓码及客户归属均由服务端校验。整柜最后一票计划保存后,服务端自动生成并绑定全柜托盘码。Required authority: "
|
||||
+ OverseasPermissions.INBOUND_ORDER_CREATE)
|
||||
public ApiResponse<InboundPlanResponse> manual(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@@ -59,7 +59,8 @@ public class InboundPlanController {
|
||||
}
|
||||
convertScope(user, request.customerId());
|
||||
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
return ApiResponse.success(service.convertManual(request, idempotencyKey, operatorId));
|
||||
return ApiResponse.success(containerConversion.convertManualAndAutoMaterialize(
|
||||
request, idempotencyKey, operatorId));
|
||||
}
|
||||
|
||||
@PostMapping("/convert-container")
|
||||
|
||||
+5
@@ -67,6 +67,11 @@ public interface InboundPlanOrderMapper {
|
||||
@Select("SELECT container_no FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1")
|
||||
String findContainerNo(@Param("customerId") String customerId, @Param("orderId") Long orderId);
|
||||
|
||||
@Select("SELECT source_container_booking_id FROM ll_order " +
|
||||
"WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1")
|
||||
String findSourceContainerBookingId(@Param("customerId") String customerId,
|
||||
@Param("orderId") Long orderId);
|
||||
|
||||
@Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.container_no AS containerNo," +
|
||||
"o.inbound_code AS inboundCode,o.current_status AS currentStatus,o.inbound_order_no AS inboundOrderNo," +
|
||||
"COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," +
|
||||
|
||||
+30
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ContainerInboundPlanPreviewResponse;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.entity.ContainerPlanConversionCommand;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
@@ -46,6 +47,35 @@ public class ContainerInboundPlanConversionCoordinator {
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private PlatformTransactionManager transactionManager;
|
||||
|
||||
/**
|
||||
* Saves one manually adjusted plan, then materializes every plan in the container as soon as
|
||||
* the last logical plan is complete. The manual endpoint is therefore never a separate pallet
|
||||
* creation workflow: for a one-order container this happens immediately; for a multi-order
|
||||
* container the final manual save closes the whole container.
|
||||
*/
|
||||
public InboundPlanResponse convertManualAndAutoMaterialize(ManualInboundPlanRequest request,
|
||||
String idempotencyKey,
|
||||
String operatorId) {
|
||||
InboundPlanResponse manual = plans.convertManual(request, idempotencyKey, operatorId);
|
||||
String bookingId = plans.sourceContainerBookingId(manual.customerId(), manual.sourceOrderId());
|
||||
ContainerInboundPlanPreviewResponse preview = plans.previewContainer(bookingId);
|
||||
List<ContainerInboundPlanPreviewResponse.Order> orders = preview.groups().stream()
|
||||
.flatMap(group -> group.orders().stream()).toList();
|
||||
boolean allLogicalPlansComplete = !orders.isEmpty() && orders.stream().allMatch(order ->
|
||||
"PLANNED".equals(order.readiness()) || "RESOURCE_PENDING".equals(order.readiness()));
|
||||
boolean needsMaterialization = orders.stream()
|
||||
.anyMatch(order -> "RESOURCE_PENDING".equals(order.readiness()));
|
||||
if (!preview.ready() || !allLogicalPlansComplete || !needsMaterialization) return manual;
|
||||
|
||||
List<InboundPlanResponse> materialized = confirm(new ConvertContainerInboundPlanRequest(
|
||||
bookingId, preview.snapshotVersion(), preview.snapshotHash()),
|
||||
"AUTO-MANUAL:" + hash(List.of(bookingId, idempotencyKey)));
|
||||
return materialized.stream()
|
||||
.filter(plan -> Objects.equals(plan.customerId(), manual.customerId())
|
||||
&& Objects.equals(plan.sourceOrderId(), manual.sourceOrderId()))
|
||||
.findFirst().orElse(manual);
|
||||
}
|
||||
|
||||
public List<InboundPlanResponse> confirm(ConvertContainerInboundPlanRequest request, String idempotencyKey) {
|
||||
String bookingId = required(request.sourceContainerBookingId(), 128, "来源柜预订 ID 不能为空");
|
||||
String key = required(idempotencyKey, 128, "Idempotency-Key 必须为1至128个字符");
|
||||
|
||||
+9
@@ -76,6 +76,15 @@ public class InboundPlanService {
|
||||
: sourcePalletMapper.palletNos(customerId, orderId);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public String sourceContainerBookingId(String customerId, long orderId) {
|
||||
String bookingId = trimToNull(orderMapper.findSourceContainerBookingId(customerId, orderId));
|
||||
if (bookingId == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在或未绑定来源柜");
|
||||
}
|
||||
return bookingId;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InboundPlanResponse convertManual(ManualInboundPlanRequest request, String idempotencyKey,
|
||||
String operatorId) {
|
||||
|
||||
+2
-2
@@ -68,7 +68,7 @@ class InboundPlanControllerTest {
|
||||
void platformManualPlanPassesVersionedIdempotentCommand() {
|
||||
ManualInboundPlanRequest request = manualRequest();
|
||||
controller.manual(platform(), "manual-key", request);
|
||||
verify(service).convertManual(request, "manual-key", "7");
|
||||
verify(containerConversion).convertManualAndAutoMaterialize(request, "manual-key", "7");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -76,7 +76,7 @@ class InboundPlanControllerTest {
|
||||
AuthenticatedUser customer = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of());
|
||||
assertThatThrownBy(() -> controller.manual(customer, "manual-key", manualRequest()))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
verify(service, never()).convertManual(org.mockito.ArgumentMatchers.any(),
|
||||
verify(containerConversion, never()).convertManualAndAutoMaterialize(org.mockito.ArgumentMatchers.any(),
|
||||
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
|
||||
+71
@@ -2,6 +2,8 @@ package com.baiyue.lastleg.inboundplan.service;
|
||||
|
||||
import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ContainerInboundPlanPreviewResponse;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.entity.ContainerPlanConversionCommand;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
@@ -18,6 +20,7 @@ import org.springframework.transaction.support.SimpleTransactionStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDate;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
@@ -28,6 +31,7 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.any;
|
||||
import static org.mockito.Mockito.eq;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ContainerInboundPlanConversionCoordinatorTest {
|
||||
@@ -57,6 +61,43 @@ class ContainerInboundPlanConversionCoordinatorTest {
|
||||
.thenReturn(new SimpleTransactionStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void finalManualPlanAutomaticallyMaterializesSingleOrderContainer() {
|
||||
ManualInboundPlanRequest request = manualRequest();
|
||||
InboundPlanResponse logical = plan("IP-MANUAL", List.of());
|
||||
InboundPlanResponse physical = plan("IP-MANUAL", List.of(new InboundPlanResponse.Pallet(
|
||||
31L, "MSCU6639870-101-001", null, "ACY2", "Amazon ACY2", BigDecimal.ONE,
|
||||
BigDecimal.ONE, 1, 51L, 61L, 71L, "WOOD", "LOCKED", LocalDateTime.now(), null, 0)));
|
||||
ContainerInboundPlanConversionCoordinator spy = org.mockito.Mockito.spy(coordinator);
|
||||
when(plans.convertManual(request, "manual-key", "7")).thenReturn(logical);
|
||||
when(plans.sourceContainerBookingId("100", 11L)).thenReturn(BOOKING_ID);
|
||||
when(plans.previewContainer(BOOKING_ID)).thenReturn(manualPreview(List.of(resourcePendingOrder())));
|
||||
doReturn(List.of(physical)).when(spy).confirm(any(ConvertContainerInboundPlanRequest.class), any(String.class));
|
||||
|
||||
assertEquals(physical, spy.convertManualAndAutoMaterialize(request, "manual-key", "7"));
|
||||
|
||||
verify(spy).confirm(eq(new ConvertContainerInboundPlanRequest(BOOKING_ID, 7L, SNAPSHOT_HASH)),
|
||||
any(String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void earlierManualPlanWaitsUntilEveryOrderInContainerHasAPlan() {
|
||||
ManualInboundPlanRequest request = manualRequest();
|
||||
InboundPlanResponse logical = plan("IP-MANUAL", List.of());
|
||||
var ready = new ContainerInboundPlanPreviewResponse.Order(
|
||||
"200", "Customer 200", "12", "LM-12", "RK-2", "FIRST_LEG", "FBA",
|
||||
null, "ONT8", "Amazon ONT8", "ONT8", 10, BigDecimal.ONE, 1,
|
||||
null, null, null, 0, "READY", List.of());
|
||||
when(plans.convertManual(request, "manual-key", "7")).thenReturn(logical);
|
||||
when(plans.sourceContainerBookingId("100", 11L)).thenReturn(BOOKING_ID);
|
||||
when(plans.previewContainer(BOOKING_ID)).thenReturn(manualPreview(List.of(resourcePendingOrder(), ready)));
|
||||
|
||||
assertEquals(logical, coordinator.convertManualAndAutoMaterialize(request, "manual-key", "7"));
|
||||
|
||||
verify(commands, never()).findByBooking(any(String.class));
|
||||
verify(warehouse, never()).ensurePalletBatch(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anotherIdempotencyKeyForCompletedCabinetReplaysWithoutCreatingAnotherBatch() throws Exception {
|
||||
ConvertContainerInboundPlanRequest request = request();
|
||||
@@ -366,6 +407,36 @@ class ContainerInboundPlanConversionCoordinatorTest {
|
||||
fbaWarehouseCode, 10, BigDecimal.ONE, 1, null, null, null, 0, "READY", List.of());
|
||||
}
|
||||
|
||||
private ManualInboundPlanRequest manualRequest() {
|
||||
return new ManualInboundPlanRequest("100", 11L, 0, LocalDate.of(2026, 9, 10),
|
||||
1, "WOOD", false, false, false, "自动生成");
|
||||
}
|
||||
|
||||
private ContainerInboundPlanPreviewResponse.Order resourcePendingOrder() {
|
||||
return new ContainerInboundPlanPreviewResponse.Order(
|
||||
"100", "Customer 100", "11", "LM-11", "RK-1", "FIRST_LEG", "FBA",
|
||||
null, "ACY2", "Amazon ACY2", "ACY2", 10, BigDecimal.ONE, 1,
|
||||
1, "IP-MANUAL", "PENDING_ASSIGNMENT", 1, "RESOURCE_PENDING", List.of());
|
||||
}
|
||||
|
||||
private ContainerInboundPlanPreviewResponse manualPreview(
|
||||
List<ContainerInboundPlanPreviewResponse.Order> orders) {
|
||||
int pending = (int) orders.stream().filter(order -> "RESOURCE_PENDING".equals(order.readiness())
|
||||
|| "READY".equals(order.readiness())).count();
|
||||
return new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
|
||||
"Los Angeles", 7L, SNAPSHOT_HASH, orders.size(), orders.size(), 0, pending, pending,
|
||||
0, orders.size(), 1, true,
|
||||
List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2",
|
||||
orders.size(), pending, pending, 0, orders.size(), 1, orders)));
|
||||
}
|
||||
|
||||
private InboundPlanResponse plan(String planNo, List<InboundPlanResponse.Pallet> pallets) {
|
||||
return new InboundPlanResponse(21L, planNo, "100", 11L, "MSCU6639870", "US-LAX-01",
|
||||
"Los Angeles", "PENDING_ASSIGNMENT", 1, 1, BigDecimal.ONE, 10, 0,
|
||||
List.of(), pallets, List.of(), 1, LocalDate.of(2026, 9, 10), "WOOD", false,
|
||||
false, false, "MANUAL", "自动生成");
|
||||
}
|
||||
|
||||
private ConvertContainerInboundPlanRequest request() {
|
||||
return new ConvertContainerInboundPlanRequest(BOOKING_ID, 7L, SNAPSHOT_HASH);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user