This commit is contained in:
2026-08-28 10:38:58 +08:00
parent b34fbb2bd1
commit 1e5ce54858
37 changed files with 256 additions and 159 deletions
@@ -16,7 +16,7 @@ public interface LoadingCompletedEventMapper {
@Select("""
SELECT p.id AS loading_plan_id, p.plan_no, p.container_booking_id, p.version AS aggregate_version,
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
b.customer_id AS booking_customer_id, b.customer_name,
i.customer_name,
b.estimated_port_arrival_at, b.actual_port_arrival_at,
b.estimated_warehouse_arrival_at,
i.account_id, i.inbound_id AS source_inbound_id,
@@ -84,8 +84,8 @@ public interface LoadingCompletedEventMapper {
class Row {
private Long loadingPlanId; private String planNo; private Long containerBookingId;
private Integer aggregateVersion; private String containerNo; private String cabinetNo;
private Long warehouseId; private String warehouseName; private Long bookingCustomerId;
private String customerName; private Long accountId;
private Long warehouseId; private String warehouseName; private String customerName;
private Long accountId;
private LocalDateTime estimatedPortArrivalAt; private LocalDateTime actualPortArrivalAt;
private LocalDateTime estimatedWarehouseArrivalAt;
private Long sourceInboundId; private Long rootInboundId; private Long sourceOrderId;
@@ -120,11 +120,11 @@ public class LoadingCompletedOutboxService {
private void validateCustomerSnapshots(List<Row> rows) {
for (Row row : rows) {
if (row.getAccountId() == null || !Objects.equals(row.getAccountId(), row.getBookingCustomerId())) {
throw new BizException(ResultCode.CONFLICT, "装柜计划客户与柜次客户快照不一致,不能生成尾程交接事件");
if (row.getAccountId() == null || row.getAccountId() <= 0) {
throw new BizException(ResultCode.CONFLICT, "装柜明细缺少有效客户标识,不能生成尾程交接事件");
}
if (row.getCustomerName() == null || row.getCustomerName().isBlank()) {
throw new BizException(ResultCode.CONFLICT, "柜次缺少客户名称快照,不能生成尾程交接事件");
throw new BizException(ResultCode.CONFLICT, "装柜明细缺少客户名称快照,不能生成尾程交接事件");
}
}
rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId)).forEach((accountId, values) -> {
@@ -50,7 +50,7 @@ public class LoadingCompletionWorker {
String traceId = cause instanceof PdaPickSettlementService.SettlementFailure remote
? remote.traceId() : null;
int attempt = command.getAttemptCount() == null ? 1 : command.getAttemptCount() + 1;
boolean terminal = attempt >= MAX_ATTEMPTS;
boolean terminal = isDeterministicLocalBusinessFailure(stage, cause) || attempt >= MAX_ATTEMPTS;
LocalDateTime retryAt = LocalDateTime.now().plusSeconds(Math.min(300L, 15L << Math.min(attempt - 1, 4)));
boolean recorded = commands.fail(command.getId(), workerId, terminal ? "FAILED" : "RETRYABLE", retryAt,
stage, code, requestId, traceId);
@@ -61,4 +61,10 @@ public class LoadingCompletionWorker {
+ "requestId={} traceId={} attempt={} terminal={}", command.getId(), command.getPickTaskId(),
command.getOperationId(), stage, code, requestId, traceId, attempt, terminal, cause);
}
private static boolean isDeterministicLocalBusinessFailure(String stage, Throwable cause) {
return cause instanceof BizException && java.util.Set.of(
"OWNER_DISCOVERY", "OWNER_READY", "PLAN_GATE", "TASK_REVIEW",
"PLAN_COMPLETE", "COMPLETED_EVENT").contains(stage);
}
}
@@ -85,7 +85,7 @@ class LoadingCompletedOutboxServiceTest {
.hasMessageContaining("缺少 SKU");
}
@Test void rejectsMissingTrustedBookingCustomerName() {
@Test void rejectsMissingTrustedPlanItemCustomerName() {
Row value = row(11, 100, 101);
value.setCustomerName(" ");
when(snapshots.rows(90L)).thenReturn(List.of(value));
@@ -93,12 +93,19 @@ class LoadingCompletedOutboxServiceTest {
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("缺少客户名称快照");
}
@Test void rejectsBookingCustomerIdThatDoesNotMatchAccountEvent() {
@Test void publishesFrozenPlanItemCustomerSnapshotWithoutDependingOnBookingOwner() throws Exception {
Row value = row(11, 100, 101);
value.setBookingCustomerId(22L);
value.setCustomerName("南玻万");
value.setDestinationType("FBA");
when(snapshots.rows(90L)).thenReturn(List.of(value));
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("客户与柜次客户快照不一致");
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox).insert(captor.capture());
EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
assertThat(event.tenantId()).isEqualTo("11");
assertThat(event.payload().at("/orders/0/customerName").asText()).isEqualTo("南玻万");
}
@Test void fbaFreezesPackageCountWithoutRequiringCartonLabels() throws Exception {
@@ -209,8 +216,9 @@ class LoadingCompletedOutboxServiceTest {
assertThat(rowsSql).contains("p.plan_status = 'COMPLETED'")
.contains("o.process_status = 'LOADED'")
.contains("b.customer_id AS booking_customer_id")
.contains("b.customer_name")
.contains("i.customer_name")
.doesNotContain("b.customer_id AS booking_customer_id")
.doesNotContain("b.customer_name")
.contains("b.estimated_port_arrival_at", "b.actual_port_arrival_at")
.contains("b.estimated_warehouse_arrival_at");
for (String sql : List.of(rowsSql, palletsSql)) {
@@ -288,7 +296,7 @@ class LoadingCompletedOutboxServiceTest {
private Row row(long account,long inbound,long detail) {
Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L);
r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L);
r.setWarehouseName("WH"); r.setBookingCustomerId(account); r.setCustomerName("客户-" + account);
r.setWarehouseName("WH"); r.setCustomerName("客户-" + account);
r.setEstimatedPortArrivalAt(java.time.LocalDateTime.of(2026, 9, 30, 12, 0));
r.setActualPortArrivalAt(java.time.LocalDateTime.of(2026, 10, 1, 8, 30));
r.setEstimatedWarehouseArrivalAt(java.time.LocalDateTime.of(2026, 10, 3, 9, 0));
@@ -63,6 +63,24 @@ class LoadingCompletionWorkerTest {
verify(picks).failCompletion(20L);
}
@Test
void completedEventBusinessFailureIsTerminalOnFirstAttempt() {
LoadingCompletionCommandEntity command = command(0);
when(commands.due(10)).thenReturn(List.of(command));
when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true);
doThrow(new LoadingPickService.CompletionProcessingException("COMPLETED_EVENT",
new BizException(ResultCode.CONFLICT, "customer snapshot conflict")))
.when(loadingPicks).processCompletion(eq(command), any(String.class));
when(commands.fail(eq(900L), any(String.class), eq("FAILED"), any(LocalDateTime.class),
eq("COMPLETED_EVENT"), eq(ResultCode.CONFLICT.code()), eq(null), eq(null))).thenReturn(true);
worker.dispatch();
verify(commands).fail(eq(900L), any(String.class), eq("FAILED"), any(LocalDateTime.class),
eq("COMPLETED_EVENT"), eq(ResultCode.CONFLICT.code()), eq(null), eq(null));
verify(picks).failCompletion(20L);
}
private static LoadingCompletionCommandEntity command(int attemptCount) {
LoadingCompletionCommandEntity command = new LoadingCompletionCommandEntity();
command.setId(900L);