feat: complete tail order controls and terminal drayage
This commit is contained in:
+24
@@ -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小时");
|
||||
}
|
||||
}
|
||||
+27
@@ -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}");
|
||||
}
|
||||
}
|
||||
+251
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user