feat: complete tail order controls and terminal drayage

This commit is contained in:
2026-09-07 23:58:18 +08:00
parent f018ca85a9
commit e32fe65fa8
53 changed files with 2566 additions and 87 deletions
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.terminaldrayage.controller;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class TerminalDrayageOpenApiContractTest {
@Test
void freezesActionsPermissionsAndServerWarningFields() throws Exception {
String yaml = Files.readString(Path.of("src/main/resources/openapi/terminal-drayage-v1.yaml"));
assertThat(yaml).contains(
"/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:",
"/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment:",
"/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:",
"/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:",
"platform:firstleg:container:list", "platform:firstleg:container:edit",
"expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt",
"emptyReturnWarning", "deadlineAt", "overdueHours", "overdueDays");
assertThat(yaml).contains("description: 卸柜完成时间加72小时");
}
}
@@ -0,0 +1,27 @@
package com.baiyue.firstleg.terminaldrayage.mapper;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class TerminalDrayageSqlContractTest {
@Test
void schemaKeepsOneAggregatePerContainerAndImmutableAudit() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"));
String mapper = Files.readString(Path.of(
"src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java"));
assertThat(sql).contains(
"CREATE TABLE IF NOT EXISTS fl_terminal_drayage (",
"UNIQUE KEY uk_terminal_drayage_container(container_booking_id)",
"CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (",
"CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (",
"UNIQUE KEY uk_terminal_drayage_operation(container_booking_id,idempotency_key)",
"服务端按卸柜完成时间加72小时计算的预计还柜时间");
assertThat(sql).doesNotContain("ON DELETE CASCADE");
assertThat(mapper).contains("WHERE b.id=#{containerBookingId} FOR UPDATE",
"WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion}");
}
}
@@ -0,0 +1,251 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.FleetSelectionRequest;
import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Operation;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class TerminalDrayageServiceTest {
private static final LocalDateTime ARRIVED_AT = LocalDateTime.of(2026, 9, 1, 8, 0);
private TerminalDrayageMapper mapper;
private WarehouseSupplierSnapshotService suppliers;
private CarrierScheduleQueryService carrierSchedules;
private TerminalDrayageService service;
@BeforeEach
void setUp() {
mapper = mock(TerminalDrayageMapper.class);
suppliers = mock(WarehouseSupplierSnapshotService.class);
carrierSchedules = mock(CarrierScheduleQueryService.class);
service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules());
service.setClock(Clock.fixed(Instant.parse("2026-09-05T02:00:00Z"), ZoneOffset.UTC));
}
@Test
void rejectsFleetSelectionBeforeActualDestinationArrival() {
when(mapper.lockContext(11L)).thenReturn(context("IN_TRANSIT", null));
assertThatThrownBy(() -> service.selectFleet(11L, 7L, "admin", "fleet-1",
new FleetSelectionRequest(0, 91L, null, null)))
.isInstanceOf(BizException.class)
.hasMessage("货柜实际到达目的港后才能安排码头车队");
verify(suppliers, never()).trucking(anyLong(), any());
}
@Test
void selectsActiveTruckingSupplierAndPersistsServerSnapshot() {
Context context = context("ARRIVED_DESTINATION_PORT", null);
when(mapper.lockContext(11L)).thenReturn(context);
when(mapper.context(11L)).thenReturn(context);
when(mapper.lock(11L)).thenReturn(null);
when(mapper.operation(11L, "fleet-1")).thenReturn(null);
when(suppliers.trucking(91L, "用户提示名")).thenReturn(
new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of()));
AtomicReference<TerminalDrayageEntity> stored = new AtomicReference<>();
when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> {
TerminalDrayageEntity value = invocation.getArgument(0);
stored.set(value);
return 1;
});
when(mapper.find(11L)).thenAnswer(ignored -> stored.get());
var result = service.selectFleet(11L, 7L, "admin", "fleet-1",
new FleetSelectionRequest(0, 91L, "用户提示名", "安排提柜"));
assertThat(result.version()).isEqualTo(1);
assertThat(result.fleetSupplierId()).isEqualTo(91L);
assertThat(result.fleetSupplierName()).isEqualTo("服务端车队");
assertThat(result.allowedActions()).containsExactlyInAnyOrder("SELECT_FLEET", "SCHEDULE_PICKUP");
verify(mapper).insertHistory(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString(),
anyInt(), anyInt(), any(), anyLong(), anyString());
verify(mapper).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong());
}
@Test
void recordsPickupOnceAndAdvancesCanonicalNode() {
Context before = context("PICKUP_READY", null);
Context after = context("PICKED_UP", null);
when(mapper.lockContext(11L)).thenReturn(before);
when(mapper.context(11L)).thenReturn(after);
when(mapper.operation(11L, "pickup-1")).thenReturn(null);
TerminalDrayageEntity row = row(2);
row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0));
when(mapper.lock(11L)).thenReturn(row);
when(mapper.updateAggregate(any(), anyInt(), anyLong(), anyString())).thenAnswer(invocation -> {
row.setVersion(3);
return 1;
});
when(mapper.find(11L)).thenReturn(row);
var result = service.recordPickup(11L, 7L, "admin", "pickup-1",
new ActualTimeRequest(2, LocalDateTime.of(2026, 9, 2, 10, 0), "码头提柜"));
assertThat(result.status()).isEqualTo("PICKED_UP");
assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0));
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class);
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
assertThat(node.getValue().standardNode()).isEqualTo("PICKED_UP");
verify(mapper, times(1)).updateAggregate(any(), anyInt(), anyLong(), anyString());
}
@Test
void requiresOverseasUnloadBeforeEmptyReturn() {
Context context = context("PICKED_UP", null);
when(mapper.lockContext(11L)).thenReturn(context);
when(mapper.operation(11L, "return-1")).thenReturn(null);
TerminalDrayageEntity row = row(3);
row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0));
when(mapper.lock(11L)).thenReturn(row);
assertThatThrownBy(() -> service.recordEmptyReturn(11L, 7L, "admin", "return-1",
new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 10, 0), null)))
.isInstanceOf(BizException.class)
.hasMessage("海外卸柜收货完成后才能确认还柜");
verify(carrierSchedules, never()).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
}
@Test
void computesSeventyTwoHourWarningAndClearsItAfterReturn() {
LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 1, 0, 0);
Context context = context("PICKED_UP", unloadedAt);
TerminalDrayageEntity row = row(3);
row.setActualPickupAt(LocalDateTime.of(2026, 8, 31, 18, 0));
when(mapper.context(11L)).thenReturn(context);
when(mapper.find(11L)).thenReturn(row);
var overdue = service.detail(11L);
assertThat(overdue.estimatedEmptyReturnAt()).isEqualTo(LocalDateTime.of(2026, 9, 4, 0, 0));
assertThat(overdue.emptyReturnWarning().status()).isEqualTo("OVERDUE");
assertThat(overdue.emptyReturnWarning().overdueHours()).isEqualTo(26);
assertThat(overdue.emptyReturnWarning().overdueDays()).isEqualTo(1);
row.setActualEmptyReturnAt(LocalDateTime.of(2026, 9, 4, 1, 0));
assertThat(service.detail(11L).emptyReturnWarning().status()).isEqualTo("RESOLVED");
assertThat(service.detail(11L).emptyReturnWarning().overdueHours()).isZero();
}
@Test
void sameIdempotencyKeyWithDifferentPayloadConflictsWithoutWriting() {
Context context = context("ARRIVED_DESTINATION_PORT", null);
when(mapper.lockContext(11L)).thenReturn(context);
when(mapper.operation(11L, "same-key")).thenReturn(new Operation("SELECT_FLEET", "different-hash", 1));
assertThatThrownBy(() -> service.selectFleet(11L, 7L, "admin", "same-key",
new FleetSelectionRequest(0, 91L, null, null)))
.isInstanceOf(BizException.class)
.hasMessage("Idempotency-Key已被不同的码头拖车操作占用");
verify(mapper, never()).insert(any(TerminalDrayageEntity.class));
verify(mapper, never()).updateAggregate(any(), anyInt(), anyLong(), anyString());
}
@Test
void sameIdempotencyKeyAndPayloadReplaysWithoutSecondWrite() {
Context context = context("ARRIVED_DESTINATION_PORT", null);
when(mapper.lockContext(11L)).thenReturn(context);
when(mapper.context(11L)).thenReturn(context);
when(mapper.lock(11L)).thenReturn(null);
when(suppliers.trucking(91L, null)).thenReturn(
new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of()));
AtomicReference<TerminalDrayageEntity> stored = new AtomicReference<>();
AtomicReference<Operation> operation = new AtomicReference<>();
when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> {
stored.set(invocation.getArgument(0));
return 1;
});
when(mapper.find(11L)).thenAnswer(ignored -> stored.get());
when(mapper.operation(11L, "fleet-replay")).thenAnswer(ignored -> operation.get());
when(mapper.insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong()))
.thenAnswer(invocation -> {
operation.set(new Operation(invocation.getArgument(2), invocation.getArgument(4), invocation.getArgument(5)));
return 1;
});
FleetSelectionRequest request = new FleetSelectionRequest(0, 91L, null, null);
var first = service.selectFleet(11L, 7L, "admin", "fleet-replay", request);
var replay = service.selectFleet(11L, 7L, "admin", "fleet-replay", request);
assertThat(replay).isEqualTo(first);
verify(mapper, times(1)).insert(any(TerminalDrayageEntity.class));
verify(mapper, times(1)).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong());
verify(suppliers, times(1)).trucking(91L, null);
}
@Test
void recordsEmptyReturnAndResolvesWarning() {
LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 3, 0, 0);
Context before = context("PICKED_UP", unloadedAt);
Context after = context("EMPTY_RETURNED", unloadedAt);
when(mapper.lockContext(11L)).thenReturn(before);
when(mapper.context(11L)).thenReturn(after);
when(mapper.operation(11L, "return-2")).thenReturn(null);
TerminalDrayageEntity row = row(3);
row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0));
row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0));
when(mapper.lock(11L)).thenReturn(row);
when(mapper.updateAggregate(any(), anyInt(), anyLong(), anyString())).thenAnswer(invocation -> {
row.setVersion(4);
return 1;
});
when(mapper.find(11L)).thenReturn(row);
var result = service.recordEmptyReturn(11L, 7L, "admin", "return-2",
new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 9, 0), "已还柜"));
assertThat(result.status()).isEqualTo("EMPTY_RETURNED");
assertThat(result.estimatedEmptyReturnAt()).isEqualTo(unloadedAt.plusHours(72));
assertThat(result.emptyReturnWarning().status()).isEqualTo("RESOLVED");
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class);
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED");
}
private Context context(String node, LocalDateTime unloadedAt) {
int sequenceVersion = switch (node) {
case "PICKED_UP" -> 5;
case "PICKUP_READY" -> 4;
case "ARRIVED_DESTINATION_PORT" -> 3;
default -> 2;
};
return new Context(11L, "MSCU1234567", 21L, "ACTIVE", unloadedAt,
31L, node, ARRIVED_AT, sequenceVersion, ARRIVED_AT,
"PICKED_UP".equals(node) || "EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(1) : null,
"EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(3) : null);
}
private TerminalDrayageEntity row(int version) {
TerminalDrayageEntity value = new TerminalDrayageEntity();
value.setId(41L); value.setContainerBookingId(11L); value.setVersion(version);
value.setFleetSupplierId(91L); value.setFleetSupplierName("服务端车队");
return value;
}
}
@@ -7,6 +7,7 @@
"booking-customs-trucking->outbound",
"booking-customs-trucking->pricebook-query",
"booking-customs-trucking->shared-technical",
"booking-customs-trucking->shipping-nodes",
"booking-customs-trucking->split-loading",
"customer-statistics->shared-technical",
"finance-integration->inbound",