feat(fleet): enforce reviewed trips and original fee coverage for dispatch

This commit is contained in:
2026-10-03 04:18:52 +08:00
parent c50e9f97d1
commit 5e683da3b9
162 changed files with 9642 additions and 218 deletions
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.financefee;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.constructor.SafeConstructor;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import static org.assertj.core.api.Assertions.*;
class OverseasFeeCancellationContractTest {
@Test void changedContractsHaveNoDuplicateKeysAndSeparateTheRequestFromFinancialConfirmation() throws Exception {
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
var yaml = new Yaml(new SafeConstructor(options));
for (var name : new String[]{"overseas-business-v1.yaml", "overseas-finance-fees-v1.yaml"}) {
String source = Files.readString(Path.of("src/main/resources/openapi", name));
Map<String,Object> parsed = yaml.load(source);
assertThat(parsed).containsKeys("paths", "components");
assertThat(source).contains("PENDING_FINANCE", "FINANCE_ATTENTION", "CONFIRMED");
}
}
}
@@ -0,0 +1,126 @@
package com.baiyue.lastleg.financefee;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.common.core.outbox.EventDeliveryJournal;
import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper;
import com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository;
import com.baiyue.lastleg.financefee.service.OverseasFeeOutboxService;
import com.baiyue.lastleg.financefee.state.OverseasFeeCancellation;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
class OverseasFeeCancellationPersistenceTest {
JdbcTemplate jdbc;
LastlegEventOutboxMapper events;
OverseasFeeSourceMapper fees;
OverseasFeeCancellation cancellation;
OverseasFeeOutboxRepository repository;
EventDeliveryJournal journal;
OverseasFeeSource source;
LastlegEventOutbox request;
final LocalDateTime now = LocalDateTime.of(2026, 10, 3, 1, 0);
@BeforeEach void setup() throws Exception {
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(dataSource);
jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),event_version INT,event_type VARCHAR(96),event_key VARCHAR(200) UNIQUE,payload_json TEXT,status VARCHAR(32),retry_count INT NOT NULL DEFAULT 0,next_retry_at TIMESTAMP,published_at TIMESTAMP,locked_at TIMESTAMP,locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP)");
jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))");
jdbc.execute("CREATE TABLE ll_finance_fee_source(id BIGINT PRIMARY KEY,source_fee_id BIGINT,source_version INT,customer_id VARCHAR(64),source_type VARCHAR(64),source_status VARCHAR(32),updated_at TIMESTAMP,business_type VARCHAR(64),direction VARCHAR(32),fee_item_code VARCHAR(64),inbound_code VARCHAR(64),review_status VARCHAR(32))");
jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,source_type,source_status) VALUES(10,701,1,'10001','OVERSEAS_DELIVERY_FEE','ACTIVE')");
var config = new MybatisConfiguration(); config.addMapper(LastlegEventOutboxMapper.class); config.addMapper(OverseasFeeSourceMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(config); factory.afterPropertiesSet();
var session = new SqlSessionTemplate(factory.getObject());
events = session.getMapper(LastlegEventOutboxMapper.class); fees = session.getMapper(OverseasFeeSourceMapper.class);
var json = new ObjectMapper().findAndRegisterModules();
cancellation = new OverseasFeeCancellation(events, fees, json);
repository = new OverseasFeeOutboxRepository(events, cancellation);
journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource));
source = fees.lockCurrent(701);
var producer = new OverseasFeeOutboxService();
ReflectionTestUtils.setField(producer, "mapper", events); ReflectionTestUtils.setField(producer, "objectMapper", json);
producer.publish(source, "PUBLISHED");
var copy = new OverseasFeeSource(); org.springframework.beans.BeanUtils.copyProperties(source, copy); copy.setSourceStatus("VOIDED");
producer.publish(copy, "VOIDED");
request = events.findByEventKey(OverseasFeeCancellation.eventKey(source, "VOIDED"));
}
@Test void waitsForOriginalPublicationAndDoesNotCallPendingARequestAVoid() {
assertThat(cancellation.prerequisitePublished(request)).isFalse();
assertThat(cancellation.describe(List.of(source)).get(0).getVoidStatus()).isEqualTo("PENDING_FINANCE");
jdbc.update("UPDATE ll_event_outbox SET status='DEAD' WHERE event_key=?", OverseasFeeCancellation.eventKey(source, "PUBLISHED"));
assertThat(cancellation.describe(List.of(source)).get(0).getVoidStatus()).isEqualTo("FINANCE_ATTENTION");
assertThat(cancellation.prerequisitePublished(request)).isFalse();
publishedOriginal(); assertThat(cancellation.prerequisitePublished(request)).isTrue();
assertThat(status()).isEqualTo("ACTIVE");
}
@Test void waitingCancellationsCannotStarveLaterReadyPublications() {
for (int i = 0; i < 60; i++) jdbc.update("INSERT INTO ll_event_outbox(id,event_type,event_key,status,created_at) VALUES(?,? ,?,'PENDING',?)",
1000 + i, request.getEventType(), "BusinessFeeSourceChanged:LASTLEG:OVERSEAS_DELIVERY_FEE:" + (800 + i) + ":1:VOIDED", now.minusDays(1));
var ready = repository.pending(50, now.plusDays(1), now.minusSeconds(60));
assertThat(ready).hasSize(1);
assertThat(ready.get(0).getEventKey()).isEqualTo(OverseasFeeCancellation.eventKey(source, "PUBLISHED"));
}
@Test void journalFailureRollsBackBothLocalVoidAndOutboxCompletionThenSameAckCanCommit() {
publishedOriginal(); claim();
jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')");
assertThatThrownBy(() -> complete(Outcome.APPLIED)).isInstanceOf(RuntimeException.class);
assertThat(status()).isEqualTo("ACTIVE");
assertThat(events.selectById(request.getId()).getStatus()).isEqualTo("PROCESSING");
jdbc.execute("ALTER TABLE event_delivery_attempt DROP CONSTRAINT reject_applied");
assertThat(complete(Outcome.DUPLICATE)).isTrue();
assertThat(status()).isEqualTo("VOIDED");
assertThat(cancellation.describe(List.of(fees.lockCurrent(701))).get(0).getVoidStatus()).isEqualTo("CONFIRMED");
assertThat(complete(Outcome.DUPLICATE)).isFalse();
}
@Test void rejectedOrUnknownConsumerOutcomeKeepsTheFinancialSourceActive() {
publishedOriginal(); claim();
assertThat(journal.complete("worker", Outcome.DEAD, now, "PERMANENT_FAILURE", null,
() -> repository.dead(request.getId(), "worker", 1, "来源已进入有效账单"))).isTrue();
assertThat(status()).isEqualTo("ACTIVE");
assertThat(cancellation.describe(List.of(source)).get(0).getVoidStatus()).isEqualTo("FINANCE_ATTENTION");
assertThat(repository.published(request.getId(), "stale-worker", now)).isZero();
assertThat(status()).isEqualTo("ACTIVE");
}
@Test void wrongSourceVersionCannotAcknowledgeOrCompleteTheEvent() {
publishedOriginal(); claim();
jdbc.update("UPDATE ll_finance_fee_source SET source_version=2 WHERE id=10");
assertThatThrownBy(() -> complete(Outcome.APPLIED)).hasMessageContaining("当前业务版本不一致");
assertThat(status()).isEqualTo("ACTIVE");
assertThat(events.selectById(request.getId()).getStatus()).isEqualTo("PROCESSING");
}
@Test void aNewShipmentWaitsForThePreviousActualFreightToBeVoidedAndUsesTheLatestVersion() {
var sourceService = new com.baiyue.lastleg.financefee.service.OverseasFeeSourceService(fees,
org.mockito.Mockito.mock(com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper.class),
org.mockito.Mockito.mock(com.baiyue.lastleg.financefee.service.OverseasFeeBusinessGuard.class),
org.mockito.Mockito.mock(OverseasFeeOutboxService.class),
org.mockito.Mockito.mock(com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter.class),
new ObjectMapper().findAndRegisterModules(), cancellation);
jdbc.update("UPDATE ll_finance_fee_source SET business_type='DELIVERY_ORDER',direction='RECEIVABLE',fee_item_code='DELIVERY_FEE',inbound_code='IN-1',review_status='APPROVED' WHERE id=10");
assertThatThrownBy(() -> sourceService.requireNoActiveDeliveryReceivables("10001", List.of("IN-1"))).hasMessageContaining("尚未确认作废");
assertThatCode(() -> sourceService.requireNoActiveDeliveryReceivables("10002", List.of("IN-1"))).doesNotThrowAnyException();
assertThatCode(() -> sourceService.requireNoActiveDeliveryReceivables("10001", List.of("IN-OTHER"))).doesNotThrowAnyException();
jdbc.update("UPDATE ll_finance_fee_source SET source_status='SUPERSEDED' WHERE id=10");
jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,source_type,source_status,business_type,direction,fee_item_code,inbound_code,review_status) VALUES(11,701,2,'10001','OVERSEAS_DELIVERY_FEE','ACTIVE','DELIVERY_ORDER','RECEIVABLE','DELIVERY_FEE','IN-1','PENDING')");
assertThatThrownBy(() -> sourceService.requireNoActiveDeliveryReceivables("10001", List.of("IN-1"))).hasMessageContaining("尚未确认作废");
jdbc.update("UPDATE ll_finance_fee_source SET source_status='VOIDED' WHERE id=11");
assertThatCode(() -> sourceService.requireNoActiveDeliveryReceivables("10001", List.of("IN-1"))).doesNotThrowAnyException();
}
void publishedOriginal() { jdbc.update("UPDATE ll_event_outbox SET status='PUBLISHED' WHERE event_key=?", OverseasFeeCancellation.eventKey(source, "PUBLISHED")); }
void claim() { assertThat(journal.begin("worker", "lastleg-service", request.getEventType(), String.valueOf(request.getId()), null, "finance", now,
() -> repository.claim(request.getId(), "worker", now))).isTrue(); }
boolean complete(Outcome outcome) { return journal.complete("worker", outcome, now, null, null, () -> repository.published(request.getId(), "worker", now)); }
String status() { return jdbc.queryForObject("SELECT source_status FROM ll_finance_fee_source WHERE id=10", String.class); }
}
@@ -19,6 +19,7 @@ import static org.mockito.Mockito.*;
class OverseasFeeOutboxRetryTest {
@Test void failedPayloadAtLimitGoesDeadAndExhaustedLeaseNeverCallsClient() {
var repository = mock(OverseasFeeOutboxRepository.class);
when(repository.prerequisitePublished(any())).thenReturn(true);
var client = mock(DirectEventClient.class);
var relay = new OverseasFeeOutboxRelay();
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
@@ -4,6 +4,7 @@ import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper;
import com.baiyue.lastleg.financefee.state.OverseasFeeCancellation;
import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
@@ -25,7 +26,7 @@ class OverseasContainerFeeSourceServiceTest {
private final OverseasFeeSourceService service = new OverseasFeeSourceService(
mapper, commands, guard, outbox,
new OverseasFeeCurrencyConverter(mock(MonthlyExchangeRateMapper.class)),
new ObjectMapper().findAndRegisterModules());
new ObjectMapper().findAndRegisterModules(), mock(OverseasFeeCancellation.class));
@Test
void createsContainerFeeUsingTheRealContainerAsItsStableBusinessReference() {
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.financefee.service;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper;
import com.baiyue.lastleg.financefee.state.OverseasFeeCancellation;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class OverseasFeeCancellationRequestTest {
final OverseasFeeSourceMapper fees = mock(OverseasFeeSourceMapper.class);
final OverseasFeeCommandMapper commands = mock(OverseasFeeCommandMapper.class);
final OverseasFeeOutboxService outbox = mock(OverseasFeeOutboxService.class);
final OverseasFeeCancellation cancellation = mock(OverseasFeeCancellation.class);
final OverseasFeeSourceService service = new OverseasFeeSourceService(fees, commands,
mock(OverseasFeeBusinessGuard.class), outbox, mock(OverseasFeeCurrencyConverter.class),
new ObjectMapper().findAndRegisterModules(), cancellation);
@Test void requestPreservesActiveSourceAndPublishesASeparateVoidSnapshot() {
var fee = approved(); when(fees.lockCurrent(701)).thenReturn(fee);
var result = service.voidSource(701, "operator", new OverseasFeeSourceDtos.VoidRequest(1, "取消未履行运输", "cancel-1"));
assertThat(result.getSourceStatus()).isEqualTo("ACTIVE");
assertThat(result.getVoidStatus()).isEqualTo("PENDING_FINANCE");
var emitted = ArgumentCaptor.forClass(OverseasFeeSource.class);
verify(outbox).publish(emitted.capture(), eq("VOIDED"));
assertThat(emitted.getValue()).isNotSameAs(result);
assertThat(emitted.getValue().getSourceStatus()).isEqualTo("VOIDED");
verify(fees).updateById(result); verify(commands).insert(any(com.baiyue.lastleg.financefee.entity.OverseasFeeCommand.class));
}
@Test void aPendingVoidCannotBeReplacedByAnAdjustment() {
var fee = approved(); when(fees.lockCurrent(701)).thenReturn(fee); when(cancellation.requested(fee)).thenReturn(true);
assertThatThrownBy(() -> service.adjust(701, "operator", new OverseasFeeSourceDtos.AdjustRequest(1, null, "adjust-1")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("已申请作废");
verifyNoInteractions(outbox); verify(fees, never()).updateById(any(OverseasFeeSource.class));
}
static OverseasFeeSource approved() {
var fee = new OverseasFeeSource(); fee.setId(10L); fee.setSourceFeeId(701L); fee.setSourceVersion(1);
fee.setCustomerId("10001"); fee.setSourceType("OVERSEAS_DELIVERY_FEE"); fee.setSourceStatus("ACTIVE");
fee.setReviewStatus("APPROVED"); fee.setPublicationStatus("PUBLISHED"); return fee;
}
}