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' }