feat(fleet): enforce reviewed trips and original fee coverage for dispatch
This commit is contained in:
+23
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+126
@@ -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); }
|
||||
}
|
||||
+1
@@ -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", "");
|
||||
|
||||
+2
-1
@@ -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() {
|
||||
|
||||
+45
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user