feat(fleet): integrate costs quotes operations and in-transit handovers
This commit is contained in:
+125
@@ -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();
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user