优化
This commit is contained in:
+2
-2
@@ -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"),
|
||||
|
||||
+9
-2
@@ -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() {
|
||||
|
||||
+2
-2
@@ -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];
|
||||
|
||||
+94
@@ -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);
|
||||
}
|
||||
}
|
||||
+79
@@ -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;
|
||||
}
|
||||
}
|
||||
+107
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
-11
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user