feat(lastleg): reserve pallets for container plans
This commit is contained in:
+4
-1
@@ -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")
|
||||
|
||||
+5
-1
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
+27
@@ -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;
|
||||
}
|
||||
+33
@@ -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);
|
||||
}
|
||||
+34
@@ -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) {}
|
||||
}
|
||||
|
||||
+21
@@ -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) { }
|
||||
}
|
||||
|
||||
+289
@@ -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) { }
|
||||
}
|
||||
+71
-7
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user