feat(lastleg): reserve pallets for container plans

This commit is contained in:
2026-09-09 00:15:23 +08:00
parent cf8053b7ce
commit 7b47de47c6
15 changed files with 700 additions and 18 deletions
@@ -16,6 +16,7 @@ import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.dto.UpdateInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
import com.baiyue.lastleg.inboundplan.service.ContainerInboundPlanConversionCoordinator;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -32,6 +33,7 @@ import org.springframework.web.server.ResponseStatusException;
@Tag(name = "尾程入仓计划")
public class InboundPlanController {
@Resource private InboundPlanService service;
@Resource private ContainerInboundPlanConversionCoordinator containerConversion;
@Resource private InboundFbaAppointmentService appointmentService;
/** 按入仓计划明细分页查询可预约的入库订单行。 */
@@ -65,11 +67,12 @@ public class InboundPlanController {
@Operation(summary = "按整柜批量生成剩余入库计划", description = "平台按 sourceContainerBookingId 锁定整柜订单;保留已有有效计划,只为剩余入仓码分别生成独立计划,整批原子且可幂等重放。Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE)
public ApiResponse<java.util.List<InboundPlanResponse>> convertContainer(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody ConvertContainerInboundPlanRequest request) {
if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可按整柜生成入库计划");
}
return ApiResponse.success(service.convertContainer(request.sourceContainerBookingId()));
return ApiResponse.success(containerConversion.confirm(request, idempotencyKey));
}
@GetMapping("/container-preview")
@@ -1,10 +1,14 @@
package com.baiyue.lastleg.inboundplan.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
/** 平台按来源头程柜预订身份批量生成入库计划。 */
public record ConvertContainerInboundPlanRequest(
@NotBlank @Size(max = 128) String sourceContainerBookingId
@NotBlank @Size(max = 128) String sourceContainerBookingId,
@Min(0) long snapshotVersion,
@NotBlank @Pattern(regexp = "^[0-9a-f]{64}$") String snapshotHash
) {
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.inboundplan.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_container_plan_conversion_command")
public class ContainerPlanConversionCommand {
@TableId(type = IdType.INPUT) private Long id;
private String sourceContainerBookingId;
private String idempotencyKey;
private String requestHash;
private Long snapshotVersion;
private String snapshotHash;
private Long warehouseId;
private String batchKey;
private String status;
private String preparationJson;
private String resultJson;
private String lastError;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.entity.ContainerPlanConversionCommand;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
@Mapper
public interface ContainerPlanConversionCommandMapper extends BaseMapper<ContainerPlanConversionCommand> {
@Select("SELECT * FROM ll_container_plan_conversion_command WHERE source_container_booking_id=#{bookingId} AND idempotency_key=#{key} LIMIT 1")
ContainerPlanConversionCommand find(@Param("bookingId") String bookingId, @Param("key") String key);
@Select("SELECT * FROM ll_container_plan_conversion_command WHERE source_container_booking_id=#{bookingId} LIMIT 1")
ContainerPlanConversionCommand findByBooking(@Param("bookingId") String bookingId);
@Update("UPDATE ll_container_plan_conversion_command SET status=#{status},result_json=COALESCE(#{resultJson},result_json),last_error=#{lastError},updated_at=#{updatedAt} WHERE id=#{id} AND status=#{expectedStatus}")
int updateState(@Param("id") long id, @Param("expectedStatus") String expectedStatus,
@Param("status") String status,
@Param("resultJson") String resultJson, @Param("lastError") String lastError,
@Param("updatedAt") LocalDateTime updatedAt);
@Update("UPDATE ll_container_plan_conversion_command SET status=#{nextStatus},last_error=NULL,updated_at=#{updatedAt} WHERE id=#{id} AND status=#{expectedStatus}")
int claim(@Param("id") long id, @Param("expectedStatus") String expectedStatus,
@Param("nextStatus") String nextStatus, @Param("updatedAt") LocalDateTime updatedAt);
@Update("UPDATE ll_container_plan_conversion_command SET last_error=NULL,updated_at=#{updatedAt} WHERE id=#{id} AND status='APPLYING' AND updated_at=#{expectedUpdatedAt}")
int reclaimStale(@Param("id") long id, @Param("expectedUpdatedAt") LocalDateTime expectedUpdatedAt,
@Param("updatedAt") LocalDateTime updatedAt);
}
@@ -230,6 +230,39 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
return response.data();
}
@Override
public PalletBatchResponse ensurePalletBatch(PalletBatchEnsure request) {
return palletBatch("/internal/v1/warehouse-pallet-batches/ensure-reserved", request);
}
@Override
public PalletBatchResponse confirmPalletBatch(PalletBatchCommand request) {
return palletBatch("/internal/v1/warehouse-pallet-batches/confirm", request);
}
@Override
public PalletBatchResponse compensatePalletBatch(PalletBatchCompensation request) {
return palletBatch("/internal/v1/warehouse-pallet-batches/compensate", request);
}
private PalletBatchResponse palletBatch(String path, Object body) {
PalletBatchEnvelope response = client.post().uri(path)
.header("X-Internal-Service-Token", internalToken).body(body).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new ResponseStatusException(result.getStatusCode().is5xxServerError()
? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.BAD_REQUEST,
"Warehouse pallet batch request failed");
}).body(PalletBatchEnvelope.class);
if (response == null || !"200".equals(response.code()) || response.data() == null) {
HttpStatus status = response != null && "A0409".equals(response.code()) ? HttpStatus.CONFLICT
: response != null && "A0404".equals(response.code()) ? HttpStatus.NOT_FOUND
: HttpStatus.SERVICE_UNAVAILABLE;
throw new ResponseStatusException(status, response == null || response.message() == null
? "Warehouse pallet batch returned no usable response" : response.message());
}
return response.data();
}
private record WarehousePalletResponse(String code, String message, WarehousePalletData data) {}
private record WarehousePalletData(@JsonAlias("palletId") long id, String palletCode, long warehouseId, long locationId,
String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version,
@@ -239,4 +272,5 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
private record ReserveData(long lockId) {}
private record WarehouseReferenceResponse(String code, String message, WarehouseReference data) {}
private record WarehouseLocationResponse(String code, String message, WarehouseLocationReference data) {}
private record PalletBatchEnvelope(String code, String message, PalletBatchResponse data) {}
}
@@ -1,5 +1,7 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
import java.util.List;
public interface WarehouseResourceGateway {
WarehousePalletReference ensureDropshipPallet(String customerId, long warehouseId, String businessId);
long reserve(String customerId, long warehouseId, long palletId, long locationId,
@@ -18,6 +20,15 @@ public interface WarehouseResourceGateway {
default WarehouseReference requireActiveSharedWarehouse(long warehouseId) {
throw new UnsupportedOperationException("Shared warehouse resolution is not configured");
}
default PalletBatchResponse ensurePalletBatch(PalletBatchEnsure request) {
throw new UnsupportedOperationException("Warehouse pallet batch reservation is not configured");
}
default PalletBatchResponse confirmPalletBatch(PalletBatchCommand request) {
throw new UnsupportedOperationException("Warehouse pallet batch confirmation is not configured");
}
default PalletBatchResponse compensatePalletBatch(PalletBatchCompensation request) {
throw new UnsupportedOperationException("Warehouse pallet batch compensation is not configured");
}
record WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId,
String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) {
@@ -35,4 +46,14 @@ public interface WarehouseResourceGateway {
String status) { }
record WarehouseLocationReference(long locationId, String locationCode, String locationType,
java.math.BigDecimal maxLoadKg, String status) { }
record PalletBatchEnsure(long warehouseId, String batchKey, List<PalletBatchItemRequest> items) { }
record PalletBatchItemRequest(long customerId, String businessKey, String inboundPlanNo,
int sequenceNo, String palletType, String fbaWarehouseCode) { }
record PalletBatchCommand(long warehouseId, String batchKey, String commandKey) { }
record PalletBatchCompensation(long warehouseId, String batchKey, String commandKey, String reason) { }
record PalletBatchResponse(long batchId, String batchKey, String status, int attemptNo,
boolean replayed, List<PalletBatchItem> items) { }
record PalletBatchItem(long customerId, String businessKey, String inboundPlanNo, int sequenceNo,
long palletId, String palletCode, long locationId, long resourceLockId,
String status) { }
}
@@ -0,0 +1,289 @@
package com.baiyue.lastleg.inboundplan.service;
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.entity.ContainerPlanConversionCommand;
import com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Service
public class ContainerInboundPlanConversionCoordinator {
private static final java.time.Duration APPLYING_LEASE = java.time.Duration.ofMinutes(2);
@Resource private InboundPlanService plans;
@Resource private WarehouseResourceGateway warehouse;
@Resource private ContainerPlanConversionCommandMapper commands;
@Resource private LastlegOrderNoGenerator numbers;
@Resource private ObjectMapper objectMapper;
@Resource private PlatformTransactionManager transactionManager;
public List<InboundPlanResponse> confirm(ConvertContainerInboundPlanRequest request, String idempotencyKey) {
String bookingId = required(request.sourceContainerBookingId(), 128, "来源柜预订 ID 不能为空");
String key = required(idempotencyKey, 128, "Idempotency-Key 必须为1至128个字符");
String requestHash = hash(List.of(bookingId, request.snapshotVersion(), request.snapshotHash()));
ContainerPlanConversionCommand resolved = commands.find(bookingId, key);
if (resolved == null) resolved = commands.findByBooking(bookingId);
if (resolved == null) resolved = createCommand(bookingId, key, request, requestHash);
final ContainerPlanConversionCommand command = resolved;
requireSameRequest(command, requestHash);
Preparation preparation = read(command.getPreparationJson(), Preparation.class);
if ("COMPLETED".equals(command.getStatus())) return result(command);
if ("APPLYING".equals(command.getStatus())) {
LocalDateTime now = LocalDateTime.now();
if (command.getUpdatedAt() == null || command.getUpdatedAt().plus(APPLYING_LEASE).isAfter(now)
|| commands.reclaimStale(command.getId(), command.getUpdatedAt(), now) != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜正在生成入库计划,请勿重复提交");
}
command.setUpdatedAt(now);
}
if (!"LOCAL_COMMITTED".equals(command.getStatus())) {
if (commands.claim(command.getId(), "PREPARED", "APPLYING", LocalDateTime.now()) != 1) {
ContainerPlanConversionCommand current = commands.findByBooking(bookingId);
if (current != null && "COMPLETED".equals(current.getStatus())) return result(current);
throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜正在生成入库计划,请勿重复提交");
}
command.setStatus("APPLYING");
}
if (preparation.items().isEmpty()) {
try {
List<InboundPlanResponse> existing = inTransaction(() -> {
List<InboundPlanResponse> converted = plans.convertContainerReserved(bookingId,
request.snapshotVersion(), request.snapshotHash(), List.of());
state(command.getId(), "APPLYING", "COMPLETED", write(converted), null);
return converted;
});
command.setStatus("COMPLETED");
return existing;
} catch (RuntimeException failure) {
resetForRetry(command, failure);
throw failure;
}
}
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.fbaWarehouseCode())).toList());
WarehouseResourceGateway.PalletBatchResponse reservation;
try {
reservation = warehouse.ensurePalletBatch(ensure);
requireBatch(reservation, command.getBatchKey(), preparation.items().size(), "RESERVED", "CONFIRMED");
} catch (RuntimeException failure) {
resetForRetry(command, failure);
throw failure;
}
List<InboundPlanResponse> localResult;
if ("LOCAL_COMMITTED".equals(command.getStatus())) {
localResult = result(command);
} else {
try {
List<InboundPlanService.PreparedPlan> preparedPlans = bind(preparation, reservation);
localResult = inTransaction(() -> {
List<InboundPlanResponse> created = plans.convertContainerReserved(bookingId,
request.snapshotVersion(), request.snapshotHash(), preparedPlans);
state(command.getId(), "APPLYING", "LOCAL_COMMITTED", write(created), null);
return created;
});
command.setStatus("LOCAL_COMMITTED");
} catch (RuntimeException failure) {
try {
warehouse.compensatePalletBatch(new WarehouseResourceGateway.PalletBatchCompensation(
preparation.warehouseId(), command.getBatchKey(), "COMPENSATE:" + command.getId(),
"Lastleg local transaction did not commit"));
} catch (RuntimeException compensationFailure) {
failure.addSuppressed(compensationFailure);
}
resetForRetry(command, failure);
throw failure;
}
}
WarehouseResourceGateway.PalletBatchResponse confirmed = warehouse.confirmPalletBatch(
new WarehouseResourceGateway.PalletBatchCommand(preparation.warehouseId(), command.getBatchKey(),
"CONFIRM:" + command.getId()));
requireBatch(confirmed, command.getBatchKey(), preparation.items().size(), "CONFIRMED");
complete(command, localResult);
return localResult;
}
private ContainerPlanConversionCommand createCommand(String bookingId, String key,
ConvertContainerInboundPlanRequest request,
String requestHash) {
ContainerInboundPlanPreviewResponse preview = plans.previewContainer(bookingId);
if (preview.snapshotVersion() != request.snapshotVersion()
|| !Objects.equals(preview.snapshotHash(), request.snapshotHash()) || !preview.ready()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级预览已变化或存在阻断,请刷新预览后重试");
}
long warehouseId;
try { warehouseId = Long.parseLong(preview.overseasWarehouseId()); }
catch (RuntimeException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级接柜仓 ID 无效", exception);
}
long commandId = IdWorker.getId();
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);
for (int localSequence = 1; localSequence <= order.suggestedPalletCount(); localSequence++) {
items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo,
localSequence, ++globalSequence, "CIP:" + commandId + ":" + globalSequence,
"FBA".equals(order.destinationType()) ? order.destinationWarehouseCode() : null));
}
}
Preparation preparation = new Preparation(warehouseId, List.copyOf(items));
ContainerPlanConversionCommand command = new ContainerPlanConversionCommand();
command.setId(commandId); command.setSourceContainerBookingId(bookingId); command.setIdempotencyKey(key);
command.setRequestHash(requestHash); command.setSnapshotVersion(request.snapshotVersion());
command.setSnapshotHash(request.snapshotHash()); command.setWarehouseId(warehouseId);
command.setBatchKey("CIP:" + hash(bookingId) + ":" + commandId); command.setStatus("PREPARED");
command.setPreparationJson(write(preparation)); command.setCreatedAt(LocalDateTime.now());
command.setUpdatedAt(command.getCreatedAt());
try { inTransaction(() -> { commands.insert(command); return null; }); }
catch (DuplicateKeyException concurrent) {
ContainerPlanConversionCommand existing = commands.findByBooking(bookingId);
if (existing == null) throw concurrent;
return existing;
}
return command;
}
private List<InboundPlanService.PreparedPlan> bind(Preparation preparation,
WarehouseResourceGateway.PalletBatchResponse response) {
Map<String, WarehouseResourceGateway.PalletBatchItem> byKey = new LinkedHashMap<>();
for (WarehouseResourceGateway.PalletBatchItem item : response.items()) {
if (item.businessKey() == null || byKey.putIfAbsent(item.businessKey(), item) != null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次包含重复业务键");
}
}
if (response.items().stream().map(WarehouseResourceGateway.PalletBatchItem::palletId).distinct().count() != response.items().size()
|| response.items().stream().map(WarehouseResourceGateway.PalletBatchItem::resourceLockId).distinct().count() != response.items().size()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次包含重复托盘或资源锁");
}
return preparation.items().stream().collect(Collectors.groupingBy(PreparedItem::orderId)).values().stream()
.map(items -> {
PreparedItem first = items.get(0);
return new InboundPlanService.PreparedPlan(first.planId(), first.planNo(), first.customerId(),
first.orderId(), items.stream().sorted(Comparator.comparingInt(PreparedItem::localSequenceNo))
.map(item -> new InboundPlanService.PreparedPallet(item.localSequenceNo(), item.businessKey(),
requiredReservation(byKey.get(item.businessKey()), item))).toList());
}).toList();
}
private WarehouseResourceGateway.PalletBatchItem requiredReservation(
WarehouseResourceGateway.PalletBatchItem value, PreparedItem expected) {
if (value == null || value.customerId() != Long.parseLong(expected.customerId())
|| !Objects.equals(value.businessKey(), expected.businessKey())
|| !Objects.equals(value.inboundPlanNo(), expected.planNo())
|| value.sequenceNo() != expected.globalSequenceNo() || value.palletId() <= 0
|| value.locationId() <= 0 || value.resourceLockId() <= 0
|| value.palletCode() == null || value.palletCode().isBlank()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘预留响应与计划快照不一致");
}
return value;
}
private void requireBatch(WarehouseResourceGateway.PalletBatchResponse response, String batchKey, int size,
String... acceptedStatuses) {
if (response == null || !Objects.equals(batchKey, response.batchKey()) || response.items() == null
|| response.items().size() != size || java.util.Arrays.stream(acceptedStatuses)
.noneMatch(status -> status.equals(response.status()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次响应不完整或状态不一致");
}
}
private void requireSameRequest(ContainerPlanConversionCommand command, String requestHash) {
if (!MessageDigest.isEqual(command.getRequestHash().getBytes(StandardCharsets.UTF_8),
requestHash.getBytes(StandardCharsets.UTF_8))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "同一幂等键不能用于不同柜级确认请求");
}
}
private List<InboundPlanResponse> result(ContainerPlanConversionCommand command) {
if (command.getResultJson() == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "柜级转换结果尚未可用");
}
return read(command.getResultJson(), new TypeReference<List<InboundPlanResponse>>() { });
}
private void complete(ContainerPlanConversionCommand command, List<InboundPlanResponse> result) {
inTransaction(() -> { state(command.getId(), "LOCAL_COMMITTED", "COMPLETED", write(result), null); return null; });
command.setStatus("COMPLETED");
}
private void resetForRetry(ContainerPlanConversionCommand command, RuntimeException failure) {
if ("APPLYING".equals(command.getStatus())) {
state(command.getId(), "APPLYING", "PREPARED", null, safeMessage(failure));
command.setStatus("PREPARED");
}
}
private void state(long id, String expectedStatus, String status, String resultJson, String lastError) {
if (commands.updateState(id, expectedStatus, status, resultJson, lastError, LocalDateTime.now()) != 1) {
throw new IllegalStateException("无法更新柜级转换命令状态");
}
}
private <T> T inTransaction(java.util.function.Supplier<T> action) {
return new TransactionTemplate(transactionManager).execute(status -> action.get());
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
}
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException exception) { throw new IllegalStateException("无法序列化柜级转换快照", exception); }
}
private <T> T read(String value, Class<T> type) {
try { return objectMapper.readValue(value, type); }
catch (JsonProcessingException exception) { throw new IllegalStateException("无法读取柜级转换快照", exception); }
}
private <T> T read(String value, TypeReference<T> type) {
try { return objectMapper.readValue(value, type); }
catch (JsonProcessingException exception) { throw new IllegalStateException("无法读取柜级转换结果", exception); }
}
private static String required(String value, int max, String message) {
String normalized = value == null || value.isBlank() ? null : value.trim();
if (normalized == null || normalized.length() > max) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
return normalized;
}
private static String safeMessage(Throwable value) {
String message = value.getMessage();
return message == null ? value.getClass().getSimpleName() : message.substring(0, Math.min(1000, message.length()));
}
public record Preparation(long warehouseId, List<PreparedItem> items) { }
public record PreparedItem(String customerId, long orderId, long planId, String planNo,
int localSequenceNo, int globalSequenceNo, String businessKey,
String fbaWarehouseCode) { }
}
@@ -231,6 +231,7 @@ public class InboundPlanService {
|| row.packageCount() > 0 && row.volumeCbm().signum() == 0) blockers.add("CARGO_FACT_INVALID");
}
Integer suggested = suggestedPalletCount(row, blockers);
if (!planned && (suggested == null || suggested <= 0)) blockers.add("PALLET_COUNT_NOT_READY");
String destinationType = destinationType(row);
String destinationCode = "FBA".equals(destinationType) && trimToNull(row.fbaWarehouseCode()) != null
? row.fbaWarehouseCode().trim() : row.destinationWarehouseCode();
@@ -335,11 +336,23 @@ public class InboundPlanService {
List<SourceOrderSnapshot> orders, List<String> sourcePalletNos,
List<WholePalletInput> wholePallets, ManualInboundPlanRequest manual,
WarehouseResourceGateway.WarehouseReference receivingWarehouse) {
return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, manual,
receivingWarehouse, null, Map.of());
}
private InboundPlanResponse createPlan(String customerId, Long selectedOrderId,
List<SourceOrderSnapshot> orders, List<String> sourcePalletNos,
List<WholePalletInput> wholePallets, ManualInboundPlanRequest manual,
WarehouseResourceGateway.WarehouseReference receivingWarehouse,
PreparedPlan preparedPlan,
Map<Integer, WarehouseResourceGateway.PalletBatchItem> reservedPallets) {
String containerNo = requiredContainerNo(orders.get(0).containerNo());
LocalDateTime now = LocalDateTime.now();
InboundPlan plan = new InboundPlan();
plan.setId(IdWorker.getId());
plan.setPlanNo(orderNoGenerator.next("INBOUND_PLAN", orders.get(0).destinationWarehouseCode(), plan.getId()));
plan.setId(preparedPlan == null ? IdWorker.getId() : preparedPlan.planId());
plan.setPlanNo(preparedPlan == null
? orderNoGenerator.next("INBOUND_PLAN", orders.get(0).destinationWarehouseCode(), plan.getId())
: preparedPlan.planNo());
plan.setCustomerId(customerId);
plan.setSourceOrderId(selectedOrderId);
plan.setContainerNo(containerNo);
@@ -384,14 +397,14 @@ public class InboundPlanService {
palletCounts = Map.of(orders.get(0).orderId(), manual.palletCount());
} else if (wholePallets == null) {
Map<PalletAllocator.PalletDraft, InboundPlanPallet> persistedPallets = createPallets(plan, allocation,
orderById, sourcePalletNos == null ? List.of() : sourcePalletNos);
orderById, sourcePalletNos == null ? List.of() : sourcePalletNos, reservedPallets);
createAllocations(plan, allocation, lineByOrder, persistedPallets);
palletCounts = allocation.allocations().stream().collect(Collectors.groupingBy(
PalletAllocator.AllocationDraft::orderId, Collectors.collectingAndThen(Collectors.mapping(
item -> item.pallet().sequenceNo(), Collectors.toSet()), java.util.Set::size)));
} else {
SourceOrderSnapshot order = orders.get(0);
createWholePallets(plan, order, lineByOrder.get(order.orderId()), wholePallets);
createWholePallets(plan, order, lineByOrder.get(order.orderId()), wholePallets, reservedPallets);
palletCounts = Map.of(order.orderId(), wholePallets.size());
}
for (SourceOrderSnapshot order : orders) {
@@ -417,10 +430,32 @@ public class InboundPlanService {
/** 同一来源柜的每个尾程订单独立建计划;整个柜级批次共用一个事务。 */
@Transactional
public List<InboundPlanResponse> convertContainer(String sourceContainerBookingId) {
return convertContainerInternal(sourceContainerBookingId, null, null, Map.of());
}
@Transactional
public List<InboundPlanResponse> convertContainerReserved(String sourceContainerBookingId,
long snapshotVersion, String snapshotHash,
List<PreparedPlan> preparedPlans) {
Map<Long, PreparedPlan> prepared = preparedPlans.stream().collect(Collectors.toMap(
PreparedPlan::orderId, value -> value));
return convertContainerInternal(sourceContainerBookingId, snapshotVersion, snapshotHash, prepared);
}
private List<InboundPlanResponse> convertContainerInternal(String sourceContainerBookingId,
Long snapshotVersion, String snapshotHash,
Map<Long, PreparedPlan> preparedPlans) {
String bookingId = trimToNull(sourceContainerBookingId);
if (bookingId == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订 ID 不能为空");
List<InboundPlanOrderMapper.ContainerOrderRef> refs = orderMapper.lockContainerOrders(bookingId);
if (refs.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜没有尾程订单");
if (snapshotVersion != null) {
ContainerInboundPlanPreviewResponse current = previewContainer(bookingId);
if (current.snapshotVersion() != snapshotVersion || !Objects.equals(current.snapshotHash(), snapshotHash)
|| !current.ready()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级预览已变化或存在阻断,请刷新预览后重试");
}
}
String containerNo = null;
for (InboundPlanOrderMapper.ContainerOrderRef ref : refs) {
String current = requiredContainerNo(ref.containerNo());
@@ -481,8 +516,15 @@ public class InboundPlanService {
for (int pendingIndex = 0; pendingIndex < pendingIndexes.size(); pendingIndex++) {
int refIndex = pendingIndexes.get(pendingIndex);
InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(refIndex);
PreparedPlan prepared = preparedPlans.get(ref.orderId());
if (snapshotVersion != null && (prepared == null || !Objects.equals(prepared.customerId(), ref.customerId()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级托盘预留与待生成计划不一致");
}
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), List.of(),
wholePallets.get(pendingIndex), null, null));
wholePallets.get(pendingIndex), null, null, prepared, reserved));
}
List<InboundPlanResponse> result = new ArrayList<>(refs.size());
for (int index = 0; index < refs.size(); index++) {
@@ -492,6 +534,11 @@ public class InboundPlanService {
return List.copyOf(result);
}
public record PreparedPlan(long planId, String planNo, String customerId, long orderId,
List<PreparedPallet> pallets) { }
public record PreparedPallet(int sequenceNo, String businessKey,
WarehouseResourceGateway.PalletBatchItem reservation) { }
private WholePalletInput wholePallet(DropshipPalletSnapshot pallet) {
return new WholePalletInput(pallet.sourcePalletNo(), Math.toIntExact(pallet.packageCount()));
}
@@ -569,7 +616,8 @@ public class InboundPlanService {
}
private void createWholePallets(InboundPlan plan, SourceOrderSnapshot order, InboundPlanLine line,
List<WholePalletInput> sourcePallets) {
List<WholePalletInput> sourcePallets,
Map<Integer, WarehouseResourceGateway.PalletBatchItem> reservedPallets) {
BigDecimal remainingVolume = volume(order);
int remainingPackages = order.packageCount();
for (int index = 0; index < sourcePallets.size(); index++) {
@@ -586,6 +634,7 @@ public class InboundPlanService {
pallet.setDestinationWarehouseName(order.destinationWarehouseName());
pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM); pallet.setAllocatedVolumeCbm(palletVolume);
pallet.setSequenceNo(index + 1); pallet.setAssignmentStatus("UNASSIGNED");
applyReservedPallet(pallet, reservedPallets.get(index + 1));
pallet.setActualVolumeCbm(BigDecimal.ZERO); pallet.setActualPackageCount(0); palletMapper.insert(pallet);
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation();
@@ -707,7 +756,7 @@ public class InboundPlanService {
private Map<PalletAllocator.PalletDraft, InboundPlanPallet> createPallets(
InboundPlan plan, PalletAllocator.Result allocation, Map<Long, SourceOrderSnapshot> orderById,
List<String> sourcePalletNos) {
List<String> sourcePalletNos, Map<Integer, WarehouseResourceGateway.PalletBatchItem> reservedPallets) {
Map<String, SourceOrderSnapshot> warehouseSamples = orderById.values().stream()
.collect(Collectors.toMap(this::warehouseKey, value -> value, (left, right) -> left));
Map<PalletAllocator.PalletDraft, InboundPlanPallet> result = new HashMap<>();
@@ -729,6 +778,7 @@ public class InboundPlanService {
pallet.setAllocatedVolumeCbm(draft.allocatedVolumeCbm());
pallet.setSequenceNo(result.size() + 1);
pallet.setAssignmentStatus("UNASSIGNED");
applyReservedPallet(pallet, reservedPallets.get(pallet.getSequenceNo()));
pallet.setActualVolumeCbm(BigDecimal.ZERO);
pallet.setActualPackageCount(0);
palletMapper.insert(pallet);
@@ -737,6 +787,20 @@ public class InboundPlanService {
return result;
}
private void applyReservedPallet(InboundPlanPallet pallet,
WarehouseResourceGateway.PalletBatchItem reserved) {
if (reserved == null) return;
if (trimToNull(reserved.palletCode()) == null || reserved.palletCode().length() > 128) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓返回的真实托盘码无效");
}
pallet.setPalletNo(reserved.palletCode());
pallet.setWarehousePalletId(reserved.palletId());
pallet.setWarehouseLocationId(reserved.locationId());
pallet.setWarehouseResourceLockId(reserved.resourceLockId());
pallet.setAssignmentStatus("RESERVED");
pallet.setLockedAt(LocalDateTime.now());
}
private void createAllocations(InboundPlan plan, PalletAllocator.Result allocation,
Map<Long, InboundPlanLine> lineByOrder,
Map<PalletAllocator.PalletDraft, InboundPlanPallet> pallets) {