This commit is contained in:
2026-09-14 20:03:22 +08:00
parent d53de922db
commit ebc299d58a
28 changed files with 756 additions and 95 deletions
@@ -19,7 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class TruckAppointmentControllerContractTest {
@Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","candidates","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();}
@Test void schemaHasBookingLevelUniqueAggregateWithoutCustomerScope() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","bill_of_lading_no VARCHAR(128)","active_ocean_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_ocean_booking(active_ocean_booking_id)","ALTER TABLE fl_truck_appointment DROP INDEX uk_truck_active_container","chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))").doesNotContain("customer_id", "customer_name", "receivable_amount");}
@Test void schemaHasBookingLevelUniqueAggregateWithoutCustomerScope() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","bill_of_lading_no VARCHAR(128)","active_ocean_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_ocean_booking(active_ocean_booking_id)","ALTER TABLE fl_truck_appointment DROP INDEX uk_truck_active_container","chk_truck_appointment_currency CHECK(currency IN ('RMB'))").doesNotContain("customer_id", "customer_name", "receivable_amount", "'DEPARTED'");}
@Test void existingAppointmentTableReceivesAllLateColumns() throws Exception {
String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);
Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql");
@@ -34,7 +34,7 @@ class TruckAppointmentControllerContractTest {
@Test void openApiPublishesBookingLevelCandidateContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/candidates:","Paged booking candidates","bookingDocumentReady","Container allocation and loading status do not affect eligibility").doesNotContain("requirementId:","containerBookingId:","loadingPlanId:");}
@Test void openApiRequiresReleasedBookingWithoutContainerWriteProtocol() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","PICKUP does not accept or write a container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}").doesNotContain("containerBookingId:","loadingPlanId:","containerNo:","customerId", "customerName");}
@Test void openApiKeepsOnlyNonBookingTruckAttachments() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","billOfLadingNo:").doesNotContain("enum: [BILL_OF_LADING, DISPATCH_ORDER");}
@Test void openApiExposesOnlyPayableWithFixedCurrencies() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("payableAmount:","enum: [RMB, USD]","default: RMB").doesNotContain("receivableAmount");}
@Test void openApiExposesOnlyPayableWithFixedCurrencies() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("payableAmount:","enum: [RMB]","default: RMB").doesNotContain("receivableAmount");}
@Test void methodsHavePlatformPermissionsAndAuthenticatedScope() {
Map<String,String> expected=Map.ofEntries(
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("candidates","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"),
@@ -22,6 +22,7 @@ import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class OutboundDispatchServiceTest {
private final com.baiyue.firstleg.service.TruckAppointmentCompletionService truckCompletion = mock(com.baiyue.firstleg.service.TruckAppointmentCompletionService.class);
private OutboundDispatchRepository repository;
@BeforeEach
@@ -52,7 +53,7 @@ class OutboundDispatchServiceTest {
when(customs.code()).thenReturn("CUSTOMS_CLEARANCE");
when(customs.assess(any())).thenReturn(new GateItem(
"CUSTOMS_CLEARANCE", "报关完成", true, "CLEARANCE_COMPLETED", "CLEARANCE_COMPLETED"));
OutboundDispatchService service = new OutboundDispatchService(repository, List.of(customs), null);
OutboundDispatchService service = new OutboundDispatchService(repository, List.of(customs), null, truckCompletion);
List<OutboundDispatchReadMapper.Binding> tenBindings = LongStream.rangeClosed(11L, 20L)
.mapToObj(id -> binding(id, "I-" + id)).toList();
stubContainer(tenBindings);
@@ -124,6 +125,7 @@ class OutboundDispatchServiceTest {
@Test
void confirmLocksAndAdvancesAllBindingsButWritesOneHistoryAndOneShippingFact() {
when(truckCompletion.lockForContainer(22L)).thenReturn(66L);
CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class);
OutboundDispatchService service = service(carrierSchedules);
stubContainer();
@@ -141,6 +143,11 @@ class OutboundDispatchServiceTest {
verify(repository).lockBindings(22L);
verify(repository).confirm(31L, 0, 9L, "admin", "ok");
verify(repository).confirm(32L, 0, 9L, "admin", "ok");
var departureOrder = org.mockito.Mockito.inOrder(truckCompletion, repository);
departureOrder.verify(truckCompletion).lockForContainer(22L);
departureOrder.verify(repository).confirm(31L, 0, 9L, "admin", "ok");
departureOrder.verify(repository).confirm(32L, 0, 9L, "admin", "ok");
departureOrder.verify(truckCompletion).completeForBooking(66L);
verify(repository, times(1)).history(argThat(value -> "CONFIRM_DISPATCH".equals(value.getAction())
&& value.getContainerBookingId().equals(22L)));
verify(carrierSchedules, times(1)).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin");
@@ -271,7 +278,7 @@ class OutboundDispatchServiceTest {
private OutboundDispatchService service(CarrierScheduleQueryService carrierSchedules) {
return new OutboundDispatchService(repository,
List.of(passing("CUSTOMS_CLEARANCE")), carrierSchedules);
List.of(passing("CUSTOMS_CLEARANCE")), carrierSchedules, truckCompletion);
}
private void stubContainer() {
@@ -20,7 +20,7 @@ class TruckAppointmentMapperContractTest {
String candidates = TruckAppointmentMapper.class.getMethod("candidates", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.CandidateQuery.class).getAnnotation(Select.class).value()[0];
assertThat(candidates).contains("FROM fl_ocean_booking o", "o.status='RELEASED'", "d.valid=1",
"'BOOKING_CONFIRMATION','BILL_OF_LADING'", "t.ocean_booking_id=o.id",
"t.status NOT IN ('COMPLETED','CANCELLED','EXCEPTION')", "bookingDocumentReady");
"t.status NOT IN ('CANCELLED','EXCEPTION')", "bookingDocumentReady");
assertThat(candidates).doesNotContain("fl_ocean_booking_container_allocation", "fl_container_booking",
"loadingPlanId", "containerBookingId", "cabinetNo", "containerNo");
String transition = TruckAppointmentMapper.class.getMethod("transition", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0];
@@ -29,7 +29,7 @@ class TruckAppointmentMapperContractTest {
assertThat(dispatch).contains("status='DISPATCHED'", "bill_of_lading_no=#{billOfLadingNo}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1");
String firstActive = TruckAppointmentMapper.class.getMethod("firstActiveForUpdate", long.class).getAnnotation(Select.class).value()[0];
assertThat(firstActive).contains("ocean_booking_id=#{oceanBookingId}", "ORDER BY created_at,id", "LIMIT 1 FOR UPDATE")
.contains("'COMPLETED','CANCELLED','EXCEPTION'");
.contains("'CANCELLED','EXCEPTION'");
String reschedule = TruckAppointmentMapper.class.getMethod("reschedule", long.class, java.time.LocalDateTime.class, String.class, int.class).getAnnotation(Update.class).value()[0];
assertThat(reschedule).contains("status='RESCHEDULED'", "version=#{version}", "revision=revision+1");
String edit = TruckAppointmentMapper.class.getMethod("updateEdit", TruckAppointmentEntity.class, int.class).getAnnotation(Update.class).value()[0];
@@ -0,0 +1,94 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.domain.TruckAppointmentStatus;
import com.baiyue.firstleg.entity.TruckAppointmentEntity;
import com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity;
import com.baiyue.firstleg.mapper.TruckAppointmentCompletionMapper;
import com.baiyue.firstleg.mapper.TruckAppointmentCompletionMapper.DepartureRow;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.repository.TruckAppointmentRepository;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class TruckAppointmentCompletionServiceTest {
static final LocalDateTime DEPARTED = LocalDateTime.of(2026, 9, 14, 18, 41);
private final TruckAppointmentCompletionMapper mapper = mock(TruckAppointmentCompletionMapper.class);
private final OceanBookingRepository bookings = mock(OceanBookingRepository.class);
private final TruckAppointmentRepository appointments = mock(TruckAppointmentRepository.class);
private final TruckAppointmentFeeService fees = mock(TruckAppointmentFeeService.class);
private final TruckAppointmentCompletionService service = new TruckAppointmentCompletionService(mapper, bookings, appointments, fees);
static DepartureRow row(long container, int quantity, String status, String volume) {
return new DepartureRow(9L, quantity, container, "CONT" + container, "ACTIVE", container * 10,
"COMPLETED", container * 100, container * 1000, container * 10000, "ORDER" + container,
container * 100000, "INBOUND" + container, new BigDecimal(volume), status,
"WAITING_FOR_LOADING".equals(status) ? DEPARTED : null);
}
@Test void loadingCompletionAloneDoesNotCompleteTruck() {
assertThat(TruckAppointmentCompletionService.allDeparted(List.of(row(1, 1, "READY_FOR_DISPATCH", "2")))).isFalse();
}
@Test void allRequiredContainersAndEveryInboundMustHaveDeparted() {
var first = row(1, 2, "WAITING_FOR_LOADING", "2");
assertThat(TruckAppointmentCompletionService.allDeparted(List.of(first))).isFalse();
assertThat(TruckAppointmentCompletionService.allDeparted(List.of(first, row(2, 2, "READY_FOR_DISPATCH", "4")))).isFalse();
assertThat(TruckAppointmentCompletionService.allDeparted(List.of(first, row(2, 2, "WAITING_FOR_LOADING", "4")))).isTrue();
assertThat(TruckAppointmentCompletionService.allDeparted(List.of(row(1, 1, "WAITING_FOR_LOADING", "2"), row(1, 1, null, "4")))).isFalse();
assertThat(TruckAppointmentCompletionService.allDeparted(List.of())).isFalse();
}
@Test void missingRequirementAllocationCannotBeHiddenByAnInnerJoin() {
var missing = new DepartureRow(10L, 1, null, null, null, null, null, null, null, null, null, null, null, null, null, null);
assertThat(TruckAppointmentCompletionService.allDeparted(List.of(row(1, 1, "WAITING_FOR_LOADING", "2"), missing))).isFalse();
}
@Test void completesDraftOrLateSubmittedTruckAndPublishesExactlyOnce() {
var appointment = new TruckAppointmentEntity();
appointment.setId(11L); appointment.setVersion(1); appointment.setRevision(1);
appointment.setStatus(TruckAppointmentStatus.PENDING_DISPATCH);
var rows = List.of(row(1, 1, "WAITING_FOR_LOADING", "2"));
when(bookings.findForUpdate(99L)).thenReturn(new OceanBookingEntity());
when(mapper.activeForUpdate(99L)).thenReturn(List.of(appointment), List.of());
when(mapper.departuresForUpdate(99L)).thenReturn(rows);
when(mapper.complete(11L, 1, DEPARTED)).thenReturn(1);
service.completeForBooking(99L);
service.completeForBooking(99L);
verify(fees, times(1)).publish(appointment, rows, DEPARTED);
var history = ArgumentCaptor.forClass(TruckAppointmentHistoryEntity.class);
verify(appointments, times(1)).history(history.capture());
assertThat(history.getValue().getAction()).isEqualTo("AUTO_COMPLETE");
assertThat(history.getValue().getFromStatus()).isEqualTo("PENDING_DISPATCH");
assertThat(history.getValue().getToStatus()).isEqualTo("COMPLETED");
assertThat(history.getValue().getRevision()).isEqualTo(2);
var order = inOrder(bookings, mapper, fees, appointments);
order.verify(bookings).findForUpdate(99L);
order.verify(mapper).activeForUpdate(99L);
order.verify(mapper).departuresForUpdate(99L);
order.verify(fees).publish(appointment, rows, DEPARTED);
order.verify(mapper).complete(11L, 1, DEPARTED);
order.verify(appointments).history(any());
}
@Test void feeFailureDoesNotLeaveACompletedTruck() {
var appointment = new TruckAppointmentEntity();
when(bookings.findForUpdate(99L)).thenReturn(new OceanBookingEntity());
when(mapper.activeForUpdate(99L)).thenReturn(List.of(appointment));
when(mapper.departuresForUpdate(99L)).thenReturn(List.of(row(1, 1, "WAITING_FOR_LOADING", "2")));
doThrow(new IllegalStateException("fee unavailable")).when(fees).publish(any(), any(), any());
assertThatThrownBy(() -> service.completeForBooking(99L)).hasMessage("fee unavailable");
verify(mapper, never()).complete(anyLong(), anyInt(), any());
verifyNoInteractions(appointments);
}
}
@@ -0,0 +1,79 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AllocationGroupRequest;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.TruckAppointmentEntity;
import com.baiyue.firstleg.repository.FeeItemRepository;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.math.BigDecimal;
import java.util.List;
import static com.baiyue.firstleg.service.TruckAppointmentCompletionServiceTest.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class TruckAppointmentFeeServiceTest {
private final FeeItemRepository items = mock(FeeItemRepository.class);
private final ContainerFeeDetailService containers = mock(ContainerFeeDetailService.class);
private final WarehouseSupplierSnapshotService suppliers = mock(WarehouseSupplierSnapshotService.class);
private final TruckAppointmentFeeService service = new TruckAppointmentFeeService(items, containers, suppliers);
@Test void allocatesAcrossCustomersAndContainersByVolumeWithExactCents() {
var shares = TruckAppointmentFeeService.allocate(new BigDecimal("100.00"), List.of(
row(3, 3, "WAITING_FOR_LOADING", "1"), row(1, 3, "WAITING_FOR_LOADING", "1"), row(2, 3, "WAITING_FOR_LOADING", "1")));
assertThat(shares).extracting(TruckAppointmentFeeService.Share::amount)
.containsExactly(new BigDecimal("33.34"), new BigDecimal("33.33"), new BigDecimal("33.33"));
assertThat(shares.stream().map(TruckAppointmentFeeService.Share::amount).reduce(BigDecimal.ZERO, BigDecimal::add)).isEqualByComparingTo("100.00");
}
@Test void tinyAmountsNeverPublishZeroOrNegativeSources() {
var shares = TruckAppointmentFeeService.allocate(new BigDecimal("0.01"), List.of(
row(1, 2, "WAITING_FOR_LOADING", "1"), row(2, 2, "WAITING_FOR_LOADING", "2")));
assertThat(shares).hasSize(1);
assertThat(shares.get(0).owner().planId()).isEqualTo(20L);
assertThat(shares.get(0).amount()).isEqualByComparingTo("0.01");
}
@Test void missingVolumeRejectsInsteadOfInventingEqualCosts() {
assertThatThrownBy(() -> TruckAppointmentFeeService.allocate(new BigDecimal("100.00"),
List.of(row(1, 1, "WAITING_FOR_LOADING", "0")))).hasMessageContaining("实装体积");
}
@Test void publishesApprovedCnyOnAccountSourcesWithTruckAndSupplierReferences() {
var appointment = appointment();
var item = new FeeItemEntity(); item.setId(108L); item.setStatus("ACTIVE"); item.setPickupPayable(true);
when(items.findItemByCode("FEE_TYPE_108")).thenReturn(item);
var created = new ContainerFeeDetailEntity(); created.setAllocationGroupId(500L);
when(containers.createAllocationGroup(anyLong(), eq(0L), any())).thenReturn(List.of(created));
service.publish(appointment, List.of(row(1, 1, "WAITING_FOR_LOADING", "5")), DEPARTED);
var request = ArgumentCaptor.forClass(AllocationGroupRequest.class);
verify(containers).createAllocationGroup(eq(10L), eq(0L), request.capture());
assertThat(request.getValue().fee().direction()).isEqualTo("PAYABLE");
assertThat(request.getValue().fee().currency()).isEqualTo("CNY");
assertThat(request.getValue().fee().settlementMode()).isEqualTo("ON_ACCOUNT");
assertThat(request.getValue().fee().supplierId()).isEqualTo(8L);
assertThat(request.getValue().fee().remark()).contains("TA-260914-0111");
assertThat(request.getValue().fee().businessDate()).isEqualTo(DEPARTED.toLocalDate());
assertThat(request.getValue().allocations().get(0).amount()).isEqualByComparingTo("100");
verify(suppliers).trucking(8L, null);
verify(containers).approveAllocationGroup(10L, 500L, 0L, "系统(拖车离仓完成)");
}
@Test void zeroFeeCreatesNoPayableAndUnsupportedCurrencyFailsClosed() {
var appointment = appointment(); appointment.setPayableAmount(BigDecimal.ZERO);
service.publish(appointment, List.of(), DEPARTED);
verifyNoInteractions(items, containers, suppliers);
appointment.setPayableAmount(BigDecimal.ONE); appointment.setCurrency("USD");
assertThatThrownBy(() -> service.publish(appointment, List.of(), DEPARTED)).hasMessageContaining("人民币");
}
private TruckAppointmentEntity appointment() {
var appointment = new TruckAppointmentEntity(); appointment.setAppointmentNo("TA-260914-0111");
appointment.setCarrierId(8L); appointment.setCurrency("RMB"); appointment.setPayableAmount(new BigDecimal("100.00"));
return appointment;
}
}
@@ -0,0 +1,107 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.client.FeePaymentVoucherFileClient;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
import com.baiyue.firstleg.entity.*;
import com.baiyue.firstleg.mapper.ContainerFeeSourceSnapshotMapper;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import com.baiyue.firstleg.mapper.TruckAppointmentCompletionMapper.DepartureRow;
import com.baiyue.firstleg.repository.*;
import com.fasterxml.jackson.databind.json.JsonMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static com.baiyue.firstleg.service.TruckAppointmentCompletionServiceTest.DEPARTED;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** Real business services from trucking through approval, immutable snapshot and Finance Outbox. */
class TruckAppointmentPayableFlowTest {
@Test void truckTotalBecomesCustomerOwnedPublishedPayablesWithoutRegisteringPayment() throws Exception {
var repository = mock(ContainerFeeDetailRepository.class);
var loading = mock(SmartLoadingRepository.class);
var items = mock(FeeItemRepository.class);
var suppliers = mock(WarehouseSupplierSnapshotService.class);
var snapshotMapper = mock(ContainerFeeSourceSnapshotMapper.class);
var outboxMapper = mock(InboundOutboxMapper.class);
var mapper = JsonMapper.builder().findAndAddModules().build();
var snapshots = new ContainerFeeSourceSnapshotRepository(snapshotMapper);
var outbox = new FinanceFeeSourceOutboxService(outboxMapper, mapper);
var payments = new FeePaymentSnapshotValidator(mock(FeePaymentVoucherFileClient.class), "test");
var containerFees = new ContainerFeeDetailService(repository, loading, items, outbox, suppliers, snapshots, payments);
var trucking = new TruckAppointmentFeeService(items, containerFees, suppliers);
var snapshotQueries = new FinanceFeeSourceSnapshotService(items, snapshots);
var item = new FeeItemEntity(); item.setId(108L); item.setFeeCode("FEE_TYPE_108");
item.setFeeTypeName("拖车费"); item.setStatus("ACTIVE"); item.setPickupPayable(true); item.setSupplierCategory("TRUCKING");
when(items.findItemByCode("FEE_TYPE_108")).thenReturn(item);
when(items.findItem(108L)).thenReturn(item);
var supplier = new WarehouseSupplierClient.Snapshot(8L, "测试车队", "TRUCKING", "ACTIVE", List.of());
when(suppliers.trucking(8L, null)).thenReturn(supplier);
when(suppliers.fee(8L, "TRUCKING")).thenReturn(supplier);
var plan = new LoadingPlanEntity(); plan.setId(10L); plan.setContainerBookingId(1L);
var container = new ContainerBookingEntity(); container.setId(1L); container.setContainerNo("TEST1234567");
when(loading.plan(10L)).thenReturn(plan); when(loading.booking(1L)).thenReturn(container);
var loaded = new ArrayList<LoadingPlanItemEntity>();
var departures = new ArrayList<DepartureRow>();
for (long customer = 1; customer <= 2; customer++) {
var load = new LoadingPlanItemEntity(); load.setAccountId(customer); load.setOrderId(customer * 100);
load.setOrderNo("ORDER-" + customer); load.setInboundId(customer * 1000); load.setInboundCode("IN-" + customer);
loaded.add(load);
departures.add(new DepartureRow(9L, 1, 1L, "TEST1234567", "ACTIVE", 10L, "COMPLETED", customer,
customer, load.getOrderId(), load.getOrderNo(), load.getInboundId(), load.getInboundCode(),
BigDecimal.valueOf(customer), "WAITING_FOR_LOADING", DEPARTED));
}
when(loading.items(10L)).thenReturn(loaded);
Map<Long, ContainerFeeDetailEntity> saved = new LinkedHashMap<>();
doAnswer(call -> { var value = (ContainerFeeDetailEntity) call.getArgument(0); saved.put(value.getId(), value); return null; })
.when(repository).insert(any());
when(repository.listGroup(eq(10L), anyLong())).thenAnswer(call -> List.copyOf(saved.values()));
when(repository.find(anyLong(), eq(10L), anyLong())).thenAnswer(call -> saved.get(call.getArgument(2)));
when(repository.updateSupplierSnapshot(anyLong(), eq(10L), anyLong(), eq(8L), eq("测试车队"), eq("TRUCKING"), eq(0L))).thenReturn(1);
when(repository.review(anyLong(), eq(10L), anyLong(), eq("APPROVED"), eq(0L), anyString(), isNull()))
.thenAnswer(call -> { var value = saved.get(call.getArgument(2)); value.setReviewStatus("APPROVED"); value.setReviewedAt(DEPARTED); return 1; });
var appointment = new TruckAppointmentEntity(); appointment.setAppointmentNo("TA-260914-0111");
appointment.setCarrierId(8L); appointment.setCurrency("RMB"); appointment.setPayableAmount(new BigDecimal("100.00"));
trucking.publish(appointment, departures, DEPARTED);
assertThat(saved.values()).hasSize(2).allSatisfy(value -> {
assertThat(value.getDirection()).isEqualTo("PAYABLE");
assertThat(value.getReviewStatus()).isEqualTo("APPROVED");
assertThat(value.getSettlementMode()).isEqualTo("ON_ACCOUNT");
assertThat(value.getPaidAt()).isNull();
assertThat(value.getRemark()).contains("TA-260914-0111");
});
assertThat(saved.values().stream().map(ContainerFeeDetailEntity::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add))
.isEqualByComparingTo("100.00");
var frozen = ArgumentCaptor.forClass(ContainerFeeSourceSnapshotEntity.class);
verify(snapshotMapper, times(2)).insert(frozen.capture());
var events = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outboxMapper, times(2)).insert(events.capture());
for (int i = 0; i < 2; i++) {
var source = frozen.getAllValues().get(i);
when(snapshotMapper.selectOne(any())).thenReturn(source);
var snapshot = snapshotQueries.container(1L, source.getSourceFeeId(), 1);
assertThat(snapshot.publicationStatus()).isEqualTo("PUBLISHED");
assertThat(snapshot.currency()).isEqualTo("CNY");
assertThat(snapshot.customerId()).isEqualTo(source.getTenantId());
assertThat(snapshot.orderId()).isEqualTo(source.getAllocationOrderId());
assertThat(snapshot.supplierId()).isEqualTo(8L);
assertThat(snapshot.sourceHash()).hasSize(64);
var event = mapper.readTree(events.getAllValues().get(i).getPayloadJson());
assertThat(event.path("eventType").asText()).isEqualTo("FIRSTLEG_FEE_PUBLISHED");
assertThat(event.path("aggregateType").asText()).isEqualTo("CONTAINER");
assertThat(event.path("payload").path("feeId").asText()).isEqualTo(source.getSourceFeeId().toString());
assertThat(event.path("tenantId").asText()).isEqualTo(source.getTenantId().toString());
assertThat(events.getAllValues().get(i).getPublishStatus()).isEqualTo("PENDING");
}
}
}
@@ -41,6 +41,7 @@ import static org.mockito.Mockito.*;
class TruckAppointmentServiceTest {
@Mock TruckAppointmentRepository repository;
@Mock TruckAppointmentCompletionService completion;
@Mock OceanBookingRepository oceanBookings;
@Mock WarehouseSupplierSnapshotService suppliers;
@Mock TruckAppointmentFileClient files;
@@ -51,6 +52,11 @@ class TruckAppointmentServiceTest {
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this);
service.internalToken = "test-internal-token";
lenient().doAnswer(invocation -> {
TruckAppointmentEntity inserted = invocation.getArgument(0);
lenient().when(repository.find(inserted.getId())).thenReturn(inserted);
return null;
}).when(repository).insert(any());
lenient().when(documentNumbers.next("TA")).thenReturn("TA-260818-0001");
when(suppliers.trucking(any(), any())).thenReturn(
new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE", java.util.List.of()));
@@ -88,6 +94,63 @@ class TruckAppointmentServiceTest {
verifyNoInteractions(oceanBookings);
}
@Test void createAfterDepartureReturnsCompletedVersion() {
doAnswer(invocation -> {
var inserted = org.mockito.Mockito.mockingDetails(repository).getInvocations().stream()
.filter(call -> call.getMethod().getName().equals("insert"))
.map(call -> (TruckAppointmentEntity) call.getArguments()[0]).findFirst().orElseThrow();
inserted.setStatus(TruckAppointmentStatus.COMPLETED); inserted.setVersion(1);
return null;
}).when(completion).completeForBooking(99L);
var result = service.create(7L, "admin", validRequest());
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.COMPLETED);
assertThat(result.getVersion()).isEqualTo(1);
}
@Test void completedBookingCannotCreateAnotherAutomaticCharge() {
var existing = entity(TruckAppointmentStatus.COMPLETED, 3);
existing.setAppointmentNo("TA-260914-0111");
when(repository.firstActiveForUpdate(99L)).thenReturn(existing);
assertThatThrownBy(() -> service.create(7L, "admin", validRequest())).hasMessageContaining("TA-260914-0111");
verify(repository, never()).insert(any());
}
@Test void lateSubmitReturnsAutomaticCompletionInsteadOfPendingDispatch() {
var before = entity(TruckAppointmentStatus.DRAFT, 0);
var submitted = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
when(repository.find(1L)).thenReturn(before, submitted);
when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("late-submit"), anyString())).thenReturn(true);
when(repository.transition(1L, "DRAFT", "PENDING_DISPATCH", 0)).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("SUBMIT"), eq("late-submit"), anyString())).thenReturn(1);
doAnswer(invocation -> { submitted.setStatus(TruckAppointmentStatus.COMPLETED); submitted.setVersion(2); return null; })
.when(completion).completeForBooking(99L);
var result = service.action(1L, "SUBMIT", new ActionRequest(0, null), "late-submit", null, 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.COMPLETED);
assertThat(result.getVersion()).isEqualTo(2);
}
@Test void reschedulingAnExceptionCannotDuplicateAnAlreadyCompletedBookingCharge() {
var failed = entity(TruckAppointmentStatus.EXCEPTION, 1);
var completed = entity(TruckAppointmentStatus.COMPLETED, 2); completed.setId(2L);
completed.setAppointmentNo("TA-260914-0111");
when(repository.find(1L)).thenReturn(failed);
when(repository.firstActiveForUpdate(99L)).thenReturn(completed);
when(repository.claimOperation(anyLong(), eq(1L), eq("RESCHEDULE"), eq("reschedule-old"), anyString())).thenReturn(true);
assertThatThrownBy(() -> service.reschedule(1L, LocalDateTime.now(), new ActionRequest(1, null),
"reschedule-old", null, 7L, "admin")).hasMessageContaining("已完成拖车单");
verify(repository, never()).reschedule(anyLong(), any(), any(), anyInt());
}
@Test void manualCompleteAndDepartureCannotBypassActualDeparture() {
for (String action : List.of("COMPLETE", "DEPART")) {
when(repository.find(1L)).thenReturn(entity(TruckAppointmentStatus.LOADING, 4));
when(repository.claimOperation(anyLong(), eq(1L), eq(action), eq(action), anyString())).thenReturn(true);
assertThatThrownBy(() -> service.action(1L, action, new ActionRequest(4, null), action, null, 7L, "admin"))
.isInstanceOf(BizException.class);
}
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
}
@Test void createRequiresZeroVersionAndInitialisesDraft() {
SaveRequest invalid = request(1);
assertThatThrownBy(() -> service.create(7L, "admin", invalid)).isInstanceOf(BizException.class);
@@ -174,17 +237,12 @@ class TruckAppointmentServiceTest {
assertThat(inserted.getValue().getCurrency()).isEqualTo("RMB");
}
@Test void createAcceptsUsd() {
@Test void createRejectsUsdBeforeWriting() {
SaveRequest request = validRequest();
when(request.currency()).thenReturn("USD");
when(request.payableAmount()).thenReturn(new java.math.BigDecimal("123.45"));
service.create(7L, "admin", request);
ArgumentCaptor<TruckAppointmentEntity> inserted = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).insert(inserted.capture());
assertThat(inserted.getValue().getCurrency()).isEqualTo("USD");
assertThat(inserted.getValue().getPayableAmount()).isEqualByComparingTo("123.45");
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("人民币");
verify(repository, never()).insert(any());
}
@Test void createRejectsUnsupportedCurrencyWithoutWriting() {
@@ -192,7 +250,7 @@ class TruckAppointmentServiceTest {
when(request.currency()).thenReturn("CNY");
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("RMB或USD");
.isInstanceOf(BizException.class).hasMessageContaining("人民币");
verify(repository, never()).insert(any());
verify(repository, never()).history(any());
@@ -534,7 +592,7 @@ class TruckAppointmentServiceTest {
}
private static TruckAppointmentEntity entity(TruckAppointmentStatus status, int version) {
TruckAppointmentEntity e = new TruckAppointmentEntity(); e.setId(1L); e.setStatus(status); e.setVersion(version); e.setRevision(version); return e;
TruckAppointmentEntity e = new TruckAppointmentEntity(); e.setId(1L); e.setOceanBookingId(99L); e.setStatus(status); e.setVersion(version); e.setRevision(version); return e;
}
private static SaveRequest request(int expectedVersion) {