feat(fleet): integrate costs quotes operations and in-transit handovers

This commit is contained in:
2026-10-03 12:38:04 +08:00
parent 274ecac474
commit cb6c54ffe5
107 changed files with 7304 additions and 42 deletions
@@ -0,0 +1,125 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventTypes;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.financefee.fleet.VehicleCareFinanceAdapter;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.*;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class VehicleCareFinancePersistenceTest {
private final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule());
private JdbcTemplate jdbc;
private VehicleCareFinanceService service;
private VehicleCareMapper records;
private VehicleCareFinanceClient finance;
private VehicleCareSupplierClient suppliers;
private VehicleCareEntity row;
@BeforeEach void setup() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:care-finance-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE ll_fleet_finance_source(id BIGINT PRIMARY KEY,record_id BIGINT NOT NULL,source_version BIGINT NOT NULL,record_version BIGINT NOT NULL,operator_id VARCHAR(64) NOT NULL,request_key VARCHAR(128) NOT NULL,request_hash VARCHAR(71) NOT NULL,event_id VARCHAR(128) NOT NULL,payload_json CLOB NOT NULL,created_at TIMESTAMP(3),UNIQUE(record_id,source_version),UNIQUE(record_id,operator_id,request_key),UNIQUE(event_id))");
jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(128),event_type VARCHAR(64),event_version INT,event_key VARCHAR(255) UNIQUE,payload_json CLOB,status VARCHAR(32),retry_count INT,next_retry_at TIMESTAMP(3),published_at TIMESTAMP(3),locked_at TIMESTAMP(3),locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP(3))");
var config = new MybatisConfiguration(); config.addMapper(LastlegEventOutboxMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config);
var outbox = new SqlSessionTemplate(factory.getObject()).getMapper(LastlegEventOutboxMapper.class);
records = mock(VehicleCareMapper.class); finance = mock(VehicleCareFinanceClient.class); suppliers = mock(VehicleCareSupplierClient.class);
row = new VehicleCareEntity(); row.setId(1L); row.setVehicleId(71L); row.setVehiclePlate("VEH-A"); row.setRecordNo("VR-ONE"); row.setRecordType("REPAIR"); row.setRecordVersion(0L); row.setDetailsJson(json.writeValueAsString(draft("10", "PENDING")));
when(records.lock(71, "VR-ONE")).thenReturn(row);
when(records.findFinanceRecords(71, List.of("VR-ONE"))).thenReturn(List.of(row));
when(finance.query(anyString(), any())).thenReturn(ApiResponse.success(List.of()));
when(suppliers.get("test-token", 8, true)).thenReturn(ApiResponse.success(new VehicleCareSupplierClient.Supplier(8, "维修供应商", null, "ACTIVE")));
var converter = mock(OverseasFeeCurrencyConverter.class);
when(converter.convert(eq("USD"), any(), eq(LocalDate.of(2026, 1, 1)))).thenAnswer(call -> {
BigDecimal amount = call.getArgument(1);
return new OverseasFeeCurrencyConverter.Conversion("USD", amount.setScale(2), new BigDecimal("7.000000"), "2026-01", amount.multiply(new BigDecimal("7")).setScale(2));
});
var adapterProxy = new ProxyFactory(new VehicleCareFinanceAdapter(converter, outbox, jdbc, json)); adapterProxy.setProxyTargetClass(true);
adapterProxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
var accounting = (VehicleCareFinanceGateway) adapterProxy.getProxy();
var target = new VehicleCareFinanceService(records, jdbc, json, accounting, finance, suppliers, "test-token");
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
service = (VehicleCareFinanceService) proxy.getProxy();
}
private static VehicleCareModels.Draft draft(String amount, String processing) {
return new VehicleCareModels.Draft("REPAIR", Instant.parse("2026-01-01T00:00:00Z"), null, null, null, null, null, null, null, null,
new VehicleCareModels.Incident("轮胎损坏", "换轮胎", "维修站", "工作人员", processing, null, new BigDecimal(amount), "USD"));
}
private static VehicleCareFinanceModels.Submit command(String key) { return new VehicleCareFinanceModels.Submit(0L, 8L, LocalDate.of(2026, 1, 1), key); }
private VehicleCareFinanceModels.Source source() throws Exception {
return json.readValue(jdbc.queryForObject("SELECT payload_json FROM ll_fleet_finance_source ORDER BY source_version DESC LIMIT 1", String.class), VehicleCareFinanceModels.Source.class);
}
private void remote(String status) throws Exception {
var source = source();
when(finance.query(anyString(), any())).thenReturn(ApiResponse.success(List.of(new VehicleCareFinanceModels.Cost("VC-ONE", "PLATFORM_DEFAULT", source, "维修供应商", "CNY", status,
"PAID".equals(status) ? source.amount() : BigDecimal.ZERO, "PAID".equals(status) ? BigDecimal.ZERO : source.amount(), 1))));
}
@Test void submitWritesImmutableSourceAndOutboxTogetherAndUnknownResponseReplaysTheSameEvent() throws Exception {
var first = service.submit(71, "VR-ONE", "1", command("request-one"));
var replay = service.submit(71, "VR-ONE", "1", command("request-one"));
assertThat(first.status()).isEqualTo("PENDING_FINANCE"); assertThat(replay.replayed()).isTrue();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_finance_source", Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox", Integer.class)).isEqualTo(1);
var envelope = json.readValue(jdbc.queryForObject("SELECT payload_json FROM ll_event_outbox", String.class), EventEnvelope.class);
assertThat(envelope.eventType()).isEqualTo(EventTypes.COMPANY_VEHICLE_COST_SUBMITTED);
assertThat(envelope.tenantId()).isEqualTo("0"); assertThat(envelope.payload().has("customerId")).isFalse();
assertThat(source().amount()).isEqualByComparingTo("70.00");
verify(finance, times(1)).query(anyString(), any());
assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", new VehicleCareFinanceModels.Submit(0L, 9L, LocalDate.of(2026,1,1), "request-one"))).hasMessageContaining("其他内容");
}
@Test void outboxFailureRollsBackTheSourceAndAllowsTheOriginalRequestToRetry() {
jdbc.execute("ALTER TABLE ll_event_outbox ADD CONSTRAINT reject_pending CHECK(status<>'PENDING')");
assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", command("request-one"))).isInstanceOf(RuntimeException.class);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_finance_source", Integer.class)).isZero();
jdbc.execute("ALTER TABLE ll_event_outbox DROP CONSTRAINT reject_pending");
assertThat(service.submit(71, "VR-ONE", "1", command("request-one")).sourceVersion()).isEqualTo(1);
}
@Test void statusesDistinguishUnlinkedDeliveryFailureAndRealFinancePayment() throws Exception {
assertThat(service.statuses(71, List.of("VR-ONE")).get(0).status()).isEqualTo("UNLINKED");
verifyNoInteractions(finance);
service.submit(71, "VR-ONE", "1", command("request-one"));
assertThat(service.statuses(71, List.of("VR-ONE")).get(0).status()).isEqualTo("PENDING_FINANCE");
jdbc.update("UPDATE ll_event_outbox SET status='DEAD'");
assertThat(service.statuses(71, List.of("VR-ONE")).get(0).status()).isEqualTo("FINANCE_ATTENTION");
remote("PAID");
var status = service.statuses(71, List.of("VR-ONE")).get(0);
assertThat(status.status()).isEqualTo("PAID"); assertThat(status.finance().paidAmount()).isEqualByComparingTo("70");
}
@Test void submittedAmountsStayFrozenUntilFinancialVoidButProcessingUpdatesContinue() throws Exception {
service.submit(71, "VR-ONE", "1", command("request-one")); remote("PENDING");
assertThatThrownBy(() -> service.guardFinancialEdit(row, draft("20", "PENDING"))).hasMessageContaining("先在财务作废");
clearInvocations(finance);
service.guardFinancialEdit(row, draft("10", "COMPLETED")); verifyNoInteractions(finance);
remote("PAID"); assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", command("request-two"))).hasMessageContaining("不可改写");
remote("VOIDED"); service.guardFinancialEdit(row, draft("20", "PENDING"));
row.setDetailsJson(json.writeValueAsString(draft("20", "PENDING")));
assertThat(service.submit(71, "VR-ONE", "1", command("request-two")).sourceVersion()).isEqualTo(2);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_finance_source", Integer.class)).isEqualTo(2);
}
@Test void wrongVehicleStaleVersionAndFinanceFailureCannotPretendToSubmit() {
assertThatThrownBy(() -> service.submit(72, "VR-ONE", "1", command("request-one"))).hasMessageContaining("不存在");
assertThatThrownBy(() -> service.statuses(72, List.of("VR-ONE"))).hasMessageContaining("不存在");
assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", new VehicleCareFinanceModels.Submit(1L,8L,LocalDate.of(2026,1,1),"request-one"))).hasMessageContaining("已变化");
when(finance.query(anyString(), any())).thenReturn(null);
assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", command("request-one"))).hasMessageContaining("未确认入账或付款");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_finance_source", Integer.class)).isZero();
}
}
@@ -50,7 +50,7 @@ class VehicleCarePersistenceTest {
files = mock(VehicleCareFileGateway.class);
when(files.upload(anyLong(), anyString(), any())).thenReturn(new VehicleCareModels.File("9001", "photo.png", "image/png", 8));
var target = new VehicleCareService(session.getMapper(VehicleCareMapper.class), session.getMapper(VehicleCareHistoryMapper.class), vehicles, files,
new ObjectMapper().registerModule(new JavaTimeModule()));
new ObjectMapper().registerModule(new JavaTimeModule()), mock(VehicleCareFinanceService.class));
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource()));
service = (VehicleCareService) proxy.getProxy();
@@ -128,7 +128,7 @@ class VehicleCarePersistenceTest {
if (List.of("REPAIR", "RESCUE").contains(type)) {
assertThat(row.details().incident().item()).isEqualTo("更换轮胎");
assertThat(row.details().incident().place()).isEqualTo("实际作业地点");
assertThat(row.paymentStatus()).isEqualTo("UNLINKED");
assertThat(row.paymentStatus()).isNull();
}
}
}
@@ -137,7 +137,7 @@ class VehicleCarePersistenceTest {
var started = service.update(vehicleA, created.recordNo(), "2", new VehicleCareModels.Update(0L, "已安排维修", incident("REPAIR", "IN_PROGRESS", "维修人员", new BigDecimal("120.50"), "USD")));
var completed = service.update(vehicleA, created.recordNo(), "2", new VehicleCareModels.Update(started.recordVersion(), "现场处理完成", incident("REPAIR", "COMPLETED", "维修人员", BigDecimal.ZERO, "USD")));
assertThat(completed.details().incident().reportedCost()).isEqualByComparingTo("0");
assertThat(completed.paymentStatus()).isEqualTo("UNLINKED");
assertThat(completed.paymentStatus()).isNull();
assertThat(jdbc.queryForObject("SELECT status FROM ll_vehicle WHERE id=?", String.class, vehicleA)).isEqualTo("IN_SERVICE");
var history = service.history(vehicleA, created.recordNo(), 1, 20).records();
assertThat(history).hasSize(3);