Merge test fixes into dev dropship flow

This commit is contained in:
2026-09-10 18:18:41 +08:00
131 changed files with 3062 additions and 651 deletions
@@ -18,6 +18,7 @@ public record ContainerPlanProgressSummary(
long planCount,
long plannedInboundCodeCount,
long pendingPlanCount,
long resourcePendingPlanCount,
LocalDateTime plannedArrivalAt,
boolean allPlansReady,
String blockingReason
@@ -30,6 +31,6 @@ public record ContainerPlanProgressSummary(
String blockingReason) {
this(sourceContainerBookingId, containerNo, null, null, null, -1, false, null,
totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount,
plannedInboundCodeCount, pendingPlanCount, plannedArrivalAt, allPlansReady, blockingReason);
plannedInboundCodeCount, pendingPlanCount, 0, plannedArrivalAt, allPlansReady, blockingReason);
}
}
@@ -29,7 +29,6 @@ public class OverseasFeeBusinessGuard {
(result, rowNum) -> new ContainerCustomerOption(
result.getString("customer_id"), result.getString("customer_name")),
String.valueOf(containerBookingId));
if (customers.isEmpty()) throw bad("真实货柜不存在或尚无尾程订单");
return customers;
}
@@ -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")
@@ -25,7 +25,8 @@ public interface InboundPlanOrderMapper {
java.math.BigDecimal volumeCbm, String overseasWarehouseId, String overseasWarehouseCode,
String overseasWarehouseName, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName, int destinationCount,
boolean detained, int version, String planNo, String planStatus, Integer plannedPalletCount
boolean detained, int version, String planNo, String planStatus,
Integer plannedPalletCount, Integer materializedPalletCount
) { }
@Select("SELECT o.customer_id AS customerId,o.id AS orderId,o.order_no AS orderNo," +
@@ -40,7 +41,10 @@ public interface InboundPlanOrderMapper {
"COALESCE(NULLIF(o.transfer_warehouse_name,''),(SELECT MIN(NULLIF(d.destination_warehouse_name,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseName," +
"CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 ELSE " +
"(SELECT COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) FROM ll_order_detail d WHERE d.order_id=o.id) END AS destinationCount," +
"o.detained AS detained,o.version AS version,p.plan_no AS planNo,p.status AS planStatus,p.pallet_count AS plannedPalletCount " +
"o.detained AS detained,o.version AS version,p.plan_no AS planNo,p.status AS planStatus," +
"p.pallet_count AS plannedPalletCount," +
"(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " +
"AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL) AS materializedPalletCount " +
"FROM ll_order o LEFT JOIN ll_inbound_plan p ON p.customer_id=o.customer_id AND p.source_order_id=o.id " +
"WHERE o.source_container_booking_id=#{sourceContainerBookingId} ORDER BY o.customer_id,o.id")
List<ContainerPreviewOrderRow> containerPreviewOrders(
@@ -63,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," +
@@ -128,6 +128,7 @@ public class ReceivingContainerService {
public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) {
requireReadyContainer(containerNo, overseasWarehouseId, false);
List<InboundPlan> containerPlans = requirePlans(containerNo);
requireMaterializedPlans(containerPlans);
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作");
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
requireSessionWarehouse(current, overseasWarehouseId);
@@ -158,6 +159,7 @@ public class ReceivingContainerService {
return result;
}
List<InboundPlan> containerPlans = requirePlans(containerNo);
requireMaterializedPlans(containerPlans);
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作");
String activeOperatorKey = activeOperatorKey(operatorKey);
ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey);
@@ -210,10 +212,11 @@ public class ReceivingContainerService {
List<ReceivingContainerResponses.SignedPhoto> photos = validateSignPhotos(request);
List<Long> photoFileIds = photos.stream().map(photo -> positiveFileId(photo.fileId())).toList();
photoFileIds.forEach(fileId -> unloadPhotos.validate(PLATFORM_SCOPE, session.getId(), fileId));
List<InboundPlan> containerPlans = requirePlans(session.getContainerNo());
requireMaterializedPlans(containerPlans);
LocalDateTime now = LocalDateTime.now();
session.setSignPhotos(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now);
session.setOperatorName(operatorName); session.setUpdatedAt(now); update(session);
List<InboundPlan> containerPlans = requirePlans(session.getContainerNo());
for (InboundPlan plan : containerPlans) {
workflow.ensureSignedTask(plan.getCustomerId(), plan, operatorKey, operatorName, photoFileIds, now);
}
@@ -705,6 +708,21 @@ public class ReceivingContainerService {
|| "LOCKED".equals(pallet.getAssignmentStatus()));
}
private void requireMaterializedPlans(List<InboundPlan> containerPlans) {
for (InboundPlan plan : containerPlans) {
int expected = plan.getPalletCount() == null ? 0 : plan.getPalletCount();
long materialized = pallets.selectCount(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getCustomerId, plan.getCustomerId())
.eq(InboundPlanPallet::getPlanId, plan.getId())
.isNotNull(InboundPlanPallet::getWarehousePalletId)
.isNotNull(InboundPlanPallet::getWarehouseLocationId)
.isNotNull(InboundPlanPallet::getWarehouseResourceLockId));
if (expected <= 0 || materialized < expected) {
throw conflict("入库计划尚未完整绑定真实托盘,请先在平台按柜生成入库计划");
}
}
}
private void validatePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference pallet) {
if ("SCRAPPED".equals(pallet.status())) throw conflict("Pallet is scrapped");
long actualWarehouse;
@@ -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;
@@ -30,6 +31,10 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Service
@@ -42,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个字符");
@@ -116,7 +150,7 @@ public class ContainerInboundPlanConversionCoordinator {
WarehouseResourceGateway.PalletBatchEnsure ensure = new WarehouseResourceGateway.PalletBatchEnsure(
preparation.warehouseId(), command.getBatchKey(), preparation.items().stream().map(item ->
new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(item.customerId()),
item.businessKey(), item.planNo(), item.globalSequenceNo(), "STANDARD",
item.businessKey(), item.planNo(), item.globalSequenceNo(), "WOOD",
item.fbaWarehouseCode(), item.palletCode())).toList());
WarehouseResourceGateway.PalletBatchResponse reservation;
try {
@@ -201,21 +235,76 @@ public class ContainerInboundPlanConversionCoordinator {
catch (RuntimeException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级接柜仓 ID 无效", exception);
}
List<PreparedItem> items = new ArrayList<>();
int globalSequence = 0;
for (ContainerInboundPlanPreviewResponse.Order order : preview.groups().stream()
.flatMap(group -> group.orders().stream()).filter(item -> "READY".equals(item.readiness())).toList()) {
long planId = IdWorker.getId();
String planNo = numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId);
List<PreparedOrder> preparedOrders = new ArrayList<>();
Map<String, String> prefixByFba = new LinkedHashMap<>();
Map<String, Integer> nextIndexByFba = new LinkedHashMap<>();
Set<String> usedPalletCodes = new HashSet<>();
List<ContainerInboundPlanPreviewResponse.Order> convertibleOrders = preview.groups().stream()
.flatMap(group -> group.orders().stream())
.filter(item -> "READY".equals(item.readiness()) || "RESOURCE_PENDING".equals(item.readiness()))
.toList();
for (ContainerInboundPlanPreviewResponse.Order order : convertibleOrders) {
List<String> sourcePalletNos = plans.sourcePalletNos(
order.customerId(), Long.parseLong(order.orderId()), order.orderType());
int targetPalletCount = "RESOURCE_PENDING".equals(order.readiness())
? Objects.requireNonNullElse(order.plannedPalletCount(), 0)
: Objects.requireNonNullElse(order.suggestedPalletCount(), 0);
if (sourcePalletNos.size() > targetPalletCount) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "计划托数不能少于已生成的头程托盘码数量");
}
String fbaCode = trimToNull(order.fbaWarehouseCode());
if (fbaCode != null) {
for (String palletCode : sourcePalletNos) {
PalletCodeParts parts = palletCodeParts(preview.containerNo(), palletCode);
String previousPrefix = prefixByFba.putIfAbsent(fbaCode, parts.prefix());
if (previousPrefix != null && !previousPrefix.equals(parts.prefix())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "同一 FBA 仓的头程托盘码内部编码不一致");
}
nextIndexByFba.merge(fbaCode, parts.index(), Math::max);
if (!usedPalletCodes.add(palletCode)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "头程托盘码存在重复");
}
}
if (sourcePalletNos.isEmpty()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "散货订单缺少头程托盘码快照");
}
}
InboundPlanService.PlanIdentity existing = "RESOURCE_PENDING".equals(order.readiness())
? plans.existingPlanIdentity(order.customerId(), Long.parseLong(order.orderId())) : null;
long planId = existing == null ? IdWorker.getId() : existing.planId();
String planNo = existing == null
? numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId) : existing.planNo();
if (existing != null && existing.palletCount() != targetPalletCount) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划托数与柜级预览已不一致");
}
preparedOrders.add(new PreparedOrder(order, planId, planNo, targetPalletCount,
List.copyOf(sourcePalletNos)));
}
List<PreparedItem> items = new ArrayList<>();
int globalSequence = 0;
for (PreparedOrder preparedOrder : preparedOrders) {
ContainerInboundPlanPreviewResponse.Order order = preparedOrder.order();
List<String> sourcePalletNos = new ArrayList<>(preparedOrder.sourcePalletNos());
boolean warehouseGeneratedPallets = trimToNull(order.fbaWarehouseCode()) == null
&& !"ONE_CLICK_DROPSHIP".equals(order.orderType());
if (!warehouseGeneratedPallets && sourcePalletNos.size() != order.suggestedPalletCount()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "头程托盘码数量与柜级预览不一致");
if (!warehouseGeneratedPallets && !"ONE_CLICK_DROPSHIP".equals(order.orderType())) {
String fbaCode = order.fbaWarehouseCode().trim();
String prefix = prefixByFba.get(fbaCode);
while (sourcePalletNos.size() < preparedOrder.targetPalletCount()) {
int next = nextIndexByFba.compute(fbaCode, (ignored, current) -> current == null ? 1 : current + 1);
String palletCode = prefix + "-" + String.format("%03d", next);
if (!usedPalletCodes.add(palletCode)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "补生成托盘码发生重复");
}
sourcePalletNos.add(palletCode);
}
} else if (!warehouseGeneratedPallets && sourcePalletNos.size() != preparedOrder.targetPalletCount()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "一件代发托盘码数量与柜级预览不一致");
}
for (int localSequence = 1; localSequence <= order.suggestedPalletCount(); localSequence++) {
items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo,
for (int localSequence = 1; localSequence <= preparedOrder.targetPalletCount(); localSequence++) {
items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), preparedOrder.planId(),
preparedOrder.planNo(),
localSequence, ++globalSequence, "CIP:" + generationId + ":" + globalSequence,
trimToNull(order.fbaWarehouseCode()), warehouseGeneratedPallets
? null : sourcePalletNos.get(localSequence - 1)));
@@ -379,7 +468,26 @@ public class ContainerInboundPlanConversionCoordinator {
}
public record Preparation(long warehouseId, List<PreparedItem> items) { }
private record PreparedOrder(ContainerInboundPlanPreviewResponse.Order order, long planId, String planNo,
int targetPalletCount, List<String> sourcePalletNos) { }
public record PreparedItem(String customerId, long orderId, long planId, String planNo,
int localSequenceNo, int globalSequenceNo, String businessKey,
String fbaWarehouseCode, String palletCode) { }
private static PalletCodeParts palletCodeParts(String containerNo, String palletCode) {
String stableContainerNo = trimToNull(containerNo);
String stablePalletCode = trimToNull(palletCode);
if (stableContainerNo == null || stablePalletCode == null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "集装箱号或头程托盘码缺失");
}
Matcher matcher = Pattern.compile("^(" + Pattern.quote(stableContainerNo) + "-.+)-(\\d{3})$")
.matcher(stablePalletCode);
if (!matcher.matches()) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"头程托盘码不符合‘集装箱号-FBA内部编码-下标’规则");
}
return new PalletCodeParts(matcher.group(1), Integer.parseInt(matcher.group(2)));
}
private record PalletCodeParts(String prefix, int index) { }
}
@@ -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) {
@@ -121,6 +130,14 @@ public class InboundPlanService {
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
requiredReceivingWarehouse(containerWarehouseId);
validateOrders(snapshots);
int minimumPalletCount = suggestedPalletCount(order);
int sourcePalletCount = Objects.requireNonNullElse(
sourcePalletMapper.palletNos(customerId, request.orderId()), List.<String>of()).size();
minimumPalletCount = Math.max(minimumPalletCount, sourcePalletCount);
if (request.palletCount() < minimumPalletCount) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"人工计划托数不能少于柜级预览建议托数 " + minimumPalletCount);
}
InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), null,
request, receivingWarehouse);
LastlegOrderCommandRecord command = new LastlegOrderCommandRecord();
@@ -186,9 +203,9 @@ public class InboundPlanService {
ContainerInboundPlanPreviewResponse.Order first = items.get(0);
return new ContainerInboundPlanPreviewResponse.Group(first.destinationType(),
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
items.size(), (int) items.stream().filter(item -> "READY".equals(item.readiness())).count(),
items.stream().filter(item -> "READY".equals(item.readiness()))
.map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount)
items.size(), (int) items.stream().filter(InboundPlanService::requiresContainerConversion).count(),
items.stream().filter(InboundPlanService::requiresContainerConversion)
.map(InboundPlanService::pendingPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(),
items.stream().filter(item -> "PLANNED".equals(item.readiness()))
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
@@ -199,9 +216,9 @@ public class InboundPlanService {
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(), List.copyOf(items));
}).toList();
int blocked = (int) orders.stream().filter(item -> "BLOCKED".equals(item.readiness())).count();
int pendingOrders = (int) orders.stream().filter(item -> "READY".equals(item.readiness())).count();
int pendingPallets = orders.stream().filter(item -> "READY".equals(item.readiness()))
.map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount)
int pendingOrders = (int) orders.stream().filter(InboundPlanService::requiresContainerConversion).count();
int pendingPallets = orders.stream().filter(InboundPlanService::requiresContainerConversion)
.map(InboundPlanService::pendingPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
int existingPallets = orders.stream().filter(item -> "PLANNED".equals(item.readiness()))
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
@@ -246,7 +263,20 @@ public class InboundPlanService {
String fbaWarehouseCode = isFbaDestination(row) ? trimToNull(row.fbaWarehouseCode()) : null;
String destinationCode = fbaWarehouseCode != null
? row.fbaWarehouseCode().trim() : row.destinationWarehouseCode();
String readiness = planned ? "PLANNED" : blockers.isEmpty() ? "READY" : "BLOCKED";
boolean materialized = planned && row.plannedPalletCount() != null
&& row.materializedPalletCount() != null
&& row.materializedPalletCount() >= row.plannedPalletCount();
if (planned && !materialized && row.materializedPalletCount() != null
&& row.materializedPalletCount() > 0) {
blockers.add("PLAN_PALLET_BINDING_INCOMPLETE");
}
if (planned && !materialized && !("PENDING_ASSIGNMENT".equals(row.planStatus())
|| "SIGNED".equals(row.planStatus()))) {
blockers.add("PLAN_ALREADY_STARTED");
}
String readiness = planned ? materialized ? "PLANNED"
: blockers.isEmpty() ? "RESOURCE_PENDING" : "BLOCKED"
: blockers.isEmpty() ? "READY" : "BLOCKED";
return new ContainerInboundPlanPreviewResponse.Order(row.customerId(), row.customerName(),
String.valueOf(row.orderId()), trimToNull(row.orderNo()) == null ? row.sourceOrderNo() : row.orderNo(),
row.inboundCode(), row.orderType(), destinationType, row.destinationWarehouseId(),
@@ -276,6 +306,26 @@ public class InboundPlanService {
}
}
private int suggestedPalletCount(SourceOrderSnapshot order) {
try {
return PalletAllocator.allocate(List.of(new PalletAllocator.LineInput(order.orderId(),
warehouseKey(order), volume(order), order.packageCount(), order.recommendedPalletCount())))
.pallets().size();
} catch (IllegalArgumentException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "订单货量不能计算预览建议托数", exception);
}
}
private static boolean requiresContainerConversion(ContainerInboundPlanPreviewResponse.Order order) {
return "READY".equals(order.readiness()) || "RESOURCE_PENDING".equals(order.readiness());
}
private static Integer pendingPalletCount(ContainerInboundPlanPreviewResponse.Order order) {
return "RESOURCE_PENDING".equals(order.readiness())
? Objects.requireNonNullElse(order.plannedPalletCount(), 0)
: Objects.requireNonNullElse(order.suggestedPalletCount(), 0);
}
private static String destinationType(InboundPlanOrderMapper.ContainerPreviewOrderRow row) {
if ("ONE_CLICK_DROPSHIP".equals(row.orderType())) return "ONE_CLICK_DROPSHIP";
if (isFbaDestination(row)) return "FBA";
@@ -552,9 +602,22 @@ public class InboundPlanService {
Map<Integer, WarehouseResourceGateway.PalletBatchItem> reserved = prepared == null ? Map.of()
: prepared.pallets().stream().collect(Collectors.toMap(PreparedPallet::sequenceNo,
PreparedPallet::reservation));
created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), standardPalletNos.get(pendingIndex),
List<String> effectivePalletNos = standardPalletNos.get(pendingIndex);
if (prepared != null && !effectivePalletNos.isEmpty()) {
effectivePalletNos = prepared.pallets().stream()
.sorted(Comparator.comparingInt(PreparedPallet::sequenceNo))
.map(value -> value.reservation().palletCode()).toList();
}
created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), effectivePalletNos,
wholePallets.get(pendingIndex), null, null, prepared, reserved));
}
for (int index = 0; index < refs.size(); index++) {
InboundPlan existing = existingPlans.get(index);
PreparedPlan prepared = preparedPlans.get(refs.get(index).orderId());
if (existing != null && prepared != null) {
materializeExistingPlan(existing, refs.get(index), prepared);
}
}
List<InboundPlanResponse> result = new ArrayList<>(refs.size());
for (int index = 0; index < refs.size(); index++) {
InboundPlan existing = existingPlans.get(index);
@@ -568,6 +631,47 @@ public class InboundPlanService {
public record PreparedPallet(int sequenceNo, String businessKey,
WarehouseResourceGateway.PalletBatchItem reservation) { }
public PlanIdentity existingPlanIdentity(String customerId, long orderId) {
InboundPlan plan = planMapper.findBySourceOrder(customerId, orderId);
if (plan == null) return null;
return new PlanIdentity(plan.getId(), plan.getPlanNo(), Objects.requireNonNullElse(plan.getPalletCount(), 0));
}
public record PlanIdentity(long planId, String planNo, int palletCount) { }
private void materializeExistingPlan(InboundPlan plan, InboundPlanOrderMapper.ContainerOrderRef ref,
PreparedPlan prepared) {
if (!Objects.equals(plan.getId(), prepared.planId()) || !Objects.equals(plan.getPlanNo(), prepared.planNo())
|| !Objects.equals(plan.getCustomerId(), prepared.customerId())
|| !Objects.equals(ref.orderId(), prepared.orderId())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划与柜级托盘预留快照不一致");
}
if (!("PENDING_ASSIGNMENT".equals(plan.getStatus()) || "SIGNED".equals(plan.getStatus()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "仅待入库或已签收但未实收的已有计划可由整柜生成补齐托盘");
}
List<InboundPlanPallet> pallets = palletMapper.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getCustomerId, plan.getCustomerId())
.eq(InboundPlanPallet::getPlanId, plan.getId())
.orderByAsc(InboundPlanPallet::getSequenceNo).last("FOR UPDATE"));
if (pallets.size() != prepared.pallets().size() || pallets.size() != plan.getPalletCount()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划托数与柜级托盘预留数量不一致");
}
Map<Integer, PreparedPallet> preparedBySequence = prepared.pallets().stream().collect(Collectors.toMap(
PreparedPallet::sequenceNo, value -> value));
for (InboundPlanPallet pallet : pallets) {
if (pallet.getWarehousePalletId() != null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划已存在真实托盘,不能重复补齐");
}
PreparedPallet value = preparedBySequence.get(pallet.getSequenceNo());
if (value == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划托盘序号不完整");
pallet.setSourcePalletNo(value.reservation().palletCode());
applyReservedPallet(pallet, value.reservation());
palletMapper.updateById(pallet);
}
plan.setUpdatedAt(LocalDateTime.now());
planMapper.updateById(plan);
}
private WholePalletInput wholePallet(DropshipPalletSnapshot pallet) {
return new WholePalletInput(pallet.sourcePalletNo(), Math.toIntExact(pallet.packageCount()));
}
@@ -42,6 +42,11 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
"AND NULLIF(TRIM(p.status),'') IS NOT NULL THEN NULLIF(TRIM(o.inbound_code),'') END) AS plannedInboundCodeCount," +
"SUM(CASE WHEN p.id IS NULL OR NULLIF(TRIM(p.plan_no),'') IS NULL OR NULLIF(TRIM(p.status),'') IS NULL " +
"THEN 1 ELSE 0 END) AS pendingPlanCount," +
"SUM(CASE WHEN p.id IS NOT NULL AND p.status IN ('PENDING_ASSIGNMENT','SIGNED') " +
"AND COALESCE(p.pallet_count,0)&gt;0 AND " +
"(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " +
"AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL)&lt;COALESCE(p.pallet_count,0) " +
"THEN 1 ELSE 0 END) AS resourcePendingPlanCount," +
"CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) " +
"AND COUNT(DISTINCT o.estimated_warehouse_at)=1 THEN MAX(o.estimated_warehouse_at) ELSE NULL END AS plannedArrivalAt," +
"CASE WHEN SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END)=0 " +
@@ -176,7 +176,7 @@ public class LastlegOrderService {
summary.assignmentVersion(), invalidContainer ? false : summary.canChangeReceivingWarehouse(),
invalidContainer ? issue : summary.receivingWarehouseLockReason(), summary.totalOrderCount(),
summary.inboundCodeCount(), summary.missingInboundCodeCount(), summary.planCount(),
summary.plannedInboundCodeCount(), summary.pendingPlanCount(), summary.plannedArrivalAt(),
summary.plannedInboundCodeCount(), summary.pendingPlanCount(), summary.resourcePendingPlanCount(), summary.plannedArrivalAt(),
false, issue);
}
@@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS ll_loading_event_inbox (
CREATE TABLE IF NOT EXISTS ll_container_shipping_gate (
container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID',
ocean_booking_id VARCHAR(128) NOT NULL COMMENT '来源海运订舱ID', container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号',
ocean_booking_id VARCHAR(128) NULL COMMENT '来源海运订舱ID;货柜未关联订舱时为空', container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号',
status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE',
standard_node VARCHAR(64) NULL COMMENT '最新有效标准船节点', node_sequence INT NOT NULL DEFAULT 0 COMMENT '最新有效节点单调序号',
observed_at DATETIME(3) NULL COMMENT '供应商节点观测时间', actual_at DATETIME(3) NULL COMMENT '节点实际发生时间',
@@ -1296,7 +1296,7 @@ components:
items: { type: string, pattern: '^[1-9][0-9]*$' }
ContainerPlanProgressSummary:
type: object
required: [sourceContainerBookingId, containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, assignmentVersion, canChangeReceivingWarehouse, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, allPlansReady]
required: [sourceContainerBookingId, containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, assignmentVersion, canChangeReceivingWarehouse, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, resourcePendingPlanCount, allPlansReady]
properties:
sourceContainerBookingId: { type: string }
containerNo: { type: string, pattern: '^[A-Z]{3}[UJZ][0-9]{7}$', description: 装柜完成事件非空且通过 ISO 6346 校验位验证的规范化真实集装箱号;不使用 cabinetNo、订单号或内部 ID 回退 }
@@ -1312,6 +1312,7 @@ components:
planCount: { type: integer, format: int64 }
plannedInboundCodeCount: { type: integer, format: int64 }
pendingPlanCount: { type: integer, format: int64, description: 尚未生成有效计划的尾程订单数,即未操作数 }
resourcePendingPlanCount: { type: integer, format: int64, description: 已有有效计划但尚未完整绑定海外仓真实托盘的订单数 }
plannedArrivalAt: { type: string, format: date-time, nullable: true, description: 仅来源于装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at }
allPlansReady: { type: boolean, description: 全柜每票均有入仓码且均存在有效独立计划时为 true }
blockingReason: { type: string, nullable: true }
@@ -21,13 +21,13 @@ paths:
/api/platform/v1/last-leg/finance-fee-sources/container-customers:
get:
summary: 查询真实来源柜内客户选项
description: 单次按真实来源柜聚合全部客户,供柜详情维护费用时选择,不逐票查询。
description: 单次按真实来源柜聚合全部客户,供柜详情维护费用时选择,不逐票查询;货柜不存在或尚无尾程订单时返回空列表。
security: [{ bearerAuth: [] }]
parameters:
- { in: query, name: containerBookingId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
responses:
'200':
description: 柜内客户选项
description: 柜内客户选项;无数据时data为空数组
content:
application/json:
schema: { $ref: '#/components/schemas/ContainerCustomerListResponse' }
@@ -80,6 +80,16 @@ class OverseasFeeBusinessGuardTest {
assertThat(jdbc.sql()).singleElement().asString().contains("GROUP BY customer_id");
}
@Test
void returnsEmptyWhenContainerHasNoLastlegOrders() {
StubJdbc jdbc = new StubJdbc();
var result = new OverseasFeeBusinessGuard(jdbc).listContainerCustomers(701L);
assertThat(result).isEmpty();
assertThat(jdbc.sql()).singleElement().asString().contains("source_container_booking_id");
}
static SaveRequest containerFee(String customerId, Long bookingId, String containerNo) {
return new SaveRequest(customerId, "客户A", "OVERSEAS_CONTAINER_FEE", "OVERSEAS_CONTAINER",
bookingId, containerNo, null, null, null, null, bookingId, containerNo, 91L,
@@ -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());
}
@@ -33,11 +33,11 @@ class HttpUnloadPhotoFileGatewayTest {
@Test
void forgedImageBusinessFailureMapsToBadRequestInsteadOfInternalError() {
server.expect(requestTo("http://file-service/internal/file/v1/business-files/100/OVERSEAS_UNLOAD_PHOTO/8"))
server.expect(requestTo("http://file-service/internal/file/v1/business-files/0/OVERSEAS_UNLOAD_PHOTO/8"))
.andRespond(withSuccess("{\"code\":\"A0400\",\"message\":\"上传内容不是有效图片\",\"data\":null}",
MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.upload("100", 8L, 7L,
assertThatThrownBy(() -> gateway.upload("0", 8L, 7L,
new MockMultipartFile("file", "forged.png", "image/png", "<script>".getBytes())))
.isInstanceOfSatisfying(ResponseStatusException.class,
error -> assertThat(error.getStatusCode().value()).isEqualTo(400));
@@ -46,11 +46,11 @@ class HttpUnloadPhotoFileGatewayTest {
@Test
void mismatchedPhotoMetadataMapsToBadRequest() {
server.expect(requestTo("http://file-service/internal/file/v1/business-files/100/OVERSEAS_UNLOAD_PHOTO/8/9"))
.andRespond(withSuccess("{\"code\":\"200\",\"data\":{\"fileId\":9,\"tenantId\":101,\"businessType\":\"OVERSEAS_UNLOAD_PHOTO\",\"businessId\":\"8\"}}",
server.expect(requestTo("http://file-service/internal/file/v1/business-files/0/OVERSEAS_UNLOAD_PHOTO/8/9"))
.andRespond(withSuccess("{\"code\":\"200\",\"data\":{\"fileId\":9,\"tenantId\":1,\"businessType\":\"OVERSEAS_UNLOAD_PHOTO\",\"businessId\":\"8\"}}",
MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.validate("100", 8L, 9L))
assertThatThrownBy(() -> gateway.validate("0", 8L, 9L))
.isInstanceOfSatisfying(ResponseStatusException.class,
error -> assertThat(error.getStatusCode().value()).isEqualTo(400));
server.verify();
@@ -59,12 +59,12 @@ class HttpUnloadPhotoFileGatewayTest {
@Test
void downloadUsesBusinessOwnershipPathAndPreservesBlobMetadata() {
server.expect(requestTo(
"http://file-service/internal/file/v1/business-files/100/OVERSEAS_UNLOAD_PHOTO/8/9/content"))
"http://file-service/internal/file/v1/business-files/0/OVERSEAS_UNLOAD_PHOTO/8/9/content"))
.andExpect(header("X-Internal-Service-Token", "internal-token"))
.andRespond(withSuccess("photo-bytes".getBytes(), MediaType.IMAGE_JPEG)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=door.jpg"));
UnloadPhotoFileGateway.DownloadedFile result = gateway.download("100", 8L, 9L);
UnloadPhotoFileGateway.DownloadedFile result = gateway.download("0", 8L, 9L);
assertThat(result.content()).containsExactly("photo-bytes".getBytes());
assertThat(result.fileName()).isEqualTo("door.jpg");
@@ -75,10 +75,10 @@ class HttpUnloadPhotoFileGatewayTest {
@Test
void missingOwnedPhotoMapsToNotFound() {
server.expect(requestTo(
"http://file-service/internal/file/v1/business-files/100/OVERSEAS_UNLOAD_PHOTO/8/9/content"))
"http://file-service/internal/file/v1/business-files/0/OVERSEAS_UNLOAD_PHOTO/8/9/content"))
.andRespond(withStatus(HttpStatus.NOT_FOUND));
assertThatThrownBy(() -> gateway.download("100", 8L, 9L))
assertThatThrownBy(() -> gateway.download("0", 8L, 9L))
.isInstanceOfSatisfying(ResponseStatusException.class,
error -> assertThat(error.getStatusCode().value()).isEqualTo(404));
server.verify();
@@ -85,6 +85,7 @@ class ReceivingContainerServiceBehaviorTest {
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules());
service.initializeObjectMapper();
org.mockito.Mockito.lenient().when(shippingGates.receivingAvailable(any(), any())).thenReturn(1);
org.mockito.Mockito.lenient().when(pallets.selectCount(any())).thenReturn(1L);
}
@Test
@@ -256,6 +257,23 @@ class ReceivingContainerServiceBehaviorTest {
verify(plans).updateById(unassigned);
}
@Test
void prepareRejectsManualPlanBeforeRealPalletsAreMaterialized() {
InboundPlan plan = plan("TSTU0195472");
InboundPlanLine planLine = line("FBA");
LastlegOrder order = containerOrder(1L, "100", "TSTU0195472", "BOOKING-472", "IN-472");
when(plans.selectList(any())).thenReturn(List.of(plan));
when(lines.selectList(any())).thenReturn(List.of(planLine));
when(orders.selectList(any())).thenReturn(List.of(order));
when(pallets.selectCount(any())).thenReturn(0L);
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "TSTU0195472",
new ReceivingContainerRequests.Action("missing-real-pallets")))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("尚未完整绑定真实托盘");
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
}
@Test
void candidatesHideContainersReservedByAnotherOperator() {
InboundPlan available = plan("CONT-A");
@@ -863,7 +881,7 @@ class ReceivingContainerServiceBehaviorTest {
private static InboundPlan plan(String containerNo) {
InboundPlan value = new InboundPlan();
value.setId(2L); value.setPlanNo("PLAN-1"); value.setCustomerId("100"); value.setContainerNo(containerNo);
value.setStatus("SIGNED"); value.setOverseasWarehouseId(100L);
value.setStatus("SIGNED"); value.setOverseasWarehouseId(100L); value.setPalletCount(1);
return value;
}
@@ -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();
@@ -290,6 +331,72 @@ class ContainerInboundPlanConversionCoordinatorTest {
.map(ContainerInboundPlanConversionCoordinator.PreparedItem::palletCode).toList());
}
@Test
void standardFbaPreviewMaySupplementButNeverReduceHeadLegPalletCodes() {
ConvertContainerInboundPlanRequest request = request();
var order = new ContainerInboundPlanPreviewResponse.Order(
"100", "Customer 100", "11", "LM-11", "RK-FBA", "FIRST_LEG", "FBA",
null, "ACY2", "Amazon ACY2", "ACY2", 20, new BigDecimal("5.8000"), 3,
null, null, null, 0, "READY", List.of());
var preview = new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
"Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 1, 3, 0, 3, 0, true,
List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2",
1, 1, 3, 0, 3, 0, List.of(order))));
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))).thenReturn("IP-ACY2");
when(plans.sourcePalletNos("100", 11L, "FIRST_LEG")).thenReturn(List.of(
"MSCU6639870-101-001", "MSCU6639870-101-002"));
ContainerPlanConversionCommand command = ReflectionTestUtils.invokeMethod(coordinator, "prepareCommand",
1L, 2L, BOOKING_ID, "FIRST-KEY", request,
ReflectionTestUtils.invokeMethod(coordinator, "hash",
List.of(BOOKING_ID, request.snapshotVersion(), request.snapshotHash())));
ContainerInboundPlanConversionCoordinator.Preparation preparation;
try {
preparation = objectMapper.readValue(command.getPreparationJson(),
ContainerInboundPlanConversionCoordinator.Preparation.class);
} catch (Exception exception) {
throw new AssertionError(exception);
}
assertEquals(List.of("MSCU6639870-101-001", "MSCU6639870-101-002", "MSCU6639870-101-003"),
preparation.items().stream().map(ContainerInboundPlanConversionCoordinator.PreparedItem::palletCode).toList());
}
@Test
void existingManualPlanIsPreparedForWarehouseMaterialization() {
ConvertContainerInboundPlanRequest request = request();
var order = new ContainerInboundPlanPreviewResponse.Order(
"100", "Customer 100", "11", "LM-11", "RK-FBA", "FIRST_LEG", "FBA",
null, "ACY2", "Amazon ACY2", "ACY2", 20, new BigDecimal("5.8000"), 3,
3, "IP-MANUAL", "PENDING_ASSIGNMENT", 1, "RESOURCE_PENDING", List.of());
var preview = new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
"Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 1, 3, 3, 3, 3, true,
List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2",
1, 1, 3, 3, 3, 3, List.of(order))));
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
when(plans.sourcePalletNos("100", 11L, "FIRST_LEG")).thenReturn(List.of(
"MSCU6639870-101-001", "MSCU6639870-101-002"));
when(plans.existingPlanIdentity("100", 11L))
.thenReturn(new InboundPlanService.PlanIdentity(21L, "IP-MANUAL", 3));
ContainerPlanConversionCommand command = ReflectionTestUtils.invokeMethod(coordinator, "prepareCommand",
1L, 2L, BOOKING_ID, "FIRST-KEY", request,
ReflectionTestUtils.invokeMethod(coordinator, "hash",
List.of(BOOKING_ID, request.snapshotVersion(), request.snapshotHash())));
ContainerInboundPlanConversionCoordinator.Preparation preparation;
try {
preparation = objectMapper.readValue(command.getPreparationJson(),
ContainerInboundPlanConversionCoordinator.Preparation.class);
} catch (Exception exception) {
throw new AssertionError(exception);
}
assertEquals(21L, preparation.items().get(0).planId());
assertEquals("IP-MANUAL", preparation.items().get(0).planNo());
verify(numbers, never()).next(any(String.class), any(String.class), any(Long.class));
}
private ContainerInboundPlanPreviewResponse.Order previewOrder(String customerId, String orderId,
String inboundCode, String destinationCode,
String fbaWarehouseCode) {
@@ -300,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);
}
@@ -74,7 +74,7 @@ class ContainerInboundPlanPreviewTest {
new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", 1L, "LM-1", "FO-1", "测试客户",
"FIRST_LEG", "OVERSEAS", "IN-1", "PENDING_ASSIGNMENT", null, null, 0, 0,
BigDecimal.ZERO, null, null, null, null, null, null, null, 0, false, 1,
null, null, null)));
null, null, null, null)));
var result = service.previewContainer("BOOKING-NULL");
@@ -103,9 +103,33 @@ class ContainerInboundPlanPreviewTest {
.map(ContainerInboundPlanPreviewResponse.Group::destinationType).toList());
}
@Test
void signedManualPlanWithoutWarehousePalletsRemainsResourcePending() {
when(orderMapper.containerPreviewOrders("BOOKING-SIGNED")).thenReturn(List.of(
row(1L, "LM-SIGNED", "FIRST_LEG", "FBA", "ACY2", "Amazon ACY2", 3,
"IP-SIGNED", 3, 1, "SIGNED", 0)
));
var result = service.previewContainer("BOOKING-SIGNED");
assertEquals(true, result.ready());
assertEquals(1, result.pendingOrderCount());
assertEquals(3, result.pendingPalletCount());
assertEquals("RESOURCE_PENDING", result.groups().get(0).orders().get(0).readiness());
}
private InboundPlanOrderMapper.ContainerPreviewOrderRow row(
long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName,
int recommendedPallets, String planNo, Integer plannedPallets, int version) {
return row(id, orderNo, orderType, destinationType, destinationCode, destinationName,
recommendedPallets, planNo, plannedPallets, version,
planNo == null ? null : "PENDING_ASSIGNMENT", plannedPallets);
}
private InboundPlanOrderMapper.ContainerPreviewOrderRow row(
long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName,
int recommendedPallets, String planNo, Integer plannedPallets, int version, String planStatus,
Integer materializedPallets) {
return new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", id, orderNo, "FO-" + id,
"测试客户", orderType, destinationType, "IN-" + id, "PENDING_ASSIGNMENT", planNo,
"MSCU6639870", recommendedPallets, 10, new BigDecimal("1.0000"),
@@ -113,6 +137,6 @@ class ContainerInboundPlanPreviewTest {
"FBA".equals(destinationType) || destinationCode == null ? null : "WH-" + id,
"FBA".equals(destinationType) ? null : destinationCode, destinationName,
"FBA".equals(destinationType) || destinationCode == null ? 0 : 1, false, version, planNo,
planNo == null ? null : "PENDING_ASSIGNMENT", plannedPallets);
planStatus, plannedPallets, materializedPallets);
}
}
@@ -164,6 +164,54 @@ class InboundPlanServiceTest {
verify(orderMapper).lockContainerWarehouseFacts("100", 2L);
}
@Test
void manualPlanMayExceedButCannotUndercutPreviewPalletCount() {
SourceOrderSnapshot source = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
"PENDING_ASSIGNMENT", null, new BigDecimal("5.8000"), 58, 2,
"193000000000200003", "US-LAX-01", "洛杉矶仓", "ACY2",
null, null, "Amazon ACY2", 0, 0, false, 0);
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual(
new ManualInboundPlanRequest("100", 2L, 0, LocalDate.of(2026, 9, 15),
2, "WOOD", false, false, false, null), "manual-below-preview", "900"));
assertEquals(400, error.getStatusCode().value());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(commandMapper, never()).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class));
}
@Test
void containerConversionBindsRealWarehousePalletsBackToSignedManualPlanBeforeReceiving() {
InboundPlan plan = existingPlan("100", 2L, "IP-MANUAL");
plan.setCreationMode("MANUAL");
plan.setPalletCount(2);
plan.setStatus("SIGNED");
InboundPlanPallet first = new InboundPlanPallet();
first.setId(301L); first.setCustomerId("100"); first.setPlanId(plan.getId()); first.setSequenceNo(1);
InboundPlanPallet second = new InboundPlanPallet();
second.setId(302L); second.setCustomerId("100"); second.setPlanId(plan.getId()); second.setSequenceNo(2);
when(palletMapper.selectList(any())).thenReturn(List.of(first, second));
var firstReservation = new WarehouseResourceGateway.PalletBatchItem(100L, "CIP:1:1", "IP-MANUAL",
1, 401L, "MSCU6639870-101-001", 501L, 601L, "RESERVED");
var secondReservation = new WarehouseResourceGateway.PalletBatchItem(100L, "CIP:1:2", "IP-MANUAL",
2, 402L, "MSCU6639870-101-002", 502L, 602L, "RESERVED");
var prepared = new InboundPlanService.PreparedPlan(plan.getId(), plan.getPlanNo(), "100", 2L, List.of(
new InboundPlanService.PreparedPallet(1, "CIP:1:1", firstReservation),
new InboundPlanService.PreparedPallet(2, "CIP:1:2", secondReservation)));
ReflectionTestUtils.invokeMethod(service, "materializeExistingPlan", plan,
new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "INBOUND_PLANNED", "MSCU6639870", "FIRST_LEG"),
prepared);
assertEquals("MSCU6639870-101-001", first.getPalletNo());
assertEquals("MSCU6639870-101-001", first.getSourcePalletNo());
assertEquals(401L, first.getWarehousePalletId());
assertEquals("RESERVED", first.getAssignmentStatus());
assertEquals(402L, second.getWarehousePalletId());
verify(palletMapper, times(2)).updateById(any(InboundPlanPallet.class));
verify(planMapper).updateById(plan);
}
@Test
void manuallyCreatesPlanWhenStableWarehouseIdExistsWithoutDisplaySnapshots() {
SourceOrderSnapshot source = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
@@ -27,7 +27,7 @@ class ContainerPlanProgressContractTest {
assertEquals(List.of("sourceContainerBookingId", "containerNo", "overseasWarehouseId",
"overseasWarehouseCode", "overseasWarehouseName", "assignmentVersion",
"canChangeReceivingWarehouse", "receivingWarehouseLockReason", "totalOrderCount", "inboundCodeCount",
"missingInboundCodeCount", "planCount", "plannedInboundCodeCount", "pendingPlanCount",
"missingInboundCodeCount", "planCount", "plannedInboundCodeCount", "pendingPlanCount", "resourcePendingPlanCount",
"plannedArrivalAt", "allPlansReady", "blockingReason"),
Arrays.stream(ContainerPlanProgressSummary.class.getRecordComponents())
.map(value -> value.getName()).toList());
@@ -40,6 +40,7 @@ class ContainerPlanProgressContractTest {
LastlegOrderRequests.ContainerProgressQuery.class, long.class, long.class).getAnnotation(Select.class);
String sql = String.join("", rows.value()).toLowerCase();
assertTrue(sql.contains("left join ll_inbound_plan"));
assertTrue(sql.contains("ll_inbound_plan_pallet"));
assertTrue(sql.contains("o.estimated_warehouse_at"));
assertTrue(sql.contains("group by o.source_container_booking_id"));
assertFalse(sql.contains("having "));
@@ -97,7 +97,7 @@ class LastlegOrderServiceTest {
}
var updated = new ContainerPlanProgressSummary("BOOKING-1", "MSCU6639870", "900",
"US-NYC-01", "纽约仓", 1, true, null, 2, 2, 0, 2, 2, 0,
null, true, null);
0, null, true, null);
when(orders.lockContainerOrders("BOOKING-1")).thenReturn(List.of(first, second));
when(warehouseResourceGateway.requireActiveSharedWarehouse(900L)).thenReturn(
new WarehouseResourceGateway.WarehouseReference(900L, "US-NYC-01", "纽约仓", "ACTIVE"));
@@ -382,7 +382,7 @@ class LastlegOrderServiceTest {
var healthy = summary(false, 3, "存在3票尚未生成有效入库计划");
var missingWarehouse = new ContainerPlanProgressSummary("BOOKING-2", "MSCU6639870",
null, null, null, 0, true, null, 2, 2, 0, 0, 0, 2,
LocalDateTime.of(2026, 10, 4, 9, 0), false, "存在2票尚未生成有效入库计划");
0, LocalDateTime.of(2026, 10, 4, 9, 0), false, "存在2票尚未生成有效入库计划");
when(orders.countContainerProgress(query)).thenReturn(2L);
when(orders.pageContainerProgress(query, 0, 10)).thenReturn(List.of(healthy, missingWarehouse));
@@ -402,7 +402,7 @@ class LastlegOrderServiceTest {
var locked = new ContainerPlanProgressSummary("BOOKING-2", "TSTU0000061",
"193000000000200003", "US-LAX-01", "洛杉矶仓", 4, false,
"海外PDA已建立接柜会话,接柜仓已永久锁定", 1, 1, 0, 1, 1, 0,
LocalDateTime.of(2026, 10, 4, 9, 0), true, null);
0, LocalDateTime.of(2026, 10, 4, 9, 0), true, null);
when(orders.countContainerProgress(query)).thenReturn(2L);
when(orders.pageContainerProgress(query, 0, 10)).thenReturn(List.of(ready, locked));
@@ -419,7 +419,7 @@ class LastlegOrderServiceTest {
private static ContainerPlanProgressSummary summary(boolean ready, long pending, String reason) {
return new ContainerPlanProgressSummary("BOOKING-1", "MSCU6639870", "193000000000200003",
"US-LAX-01", "洛杉矶仓", 0, true, null, 15, 15, 0,
15 - pending, 15 - pending, pending, LocalDateTime.of(2026, 10, 3, 9, 0), ready, reason);
15 - pending, 15 - pending, pending, 0, LocalDateTime.of(2026, 10, 3, 9, 0), ready, reason);
}
@Test