Merge remote-tracking branch 'origin/test' into dev
This commit is contained in:
+5
-5
@@ -164,12 +164,11 @@ public class TerminalDrayageService {
|
||||
WorkContext context = requiredLockedContext(containerBookingId);
|
||||
View replay = replay(context, action, key, request);
|
||||
if (replay != null) return replay;
|
||||
TerminalDrayageEntity value = requiredAggregate(containerBookingId);
|
||||
TerminalDrayageEntity value = mapper.lock(containerBookingId);
|
||||
requireVersion(value, request.expectedVersion());
|
||||
LocalDateTime pickupAt = actualPickupAt(context, value);
|
||||
if (pickupAt == null) throw conflict("实际提柜后才能确认还柜");
|
||||
if (context.base().unloadingCompletedAt() == null) throw conflict("海外卸柜收货完成后才能确认还柜");
|
||||
LocalDateTime earliest = pickupAt.isAfter(context.base().unloadingCompletedAt())
|
||||
LocalDateTime earliest = pickupAt != null && pickupAt.isAfter(context.base().unloadingCompletedAt())
|
||||
? pickupAt : context.base().unloadingCompletedAt();
|
||||
if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间");
|
||||
if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认");
|
||||
@@ -177,6 +176,7 @@ public class TerminalDrayageService {
|
||||
request.remark(), operatorId, operatorName);
|
||||
int fromVersion = version(value);
|
||||
String fromStatus = status(context, value);
|
||||
if (value == null) value = create(containerBookingId, operatorId, operatorName);
|
||||
applyAuthoritativeDeadline(context, value);
|
||||
value.setActualEmptyReturnAt(request.actualAt());
|
||||
persist(value, fromVersion, operatorId, operatorName);
|
||||
@@ -277,14 +277,14 @@ public class TerminalDrayageService {
|
||||
if (current != null && current.sequence() >= CarrierScheduleNode.PICKUP_READY.sequence()
|
||||
&& value != null && value.getFleetSupplierId() != null && value.getPickupAppointmentAt() != null
|
||||
&& actualPickupAt(context, value) == null) result.add("RECORD_PICKUP");
|
||||
if (value != null && context.base().unloadingCompletedAt() != null && actualPickupAt(context, value) != null
|
||||
if (context.base().unloadingCompletedAt() != null
|
||||
&& actualEmptyReturnAt(context, value) == null) result.add("RECORD_EMPTY_RETURN");
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private String status(WorkContext context, TerminalDrayageEntity value) {
|
||||
if (actualEmptyReturnAt(context, value) != null) return "EMPTY_RETURNED";
|
||||
if (context.base().unloadingCompletedAt() != null && actualPickupAt(context, value) != null) return "UNLOADED";
|
||||
if (context.base().unloadingCompletedAt() != null) return "UNLOADED";
|
||||
if (actualPickupAt(context, value) != null) return "PICKED_UP";
|
||||
if (value != null && value.getPickupAppointmentAt() != null) return "PICKUP_SCHEDULED";
|
||||
CarrierScheduleNode current = node(context.facts().latestActualNode());
|
||||
|
||||
@@ -4,8 +4,8 @@ USE baiyue_firstleg;
|
||||
CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID',
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID',
|
||||
fleet_supplier_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照',
|
||||
fleet_supplier_name VARCHAR(128) NOT NULL COMMENT 'Warehouse供应商名称快照',
|
||||
fleet_supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照',
|
||||
fleet_supplier_name VARCHAR(128) NULL COMMENT 'Warehouse供应商名称快照',
|
||||
pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间',
|
||||
actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间',
|
||||
estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间',
|
||||
|
||||
@@ -47,6 +47,7 @@ paths:
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:
|
||||
post:
|
||||
summary: 海外卸柜收货完成后确认实际还柜并推进EMPTY_RETURNED节点
|
||||
description: 以海外卸柜完成事实开放还柜;无需已有车队、预约或实际提柜记录。无拖车记录时提交 expectedVersion=0,由命令创建记录,不补造提柜时间。实际还柜时间不得早于卸柜完成时间及已有实际提柜时间。
|
||||
x-permission: platform:firstleg:terminal-drayage:empty-return:confirm
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}}
|
||||
|
||||
+2
@@ -15,6 +15,8 @@ class TerminalDrayageSqlContractTest {
|
||||
"src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java"));
|
||||
assertThat(sql).contains(
|
||||
"CREATE TABLE IF NOT EXISTS fl_terminal_drayage (",
|
||||
"fleet_supplier_id BIGINT UNSIGNED NULL",
|
||||
"fleet_supplier_name VARCHAR(128) NULL",
|
||||
"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 (",
|
||||
|
||||
+42
-2
@@ -95,7 +95,7 @@ class ContainerWorkbenchServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void unloadedListWithoutDrayageAggregateDoesNotOfferUnexecutableReturn() throws Exception {
|
||||
void unloadedListWithoutDrayageAggregateOffersEmptyReturn() throws Exception {
|
||||
verifyRealEmptyReturnProjection(true, false);
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ class ContainerWorkbenchServiceTest {
|
||||
var page = service.overseasPage(query(WorkbenchScope.ACTIVE), permissions);
|
||||
var listed = page.records().get(0);
|
||||
var detail = service.overseasDetail(11L, permissions);
|
||||
List<String> expected = hasPermission && hasAggregate ? List.of("RECORD_EMPTY_RETURN") : List.of();
|
||||
List<String> expected = hasPermission ? List.of("RECORD_EMPTY_RETURN") : List.of();
|
||||
assertThat(listed.workbenchStage()).isEqualTo("UNLOADED");
|
||||
assertThat(listed.terminalDrayage().status()).isEqualTo("UNLOADED");
|
||||
assertThat(listed.allowedActions()).isEqualTo(expected);
|
||||
@@ -143,6 +143,46 @@ class ContainerWorkbenchServiceTest {
|
||||
verify(mapper).contexts(List.of(11L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unloadedWorkbenchWithoutPickupOffersEmptyReturn() {
|
||||
var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class);
|
||||
var realDrayages = new TerminalDrayageService(mapper,
|
||||
mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules,
|
||||
new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
|
||||
ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages);
|
||||
ContainerBookingEntity container = container();
|
||||
LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 5, 8, 0);
|
||||
container.setUnloadingCompletedAt(unloadedAt);
|
||||
var context = new com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context(
|
||||
11L, container.getContainerNo(), 21L, "ACTIVE", unloadedAt);
|
||||
LocalDateTime readyAt = unloadedAt.minusDays(1);
|
||||
var actualFacts = new ContainerActualFacts(11L, 41L, 8, "READY", "PICKUP_READY", readyAt,
|
||||
readyAt.minusDays(5), readyAt.minusDays(4), readyAt.minusDays(1), readyAt, null, null);
|
||||
when(repository.page(eq("OVERSEAS"), any())).thenReturn(new PageResult<>(List.of(container), 1, 20, 1, 1));
|
||||
when(repository.find(11L)).thenReturn(container);
|
||||
when(repository.departed(11L)).thenReturn(true);
|
||||
when(mapper.contexts(List.of(11L))).thenReturn(List.of(context));
|
||||
when(mapper.context(11L)).thenReturn(context);
|
||||
when(mapper.findAll(List.of(11L))).thenReturn(List.of());
|
||||
when(mapper.find(11L)).thenReturn(null);
|
||||
when(carrierSchedules.containerActualFacts(List.of(11L))).thenReturn(List.of(actualFacts));
|
||||
Set<String> permissions = Set.of("platform:firstleg:terminal-drayage:fleet:edit",
|
||||
"platform:firstleg:terminal-drayage:empty-return:confirm");
|
||||
|
||||
var listed = service.overseasPage(query(WorkbenchScope.ACTIVE), permissions).records().get(0);
|
||||
var detail = service.overseasDetail(11L, permissions);
|
||||
|
||||
for (var result : List.of(listed, detail)) {
|
||||
assertThat(result.workbenchStage()).isEqualTo("UNLOADED");
|
||||
assertThat(result.terminalDrayage().status()).isEqualTo("UNLOADED");
|
||||
assertThat(result.terminalDrayage().version()).isZero();
|
||||
assertThat(result.terminalDrayage().actualPickupAt()).isNull();
|
||||
assertThat(result.allowedActions()).containsExactly("SELECT_FLEET", "RECORD_EMPTY_RETURN");
|
||||
assertThat(result.terminalDrayage().allowedActions()).containsExactly("SELECT_FLEET", "RECORD_EMPTY_RETURN");
|
||||
}
|
||||
verify(mapper, never()).insert(any(com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void domesticHistoryDetailKeepsTheDepartedContainerAndItsUniqueLoadingPlan() {
|
||||
ContainerBookingEntity container = container();
|
||||
|
||||
+47
-2
@@ -165,16 +165,61 @@ class TerminalDrayageServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotOfferEmptyReturnWithoutExecutableDrayageAggregate() {
|
||||
void offersEmptyReturnAfterUnloadWithoutDrayageAggregateOrPickupFact() {
|
||||
LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 3, 0, 0);
|
||||
Context context = context("PICKED_UP", unloadedAt);
|
||||
Context context = context("ARRIVED_DESTINATION_PORT", unloadedAt);
|
||||
when(mapper.context(11L)).thenReturn(context);
|
||||
when(mapper.find(11L)).thenReturn(null);
|
||||
|
||||
var result = service.detail(11L);
|
||||
|
||||
assertThat(result.status()).isEqualTo("UNLOADED");
|
||||
assertThat(result.allowedActions()).contains("RECORD_EMPTY_RETURN");
|
||||
assertThat(result.actualPickupAt()).isNull();
|
||||
assertThat(result.version()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsReturnAggregateWithoutInventingPickupAndReplaysWithoutWritingAgain() {
|
||||
Context context = context("ARRIVED_DESTINATION_PORT", LocalDateTime.of(2026, 9, 3, 0, 0));
|
||||
when(mapper.lockContext(11L)).thenReturn(context);
|
||||
when(mapper.context(11L)).thenReturn(context);
|
||||
AtomicReference<TerminalDrayageEntity> stored = new AtomicReference<>();
|
||||
AtomicReference<Operation> operation = new AtomicReference<>();
|
||||
when(mapper.lock(11L)).thenAnswer(ignored -> stored.get());
|
||||
when(mapper.find(11L)).thenAnswer(ignored -> stored.get());
|
||||
when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> {
|
||||
stored.set(invocation.getArgument(0));
|
||||
return 1;
|
||||
});
|
||||
when(mapper.operation(11L, "return-new")).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;
|
||||
});
|
||||
ActualTimeRequest request = new ActualTimeRequest(0, LocalDateTime.of(2026, 9, 4, 9, 0), "手动还柜");
|
||||
|
||||
var result = service.recordEmptyReturn(11L, 7L, "admin", "return-new", request);
|
||||
assertThat(result.status()).isEqualTo("EMPTY_RETURNED");
|
||||
assertThat(result.emptyReturnWarning().status()).isEqualTo("RESOLVED");
|
||||
assertThat(result.actualPickupAt()).isNull();
|
||||
assertThat(result.fleetSupplierId()).isNull();
|
||||
assertThat(result.version()).isEqualTo(1);
|
||||
assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN");
|
||||
assertThat(service.recordEmptyReturn(11L, 7L, "admin", "return-new", request)).isEqualTo(result);
|
||||
verify(mapper, times(1)).insert(any(TerminalDrayageEntity.class));
|
||||
verify(carrierSchedules, times(1)).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsReturnBeforeUnloadWithoutAggregateOrPickup() {
|
||||
when(mapper.lockContext(11L)).thenReturn(context("ARRIVED_DESTINATION_PORT", LocalDateTime.of(2026, 9, 3, 0, 0)));
|
||||
assertThatThrownBy(() -> service.recordEmptyReturn(11L, 7L, "admin", "return-early",
|
||||
new ActualTimeRequest(0, LocalDateTime.of(2026, 9, 2, 23, 0), null)))
|
||||
.isInstanceOf(BizException.class).hasMessage("实际还柜时间不能早于提柜或卸柜完成时间");
|
||||
verify(mapper, never()).insert(any(TerminalDrayageEntity.class));
|
||||
verify(carrierSchedules, never()).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user