fix(lastleg): harden container plan saga recovery

This commit is contained in:
2026-09-09 00:31:50 +08:00
parent 990b74f3e8
commit 2ba5f0c307
8 changed files with 327 additions and 60 deletions
@@ -1995,12 +1995,14 @@ CREATE TABLE IF NOT EXISTS ll_container_plan_conversion_command (
idempotency_key VARCHAR(128) NOT NULL COMMENT '首次调用幂等键', request_hash CHAR(64) NOT NULL COMMENT '确认请求SHA-256摘要', idempotency_key VARCHAR(128) NOT NULL COMMENT '首次调用幂等键', request_hash CHAR(64) NOT NULL COMMENT '确认请求SHA-256摘要',
snapshot_version BIGINT NOT NULL COMMENT '确认的柜级快照版本', snapshot_hash CHAR(64) NOT NULL COMMENT '确认的柜级快照SHA-256', snapshot_version BIGINT NOT NULL COMMENT '确认的柜级快照版本', snapshot_hash CHAR(64) NOT NULL COMMENT '确认的柜级快照SHA-256',
warehouse_id BIGINT NOT NULL COMMENT '海外接柜仓ID', batch_key VARCHAR(160) NOT NULL COMMENT '海外仓托盘批次幂等键', warehouse_id BIGINT NOT NULL COMMENT '海外接柜仓ID', batch_key VARCHAR(160) NOT NULL COMMENT '海外仓托盘批次幂等键',
status VARCHAR(32) NOT NULL COMMENT 'PREPARED/APPLYING/LOCAL_COMMITTED/COMPLETED', preparation_json JSON NOT NULL COMMENT '稳定计划号与托盘业务键快照', status VARCHAR(32) NOT NULL COMMENT '命令状态:PREPARED/APPLYING/FAILED/LOCAL_COMMITTED/COMPLETED', lease_token VARCHAR(64) NULL COMMENT '当前执行租约令牌,用于陈旧执行者隔离', preparation_json JSON NOT NULL COMMENT '稳定计划号与托盘业务键快照',
result_json JSON NULL COMMENT '本地计划提交结果', last_error VARCHAR(1000) NULL COMMENT '最近一次跨服务错误', result_json JSON NULL COMMENT '本地计划提交结果', last_error VARCHAR(1000) NULL COMMENT '最近一次跨服务错误',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '命令创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '命令状态或租约更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_container_conversion_command(source_container_booking_id), PRIMARY KEY(id), UNIQUE KEY uk_ll_container_conversion_command(source_container_booking_id),
UNIQUE KEY uk_ll_container_conversion_batch(warehouse_id,batch_key), KEY idx_ll_container_conversion_status(status,updated_at) UNIQUE KEY uk_ll_container_conversion_batch(warehouse_id,batch_key), KEY idx_ll_container_conversion_status(status,updated_at)
) ENGINE=InnoDB COMMENT='柜级入库计划转换Saga与幂等命令'; ) ENGINE=InnoDB COMMENT='柜级入库计划转换Saga与幂等命令';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_container_plan_conversion_command' AND column_name='lease_token'), 'SELECT 1', 'ALTER TABLE ll_container_plan_conversion_command ADD COLUMN lease_token VARCHAR(64) NULL COMMENT ''当前执行租约令牌,用于陈旧执行者隔离'' AFTER status'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN pallet_no VARCHAR(128) NOT NULL COMMENT 'Warehouse权威实物托盘码'; ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN pallet_no VARCHAR(128) NOT NULL COMMENT 'Warehouse权威实物托盘码';
SELECT 'PASS' AS migration_status, '海外尾程人工计划、暂扣与人工节点结构已就绪' AS message; SELECT 'PASS' AS migration_status, '海外尾程人工计划、暂扣与人工节点结构已就绪' AS message;
@@ -19,6 +19,7 @@ public class ContainerPlanConversionCommand {
private Long warehouseId; private Long warehouseId;
private String batchKey; private String batchKey;
private String status; private String status;
private String leaseToken;
private String preparationJson; private String preparationJson;
private String resultJson; private String resultJson;
private String lastError; private String lastError;
@@ -17,17 +17,33 @@ public interface ContainerPlanConversionCommandMapper extends BaseMapper<Contain
@Select("SELECT * FROM ll_container_plan_conversion_command WHERE source_container_booking_id=#{bookingId} LIMIT 1") @Select("SELECT * FROM ll_container_plan_conversion_command WHERE source_container_booking_id=#{bookingId} LIMIT 1")
ContainerPlanConversionCommand findByBooking(@Param("bookingId") String bookingId); 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}") @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} AND lease_token=#{leaseToken}")
int updateState(@Param("id") long id, @Param("expectedStatus") String expectedStatus, int updateState(@Param("id") long id, @Param("expectedStatus") String expectedStatus,
@Param("leaseToken") String leaseToken,
@Param("status") String status, @Param("status") String status,
@Param("resultJson") String resultJson, @Param("lastError") String lastError, @Param("resultJson") String resultJson, @Param("lastError") String lastError,
@Param("updatedAt") LocalDateTime updatedAt); @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}") @Update("UPDATE ll_container_plan_conversion_command SET status=#{nextStatus},lease_token=#{leaseToken},last_error=NULL,updated_at=#{updatedAt} WHERE id=#{id} AND status=#{expectedStatus}")
int claim(@Param("id") long id, @Param("expectedStatus") String expectedStatus, int claim(@Param("id") long id, @Param("expectedStatus") String expectedStatus,
@Param("nextStatus") String nextStatus, @Param("updatedAt") LocalDateTime updatedAt); @Param("nextStatus") String nextStatus, @Param("leaseToken") String leaseToken,
@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}") @Update("UPDATE ll_container_plan_conversion_command SET lease_token=#{leaseToken},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, int reclaimStale(@Param("id") long id, @Param("expectedUpdatedAt") LocalDateTime expectedUpdatedAt,
@Param("updatedAt") LocalDateTime updatedAt); @Param("leaseToken") String leaseToken, @Param("updatedAt") LocalDateTime updatedAt);
@Update("""
UPDATE ll_container_plan_conversion_command
SET idempotency_key=#{key},request_hash=#{requestHash},snapshot_version=#{snapshotVersion},
snapshot_hash=#{snapshotHash},warehouse_id=#{warehouseId},batch_key=#{batchKey},
status='PREPARED',lease_token=NULL,preparation_json=#{preparationJson},result_json=NULL,last_error=NULL,
updated_at=#{updatedAt}
WHERE id=#{id} AND status='FAILED'
""")
int replaceFailed(@Param("id") long id, @Param("key") String key,
@Param("requestHash") String requestHash, @Param("snapshotVersion") long snapshotVersion,
@Param("snapshotHash") String snapshotHash, @Param("warehouseId") long warehouseId,
@Param("batchKey") String batchKey, @Param("preparationJson") String preparationJson,
@Param("updatedAt") LocalDateTime updatedAt);
} }
@@ -46,82 +46,108 @@ public class ContainerInboundPlanConversionCoordinator {
String bookingId = required(request.sourceContainerBookingId(), 128, "来源柜预订 ID 不能为空"); String bookingId = required(request.sourceContainerBookingId(), 128, "来源柜预订 ID 不能为空");
String key = required(idempotencyKey, 128, "Idempotency-Key 必须为1至128个字符"); String key = required(idempotencyKey, 128, "Idempotency-Key 必须为1至128个字符");
String requestHash = hash(List.of(bookingId, request.snapshotVersion(), request.snapshotHash())); String requestHash = hash(List.of(bookingId, request.snapshotVersion(), request.snapshotHash()));
ContainerPlanConversionCommand resolved = commands.find(bookingId, key); ContainerPlanConversionCommand direct = commands.find(bookingId, key);
if (resolved == null) resolved = commands.findByBooking(bookingId); ContainerPlanConversionCommand resolved = direct == null ? commands.findByBooking(bookingId) : direct;
if (resolved == null) resolved = createCommand(bookingId, key, request, requestHash); if (resolved == null) {
resolved = createCommand(bookingId, key, request, requestHash);
} else if ("COMPLETED".equals(resolved.getStatus())) {
if (direct != null) requireSameRequest(resolved, requestHash);
return result(resolved);
} else if ("FAILED".equals(resolved.getStatus())) {
resolved = replaceFailedCommand(resolved, bookingId, key, request, requestHash);
} else {
requireSameRequest(resolved, requestHash);
}
if ("COMPLETED".equals(resolved.getStatus())) {
requireSameRequest(resolved, requestHash);
return result(resolved);
}
final ContainerPlanConversionCommand command = resolved; final ContainerPlanConversionCommand command = resolved;
requireSameRequest(command, requestHash);
Preparation preparation = read(command.getPreparationJson(), Preparation.class); Preparation preparation = read(command.getPreparationJson(), Preparation.class);
if ("COMPLETED".equals(command.getStatus())) return result(command); boolean applyingLeaseOwned = false;
if ("APPLYING".equals(command.getStatus())) { if ("APPLYING".equals(command.getStatus())) {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
String leaseToken = IdWorker.getIdStr();
if (command.getUpdatedAt() == null || command.getUpdatedAt().plus(APPLYING_LEASE).isAfter(now) if (command.getUpdatedAt() == null || command.getUpdatedAt().plus(APPLYING_LEASE).isAfter(now)
|| commands.reclaimStale(command.getId(), command.getUpdatedAt(), now) != 1) { || commands.reclaimStale(command.getId(), command.getUpdatedAt(), leaseToken, now) != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜正在生成入库计划,请勿重复提交"); throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜正在生成入库计划,请勿重复提交");
} }
command.setLeaseToken(leaseToken);
command.setUpdatedAt(now); command.setUpdatedAt(now);
applyingLeaseOwned = true;
} }
if (!"LOCAL_COMMITTED".equals(command.getStatus())) { if ("PREPARED".equals(command.getStatus())) {
if (commands.claim(command.getId(), "PREPARED", "APPLYING", LocalDateTime.now()) != 1) { String leaseToken = IdWorker.getIdStr();
if (commands.claim(command.getId(), "PREPARED", "APPLYING", leaseToken, LocalDateTime.now()) != 1) {
ContainerPlanConversionCommand current = commands.findByBooking(bookingId); ContainerPlanConversionCommand current = commands.findByBooking(bookingId);
if (current != null && "COMPLETED".equals(current.getStatus())) return result(current); if (current != null && "COMPLETED".equals(current.getStatus())) return result(current);
throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜正在生成入库计划,请勿重复提交"); throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜正在生成入库计划,请勿重复提交");
} }
command.setStatus("APPLYING"); command.setStatus("APPLYING");
command.setLeaseToken(leaseToken);
applyingLeaseOwned = true;
}
if (!applyingLeaseOwned && !"LOCAL_COMMITTED".equals(command.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级转换命令状态无效");
} }
if (preparation.items().isEmpty()) { if (preparation.items().isEmpty()) {
try { try {
List<InboundPlanResponse> existing = inTransaction(() -> { List<InboundPlanResponse> existing = inTransaction(() -> {
List<InboundPlanResponse> converted = plans.convertContainerReserved(bookingId, List<InboundPlanResponse> converted = plans.convertContainerReserved(bookingId,
request.snapshotVersion(), request.snapshotHash(), List.of()); request.snapshotVersion(), request.snapshotHash(), List.of());
state(command.getId(), "APPLYING", "COMPLETED", write(converted), null); state(command, "APPLYING", "COMPLETED", write(converted), null);
return converted; return converted;
}); });
command.setStatus("COMPLETED"); command.setStatus("COMPLETED");
return existing; return existing;
} catch (RuntimeException failure) { } catch (RuntimeException failure) {
resetForRetry(command, failure); ContainerPlanConversionCommand current = commands.findByBooking(bookingId);
if (current != null && "COMPLETED".equals(current.getStatus())) return result(current);
failWithoutResources(command, failure);
throw 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; List<InboundPlanResponse> localResult;
if ("LOCAL_COMMITTED".equals(command.getStatus())) { if ("LOCAL_COMMITTED".equals(command.getStatus())) {
localResult = result(command); localResult = result(command);
} else { } else {
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) {
compensateAndFail(command, preparation, failure);
throw failure;
}
try { try {
List<InboundPlanService.PreparedPlan> preparedPlans = bind(preparation, reservation); List<InboundPlanService.PreparedPlan> preparedPlans = bind(preparation, reservation);
localResult = inTransaction(() -> { localResult = inTransaction(() -> {
List<InboundPlanResponse> created = plans.convertContainerReserved(bookingId, List<InboundPlanResponse> created = plans.convertContainerReserved(bookingId,
request.snapshotVersion(), request.snapshotHash(), preparedPlans); request.snapshotVersion(), request.snapshotHash(), preparedPlans);
state(command.getId(), "APPLYING", "LOCAL_COMMITTED", write(created), null); state(command, "APPLYING", "LOCAL_COMMITTED", write(created), null);
return created; return created;
}); });
command.setStatus("LOCAL_COMMITTED"); command.setStatus("LOCAL_COMMITTED");
} catch (RuntimeException failure) { } catch (RuntimeException failure) {
try { ContainerPlanConversionCommand current = commands.findByBooking(bookingId);
warehouse.compensatePalletBatch(new WarehouseResourceGateway.PalletBatchCompensation( if (current != null && "COMPLETED".equals(current.getStatus())) {
preparation.warehouseId(), command.getBatchKey(), "COMPENSATE:" + command.getId(), return result(current);
"Lastleg local transaction did not commit")); }
} catch (RuntimeException compensationFailure) { if (current != null && "LOCAL_COMMITTED".equals(current.getStatus())) {
failure.addSuppressed(compensationFailure); command.setStatus(current.getStatus());
command.setResultJson(current.getResultJson());
localResult = result(current);
} else {
compensateAndFail(command, preparation, failure);
throw failure;
} }
resetForRetry(command, failure);
throw failure;
} }
} }
@@ -136,6 +162,35 @@ public class ContainerInboundPlanConversionCoordinator {
private ContainerPlanConversionCommand createCommand(String bookingId, String key, private ContainerPlanConversionCommand createCommand(String bookingId, String key,
ConvertContainerInboundPlanRequest request, ConvertContainerInboundPlanRequest request,
String requestHash) { String requestHash) {
long commandId = IdWorker.getId();
ContainerPlanConversionCommand command = prepareCommand(commandId, commandId, bookingId, key, request, requestHash);
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 ContainerPlanConversionCommand replaceFailedCommand(ContainerPlanConversionCommand previous,
String bookingId, String key,
ConvertContainerInboundPlanRequest request,
String requestHash) {
ContainerPlanConversionCommand replacement = prepareCommand(previous.getId(), IdWorker.getId(),
bookingId, key, request, requestHash);
int updated = commands.replaceFailed(previous.getId(), key, requestHash, request.snapshotVersion(),
request.snapshotHash(), replacement.getWarehouseId(), replacement.getBatchKey(),
replacement.getPreparationJson(), replacement.getUpdatedAt());
if (updated != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜正在刷新入库计划,请勿重复提交");
}
return replacement;
}
private ContainerPlanConversionCommand prepareCommand(long commandId, long generationId, String bookingId,
String key, ConvertContainerInboundPlanRequest request,
String requestHash) {
ContainerInboundPlanPreviewResponse preview = plans.previewContainer(bookingId); ContainerInboundPlanPreviewResponse preview = plans.previewContainer(bookingId);
if (preview.snapshotVersion() != request.snapshotVersion() if (preview.snapshotVersion() != request.snapshotVersion()
|| !Objects.equals(preview.snapshotHash(), request.snapshotHash()) || !preview.ready()) { || !Objects.equals(preview.snapshotHash(), request.snapshotHash()) || !preview.ready()) {
@@ -146,7 +201,6 @@ public class ContainerInboundPlanConversionCoordinator {
catch (RuntimeException exception) { catch (RuntimeException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级接柜仓 ID 无效", exception); throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级接柜仓 ID 无效", exception);
} }
long commandId = IdWorker.getId();
List<PreparedItem> items = new ArrayList<>(); List<PreparedItem> items = new ArrayList<>();
int globalSequence = 0; int globalSequence = 0;
for (ContainerInboundPlanPreviewResponse.Order order : preview.groups().stream() for (ContainerInboundPlanPreviewResponse.Order order : preview.groups().stream()
@@ -155,7 +209,7 @@ public class ContainerInboundPlanConversionCoordinator {
String planNo = numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId); String planNo = numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId);
for (int localSequence = 1; localSequence <= order.suggestedPalletCount(); localSequence++) { for (int localSequence = 1; localSequence <= order.suggestedPalletCount(); localSequence++) {
items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo, items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo,
localSequence, ++globalSequence, "CIP:" + commandId + ":" + globalSequence, localSequence, ++globalSequence, "CIP:" + generationId + ":" + globalSequence,
"FBA".equals(order.destinationType()) ? order.destinationWarehouseCode() : null)); "FBA".equals(order.destinationType()) ? order.destinationWarehouseCode() : null));
} }
} }
@@ -164,15 +218,9 @@ public class ContainerInboundPlanConversionCoordinator {
command.setId(commandId); command.setSourceContainerBookingId(bookingId); command.setIdempotencyKey(key); command.setId(commandId); command.setSourceContainerBookingId(bookingId); command.setIdempotencyKey(key);
command.setRequestHash(requestHash); command.setSnapshotVersion(request.snapshotVersion()); command.setRequestHash(requestHash); command.setSnapshotVersion(request.snapshotVersion());
command.setSnapshotHash(request.snapshotHash()); command.setWarehouseId(warehouseId); command.setSnapshotHash(request.snapshotHash()); command.setWarehouseId(warehouseId);
command.setBatchKey("CIP:" + hash(bookingId) + ":" + commandId); command.setStatus("PREPARED"); command.setBatchKey("CIP:" + hash(bookingId) + ":" + generationId); command.setStatus("PREPARED");
command.setPreparationJson(write(preparation)); command.setCreatedAt(LocalDateTime.now()); command.setPreparationJson(write(preparation)); command.setCreatedAt(LocalDateTime.now());
command.setUpdatedAt(command.getCreatedAt()); 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; return command;
} }
@@ -213,11 +261,26 @@ public class ContainerInboundPlanConversionCoordinator {
private void requireBatch(WarehouseResourceGateway.PalletBatchResponse response, String batchKey, int size, private void requireBatch(WarehouseResourceGateway.PalletBatchResponse response, String batchKey, int size,
String... acceptedStatuses) { String... acceptedStatuses) {
if (response == null || !Objects.equals(batchKey, response.batchKey()) || response.items() == null if (response == null || response.batchId() <= 0 || response.attemptNo() <= 0
|| !Objects.equals(batchKey, response.batchKey()) || response.items() == null
|| response.items().size() != size || java.util.Arrays.stream(acceptedStatuses) || response.items().size() != size || java.util.Arrays.stream(acceptedStatuses)
.noneMatch(status -> status.equals(response.status()))) { .noneMatch(status -> status.equals(response.status()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次响应不完整或状态不一致"); throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次响应不完整或状态不一致");
} }
String expectedItemStatus = switch (response.status()) {
case "RESERVED" -> "RESERVED";
case "CONFIRMED" -> "CONFIRMED";
case "COMPENSATED" -> "RELEASED";
default -> null;
};
boolean invalidItem = expectedItemStatus == null || response.items().stream().anyMatch(item ->
item.customerId() <= 0 || item.sequenceNo() <= 0 || item.palletId() <= 0 || item.locationId() <= 0
|| item.resourceLockId() <= 0 || trimToNull(item.businessKey()) == null
|| trimToNull(item.inboundPlanNo()) == null || trimToNull(item.palletCode()) == null
|| item.palletCode().length() > 128 || !expectedItemStatus.equals(item.status()));
if (invalidItem) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次明细不完整或状态不一致");
}
} }
private void requireSameRequest(ContainerPlanConversionCommand command, String requestHash) { private void requireSameRequest(ContainerPlanConversionCommand command, String requestHash) {
@@ -235,19 +298,41 @@ public class ContainerInboundPlanConversionCoordinator {
} }
private void complete(ContainerPlanConversionCommand command, List<InboundPlanResponse> result) { private void complete(ContainerPlanConversionCommand command, List<InboundPlanResponse> result) {
inTransaction(() -> { state(command.getId(), "LOCAL_COMMITTED", "COMPLETED", write(result), null); return null; }); inTransaction(() -> { state(command, "LOCAL_COMMITTED", "COMPLETED", write(result), null); return null; });
command.setStatus("COMPLETED"); command.setStatus("COMPLETED");
} }
private void resetForRetry(ContainerPlanConversionCommand command, RuntimeException failure) { private void failWithoutResources(ContainerPlanConversionCommand command, RuntimeException failure) {
if ("APPLYING".equals(command.getStatus())) { if ("APPLYING".equals(command.getStatus())) {
state(command.getId(), "APPLYING", "PREPARED", null, safeMessage(failure)); state(command, "APPLYING", "FAILED", null, safeMessage(failure));
command.setStatus("PREPARED"); command.setStatus("FAILED");
} }
} }
private void state(long id, String expectedStatus, String status, String resultJson, String lastError) { private void compensateAndFail(ContainerPlanConversionCommand command, Preparation preparation,
if (commands.updateState(id, expectedStatus, status, resultJson, lastError, LocalDateTime.now()) != 1) { RuntimeException failure) {
try {
WarehouseResourceGateway.PalletBatchResponse compensated = warehouse.compensatePalletBatch(
new WarehouseResourceGateway.PalletBatchCompensation(preparation.warehouseId(),
command.getBatchKey(), "COMPENSATE:" + command.getBatchKey(),
"Lastleg local transaction did not commit"));
requireBatch(compensated, command.getBatchKey(), preparation.items().size(), "COMPENSATED");
failWithoutResources(command, failure);
} catch (ResponseStatusException compensationFailure) {
if (compensationFailure.getStatusCode().value() == 404) {
failWithoutResources(command, failure);
return;
}
failure.addSuppressed(compensationFailure);
} catch (RuntimeException compensationFailure) {
failure.addSuppressed(compensationFailure);
}
}
private void state(ContainerPlanConversionCommand command, String expectedStatus, String status,
String resultJson, String lastError) {
if (commands.updateState(command.getId(), expectedStatus, command.getLeaseToken(), status,
resultJson, lastError, LocalDateTime.now()) != 1) {
throw new IllegalStateException("无法更新柜级转换命令状态"); throw new IllegalStateException("无法更新柜级转换命令状态");
} }
} }
@@ -281,6 +366,9 @@ public class ContainerInboundPlanConversionCoordinator {
String message = value.getMessage(); String message = value.getMessage();
return message == null ? value.getClass().getSimpleName() : message.substring(0, Math.min(1000, message.length())); return message == null ? value.getClass().getSimpleName() : message.substring(0, Math.min(1000, message.length()));
} }
private static String trimToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
public record Preparation(long warehouseId, List<PreparedItem> items) { } public record Preparation(long warehouseId, List<PreparedItem> items) { }
public record PreparedItem(String customerId, long orderId, long planId, String planNo, public record PreparedItem(String customerId, long orderId, long planId, String planNo,
@@ -198,7 +198,8 @@ public class InboundPlanService {
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount) .map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); .filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
long snapshotVersion = rows.stream().mapToLong(InboundPlanOrderMapper.ContainerPreviewOrderRow::version).max().orElse(0); long snapshotVersion = rows.stream().mapToLong(InboundPlanOrderMapper.ContainerPreviewOrderRow::version).max().orElse(0);
String snapshotHash = sha256(List.of(bookingId, containerNo, warehouseId, snapshotVersion, orders)); String snapshotHash = sha256(java.util.Arrays.asList(
bookingId, containerNo, warehouseId, snapshotVersion, orders));
return new ContainerInboundPlanPreviewResponse(bookingId, containerNo, warehouseId, warehouseCode, return new ContainerInboundPlanPreviewResponse(bookingId, containerNo, warehouseId, warehouseCode,
warehouseName, snapshotVersion, snapshotHash, orders.size(), orders.size() - blocked, blocked, warehouseName, snapshotVersion, snapshotHash, orders.size(), orders.size() - blocked, blocked,
pendingOrders, pendingPallets, existingPallets, pendingOrders, pendingPallets, existingPallets,
@@ -587,17 +587,20 @@ CREATE TABLE IF NOT EXISTS ll_container_plan_conversion_command (
snapshot_hash CHAR(64) NOT NULL COMMENT '确认的柜级快照SHA-256', snapshot_hash CHAR(64) NOT NULL COMMENT '确认的柜级快照SHA-256',
warehouse_id BIGINT NOT NULL COMMENT '海外接柜仓ID', warehouse_id BIGINT NOT NULL COMMENT '海外接柜仓ID',
batch_key VARCHAR(160) NOT NULL COMMENT '海外仓托盘批次幂等键', batch_key VARCHAR(160) NOT NULL COMMENT '海外仓托盘批次幂等键',
status VARCHAR(32) NOT NULL COMMENT 'PREPARED/APPLYING/LOCAL_COMMITTED/COMPLETED', status VARCHAR(32) NOT NULL COMMENT '命令状态:PREPARED/APPLYING/FAILED/LOCAL_COMMITTED/COMPLETED',
lease_token VARCHAR(64) NULL COMMENT '当前执行租约令牌,用于陈旧执行者隔离',
preparation_json JSON NOT NULL COMMENT '稳定计划号与托盘业务键快照', preparation_json JSON NOT NULL COMMENT '稳定计划号与托盘业务键快照',
result_json JSON NULL COMMENT '本地计划提交结果', result_json JSON NULL COMMENT '本地计划提交结果',
last_error VARCHAR(1000) NULL COMMENT '最近一次跨服务错误', last_error VARCHAR(1000) NULL COMMENT '最近一次跨服务错误',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '命令创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '命令状态或租约更新时间',
PRIMARY KEY(id), PRIMARY KEY(id),
UNIQUE KEY uk_ll_container_conversion_command(source_container_booking_id), UNIQUE KEY uk_ll_container_conversion_command(source_container_booking_id),
UNIQUE KEY uk_ll_container_conversion_batch(warehouse_id,batch_key), UNIQUE KEY uk_ll_container_conversion_batch(warehouse_id,batch_key),
KEY idx_ll_container_conversion_status(status,updated_at) KEY idx_ll_container_conversion_status(status,updated_at)
) ENGINE=InnoDB COMMENT='柜级入库计划转换Saga与幂等命令'; ) ENGINE=InnoDB COMMENT='柜级入库计划转换Saga与幂等命令';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_container_plan_conversion_command' AND column_name='lease_token'), 'SELECT 1', 'ALTER TABLE ll_container_plan_conversion_command ADD COLUMN lease_token VARCHAR(64) NULL COMMENT ''当前执行租约令牌,用于陈旧执行者隔离'' AFTER status'));
PREPARE container_conversion_lease_stmt FROM @ddl; EXECUTE container_conversion_lease_stmt; DEALLOCATE PREPARE container_conversion_lease_stmt;
CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( CREATE TABLE IF NOT EXISTS ll_inbound_plan_line (
id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)',
@@ -1,9 +1,11 @@
package com.baiyue.lastleg.inboundplan.service; package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.ContainerInboundPlanPreviewResponse;
import com.baiyue.lastleg.inboundplan.entity.ContainerPlanConversionCommand; import com.baiyue.lastleg.inboundplan.entity.ContainerPlanConversionCommand;
import com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper; import com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.json.JsonMapper;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -11,8 +13,12 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.SimpleTransactionStatus;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.List; import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -20,6 +26,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.eq;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class ContainerInboundPlanConversionCoordinatorTest { class ContainerInboundPlanConversionCoordinatorTest {
@@ -29,6 +37,8 @@ class ContainerInboundPlanConversionCoordinatorTest {
@Mock InboundPlanService plans; @Mock InboundPlanService plans;
@Mock WarehouseResourceGateway warehouse; @Mock WarehouseResourceGateway warehouse;
@Mock ContainerPlanConversionCommandMapper commands; @Mock ContainerPlanConversionCommandMapper commands;
@Mock LastlegOrderNoGenerator numbers;
@Mock PlatformTransactionManager transactionManager;
private ContainerInboundPlanConversionCoordinator coordinator; private ContainerInboundPlanConversionCoordinator coordinator;
private JsonMapper objectMapper; private JsonMapper objectMapper;
@@ -40,7 +50,11 @@ class ContainerInboundPlanConversionCoordinatorTest {
ReflectionTestUtils.setField(coordinator, "plans", plans); ReflectionTestUtils.setField(coordinator, "plans", plans);
ReflectionTestUtils.setField(coordinator, "warehouse", warehouse); ReflectionTestUtils.setField(coordinator, "warehouse", warehouse);
ReflectionTestUtils.setField(coordinator, "commands", commands); ReflectionTestUtils.setField(coordinator, "commands", commands);
ReflectionTestUtils.setField(coordinator, "numbers", numbers);
ReflectionTestUtils.setField(coordinator, "objectMapper", objectMapper); ReflectionTestUtils.setField(coordinator, "objectMapper", objectMapper);
ReflectionTestUtils.setField(coordinator, "transactionManager", transactionManager);
org.mockito.Mockito.lenient().when(transactionManager.getTransaction(any()))
.thenReturn(new SimpleTransactionStatus());
} }
@Test @Test
@@ -57,6 +71,19 @@ class ContainerInboundPlanConversionCoordinatorTest {
verify(commands, never()).insert(org.mockito.ArgumentMatchers.any(ContainerPlanConversionCommand.class)); verify(commands, never()).insert(org.mockito.ArgumentMatchers.any(ContainerPlanConversionCommand.class));
} }
@Test
void sameIdempotencyKeyStillRejectsAlteredCompletedRequest() throws Exception {
ContainerPlanConversionCommand completed = command("FIRST-KEY", "COMPLETED", request());
completed.setResultJson("[]");
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(completed);
ConvertContainerInboundPlanRequest changed =
new ConvertContainerInboundPlanRequest(BOOKING_ID, 8L, "b".repeat(64));
assertEquals(409, assertThrows(ResponseStatusException.class,
() -> coordinator.confirm(changed, "FIRST-KEY")).getStatusCode().value());
}
@Test @Test
void concurrentCabinetClaimRejectsFollowerBeforeWarehouseReservation() throws Exception { void concurrentCabinetClaimRejectsFollowerBeforeWarehouseReservation() throws Exception {
ConvertContainerInboundPlanRequest request = request(); ConvertContainerInboundPlanRequest request = request();
@@ -78,7 +105,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
when(commands.find(BOOKING_ID, "SECOND-KEY")).thenReturn(null); when(commands.find(BOOKING_ID, "SECOND-KEY")).thenReturn(null);
when(commands.findByBooking(BOOKING_ID)).thenReturn(prepared); when(commands.findByBooking(BOOKING_ID)).thenReturn(prepared);
when(commands.claim(org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq("PREPARED"), when(commands.claim(org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq("PREPARED"),
org.mockito.ArgumentMatchers.eq("APPLYING"), org.mockito.ArgumentMatchers.any())).thenReturn(0); org.mockito.ArgumentMatchers.eq("APPLYING"), any(String.class), org.mockito.ArgumentMatchers.any())).thenReturn(0);
ResponseStatusException error = assertThrows(ResponseStatusException.class, ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> coordinator.confirm(request, "SECOND-KEY")); () -> coordinator.confirm(request, "SECOND-KEY"));
@@ -102,6 +129,102 @@ class ContainerInboundPlanConversionCoordinatorTest {
assertEquals(409, error.getStatusCode().value()); assertEquals(409, error.getStatusCode().value());
} }
@Test
void staleApplyingLeaseIsReclaimedWithoutRepeatingPreparedClaim() throws Exception {
ConvertContainerInboundPlanRequest request = request();
ContainerPlanConversionCommand stale = command("FIRST-KEY", "APPLYING", request);
stale.setUpdatedAt(LocalDateTime.now().minusMinutes(3));
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(stale);
when(commands.reclaimStale(eq(1L), eq(stale.getUpdatedAt()), any(String.class), any())).thenReturn(1);
when(plans.convertContainerReserved(BOOKING_ID, 7L, SNAPSHOT_HASH, List.of())).thenReturn(List.of());
when(commands.updateState(eq(1L), eq("APPLYING"), any(String.class), eq("COMPLETED"), eq("[]"),
org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1);
assertEquals(List.of(), coordinator.confirm(request, "FIRST-KEY"));
verify(commands, never()).claim(any(Long.class), any(String.class), any(String.class),
any(String.class), any());
}
@Test
void compensatedFailureCanBeReplacedByFreshSnapshotAndComplete() throws Exception {
ContainerPlanConversionCommand failed = command("OLD-KEY", "FAILED", request());
when(commands.find(BOOKING_ID, "NEW-KEY")).thenReturn(null);
when(commands.findByBooking(BOOKING_ID)).thenReturn(failed);
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview(8L, "b".repeat(64)));
when(commands.replaceFailed(eq(1L), eq("NEW-KEY"), any(), eq(8L), eq("b".repeat(64)),
eq(31L), any(), any(), any())).thenReturn(1);
when(commands.claim(eq(1L), eq("PREPARED"), eq("APPLYING"), any(String.class), any())).thenReturn(1);
when(plans.convertContainerReserved(BOOKING_ID, 8L, "b".repeat(64), List.of())).thenReturn(List.of());
when(commands.updateState(eq(1L), eq("APPLYING"), any(String.class), eq("COMPLETED"), eq("[]"),
org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1);
assertEquals(List.of(), coordinator.confirm(
new ConvertContainerInboundPlanRequest(BOOKING_ID, 8L, "b".repeat(64)), "NEW-KEY"));
}
@Test
void invalidEnsureResponseIsCompensatedBeforeCommandBecomesFailed() throws Exception {
ConvertContainerInboundPlanRequest request = request();
ContainerPlanConversionCommand prepared = command("FIRST-KEY", "PREPARED", request);
setOnePreparedItem(prepared);
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(prepared);
when(commands.claim(eq(1L), eq("PREPARED"), eq("APPLYING"), any(String.class), any())).thenReturn(1);
when(warehouse.ensurePalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
0L, "BATCH-1", "RESERVED", 1, false, List.of(reserved("RESERVED"))));
when(warehouse.compensatePalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
41L, "BATCH-1", "COMPENSATED", 1, false, List.of(reserved("RELEASED"))));
when(commands.updateState(eq(1L), eq("APPLYING"), any(String.class), eq("FAILED"),
org.mockito.ArgumentMatchers.isNull(), any(), any())).thenReturn(1);
assertEquals(409, assertThrows(ResponseStatusException.class,
() -> coordinator.confirm(request, "FIRST-KEY")).getStatusCode().value());
verify(warehouse).compensatePalletBatch(any());
}
@Test
void uncertainLocalCommitIsRereadBeforeDecidingWhetherToCompensate() throws Exception {
ConvertContainerInboundPlanRequest request = request();
ContainerPlanConversionCommand prepared = command("FIRST-KEY", "PREPARED", request);
setOnePreparedItem(prepared);
ContainerPlanConversionCommand committed = command("FIRST-KEY", "LOCAL_COMMITTED", request);
committed.setResultJson("[]");
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(prepared);
when(commands.claim(eq(1L), eq("PREPARED"), eq("APPLYING"), any(String.class), any())).thenReturn(1);
when(warehouse.ensurePalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
41L, "BATCH-1", "RESERVED", 1, false, List.of(reserved("RESERVED"))));
when(plans.convertContainerReserved(eq(BOOKING_ID), eq(7L), eq(SNAPSHOT_HASH), any()))
.thenThrow(new IllegalStateException("commit outcome unknown"));
when(commands.findByBooking(BOOKING_ID)).thenReturn(committed);
when(warehouse.confirmPalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
41L, "BATCH-1", "CONFIRMED", 1, false, List.of(reserved("CONFIRMED"))));
when(commands.updateState(eq(1L), eq("LOCAL_COMMITTED"), any(String.class), eq("COMPLETED"), eq("[]"),
org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1);
assertEquals(List.of(), coordinator.confirm(request, "FIRST-KEY"));
verify(warehouse, never()).compensatePalletBatch(any());
}
@Test
void localCommittedReplayOnlyRetriesRemoteConfirmation() throws Exception {
ConvertContainerInboundPlanRequest request = request();
ContainerPlanConversionCommand committed = command("FIRST-KEY", "LOCAL_COMMITTED", request);
committed.setResultJson("[]");
setOnePreparedItem(committed);
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(committed);
when(warehouse.confirmPalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
41L, "BATCH-1", "CONFIRMED", 1, false, List.of(reserved("CONFIRMED"))));
when(commands.updateState(eq(1L), eq("LOCAL_COMMITTED"), any(String.class), eq("COMPLETED"), eq("[]"),
org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1);
assertEquals(List.of(), coordinator.confirm(request, "FIRST-KEY"));
verify(warehouse, never()).ensurePalletBatch(any());
verify(warehouse).confirmPalletBatch(any());
}
private ConvertContainerInboundPlanRequest request() { private ConvertContainerInboundPlanRequest request() {
return new ConvertContainerInboundPlanRequest(BOOKING_ID, 7L, SNAPSHOT_HASH); return new ConvertContainerInboundPlanRequest(BOOKING_ID, 7L, SNAPSHOT_HASH);
} }
@@ -119,8 +242,26 @@ class ContainerInboundPlanConversionCoordinatorTest {
command.setWarehouseId(31L); command.setWarehouseId(31L);
command.setBatchKey("BATCH-1"); command.setBatchKey("BATCH-1");
command.setStatus(status); command.setStatus(status);
if ("APPLYING".equals(status) || "LOCAL_COMMITTED".equals(status)) command.setLeaseToken("LEASE-1");
command.setPreparationJson(objectMapper.writeValueAsString( command.setPreparationJson(objectMapper.writeValueAsString(
new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of()))); new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of())));
return command; return command;
} }
private void setOnePreparedItem(ContainerPlanConversionCommand command) throws Exception {
var item = new ContainerInboundPlanConversionCoordinator.PreparedItem(
"100", 11L, 21L, "IP-21", 1, 1, "CIP:1:1", "ONT8");
command.setPreparationJson(objectMapper.writeValueAsString(
new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of(item))));
}
private WarehouseResourceGateway.PalletBatchItem reserved(String status) {
return new WarehouseResourceGateway.PalletBatchItem(
100L, "CIP:1:1", "IP-21", 1, 51L, "PLT-51", 61L, 71L, status);
}
private ContainerInboundPlanPreviewResponse preview(long version, String hash) {
return new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
"Los Angeles", version, hash, 1, 1, 0, 0, 0, 1, 1, 1, true, List.of());
}
} }
@@ -67,6 +67,21 @@ class ContainerInboundPlanPreviewTest {
assertEquals(List.of("DESTINATION_NOT_READY"), result.groups().get(4).orders().get(0).blockers()); assertEquals(List.of("DESTINATION_NOT_READY"), result.groups().get(4).orders().get(0).blockers());
} }
@Test
void hashesBlockedPreviewEvenWhenContainerAndWarehouseFactsAreNull() {
when(orderMapper.containerPreviewOrders("BOOKING-NULL")).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", 1L, "LM-1", "FO-1", "测试客户",
"FIRST_LEG", "OVERSEAS", "IN-1", "PENDING_ASSIGNMENT", null, null, 0, 0,
BigDecimal.ZERO, null, null, null, null, null, null, null, 0, false, 1,
null, null, null)));
var result = service.previewContainer("BOOKING-NULL");
assertNotNull(result.snapshotHash());
assertEquals(64, result.snapshotHash().length());
assertFalse(result.ready());
}
private InboundPlanOrderMapper.ContainerPreviewOrderRow row( private InboundPlanOrderMapper.ContainerPreviewOrderRow row(
long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName, long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName,
int recommendedPallets, String planNo, Integer plannedPallets, int version) { int recommendedPallets, String planNo, Integer plannedPallets, int version) {